Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6bbcd300e4 | ||
|
|
c54d9b3c5f | ||
|
|
9ab03fbcd4 | ||
|
|
a852114e9c | ||
|
|
3c9b8c1c6f | ||
|
|
4735b51dc0 | ||
|
|
23927a1d59 | ||
|
|
40b592f7c1 | ||
|
|
4da31f5850 | ||
|
|
f79e96cbfe | ||
|
|
2dbce1e3bb | ||
|
|
cdfbbcbf8a | ||
|
|
890852bef5 | ||
|
|
3b285db21a | ||
|
|
d37be88977 | ||
|
|
e6d27a1487 | ||
|
|
5d23c17d30 | ||
|
|
9c9c1c3b94 | ||
|
|
4e94fd6ad0 | ||
|
|
81a8203cca | ||
|
|
b6b206c288 | ||
|
|
ed2310a2e7 | ||
|
|
bae5d97087 | ||
|
|
3e12ff139b | ||
|
|
7ccd8db085 | ||
|
|
fdc829d06c | ||
|
|
e7f61e34e8 | ||
|
|
141877f111 | ||
|
|
b71a976e61 | ||
|
|
68c95ed6d5 | ||
|
|
7a68cc99af | ||
|
|
7294bc7033 | ||
|
|
f8836be147 | ||
|
|
713999eb83 | ||
|
|
cc4c9787c0 | ||
|
|
ecc5cf3e1b | ||
|
|
ba7d2be03e | ||
|
|
368f349c78 | ||
|
|
639d641c07 | ||
|
|
d9313a1654 | ||
|
|
9dd532285c | ||
|
|
92f10fdb5c | ||
|
|
9d0f43a664 | ||
|
|
453365fcd6 | ||
|
|
6231c5fcc4 | ||
|
|
9821ddabcf | ||
|
|
57777f2f4b | ||
|
|
1f2c309d8c | ||
|
|
c11977870d | ||
|
|
41753cd853 | ||
|
|
003285f567 | ||
|
|
d1c1902cd3 | ||
|
|
b697deac1c | ||
|
|
fb8e6408bb | ||
|
|
836ffddd4f | ||
|
|
c8ff5a7817 | ||
|
|
c14f6ec4c2 | ||
|
|
dc8852eb0d | ||
|
|
586405f9de | ||
|
|
34e68593bc | ||
|
|
c02adfa326 | ||
|
|
021d63d973 | ||
|
|
50f6b15d23 | ||
|
|
6257c6488b | ||
|
|
defd895995 | ||
|
|
82159ee537 | ||
|
|
11f22888d5 | ||
|
|
20d932877e | ||
|
|
35631fb211 | ||
|
|
b02dd18e0b | ||
|
|
b583a8e6e2 | ||
|
|
5775a51731 | ||
|
|
9b94a11ab9 | ||
|
|
9ee1203130 | ||
|
|
711b079963 | ||
|
|
91dbd73d45 | ||
|
|
a2ac0e29e4 | ||
|
|
4271d832f1 | ||
|
|
64147d065c | ||
|
|
fb03c8e67f | ||
|
|
75979960ab | ||
|
|
1411157bde | ||
|
|
eb7289c150 | ||
|
|
c1f803bdd0 | ||
|
|
ba1efedd79 | ||
|
|
ac10997ed7 | ||
|
|
3c6f29bf3a | ||
|
|
f623393f8d | ||
|
|
66ab402a50 | ||
|
|
2076b93e19 | ||
|
|
a5aae3fce8 | ||
|
|
eb7a241e94 | ||
|
|
14ff9097c7 | ||
|
|
79f09b334c | ||
|
|
f5d1e992ef | ||
|
|
aed2c31f5a | ||
|
|
3e31e72734 | ||
|
|
7cf36d829a | ||
|
|
268cf67576 | ||
|
|
5c76158729 | ||
|
|
8c87b31e56 | ||
|
|
01678f2c91 | ||
|
|
b5e33b1707 | ||
|
|
bba388aefa | ||
|
|
46623d70a3 | ||
|
|
452b25057e | ||
|
|
67a6aec132 | ||
|
|
10ddc1c911 | ||
|
|
2a2ebefeb9 | ||
|
|
d5f39e5cb1 | ||
|
|
099be648bf | ||
|
|
6a885b7241 | ||
|
|
15cdff5aa2 | ||
|
|
8bd0e863ae | ||
|
|
94e261d7bf | ||
|
|
06f87d24a7 | ||
|
|
cdd2f40ef9 | ||
|
|
2448b461f0 | ||
|
|
4f85827585 | ||
|
|
fe042f3f83 | ||
|
|
d084958f2d | ||
|
|
ccecba7a19 | ||
|
|
d57fe44bcc | ||
|
|
a32bd9d74c | ||
|
|
39250b56c0 | ||
|
|
1c85e21e4f | ||
|
|
d331e1f9ab | ||
|
|
d7c414f6c0 | ||
|
|
523f861f77 | ||
|
|
8a0a6f383f | ||
|
|
b8b2d73eab | ||
|
|
683b4e753a | ||
|
|
a3f2b02473 | ||
|
|
befe62f8a0 | ||
|
|
621a493c5b | ||
|
|
8952cbfd20 | ||
|
|
a576eff700 | ||
|
|
506e18a137 | ||
|
|
27d2ed2304 | ||
|
|
a670ba9f95 | ||
|
|
2321312427 | ||
|
|
85382d2bb4 | ||
|
|
7745ccefab | ||
|
|
872931f507 | ||
|
|
f335d8735f | ||
|
|
fca0fd7bbb | ||
|
|
a461ed43de | ||
|
|
0f8672fb92 | ||
|
|
972fbd42ee | ||
|
|
062a217946 | ||
|
|
a59f3bb957 | ||
|
|
93cb27d324 | ||
|
|
b4e70da1f5 | ||
|
|
bf46c46fc0 | ||
|
|
af835f2c43 | ||
|
|
ffd0d4da7d | ||
|
|
e9226671c9 | ||
|
|
5fd3f3c080 | ||
|
|
519969eee0 | ||
|
|
1746ed6469 | ||
|
|
2912d442b5 | ||
|
|
a82474cf45 | ||
|
|
56a3d8bb72 | ||
|
|
add64249cd | ||
|
|
d3172ece9b | ||
|
|
f655f89c49 | ||
|
|
911e46c4f0 | ||
|
|
ec4647e2bc | ||
|
|
10f2da9e90 | ||
|
|
6aa0f2253b | ||
|
|
7c5ee940d3 | ||
|
|
d83e04c47b | ||
|
|
b0905d0270 | ||
|
|
8ce0d9ce56 | ||
|
|
ac2a1ce079 | ||
|
|
5d31499fd6 | ||
|
|
4c8217edf6 | ||
|
|
934a07a5ad | ||
|
|
7aae1edf3c | ||
|
|
7cc35e4b96 | ||
|
|
1f8a8d1ce5 | ||
|
|
e0e2f02e0b | ||
|
|
c029e519e9 | ||
|
|
ef661bba37 | ||
|
|
8d38f3358e | ||
|
|
f4e39ec7ca | ||
|
|
72744dcd29 | ||
|
|
6b374d923b | ||
|
|
03a47b06ee | ||
|
|
77d0974782 | ||
|
|
95e070a897 | ||
|
|
c32c77b963 | ||
|
|
9477860bca | ||
|
|
a5f483f518 | ||
|
|
65fcc45bc6 | ||
|
|
2e6946e1f7 | ||
|
|
64707b0928 | ||
|
|
a107a25e43 | ||
|
|
0a989e7811 | ||
|
|
4e63d43679 | ||
|
|
60cb5f398c | ||
|
|
8edeefc1cd | ||
|
|
02eabb82c1 | ||
|
|
999d434fe9 | ||
|
|
2b07c81e54 | ||
|
|
5f4ff38035 | ||
|
|
e52a5fd585 | ||
|
|
599a8e23cb | ||
|
|
81a4e4ff7b | ||
|
|
7d730dcafb | ||
|
|
446e54cb91 | ||
|
|
b9f428fd77 | ||
|
|
94e8978ce6 | ||
|
|
62376871e3 | ||
|
|
40b731534c | ||
|
|
1aea0a18a9 | ||
|
|
b1be594932 | ||
|
|
a9d7fbc083 | ||
|
|
a3e458fcc4 | ||
|
|
c1b8f8519f | ||
|
|
34d4a0b61b | ||
|
|
7c3b6c9702 | ||
|
|
e275aa1699 | ||
|
|
e9961f64a9 | ||
|
|
8e2be4704e | ||
|
|
948cd3068a | ||
|
|
162eb9afad | ||
|
|
30ae98c3de | ||
|
|
b1b10e846d | ||
|
|
56c73c12db | ||
|
|
ad802c16ea | ||
|
|
afc04d22bf | ||
|
|
b78f004f29 | ||
|
|
849b6303e8 | ||
|
|
4a82ba7c86 | ||
|
|
5b7fcca800 | ||
|
|
b549528e23 | ||
|
|
719db8d3f6 | ||
|
|
db98492d4f | ||
|
|
5430c10424 | ||
|
|
5495bcb945 | ||
|
|
7fded5e105 | ||
|
|
dc8bc2fccd | ||
|
|
af2c609405 | ||
|
|
d5d65e244b | ||
|
|
92a31c246c | ||
|
|
9082b76707 | ||
|
|
33041608f8 | ||
|
|
408faf3665 | ||
|
|
be6c675ded | ||
|
|
528854b285 | ||
|
|
87e277fad1 | ||
|
|
014588180e | ||
|
|
e02edb9f9e | ||
|
|
37fa931672 | ||
|
|
a7424cc428 | ||
|
|
837df329e2 | ||
|
|
f6c43e479d | ||
|
|
0b46a9c9b1 | ||
|
|
76de92fe1f | ||
|
|
32550a75ec | ||
|
|
9eca03cfd7 | ||
|
|
fec1ec554b | ||
|
|
db7a8cef77 | ||
|
|
5662c1983a | ||
|
|
8cfefdcc0f | ||
|
|
7d91b95320 | ||
|
|
beb790ad17 | ||
|
|
073110376a | ||
|
|
16f6f3b7e9 | ||
|
|
4758f3e557 | ||
|
|
38cc2adf0f | ||
|
|
80fbbfc2ef | ||
|
|
ca45956479 | ||
|
|
3d64c42b14 | ||
|
|
97e7a12883 | ||
|
|
e4d2b646e7 | ||
|
|
a5a8f3b6ac | ||
|
|
41b0535213 | ||
|
|
a146a157b8 | ||
|
|
96fff469fc | ||
|
|
a60ff00f7e | ||
|
|
d52c326123 | ||
|
|
76882780a4 | ||
|
|
97eb19da86 | ||
|
|
4ed9d702e9 | ||
|
|
4c678836fb | ||
|
|
fd134097a1 | ||
|
|
2a87f1de28 | ||
|
|
1f60a552e5 | ||
|
|
3b34029ca2 | ||
|
|
5f6a1ef7e9 | ||
|
|
b51f65d2a4 | ||
|
|
dc9526beb3 | ||
|
|
7188089f63 | ||
|
|
2ab9dbc72d | ||
|
|
52d0890032 | ||
|
|
b7725c455b | ||
|
|
db1d6da491 | ||
|
|
329a51f886 | ||
|
|
0c0badbae4 | ||
|
|
2da272c270 | ||
|
|
25ec5b3dcb | ||
|
|
d9cdc16ff9 | ||
|
|
066b72d54d | ||
|
|
e7c913643a | ||
|
|
9847ed9fcb | ||
|
|
721f812df0 | ||
|
|
a58965306a | ||
|
|
2b5377db04 | ||
|
|
7fe3849705 | ||
|
|
f998deb725 | ||
|
|
175e642081 | ||
|
|
e620f53705 | ||
|
|
f56561b898 | ||
|
|
a29a7669e1 | ||
|
|
29f08a5e5d | ||
|
|
f40da562a6 | ||
|
|
affcdea09b | ||
|
|
16ca7460f4 | ||
|
|
cb2aaede67 | ||
|
|
2ca9ce797d | ||
|
|
e07e7f70d6 | ||
|
|
48313b60c1 | ||
|
|
3be13025a8 | ||
|
|
505717e580 | ||
|
|
b4bb6bf81b | ||
|
|
6a8d901ce1 | ||
|
|
68f3f0e276 | ||
|
|
ddf8e66a08 | ||
|
|
5b7a492a48 | ||
|
|
9be1ee1843 | ||
|
|
b5c6acc2bc | ||
|
|
39b137c7f1 | ||
|
|
b16a7342aa | ||
|
|
3056c55d2f | ||
|
|
6b416e6fc1 | ||
|
|
e4c6093a82 | ||
|
|
3c1120ea48 | ||
|
|
a41e26e4c6 | ||
|
|
317dc366e3 | ||
|
|
cd4c280950 | ||
|
|
0ca8de5916 | ||
|
|
3840f2f344 | ||
|
|
f353a44fac | ||
|
|
a06796d9ca | ||
|
|
a69054fefa | ||
|
|
6805d7eb96 | ||
|
|
c77d0f1944 | ||
|
|
c457c8eaf7 | ||
|
|
a391db5dc2 | ||
|
|
2180e2f649 | ||
|
|
fc6d16bdd9 | ||
|
|
815529129a | ||
|
|
bad8d10a18 | ||
|
|
a4412da3ed | ||
|
|
870774b408 | ||
|
|
d7dcaa22a6 | ||
|
|
cc0b2943dd | ||
|
|
50ee0b2265 | ||
|
|
910b3a2a17 | ||
|
|
916da91ccb | ||
|
|
d70a99254a | ||
|
|
dcf5bb61a2 | ||
|
|
aa65bb10c1 | ||
|
|
158448f8a2 | ||
|
|
f4be9b3e26 | ||
|
|
15d68ca285 | ||
|
|
ad12e6a19d | ||
|
|
1f1b2fe85a | ||
|
|
480df58ce4 | ||
|
|
2decf532b2 | ||
|
|
3ba87419ae | ||
|
|
1c78af37fa | ||
|
|
2b5373aedd | ||
|
|
99bf960b8a | ||
|
|
c4aca22c28 | ||
|
|
58bd0e0cee | ||
|
|
c1c56f8f52 | ||
|
|
f930ba1941 | ||
|
|
251116a5ce | ||
|
|
06739c0401 | ||
|
|
92f069d1e6 | ||
|
|
62c5042ecc | ||
|
|
604b086c0b | ||
|
|
0e0b356f6b | ||
|
|
573768e2b2 | ||
|
|
236fd09c94 | ||
|
|
cb6cb5f637 | ||
|
|
eb916a3ab0 | ||
|
|
61b8ca1510 | ||
|
|
5d2da97dc5 | ||
|
|
d626686614 | ||
|
|
60ee33cb72 | ||
|
|
8d2498e96d | ||
|
|
1794484bed | ||
|
|
f2b6331843 | ||
|
|
8d5f33245f | ||
|
|
1ee544b842 | ||
|
|
0fd9463c7c | ||
|
|
ad5edf3aee | ||
|
|
d5fafd8cab | ||
|
|
92256f2fed | ||
|
|
5fa8d4c989 | ||
|
|
6b75f79f00 | ||
|
|
b0c9f29d90 | ||
|
|
7721afc116 | ||
|
|
bc85e9bbfa | ||
|
|
e8b6602fe5 |
@@ -21,7 +21,11 @@ kotlin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
kotlinOptions {
|
|
||||||
|
buildFeatures {
|
||||||
|
viewBinding true
|
||||||
|
}
|
||||||
|
kotlinOptions {
|
||||||
freeCompilerArgs = ['-Xjvm-default=all']
|
freeCompilerArgs = ['-Xjvm-default=all']
|
||||||
}
|
}
|
||||||
compileSdkVersion rootProject.ext.compileSdkVersion
|
compileSdkVersion rootProject.ext.compileSdkVersion
|
||||||
@@ -29,8 +33,8 @@ android {
|
|||||||
applicationId "com.audiobookshelf.app"
|
applicationId "com.audiobookshelf.app"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 73
|
versionCode 89
|
||||||
versionName "0.9.44-beta"
|
versionName "0.9.59-beta"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
@@ -39,6 +43,12 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
buildTypes {
|
buildTypes {
|
||||||
|
debug {
|
||||||
|
applicationIdSuffix ".debug"
|
||||||
|
versionNameSuffix "-debug"
|
||||||
|
minifyEnabled false
|
||||||
|
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||||
|
}
|
||||||
release {
|
release {
|
||||||
minifyEnabled false
|
minifyEnabled false
|
||||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||||
@@ -111,13 +121,13 @@ dependencies {
|
|||||||
implementation 'io.github.pilgr:paperdb:2.7.2'
|
implementation 'io.github.pilgr:paperdb:2.7.2'
|
||||||
|
|
||||||
// Simple Storage
|
// Simple Storage
|
||||||
implementation "com.anggrayudi:storage:0.13.0"
|
implementation "com.anggrayudi:storage:0.14.0"
|
||||||
|
|
||||||
// OK HTTP
|
// OK HTTP
|
||||||
implementation 'com.squareup.okhttp3:okhttp:4.9.2'
|
implementation 'com.squareup.okhttp3:okhttp:4.9.2'
|
||||||
|
|
||||||
// Jackson for JSON
|
// Jackson for JSON
|
||||||
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.12.1'
|
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.12.2'
|
||||||
|
|
||||||
// FFMPEG-Kit
|
// FFMPEG-Kit
|
||||||
implementation 'com.arthenica:ffmpeg-kit-min:4.5.1'
|
implementation 'com.arthenica:ffmpeg-kit-min:4.5.1'
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ dependencies {
|
|||||||
implementation project(':capacitor-network')
|
implementation project(':capacitor-network')
|
||||||
implementation project(':capacitor-status-bar')
|
implementation project(':capacitor-status-bar')
|
||||||
implementation project(':capacitor-storage')
|
implementation project(':capacitor-storage')
|
||||||
implementation project(':robingenz-capacitor-app-update')
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@mipmap/ic_launcher_background"/>
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@mipmap/ic_launcher_background"/>
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 879 B |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 685 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 16 KiB |
@@ -1,82 +1,94 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:dist="http://schemas.android.com/apk/distribution"
|
xmlns:dist="http://schemas.android.com/apk/distribution"
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
package="com.audiobookshelf.app">
|
package="com.audiobookshelf.app"
|
||||||
|
android:installLocation="preferExternal" >
|
||||||
|
|
||||||
<!-- Permissions -->
|
<!-- Permissions -->
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission
|
||||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
|
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.READ_EXTERNAL_STORAGE" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
|
android:requestLegacyExternalStorage="true"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/AppTheme"
|
android:theme="@style/AppTheme"
|
||||||
android:usesCleartextTraffic="true"
|
android:usesCleartextTraffic="true" >
|
||||||
android:networkSecurityConfig="@xml/network_security_config"
|
<!-- <receiver-->
|
||||||
android:requestLegacyExternalStorage="true">
|
<!-- android:name=".NewAppWidget"-->
|
||||||
|
<!-- android:exported="true" >-->
|
||||||
|
<!-- <intent-filter>-->
|
||||||
|
<!-- <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />-->
|
||||||
|
<!-- </intent-filter>-->
|
||||||
|
|
||||||
|
<!-- <meta-data-->
|
||||||
|
<!-- android:name="android.appwidget.provider"-->
|
||||||
|
<!-- android:resource="@xml/new_app_widget_info" />-->
|
||||||
|
<!-- </receiver>-->
|
||||||
|
|
||||||
<!--Used by Android Auto-->
|
<!-- Used by Android Auto -->
|
||||||
<meta-data android:name="com.google.android.gms.car.notification.SmallIcon"
|
|
||||||
android:resource="@drawable/icon" />
|
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="com.google.android.gms.car.application"
|
android:name="com.google.android.gms.car.notification.SmallIcon"
|
||||||
android:resource="@xml/automotive_app_desc"/>
|
android:resource="@drawable/icon" />
|
||||||
|
<meta-data
|
||||||
<!-- Support for Cast -->
|
android:name="com.google.android.gms.car.application"
|
||||||
<meta-data android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
|
android:resource="@xml/automotive_app_desc" /> <!-- Support for Cast -->
|
||||||
android:value="com.audiobookshelf.app.CastOptionsProvider"/>
|
<meta-data
|
||||||
|
android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
|
||||||
|
android:value="com.audiobookshelf.app.CastOptionsProvider" />
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
|
||||||
android:name="com.audiobookshelf.app.MainActivity"
|
|
||||||
android:label="@string/title_activity_main"
|
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:theme="@style/AppTheme.NoActionBarLaunch"
|
android:label="@string/title_activity_main"
|
||||||
android:launchMode="singleTask">
|
android:launchMode="singleTask"
|
||||||
|
android:theme="@style/AppTheme.NoActionBarLaunch" >
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
<intent-filter>
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
<action android:name="android.intent.action.MAIN" />
|
</intent-filter>
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<intent-filter>
|
||||||
</intent-filter>
|
<action android:name="android.media.action.MEDIA_PLAY_FROM_SEARCH" />
|
||||||
|
|
||||||
<intent-filter>
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
<action android:name="android.media.action.MEDIA_PLAY_FROM_SEARCH" />
|
</intent-filter>
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
|
||||||
</intent-filter>
|
|
||||||
|
|
||||||
<!-- Register URL scheme -->
|
<!-- Register URL scheme -->
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
<data android:scheme="@string/custom_url_scheme" />
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
</intent-filter>
|
|
||||||
|
<data android:scheme="@string/custom_url_scheme" />
|
||||||
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<receiver android:name="androidx.media.session.MediaButtonReceiver" >
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
|
||||||
<receiver android:name="androidx.media.session.MediaButtonReceiver" >
|
<service
|
||||||
<intent-filter>
|
android:name=".player.PlayerNotificationService"
|
||||||
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
android:enabled="true"
|
||||||
</intent-filter>
|
android:exported="true" >
|
||||||
</receiver>
|
<intent-filter>
|
||||||
|
<action android:name="android.media.browse.MediaBrowserService" />
|
||||||
<service
|
</intent-filter>
|
||||||
android:exported="true"
|
</service>
|
||||||
android:enabled="true"
|
|
||||||
android:name=".player.PlayerNotificationService">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.media.browse.MediaBrowserService"/>
|
|
||||||
</intent-filter>
|
|
||||||
</service>
|
|
||||||
|
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -22,9 +22,5 @@
|
|||||||
{
|
{
|
||||||
"pkg": "@capacitor/storage",
|
"pkg": "@capacitor/storage",
|
||||||
"classpath": "com.capacitorjs.plugins.storage.StoragePlugin"
|
"classpath": "com.capacitorjs.plugins.storage.StoragePlugin"
|
||||||
},
|
|
||||||
{
|
|
||||||
"pkg": "@robingenz/capacitor-app-update",
|
|
||||||
"classpath": "dev.robingenz.capacitor.appupdate.AppUpdatePlugin"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,21 +1,24 @@
|
|||||||
package com.audiobookshelf.app
|
package com.audiobookshelf.app
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.app.DownloadManager
|
import android.content.ComponentName
|
||||||
import android.content.*
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.ServiceConnection
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.os.*
|
import android.os.Bundle
|
||||||
|
import android.os.IBinder
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.core.app.ActivityCompat
|
import androidx.core.app.ActivityCompat
|
||||||
import com.anggrayudi.storage.SimpleStorage
|
import com.anggrayudi.storage.SimpleStorage
|
||||||
import com.anggrayudi.storage.SimpleStorageHelper
|
import com.anggrayudi.storage.SimpleStorageHelper
|
||||||
import com.audiobookshelf.app.data.AbsDatabase
|
import com.audiobookshelf.app.data.AbsDatabase
|
||||||
|
import com.audiobookshelf.app.data.DbManager
|
||||||
import com.audiobookshelf.app.player.PlayerNotificationService
|
import com.audiobookshelf.app.player.PlayerNotificationService
|
||||||
import com.audiobookshelf.app.plugins.AbsAudioPlayer
|
import com.audiobookshelf.app.plugins.AbsAudioPlayer
|
||||||
import com.audiobookshelf.app.plugins.AbsDownloader
|
import com.audiobookshelf.app.plugins.AbsDownloader
|
||||||
import com.audiobookshelf.app.plugins.AbsFileSystem
|
import com.audiobookshelf.app.plugins.AbsFileSystem
|
||||||
import com.getcapacitor.BridgeActivity
|
import com.getcapacitor.BridgeActivity
|
||||||
import io.paperdb.Paper
|
|
||||||
|
|
||||||
|
|
||||||
class MainActivity : BridgeActivity() {
|
class MainActivity : BridgeActivity() {
|
||||||
@@ -50,14 +53,11 @@ class MainActivity : BridgeActivity() {
|
|||||||
// .build())
|
// .build())
|
||||||
|
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
Log.d(tag, "onCreate")
|
Log.d(tag, "onCreate")
|
||||||
|
|
||||||
// Grant full storage access for testing
|
DbManager.initialize(applicationContext)
|
||||||
// var ss = SimpleStorage(this)
|
|
||||||
// ss.requestFullStorageAccess()
|
|
||||||
|
|
||||||
var permission = ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE)
|
val permission = ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE)
|
||||||
if (permission != PackageManager.PERMISSION_GRANTED) {
|
if (permission != PackageManager.PERMISSION_GRANTED) {
|
||||||
ActivityCompat.requestPermissions(this,
|
ActivityCompat.requestPermissions(this,
|
||||||
PERMISSIONS_ALL,
|
PERMISSIONS_ALL,
|
||||||
@@ -68,8 +68,6 @@ class MainActivity : BridgeActivity() {
|
|||||||
registerPlugin(AbsDownloader::class.java)
|
registerPlugin(AbsDownloader::class.java)
|
||||||
registerPlugin(AbsFileSystem::class.java)
|
registerPlugin(AbsFileSystem::class.java)
|
||||||
registerPlugin(AbsDatabase::class.java)
|
registerPlugin(AbsDatabase::class.java)
|
||||||
|
|
||||||
Paper.init(applicationContext)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
@@ -94,9 +92,7 @@ class MainActivity : BridgeActivity() {
|
|||||||
foregroundService = mLocalBinder.getService()
|
foregroundService = mLocalBinder.getService()
|
||||||
|
|
||||||
// Let NativeAudio know foreground service is ready and setup event listener
|
// Let NativeAudio know foreground service is ready and setup event listener
|
||||||
if (pluginCallback != null) {
|
pluginCallback()
|
||||||
pluginCallback()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,9 +136,4 @@ class MainActivity : BridgeActivity() {
|
|||||||
// Mandatory for Activity, but not for Fragment & ComponentActivity
|
// Mandatory for Activity, but not for Fragment & ComponentActivity
|
||||||
storageHelper.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
storageHelper.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||||
}
|
}
|
||||||
|
|
||||||
// override fun onUserInteraction() {
|
|
||||||
// super.onUserInteraction()
|
|
||||||
// Log.d(tag, "USER INTERACTION")
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package com.audiobookshelf.app
|
||||||
|
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.appwidget.AppWidgetManager
|
||||||
|
import android.appwidget.AppWidgetProvider
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.net.Uri
|
||||||
|
import android.support.v4.media.session.PlaybackStateCompat.ACTION_PLAY_PAUSE
|
||||||
|
import android.util.Log
|
||||||
|
import android.widget.RemoteViews
|
||||||
|
import androidx.media.session.MediaButtonReceiver.buildMediaButtonPendingIntent
|
||||||
|
import com.audiobookshelf.app.device.DeviceManager
|
||||||
|
import com.audiobookshelf.app.device.WidgetEventEmitter
|
||||||
|
import com.audiobookshelf.app.player.PlayerNotificationService
|
||||||
|
import com.bumptech.glide.Glide
|
||||||
|
import com.bumptech.glide.request.RequestOptions
|
||||||
|
import com.bumptech.glide.request.target.AppWidgetTarget
|
||||||
|
import com.bumptech.glide.request.transition.Transition
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implementation of App Widget functionality.
|
||||||
|
*/
|
||||||
|
class NewAppWidget : AppWidgetProvider() {
|
||||||
|
val tag = "NewAppWidget"
|
||||||
|
|
||||||
|
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
|
||||||
|
// There may be multiple widgets active, so update all of them
|
||||||
|
for (appWidgetId in appWidgetIds) {
|
||||||
|
updateAppWidget(context, appWidgetManager, appWidgetId, null,false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onEnabled(context: Context) {
|
||||||
|
Log.w(tag, "onEnabled check context ${context.packageName}")
|
||||||
|
|
||||||
|
// Enter relevant functionality for when the first widget is created
|
||||||
|
DeviceManager.widgetUpdater = (object : WidgetEventEmitter {
|
||||||
|
override fun onPlayerChanged(pns:PlayerNotificationService) {
|
||||||
|
val isPlaying = pns.currentPlayer.isPlaying
|
||||||
|
Log.i(tag, "onPlayerChanged | Is Playing? $isPlaying")
|
||||||
|
|
||||||
|
val appWidgetManager = AppWidgetManager.getInstance(context)
|
||||||
|
val componentName = ComponentName(context, NewAppWidget::class.java)
|
||||||
|
val ids = appWidgetManager.getAppWidgetIds(componentName)
|
||||||
|
|
||||||
|
val playbackSession = pns.getCurrentPlaybackSessionCopy()
|
||||||
|
val cover = playbackSession?.getCoverUri()
|
||||||
|
|
||||||
|
for (widgetId in ids) {
|
||||||
|
updateAppWidget(context, appWidgetManager, widgetId, cover, isPlaying)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDisabled(context: Context) {
|
||||||
|
// Enter relevant functionality for when the last widget is disabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int, coverUri:Uri?, isPlaying:Boolean) {
|
||||||
|
|
||||||
|
val views = RemoteViews(context.packageName, R.layout.new_app_widget)
|
||||||
|
|
||||||
|
val playPausePI = buildMediaButtonPendingIntent(context, ACTION_PLAY_PAUSE)
|
||||||
|
views.setOnClickPendingIntent(R.id.playPauseIcon, playPausePI)
|
||||||
|
|
||||||
|
val wholeWidgetClickI = Intent(context, MainActivity::class.java)
|
||||||
|
wholeWidgetClickI.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
|
val wholeWidgetClickPI = PendingIntent.getActivity(
|
||||||
|
context,
|
||||||
|
System.currentTimeMillis().toInt(),
|
||||||
|
wholeWidgetClickI,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
views.setOnClickPendingIntent(R.id.appWidget, wholeWidgetClickPI)
|
||||||
|
|
||||||
|
val imageUri = coverUri ?: Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon)
|
||||||
|
val awt: AppWidgetTarget = object : AppWidgetTarget(context.applicationContext, R.id.imageView, views, appWidgetId) {
|
||||||
|
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
|
||||||
|
super.onResourceReady(resource, transition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val options = RequestOptions().override(300, 300).placeholder(R.drawable.icon).error(R.drawable.icon)
|
||||||
|
Glide.with(context.applicationContext).asBitmap().load(imageUri).apply(options).into(awt)
|
||||||
|
|
||||||
|
val playPauseResource = if (isPlaying) R.drawable.ic_media_pause_dark else R.drawable.ic_media_play_dark
|
||||||
|
views.setImageViewResource(R.id.playPauseIcon, playPauseResource)
|
||||||
|
|
||||||
|
// Instruct the widget manager to update the widget
|
||||||
|
appWidgetManager.updateAppWidget(appWidgetId, views)
|
||||||
|
}
|
||||||
@@ -1,62 +1,11 @@
|
|||||||
package com.audiobookshelf.app.data
|
package com.audiobookshelf.app.data
|
||||||
|
|
||||||
import android.net.Uri
|
import android.os.Bundle
|
||||||
|
import android.support.v4.media.MediaDescriptionCompat
|
||||||
import android.support.v4.media.MediaMetadataCompat
|
import android.support.v4.media.MediaMetadataCompat
|
||||||
import com.audiobookshelf.app.R
|
import androidx.media.utils.MediaConstants
|
||||||
import com.audiobookshelf.app.device.DeviceManager
|
|
||||||
import com.fasterxml.jackson.annotation.*
|
import com.fasterxml.jackson.annotation.*
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
|
||||||
data class LibraryItem(
|
|
||||||
var id:String,
|
|
||||||
var ino:String,
|
|
||||||
var libraryId:String,
|
|
||||||
var folderId:String,
|
|
||||||
var path:String,
|
|
||||||
var relPath:String,
|
|
||||||
var mtimeMs:Long,
|
|
||||||
var ctimeMs:Long,
|
|
||||||
var birthtimeMs:Long,
|
|
||||||
var addedAt:Long,
|
|
||||||
var updatedAt:Long,
|
|
||||||
var lastScan:Long?,
|
|
||||||
var scanVersion:String?,
|
|
||||||
var isMissing:Boolean,
|
|
||||||
var isInvalid:Boolean,
|
|
||||||
var mediaType:String,
|
|
||||||
var media:MediaType,
|
|
||||||
var libraryFiles:MutableList<LibraryFile>?,
|
|
||||||
var userMediaProgress:MediaProgress? // Only included when requesting library item with progress (for downloads)
|
|
||||||
) : LibraryItemWrapper() {
|
|
||||||
@get:JsonIgnore
|
|
||||||
val title get() = media.metadata.title
|
|
||||||
@get:JsonIgnore
|
|
||||||
val authorName get() = media.metadata.getAuthorDisplayName()
|
|
||||||
|
|
||||||
@JsonIgnore
|
|
||||||
fun getCoverUri():Uri {
|
|
||||||
if (media.coverPath == null) {
|
|
||||||
return Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon)
|
|
||||||
}
|
|
||||||
|
|
||||||
return Uri.parse("${DeviceManager.serverAddress}/api/items/$id/cover?token=${DeviceManager.token}")
|
|
||||||
}
|
|
||||||
|
|
||||||
@JsonIgnore
|
|
||||||
fun getMediaMetadata(): MediaMetadataCompat {
|
|
||||||
return MediaMetadataCompat.Builder().apply {
|
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
|
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title)
|
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_TITLE, title)
|
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, authorName)
|
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, getCoverUri().toString())
|
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getCoverUri().toString())
|
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_ART_URI, getCoverUri().toString())
|
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, authorName)
|
|
||||||
}.build()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// This auto-detects whether it is a Book or Podcast
|
// This auto-detects whether it is a Book or Podcast
|
||||||
@JsonTypeInfo(use=JsonTypeInfo.Id.DEDUCTION)
|
@JsonTypeInfo(use=JsonTypeInfo.Id.DEDUCTION)
|
||||||
@JsonSubTypes(
|
@JsonSubTypes(
|
||||||
@@ -74,6 +23,7 @@ open class MediaType(var metadata:MediaTypeMetadata, var coverPath:String?) {
|
|||||||
open fun removeAudioTrack(localFileId:String) { }
|
open fun removeAudioTrack(localFileId:String) { }
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
open fun getLocalCopy():MediaType { return MediaType(MediaTypeMetadata(""),null) }
|
open fun getLocalCopy():MediaType { return MediaType(MediaTypeMetadata(""),null) }
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
@@ -82,7 +32,8 @@ class Podcast(
|
|||||||
coverPath:String?,
|
coverPath:String?,
|
||||||
var tags:MutableList<String>,
|
var tags:MutableList<String>,
|
||||||
var episodes:MutableList<PodcastEpisode>?,
|
var episodes:MutableList<PodcastEpisode>?,
|
||||||
var autoDownloadEpisodes:Boolean
|
var autoDownloadEpisodes:Boolean,
|
||||||
|
var numEpisodes:Int?
|
||||||
) : MediaType(metadata, coverPath) {
|
) : MediaType(metadata, coverPath) {
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
override fun getAudioTracks():List<AudioTrack> {
|
override fun getAudioTracks():List<AudioTrack> {
|
||||||
@@ -99,7 +50,7 @@ class Podcast(
|
|||||||
// Add new episodes
|
// Add new episodes
|
||||||
audioTracks.forEach { at ->
|
audioTracks.forEach { at ->
|
||||||
if (episodes?.find{ it.audioTrack?.localFileId == at.localFileId } == null) {
|
if (episodes?.find{ it.audioTrack?.localFileId == at.localFileId } == null) {
|
||||||
val newEpisode = PodcastEpisode("local_" + at.localFileId,episodes?.size ?: 0 + 1,null,null,at.title,null,null,null,at,at.duration,0, null)
|
val newEpisode = PodcastEpisode("local_ep_" + at.localFileId,episodes?.size ?: 0 + 1,null,null,at.title,null,null,null,at,at.duration,0, null)
|
||||||
episodes?.add(newEpisode)
|
episodes?.add(newEpisode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -112,7 +63,7 @@ class Podcast(
|
|||||||
}
|
}
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
override fun addAudioTrack(audioTrack:AudioTrack) {
|
override fun addAudioTrack(audioTrack:AudioTrack) {
|
||||||
val newEpisode = PodcastEpisode("local_" + audioTrack.localFileId,episodes?.size ?: 0 + 1,null,null,audioTrack.title,null,null,null,audioTrack,audioTrack.duration,0, null)
|
val newEpisode = PodcastEpisode("local_" + audioTrack.localFileId,(episodes?.size ?: 0) + 1,null,null,audioTrack.title,null,null,null,audioTrack,audioTrack.duration,0, null)
|
||||||
episodes?.add(newEpisode)
|
episodes?.add(newEpisode)
|
||||||
|
|
||||||
var index = 1
|
var index = 1
|
||||||
@@ -133,7 +84,7 @@ class Podcast(
|
|||||||
}
|
}
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun addEpisode(audioTrack:AudioTrack, episode:PodcastEpisode):PodcastEpisode {
|
fun addEpisode(audioTrack:AudioTrack, episode:PodcastEpisode):PodcastEpisode {
|
||||||
val newEpisode = PodcastEpisode("local_" + episode.id,episodes?.size ?: 0 + 1,episode.episode,episode.episodeType,episode.title,episode.subtitle,episode.description,null,audioTrack,audioTrack.duration,0, episode.id)
|
val newEpisode = PodcastEpisode("local_" + episode.id,(episodes?.size ?: 0) + 1,episode.episode,episode.episodeType,episode.title,episode.subtitle,episode.description,null,audioTrack,audioTrack.duration,0, episode.id)
|
||||||
episodes?.add(newEpisode)
|
episodes?.add(newEpisode)
|
||||||
|
|
||||||
var index = 1
|
var index = 1
|
||||||
@@ -147,7 +98,7 @@ class Podcast(
|
|||||||
// Used for FolderScanner local podcast item to get copy of Podcast excluding episodes
|
// Used for FolderScanner local podcast item to get copy of Podcast excluding episodes
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
override fun getLocalCopy(): Podcast {
|
override fun getLocalCopy(): Podcast {
|
||||||
return Podcast(metadata as PodcastMetadata,coverPath,tags, mutableListOf(),autoDownloadEpisodes)
|
return Podcast(metadata as PodcastMetadata,coverPath,tags, mutableListOf(),autoDownloadEpisodes, 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,7 +111,8 @@ class Book(
|
|||||||
var chapters:List<BookChapter>?,
|
var chapters:List<BookChapter>?,
|
||||||
var tracks:MutableList<AudioTrack>?,
|
var tracks:MutableList<AudioTrack>?,
|
||||||
var size:Long?,
|
var size:Long?,
|
||||||
var duration:Double?
|
var duration:Double?,
|
||||||
|
var numTracks:Int?
|
||||||
) : MediaType(metadata, coverPath) {
|
) : MediaType(metadata, coverPath) {
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
override fun getAudioTracks():List<AudioTrack> {
|
override fun getAudioTracks():List<AudioTrack> {
|
||||||
@@ -209,7 +161,7 @@ class Book(
|
|||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
override fun getLocalCopy(): Book {
|
override fun getLocalCopy(): Book {
|
||||||
return Book(metadata as BookMetadata,coverPath,tags, mutableListOf(),chapters,mutableListOf(),null,null)
|
return Book(metadata as BookMetadata,coverPath,tags, mutableListOf(),chapters,mutableListOf(),null,null, 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,7 +233,55 @@ data class PodcastEpisode(
|
|||||||
var duration:Double?,
|
var duration:Double?,
|
||||||
var size:Long?,
|
var size:Long?,
|
||||||
var serverEpisodeId:String? // For local podcasts to match with server podcasts
|
var serverEpisodeId:String? // For local podcasts to match with server podcasts
|
||||||
)
|
) {
|
||||||
|
@JsonIgnore
|
||||||
|
fun getMediaDescription(libraryItem:LibraryItemWrapper, progress:MediaProgressWrapper?): MediaDescriptionCompat {
|
||||||
|
val coverUri = if(libraryItem is LocalLibraryItem) {
|
||||||
|
libraryItem.getCoverUri()
|
||||||
|
} else {
|
||||||
|
(libraryItem as LibraryItem).getCoverUri()
|
||||||
|
}
|
||||||
|
|
||||||
|
val extras = Bundle()
|
||||||
|
if (progress != null) {
|
||||||
|
if (progress.isFinished) {
|
||||||
|
extras.putInt(
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_FULLY_PLAYED
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
extras.putInt(
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_PARTIALLY_PLAYED
|
||||||
|
)
|
||||||
|
extras.putDouble(
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_PERCENTAGE, progress.progress
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
extras.putInt(
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_NOT_PLAYED
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// return MediaMetadataCompat.Builder().apply {
|
||||||
|
// putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
|
||||||
|
// putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title)
|
||||||
|
// putString(MediaMetadataCompat.METADATA_KEY_TITLE, title)
|
||||||
|
// putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, podcast.metadata.getAuthorDisplayName())
|
||||||
|
// putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, coverUri.toString())
|
||||||
|
//
|
||||||
|
// }.build()
|
||||||
|
val libraryItemDescription = libraryItem.getMediaDescription(null)
|
||||||
|
return MediaDescriptionCompat.Builder()
|
||||||
|
.setMediaId(id)
|
||||||
|
.setTitle(title)
|
||||||
|
.setIconUri(coverUri)
|
||||||
|
.setSubtitle(libraryItemDescription.title)
|
||||||
|
.setExtras(extras)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
data class LibraryFile(
|
data class LibraryFile(
|
||||||
@@ -312,7 +312,16 @@ data class Library(
|
|||||||
var folders:MutableList<Folder>,
|
var folders:MutableList<Folder>,
|
||||||
var icon:String,
|
var icon:String,
|
||||||
var mediaType:String
|
var mediaType:String
|
||||||
)
|
) {
|
||||||
|
@JsonIgnore
|
||||||
|
fun getMediaMetadata(): MediaMetadataCompat {
|
||||||
|
return MediaMetadataCompat.Builder().apply {
|
||||||
|
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
|
||||||
|
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, name)
|
||||||
|
putString(MediaMetadataCompat.METADATA_KEY_TITLE, name)
|
||||||
|
}.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
data class Folder(
|
data class Folder(
|
||||||
@@ -356,18 +365,36 @@ data class BookChapter(
|
|||||||
var start:Double,
|
var start:Double,
|
||||||
var end:Double,
|
var end:Double,
|
||||||
var title:String?
|
var title:String?
|
||||||
)
|
) {
|
||||||
|
@get:JsonIgnore
|
||||||
|
val startMs get() = (start * 1000L).toLong()
|
||||||
|
@get:JsonIgnore
|
||||||
|
val endMs get() = (end * 1000L).toLong()
|
||||||
|
}
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
data class MediaProgress(
|
class MediaProgress(
|
||||||
var id:String,
|
var id:String,
|
||||||
var libraryItemId:String,
|
var libraryItemId:String,
|
||||||
var episodeId:String?,
|
var episodeId:String?,
|
||||||
var duration:Double, // seconds
|
var duration:Double, // seconds
|
||||||
var progress:Double, // 0 to 1
|
progress:Double, // 0 to 1
|
||||||
var currentTime:Double,
|
var currentTime:Double,
|
||||||
var isFinished:Boolean,
|
isFinished:Boolean,
|
||||||
var lastUpdate:Long,
|
var lastUpdate:Long,
|
||||||
var startedAt:Long,
|
var startedAt:Long,
|
||||||
var finishedAt:Long?
|
var finishedAt:Long?
|
||||||
|
) : MediaProgressWrapper(isFinished, progress)
|
||||||
|
|
||||||
|
@JsonTypeInfo(use= JsonTypeInfo.Id.DEDUCTION, defaultImpl = MediaProgress::class)
|
||||||
|
@JsonSubTypes(
|
||||||
|
JsonSubTypes.Type(MediaProgress::class),
|
||||||
|
JsonSubTypes.Type(LocalMediaProgress::class)
|
||||||
|
)
|
||||||
|
open class MediaProgressWrapper(var isFinished:Boolean, var progress:Double)
|
||||||
|
|
||||||
|
// Helper class
|
||||||
|
data class LibraryItemWithEpisode(
|
||||||
|
var libraryItemWrapper:LibraryItemWrapper,
|
||||||
|
var episode:PodcastEpisode
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,16 +1,27 @@
|
|||||||
package com.audiobookshelf.app.data
|
package com.audiobookshelf.app.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.audiobookshelf.app.plugins.AbsDownloader
|
import com.audiobookshelf.app.plugins.AbsDownloader
|
||||||
import io.paperdb.Paper
|
import io.paperdb.Paper
|
||||||
import org.json.JSONObject
|
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
class DbManager {
|
class DbManager {
|
||||||
val tag = "DbManager"
|
val tag = "DbManager"
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
var isDbInitialized = false
|
||||||
|
|
||||||
|
fun initialize(ctx: Context) {
|
||||||
|
if (isDbInitialized) return
|
||||||
|
Paper.init(ctx)
|
||||||
|
isDbInitialized = true
|
||||||
|
Log.i("DbManager", "Initialized Paper db")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun getDeviceData(): DeviceData {
|
fun getDeviceData(): DeviceData {
|
||||||
return Paper.book("device").read("data") ?: DeviceData(mutableListOf(), null, null)
|
return Paper.book("device").read("data") ?: DeviceData(mutableListOf(), null, null, DeviceSettings.default())
|
||||||
}
|
}
|
||||||
fun saveDeviceData(deviceData:DeviceData) {
|
fun saveDeviceData(deviceData:DeviceData) {
|
||||||
Paper.book("device").write("data", deviceData)
|
Paper.book("device").write("data", deviceData)
|
||||||
@@ -34,7 +45,7 @@ class DbManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getLocalLibraryItemByLLId(libraryItemId:String):LocalLibraryItem? {
|
fun getLocalLibraryItemByLId(libraryItemId:String):LocalLibraryItem? {
|
||||||
return getLocalLibraryItems().find { it.libraryItemId == libraryItemId }
|
return getLocalLibraryItems().find { it.libraryItemId == libraryItemId }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,6 +53,20 @@ class DbManager {
|
|||||||
return Paper.book("localLibraryItems").read(localLibraryItemId)
|
return Paper.book("localLibraryItems").read(localLibraryItemId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getLocalLibraryItemWithEpisode(podcastEpisodeId:String):LibraryItemWithEpisode? {
|
||||||
|
var podcastEpisode:PodcastEpisode? = null
|
||||||
|
val localLibraryItem = getLocalLibraryItems("podcast").find { localLibraryItem ->
|
||||||
|
val podcast = localLibraryItem.media as Podcast
|
||||||
|
podcastEpisode = podcast.episodes?.find { it.id == podcastEpisodeId }
|
||||||
|
podcastEpisode != null
|
||||||
|
}
|
||||||
|
return if (localLibraryItem != null) {
|
||||||
|
LibraryItemWithEpisode(localLibraryItem, podcastEpisode!!)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun removeLocalLibraryItem(localLibraryItemId:String) {
|
fun removeLocalLibraryItem(localLibraryItemId:String) {
|
||||||
Paper.book("localLibraryItems").delete(localLibraryItemId)
|
Paper.book("localLibraryItems").delete(localLibraryItemId)
|
||||||
}
|
}
|
||||||
@@ -133,6 +158,7 @@ class DbManager {
|
|||||||
|
|
||||||
// Check local files
|
// Check local files
|
||||||
lli.localFiles = lli.localFiles.filter { localFile ->
|
lli.localFiles = lli.localFiles.filter { localFile ->
|
||||||
|
|
||||||
val file = File(localFile.absolutePath)
|
val file = File(localFile.absolutePath)
|
||||||
if (!file.exists()) {
|
if (!file.exists()) {
|
||||||
Log.d(tag, "cleanLocalLibraryItems: Local file ${localFile.absolutePath} was removed from library item ${lli.media.metadata.title}")
|
Log.d(tag, "cleanLocalLibraryItems: Local file ${localFile.absolutePath} was removed from library item ${lli.media.metadata.title}")
|
||||||
@@ -187,7 +213,11 @@ class DbManager {
|
|||||||
val localLibraryItems = getLocalLibraryItems()
|
val localLibraryItems = getLocalLibraryItems()
|
||||||
localMediaProgress.forEach {
|
localMediaProgress.forEach {
|
||||||
val matchingLLI = localLibraryItems.find { lli -> lli.id == it.localLibraryItemId }
|
val matchingLLI = localLibraryItems.find { lli -> lli.id == it.localLibraryItemId }
|
||||||
if (matchingLLI == null) {
|
if (!it.id.startsWith("local")) {
|
||||||
|
// A bug on the server when syncing local media progress was replacing the media progress id causing duplicate progress. Remove them.
|
||||||
|
Log.d(tag, "cleanLocalMediaProgress: Invalid local media progress does not start with 'local' (fixed on server 2.0.24)")
|
||||||
|
Paper.book("localMediaProgress").delete(it.id)
|
||||||
|
} else if (matchingLLI == null) {
|
||||||
Log.d(tag, "cleanLocalMediaProgress: No matching local library item for local media progress ${it.id} - removing")
|
Log.d(tag, "cleanLocalMediaProgress: No matching local library item for local media progress ${it.id} - removing")
|
||||||
Paper.book("localMediaProgress").delete(it.id)
|
Paper.book("localMediaProgress").delete(it.id)
|
||||||
} else if (matchingLLI.isPodcast) {
|
} else if (matchingLLI.isPodcast) {
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
package com.audiobookshelf.app.data
|
package com.audiobookshelf.app.data
|
||||||
|
|
||||||
|
import android.support.v4.media.MediaDescriptionCompat
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||||
import com.fasterxml.jackson.annotation.JsonSubTypes
|
import com.fasterxml.jackson.annotation.JsonSubTypes
|
||||||
import com.fasterxml.jackson.annotation.JsonTypeInfo
|
import com.fasterxml.jackson.annotation.JsonTypeInfo
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
data class ServerConnectionConfig(
|
data class ServerConnectionConfig(
|
||||||
var id:String,
|
var id:String,
|
||||||
@@ -13,13 +13,41 @@ data class ServerConnectionConfig(
|
|||||||
var address:String,
|
var address:String,
|
||||||
var userId:String,
|
var userId:String,
|
||||||
var username:String,
|
var username:String,
|
||||||
var token:String
|
var token:String,
|
||||||
|
var customHeaders:Map<String, String>?
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class DeviceSettings(
|
||||||
|
var disableAutoRewind:Boolean,
|
||||||
|
var enableAltView:Boolean,
|
||||||
|
var jumpBackwardsTime:Int,
|
||||||
|
var jumpForwardTime:Int,
|
||||||
|
var disableShakeToResetSleepTimer:Boolean
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
// Static method to get default device settings
|
||||||
|
fun default():DeviceSettings {
|
||||||
|
return DeviceSettings(
|
||||||
|
disableAutoRewind = false,
|
||||||
|
enableAltView = false,
|
||||||
|
jumpBackwardsTime = 10,
|
||||||
|
jumpForwardTime = 10,
|
||||||
|
disableShakeToResetSleepTimer = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@get:JsonIgnore
|
||||||
|
val jumpBackwardsTimeMs get() = (jumpBackwardsTime ?: default().jumpBackwardsTime) * 1000L
|
||||||
|
@get:JsonIgnore
|
||||||
|
val jumpForwardTimeMs get() = (jumpForwardTime ?: default().jumpForwardTime) * 1000L
|
||||||
|
}
|
||||||
|
|
||||||
data class DeviceData(
|
data class DeviceData(
|
||||||
var serverConnectionConfigs:MutableList<ServerConnectionConfig>,
|
var serverConnectionConfigs:MutableList<ServerConnectionConfig>,
|
||||||
var lastServerConnectionConfigId:String?,
|
var lastServerConnectionConfigId:String?,
|
||||||
var currentLocalPlaybackSession:PlaybackSession? // Stored to open up where left off for local media
|
var currentLocalPlaybackSession:PlaybackSession?, // Stored to open up where left off for local media
|
||||||
|
var deviceSettings:DeviceSettings?
|
||||||
) {
|
) {
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun getLastServerConnectionConfig():ServerConnectionConfig? {
|
fun getLastServerConnectionConfig():ServerConnectionConfig? {
|
||||||
@@ -42,6 +70,8 @@ data class LocalFile(
|
|||||||
) {
|
) {
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun isAudioFile():Boolean {
|
fun isAudioFile():Boolean {
|
||||||
|
if (mimeType == "application/octet-stream") return true
|
||||||
|
if (mimeType == "video/mp4") return true
|
||||||
return mimeType?.startsWith("audio") == true
|
return mimeType?.startsWith("audio") == true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -63,4 +93,24 @@ data class LocalFolder(
|
|||||||
JsonSubTypes.Type(LibraryItem::class),
|
JsonSubTypes.Type(LibraryItem::class),
|
||||||
JsonSubTypes.Type(LocalLibraryItem::class)
|
JsonSubTypes.Type(LocalLibraryItem::class)
|
||||||
)
|
)
|
||||||
open class LibraryItemWrapper()
|
open class LibraryItemWrapper(var id:String) {
|
||||||
|
@JsonIgnore
|
||||||
|
open fun getMediaDescription(progress:MediaProgressWrapper?): MediaDescriptionCompat { return MediaDescriptionCompat.Builder().build() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
data class DeviceInfo(
|
||||||
|
var manufacturer:String,
|
||||||
|
var model:String,
|
||||||
|
var brand: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
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/*
|
||||||
|
Used in Android Auto to represent a podcast episode or an audiobook in progress
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.audiobookshelf.app.data
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||||
|
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||||
|
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||||
|
import com.fasterxml.jackson.module.kotlin.readValue
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
data class ItemInProgress(
|
||||||
|
val libraryItemWrapper: LibraryItemWrapper,
|
||||||
|
val episode: PodcastEpisode?,
|
||||||
|
val progressLastUpdate: Long,
|
||||||
|
val isLocal: Boolean
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun makeFromServerObject(serverItem: JSONObject):ItemInProgress {
|
||||||
|
val jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||||
|
val libraryItem = jacksonMapper.readValue<LibraryItem>(serverItem.toString())
|
||||||
|
|
||||||
|
var episode:PodcastEpisode? = null
|
||||||
|
if (serverItem.has("recentEpisode")) {
|
||||||
|
episode = jacksonMapper.readValue<PodcastEpisode>(serverItem.get("recentEpisode").toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
val progressLastUpdate:Long = serverItem.getLong("progressLastUpdate")
|
||||||
|
return ItemInProgress(libraryItem, episode, progressLastUpdate, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package com.audiobookshelf.app.data
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.support.v4.media.MediaDescriptionCompat
|
||||||
|
import androidx.media.utils.MediaConstants
|
||||||
|
import com.audiobookshelf.app.R
|
||||||
|
import com.audiobookshelf.app.device.DeviceManager
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
class LibraryItem(
|
||||||
|
id:String,
|
||||||
|
var ino:String,
|
||||||
|
var libraryId:String,
|
||||||
|
var folderId:String,
|
||||||
|
var path:String,
|
||||||
|
var relPath:String,
|
||||||
|
var mtimeMs:Long,
|
||||||
|
var ctimeMs:Long,
|
||||||
|
var birthtimeMs:Long,
|
||||||
|
var addedAt:Long,
|
||||||
|
var updatedAt:Long,
|
||||||
|
var lastScan:Long?,
|
||||||
|
var scanVersion:String?,
|
||||||
|
var isMissing:Boolean,
|
||||||
|
var isInvalid:Boolean,
|
||||||
|
var mediaType:String,
|
||||||
|
var media:MediaType,
|
||||||
|
var libraryFiles:MutableList<LibraryFile>?,
|
||||||
|
var userMediaProgress:MediaProgress? // Only included when requesting library item with progress (for downloads)
|
||||||
|
) : LibraryItemWrapper(id) {
|
||||||
|
@get:JsonIgnore
|
||||||
|
val title get() = media.metadata.title
|
||||||
|
@get:JsonIgnore
|
||||||
|
val authorName get() = media.metadata.getAuthorDisplayName()
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun getCoverUri(): Uri {
|
||||||
|
if (media.coverPath == null) {
|
||||||
|
return Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Uri.parse("${DeviceManager.serverAddress}/api/items/$id/cover?token=${DeviceManager.token}")
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun checkHasTracks():Boolean {
|
||||||
|
return if (mediaType == "podcast") {
|
||||||
|
((media as Podcast).numEpisodes ?: 0) > 0
|
||||||
|
} else {
|
||||||
|
((media as Book).numTracks ?: 0) > 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
override fun getMediaDescription(progress:MediaProgressWrapper?): MediaDescriptionCompat {
|
||||||
|
val extras = Bundle()
|
||||||
|
if (progress != null) {
|
||||||
|
if (progress.isFinished) {
|
||||||
|
extras.putInt(
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_FULLY_PLAYED
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
extras.putInt(
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_PARTIALLY_PLAYED
|
||||||
|
)
|
||||||
|
extras.putDouble(
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_PERCENTAGE, progress.progress
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else if (mediaType != "podcast") {
|
||||||
|
extras.putInt(
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_NOT_PLAYED
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return MediaDescriptionCompat.Builder()
|
||||||
|
.setMediaId(id)
|
||||||
|
.setTitle(title)
|
||||||
|
.setIconUri(getCoverUri())
|
||||||
|
.setSubtitle(authorName)
|
||||||
|
.setExtras(extras)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
package com.audiobookshelf.app.data
|
package com.audiobookshelf.app.data
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.support.v4.media.MediaMetadataCompat
|
import android.os.Bundle
|
||||||
|
import android.support.v4.media.MediaDescriptionCompat
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import androidx.media.utils.MediaConstants
|
||||||
import com.audiobookshelf.app.R
|
import com.audiobookshelf.app.R
|
||||||
import com.audiobookshelf.app.device.DeviceManager
|
import com.audiobookshelf.app.device.DeviceManager
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||||
@@ -10,8 +12,8 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
|||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
data class LocalLibraryItem(
|
class LocalLibraryItem(
|
||||||
var id:String,
|
id:String,
|
||||||
var folderId:String,
|
var folderId:String,
|
||||||
var basePath:String,
|
var basePath:String,
|
||||||
var absolutePath:String,
|
var absolutePath:String,
|
||||||
@@ -28,7 +30,7 @@ data class LocalLibraryItem(
|
|||||||
var serverAddress:String?,
|
var serverAddress:String?,
|
||||||
var serverUserId:String?,
|
var serverUserId:String?,
|
||||||
var libraryItemId:String?
|
var libraryItemId:String?
|
||||||
) : LibraryItemWrapper() {
|
) : LibraryItemWrapper(id) {
|
||||||
@get:JsonIgnore
|
@get:JsonIgnore
|
||||||
val title get() = media.metadata.title
|
val title get() = media.metadata.title
|
||||||
@get:JsonIgnore
|
@get:JsonIgnore
|
||||||
@@ -44,7 +46,7 @@ data class LocalLibraryItem(
|
|||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun getDuration():Double {
|
fun getDuration():Double {
|
||||||
var total = 0.0
|
var total = 0.0
|
||||||
var audioTracks = media.getAudioTracks()
|
val audioTracks = media.getAudioTracks()
|
||||||
audioTracks.forEach{ total += it.duration }
|
audioTracks.forEach{ total += it.duration }
|
||||||
return total
|
return total
|
||||||
}
|
}
|
||||||
@@ -94,16 +96,38 @@ data class LocalLibraryItem(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun getMediaMetadata(): MediaMetadataCompat {
|
override fun getMediaDescription(progress:MediaProgressWrapper?): MediaDescriptionCompat {
|
||||||
return MediaMetadataCompat.Builder().apply {
|
val coverUri = getCoverUri()
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
|
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title)
|
val extras = Bundle()
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_TITLE, title)
|
if (progress != null) {
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, authorName)
|
if (progress.isFinished) {
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, getCoverUri().toString())
|
extras.putInt(
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getCoverUri().toString())
|
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_ART_URI, getCoverUri().toString())
|
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_FULLY_PLAYED
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, authorName)
|
)
|
||||||
}.build()
|
} else {
|
||||||
|
extras.putInt(
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_PARTIALLY_PLAYED
|
||||||
|
)
|
||||||
|
extras.putDouble(
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_PERCENTAGE, progress.progress
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else if (mediaType != "podcast") {
|
||||||
|
extras.putInt(
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_NOT_PLAYED
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return MediaDescriptionCompat.Builder()
|
||||||
|
.setMediaId(id)
|
||||||
|
.setTitle(title)
|
||||||
|
.setIconUri(coverUri)
|
||||||
|
.setSubtitle(authorName)
|
||||||
|
.setExtras(extras)
|
||||||
|
.build()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,13 +58,13 @@ data class LocalMediaItem(
|
|||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun getLocalLibraryItem():LocalLibraryItem {
|
fun getLocalLibraryItem():LocalLibraryItem {
|
||||||
var mediaMetadata = getMediaMetadata()
|
val mediaMetadata = getMediaMetadata()
|
||||||
if (mediaType == "book") {
|
if (mediaType == "book") {
|
||||||
var chapters = getAudiobookChapters()
|
val chapters = getAudiobookChapters()
|
||||||
var book = Book(mediaMetadata as BookMetadata, coverAbsolutePath, mutableListOf(), mutableListOf(), chapters,audioTracks,getTotalSize(),getDuration())
|
val book = Book(mediaMetadata as BookMetadata, coverAbsolutePath, mutableListOf(), mutableListOf(), chapters,audioTracks,getTotalSize(),getDuration(),audioTracks.size)
|
||||||
return LocalLibraryItem(id, folderId, basePath,absolutePath, contentUrl, false,mediaType, book, localFiles, coverContentUrl, coverAbsolutePath,true,null,null,null,null)
|
return LocalLibraryItem(id, folderId, basePath,absolutePath, contentUrl, false,mediaType, book, localFiles, coverContentUrl, coverAbsolutePath,true,null,null,null,null)
|
||||||
} else {
|
} else {
|
||||||
var podcast = Podcast(mediaMetadata as PodcastMetadata, coverAbsolutePath, mutableListOf(), mutableListOf(), false)
|
val podcast = Podcast(mediaMetadata as PodcastMetadata, coverAbsolutePath, mutableListOf(), mutableListOf(), false, 0)
|
||||||
podcast.setAudioTracks(audioTracks) // Builds episodes from audio tracks
|
podcast.setAudioTracks(audioTracks) // Builds episodes from audio tracks
|
||||||
return LocalLibraryItem(id, folderId, basePath,absolutePath, contentUrl, false, mediaType, podcast,localFiles,coverContentUrl, coverAbsolutePath, true, null,null,null,null)
|
return LocalLibraryItem(id, folderId, basePath,absolutePath, contentUrl, false, mediaType, podcast,localFiles,coverContentUrl, coverAbsolutePath, true, null,null,null,null)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,16 +2,17 @@ package com.audiobookshelf.app.data
|
|||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
data class LocalMediaProgress(
|
class LocalMediaProgress(
|
||||||
var id:String,
|
var id:String,
|
||||||
var localLibraryItemId:String,
|
var localLibraryItemId:String,
|
||||||
var localEpisodeId:String?,
|
var localEpisodeId:String?,
|
||||||
var duration:Double,
|
var duration:Double,
|
||||||
var progress:Double, // 0 to 1
|
progress:Double, // 0 to 1
|
||||||
var currentTime:Double,
|
var currentTime:Double,
|
||||||
var isFinished:Boolean,
|
isFinished:Boolean,
|
||||||
var lastUpdate:Long,
|
var lastUpdate:Long,
|
||||||
var startedAt:Long,
|
var startedAt:Long,
|
||||||
var finishedAt:Long?,
|
var finishedAt:Long?,
|
||||||
@@ -21,7 +22,10 @@ data class LocalMediaProgress(
|
|||||||
var serverUserId:String?,
|
var serverUserId:String?,
|
||||||
var libraryItemId:String?,
|
var libraryItemId:String?,
|
||||||
var episodeId:String?
|
var episodeId:String?
|
||||||
) {
|
) : MediaProgressWrapper(isFinished, progress) {
|
||||||
|
@get:JsonIgnore
|
||||||
|
val progressPercent get() = if (progress.isNaN()) 0 else (progress * 100).roundToInt()
|
||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun updateIsFinished(finished:Boolean) {
|
fun updateIsFinished(finished:Boolean) {
|
||||||
if (isFinished != finished) { // If finished changed then set progress
|
if (isFinished != finished) { // If finished changed then set progress
|
||||||
@@ -42,4 +46,15 @@ data class LocalMediaProgress(
|
|||||||
isFinished = playbackSession.progress >= 0.99
|
isFinished = playbackSession.progress >= 0.99
|
||||||
finishedAt = if (isFinished) lastUpdate else null
|
finishedAt = if (isFinished) lastUpdate else null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun updateFromServerMediaProgress(serverMediaProgress:MediaProgress) {
|
||||||
|
isFinished = serverMediaProgress.isFinished
|
||||||
|
progress = serverMediaProgress.progress
|
||||||
|
currentTime = serverMediaProgress.currentTime
|
||||||
|
duration = serverMediaProgress.duration
|
||||||
|
lastUpdate = serverMediaProgress.lastUpdate
|
||||||
|
finishedAt = serverMediaProgress.finishedAt
|
||||||
|
startedAt = serverMediaProgress.startedAt
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,11 @@ package com.audiobookshelf.app.data
|
|||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.support.v4.media.MediaMetadataCompat
|
import android.support.v4.media.MediaMetadataCompat
|
||||||
import androidx.core.app.NotificationCompat
|
|
||||||
import com.audiobookshelf.app.R
|
import com.audiobookshelf.app.R
|
||||||
import com.audiobookshelf.app.device.DeviceManager
|
import com.audiobookshelf.app.device.DeviceManager
|
||||||
import com.audiobookshelf.app.player.MediaProgressSyncData
|
import com.audiobookshelf.app.player.MediaProgressSyncData
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||||
import com.google.android.exoplayer2.C
|
|
||||||
import com.google.android.exoplayer2.MediaItem
|
import com.google.android.exoplayer2.MediaItem
|
||||||
import com.google.android.exoplayer2.MediaMetadata
|
import com.google.android.exoplayer2.MediaMetadata
|
||||||
import com.google.android.gms.cast.MediaInfo
|
import com.google.android.gms.cast.MediaInfo
|
||||||
@@ -64,28 +62,37 @@ class PlaybackSession(
|
|||||||
val localMediaProgressId get() = if (episodeId.isNullOrEmpty()) localLibraryItemId else "$localLibraryItemId-$localEpisodeId"
|
val localMediaProgressId get() = if (episodeId.isNullOrEmpty()) localLibraryItemId else "$localLibraryItemId-$localEpisodeId"
|
||||||
@get:JsonIgnore
|
@get:JsonIgnore
|
||||||
val progress get() = currentTime / getTotalDuration()
|
val progress get() = currentTime / getTotalDuration()
|
||||||
|
@get:JsonIgnore
|
||||||
|
val isLocalLibraryItemOnly get() = localLibraryItemId != "" && libraryItemId == null
|
||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun getCurrentTrackIndex():Int {
|
fun getCurrentTrackIndex():Int {
|
||||||
for (i in 0..(audioTracks.size - 1)) {
|
for (i in 0 until audioTracks.size) {
|
||||||
var track = audioTracks[i]
|
val track = audioTracks[i]
|
||||||
if (currentTimeMs >= track.startOffsetMs && (track.endOffsetMs) > currentTimeMs) {
|
if (currentTimeMs >= track.startOffsetMs && (track.endOffsetMs > currentTimeMs)) {
|
||||||
return i
|
return i
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return audioTracks.size - 1
|
return audioTracks.size - 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun getChapterForTime(time:Long):BookChapter? {
|
||||||
|
if (chapters.isEmpty()) return null
|
||||||
|
return chapters.find { time >= it.startMs && it.endMs > time}
|
||||||
|
}
|
||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun getCurrentTrackTimeMs():Long {
|
fun getCurrentTrackTimeMs():Long {
|
||||||
var currentTrack = audioTracks[this.getCurrentTrackIndex()]
|
val currentTrack = audioTracks[this.getCurrentTrackIndex()]
|
||||||
var time = currentTime - currentTrack.startOffset
|
val time = currentTime - currentTrack.startOffset
|
||||||
return (time * 1000L).toLong()
|
return (time * 1000L).toLong()
|
||||||
}
|
}
|
||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun getTrackStartOffsetMs(index:Int):Long {
|
fun getTrackStartOffsetMs(index:Int):Long {
|
||||||
var currentTrack = audioTracks[index]
|
if (index < 0 || index >= audioTracks.size) return 0L
|
||||||
|
val currentTrack = audioTracks[index]
|
||||||
return (currentTrack.startOffset * 1000L).toLong()
|
return (currentTrack.startOffset * 1000L).toLong()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,27 +119,25 @@ class PlaybackSession(
|
|||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun getMediaMetadataCompat(): MediaMetadataCompat {
|
fun getMediaMetadataCompat(): MediaMetadataCompat {
|
||||||
var metadataBuilder = MediaMetadataCompat.Builder()
|
val metadataBuilder = MediaMetadataCompat.Builder()
|
||||||
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, displayTitle)
|
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, displayTitle)
|
||||||
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, displayTitle)
|
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, displayTitle)
|
||||||
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, displayAuthor)
|
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, displayAuthor)
|
||||||
.putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, displayAuthor)
|
.putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, displayAuthor)
|
||||||
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, displayAuthor)
|
|
||||||
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, "series")
|
|
||||||
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
|
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
|
||||||
return metadataBuilder.build()
|
return metadataBuilder.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun getExoMediaMetadata(audioTrack:AudioTrack): MediaMetadata {
|
fun getExoMediaMetadata(audioTrack:AudioTrack): MediaMetadata {
|
||||||
var metadataBuilder = MediaMetadata.Builder()
|
val metadataBuilder = MediaMetadata.Builder()
|
||||||
.setTitle(displayTitle)
|
.setTitle(displayTitle)
|
||||||
.setDisplayTitle(displayTitle)
|
.setDisplayTitle(displayTitle)
|
||||||
.setArtist(displayAuthor)
|
.setArtist(displayAuthor)
|
||||||
.setAlbumArtist(displayAuthor)
|
.setAlbumArtist(displayAuthor)
|
||||||
.setSubtitle(displayAuthor)
|
.setSubtitle(displayAuthor)
|
||||||
|
|
||||||
var contentUri = this.getContentUri(audioTrack)
|
val contentUri = this.getContentUri(audioTrack)
|
||||||
metadataBuilder.setMediaUri(contentUri)
|
metadataBuilder.setMediaUri(contentUri)
|
||||||
|
|
||||||
return metadataBuilder.build()
|
return metadataBuilder.build()
|
||||||
@@ -140,15 +145,15 @@ class PlaybackSession(
|
|||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun getMediaItems():List<MediaItem> {
|
fun getMediaItems():List<MediaItem> {
|
||||||
var mediaItems:MutableList<MediaItem> = mutableListOf()
|
val mediaItems:MutableList<MediaItem> = mutableListOf()
|
||||||
|
|
||||||
for (audioTrack in audioTracks) {
|
for (audioTrack in audioTracks) {
|
||||||
var mediaMetadata = this.getExoMediaMetadata(audioTrack)
|
val mediaMetadata = this.getExoMediaMetadata(audioTrack)
|
||||||
var mediaUri = this.getContentUri(audioTrack)
|
val mediaUri = this.getContentUri(audioTrack)
|
||||||
var mimeType = audioTrack.mimeType
|
val mimeType = audioTrack.mimeType
|
||||||
|
|
||||||
var queueItem = getQueueItem(audioTrack) // Queue item used in exo player CastManager
|
val queueItem = getQueueItem(audioTrack) // Queue item used in exo player CastManager
|
||||||
var mediaItem = MediaItem.Builder().setUri(mediaUri).setTag(queueItem).setMediaMetadata(mediaMetadata).setMimeType(mimeType).build()
|
val mediaItem = MediaItem.Builder().setUri(mediaUri).setTag(queueItem).setMediaMetadata(mediaMetadata).setMimeType(mimeType).build()
|
||||||
mediaItems.add(mediaItem)
|
mediaItems.add(mediaItem)
|
||||||
}
|
}
|
||||||
return mediaItems
|
return mediaItems
|
||||||
@@ -156,7 +161,7 @@ class PlaybackSession(
|
|||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun getCastMediaMetadata(audioTrack:AudioTrack):com.google.android.gms.cast.MediaMetadata {
|
fun getCastMediaMetadata(audioTrack:AudioTrack):com.google.android.gms.cast.MediaMetadata {
|
||||||
var castMetadata = com.google.android.gms.cast.MediaMetadata(com.google.android.gms.cast.MediaMetadata.MEDIA_TYPE_AUDIOBOOK_CHAPTER)
|
val castMetadata = com.google.android.gms.cast.MediaMetadata(com.google.android.gms.cast.MediaMetadata.MEDIA_TYPE_AUDIOBOOK_CHAPTER)
|
||||||
|
|
||||||
coverPath?.let {
|
coverPath?.let {
|
||||||
castMetadata.addImage(WebImage(Uri.parse("$serverAddress/api/items/$libraryItemId/cover?token=${DeviceManager.token}")))
|
castMetadata.addImage(WebImage(Uri.parse("$serverAddress/api/items/$libraryItemId/cover?token=${DeviceManager.token}")))
|
||||||
@@ -171,11 +176,11 @@ class PlaybackSession(
|
|||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun getQueueItem(audioTrack:AudioTrack):MediaQueueItem {
|
fun getQueueItem(audioTrack:AudioTrack):MediaQueueItem {
|
||||||
var castMetadata = getCastMediaMetadata(audioTrack)
|
val castMetadata = getCastMediaMetadata(audioTrack)
|
||||||
|
|
||||||
var mediaUri = getContentUri(audioTrack)
|
val mediaUri = getContentUri(audioTrack)
|
||||||
|
|
||||||
var mediaInfo = MediaInfo.Builder(mediaUri.toString()).apply {
|
val mediaInfo = MediaInfo.Builder(mediaUri.toString()).apply {
|
||||||
setContentUrl(mediaUri.toString())
|
setContentUrl(mediaUri.toString())
|
||||||
setContentType(audioTrack.mimeType)
|
setContentType(audioTrack.mimeType)
|
||||||
setMetadata(castMetadata)
|
setMetadata(castMetadata)
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
package com.audiobookshelf.app.device
|
package com.audiobookshelf.app.device
|
||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.audiobookshelf.app.data.DbManager
|
import com.audiobookshelf.app.data.*
|
||||||
import com.audiobookshelf.app.data.DeviceData
|
import com.audiobookshelf.app.player.PlayerNotificationService
|
||||||
import com.audiobookshelf.app.data.ServerConnectionConfig
|
|
||||||
|
interface WidgetEventEmitter {
|
||||||
|
fun onPlayerChanged(pns:PlayerNotificationService)
|
||||||
|
}
|
||||||
|
|
||||||
object DeviceManager {
|
object DeviceManager {
|
||||||
const val tag = "DeviceManager"
|
const val tag = "DeviceManager"
|
||||||
@@ -19,11 +22,18 @@ object DeviceManager {
|
|||||||
val isConnectedToServer get() = serverConnectionConfig != null
|
val isConnectedToServer get() = serverConnectionConfig != null
|
||||||
val hasLastServerConnectionConfig get() = deviceData.getLastServerConnectionConfig() != null
|
val hasLastServerConnectionConfig get() = deviceData.getLastServerConnectionConfig() != null
|
||||||
|
|
||||||
|
var widgetUpdater:WidgetEventEmitter? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
Log.d(tag, "Device Manager Singleton invoked")
|
Log.d(tag, "Device Manager Singleton invoked")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getBase64Id(id:String):String {
|
fun getBase64Id(id:String):String {
|
||||||
return android.util.Base64.encodeToString(id.toByteArray(), android.util.Base64.NO_WRAP)
|
return android.util.Base64.encodeToString(id.toByteArray(), android.util.Base64.URL_SAFE or android.util.Base64.NO_WRAP)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getServerConnectionConfig(id:String?):ServerConnectionConfig? {
|
||||||
|
if (id == null) return null
|
||||||
|
return deviceData.serverConnectionConfigs.find { it.id == id }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import com.fasterxml.jackson.core.json.JsonReadFeature
|
|||||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||||
import com.fasterxml.jackson.module.kotlin.readValue
|
import com.fasterxml.jackson.module.kotlin.readValue
|
||||||
import com.getcapacitor.JSObject
|
import com.getcapacitor.JSObject
|
||||||
|
import org.json.JSONException
|
||||||
|
|
||||||
class FolderScanner(var ctx: Context) {
|
class FolderScanner(var ctx: Context) {
|
||||||
private val tag = "FolderScanner"
|
private val tag = "FolderScanner"
|
||||||
@@ -105,7 +106,7 @@ class FolderScanner(var ctx: Context) {
|
|||||||
var coverContentUrl:String? = null
|
var coverContentUrl:String? = null
|
||||||
var coverAbsolutePath:String? = null
|
var coverAbsolutePath:String? = null
|
||||||
|
|
||||||
val filesInFolder = itemFolder.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4"))
|
val filesInFolder = itemFolder.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4", "application/octet-stream"))
|
||||||
|
|
||||||
val existingLocalFilesRemoved = existingLocalFiles.filter { elf ->
|
val existingLocalFilesRemoved = existingLocalFiles.filter { elf ->
|
||||||
filesInFolder.find { fif -> DeviceManager.getBase64Id(fif.id) == elf.id } == null // File was not found in media item folder
|
filesInFolder.find { fif -> DeviceManager.getBase64Id(fif.id) == elf.id } == null // File was not found in media item folder
|
||||||
@@ -157,11 +158,11 @@ class FolderScanner(var ctx: Context) {
|
|||||||
audioTrackToAdd = existingAudioTrack
|
audioTrackToAdd = existingAudioTrack
|
||||||
} else {
|
} else {
|
||||||
// Create new audio track
|
// Create new audio track
|
||||||
var track = AudioTrack(index, startOffset, audioProbeResult.duration, filename, localFile.contentUrl, mimeType, null, true, localFileId, audioProbeResult, null)
|
val track = AudioTrack(index, startOffset, audioProbeResult?.duration ?: 0.0, filename, localFile.contentUrl, mimeType, null, true, localFileId, audioProbeResult, null)
|
||||||
audioTrackToAdd = track
|
audioTrackToAdd = track
|
||||||
}
|
}
|
||||||
|
|
||||||
startOffset += audioProbeResult.duration
|
startOffset += audioProbeResult?.duration ?: 0.0
|
||||||
isNewOrUpdated = true
|
isNewOrUpdated = true
|
||||||
} else {
|
} else {
|
||||||
audioTrackToAdd = existingAudioTrack
|
audioTrackToAdd = existingAudioTrack
|
||||||
@@ -250,7 +251,8 @@ class FolderScanner(var ctx: Context) {
|
|||||||
Log.d(tag, "scanDownloadItem starting for ${downloadItem.itemFolderPath} | ${df.uri} | Item Folder Id:$itemFolderId | LLI Id:$localLibraryItemId")
|
Log.d(tag, "scanDownloadItem starting for ${downloadItem.itemFolderPath} | ${df.uri} | Item Folder Id:$itemFolderId | LLI Id:$localLibraryItemId")
|
||||||
|
|
||||||
// Search for files in media item folder
|
// Search for files in media item folder
|
||||||
val filesFound = df.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4"))
|
// 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/octet-stream"))
|
||||||
Log.d(tag, "scanDownloadItem ${filesFound.size} files found in ${downloadItem.itemFolderPath}")
|
Log.d(tag, "scanDownloadItem ${filesFound.size} files found in ${downloadItem.itemFolderPath}")
|
||||||
|
|
||||||
var localEpisodeId:String? = null
|
var localEpisodeId:String? = null
|
||||||
@@ -288,7 +290,7 @@ class FolderScanner(var ctx: Context) {
|
|||||||
val audioProbeResult = probeAudioFile(localFile.absolutePath)
|
val audioProbeResult = probeAudioFile(localFile.absolutePath)
|
||||||
|
|
||||||
// Create new audio track
|
// Create new audio track
|
||||||
val track = AudioTrack(audioTrackFromServer?.index ?: -1, audioTrackFromServer?.startOffset ?: 0.0, audioProbeResult.duration, localFile.filename ?: "", localFile.contentUrl, localFile.mimeType ?: "", null, true, localFileId, audioProbeResult, audioTrackFromServer?.index ?: -1)
|
val track = AudioTrack(audioTrackFromServer.index, audioTrackFromServer.startOffset, audioProbeResult?.duration ?: 0.0, localFile.filename ?: "", localFile.contentUrl, localFile.mimeType ?: "", null, true, localFileId, audioProbeResult, audioTrackFromServer.index)
|
||||||
audioTracks.add(track)
|
audioTracks.add(track)
|
||||||
|
|
||||||
Log.d(tag, "scanDownloadItem: Created Audio Track with index ${track.index} from local file ${localFile.absolutePath}")
|
Log.d(tag, "scanDownloadItem: Created Audio Track with index ${track.index} from local file ${localFile.absolutePath}")
|
||||||
@@ -296,7 +298,7 @@ class FolderScanner(var ctx: Context) {
|
|||||||
// Add podcast episodes to library
|
// Add podcast episodes to library
|
||||||
itemPart.episode?.let { podcastEpisode ->
|
itemPart.episode?.let { podcastEpisode ->
|
||||||
val podcast = localLibraryItem.media as Podcast
|
val podcast = localLibraryItem.media as Podcast
|
||||||
var newEpisode = podcast.addEpisode(track, podcastEpisode)
|
val newEpisode = podcast.addEpisode(track, podcastEpisode)
|
||||||
localEpisodeId = newEpisode.id
|
localEpisodeId = newEpisode.id
|
||||||
Log.d(tag, "scanDownloadItem: Added episode to podcast ${podcastEpisode.title} ${track.title} | Track index: ${podcastEpisode.audioTrack?.index}")
|
Log.d(tag, "scanDownloadItem: Added episode to podcast ${podcastEpisode.title} ${track.title} | Track index: ${podcastEpisode.audioTrack?.index}")
|
||||||
}
|
}
|
||||||
@@ -366,7 +368,7 @@ class FolderScanner(var ctx: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun scanLocalLibraryItem(localLibraryItem:LocalLibraryItem, forceAudioProbe:Boolean):LocalLibraryItemScanResult? {
|
fun scanLocalLibraryItem(localLibraryItem:LocalLibraryItem, forceAudioProbe:Boolean):LocalLibraryItemScanResult? {
|
||||||
var df: DocumentFile? = DocumentFileCompat.fromUri(ctx, Uri.parse(localLibraryItem.contentUrl))
|
val df: DocumentFile? = DocumentFileCompat.fromUri(ctx, Uri.parse(localLibraryItem.contentUrl))
|
||||||
|
|
||||||
if (df == null) {
|
if (df == null) {
|
||||||
Log.e(tag, "Item Folder Doc File Invalid ${localLibraryItem.absolutePath}")
|
Log.e(tag, "Item Folder Doc File Invalid ${localLibraryItem.absolutePath}")
|
||||||
@@ -377,7 +379,7 @@ class FolderScanner(var ctx: Context) {
|
|||||||
var wasUpdated = false
|
var wasUpdated = false
|
||||||
|
|
||||||
// Search for files in media item folder
|
// Search for files in media item folder
|
||||||
var filesFound = df.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4"))
|
val filesFound = df.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4", "application/octet-stream"))
|
||||||
Log.d(tag, "scanLocalLibraryItem ${filesFound.size} files found in ${localLibraryItem.absolutePath}")
|
Log.d(tag, "scanLocalLibraryItem ${filesFound.size} files found in ${localLibraryItem.absolutePath}")
|
||||||
|
|
||||||
filesFound.forEach {
|
filesFound.forEach {
|
||||||
@@ -388,10 +390,10 @@ class FolderScanner(var ctx: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var existingAudioTracks = localLibraryItem.media.getAudioTracks()
|
val existingAudioTracks = localLibraryItem.media.getAudioTracks()
|
||||||
|
|
||||||
// Remove any files no longer found in library item folder
|
// Remove any files no longer found in library item folder
|
||||||
var existingLocalFileIds = localLibraryItem.localFiles.map { it.id }
|
val existingLocalFileIds = localLibraryItem.localFiles.map { it.id }
|
||||||
existingLocalFileIds.forEach { localFileId ->
|
existingLocalFileIds.forEach { localFileId ->
|
||||||
Log.d(tag, "Checking local file id is there $localFileId")
|
Log.d(tag, "Checking local file id is there $localFileId")
|
||||||
if (filesFound.find { DeviceManager.getBase64Id(it.id) == localFileId } == null) {
|
if (filesFound.find { DeviceManager.getBase64Id(it.id) == localFileId } == null) {
|
||||||
@@ -407,12 +409,12 @@ class FolderScanner(var ctx: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
filesFound.forEach { docFile ->
|
filesFound.forEach { docFile ->
|
||||||
var localFileId = DeviceManager.getBase64Id(docFile.id)
|
val localFileId = DeviceManager.getBase64Id(docFile.id)
|
||||||
var existingLocalFile = localLibraryItem.localFiles.find { it.id == localFileId }
|
val existingLocalFile = localLibraryItem.localFiles.find { it.id == localFileId }
|
||||||
|
|
||||||
if (existingLocalFile == null || (existingLocalFile.isAudioFile() && forceAudioProbe)) {
|
if (existingLocalFile == null || (existingLocalFile.isAudioFile() && forceAudioProbe)) {
|
||||||
|
|
||||||
var localFile = existingLocalFile ?: LocalFile(localFileId,docFile.name,docFile.uri.toString(),docFile.getBasePath(ctx), docFile.getAbsolutePath(ctx),docFile.getSimplePath(ctx),docFile.mimeType,docFile.length())
|
val localFile = existingLocalFile ?: LocalFile(localFileId,docFile.name,docFile.uri.toString(),docFile.getBasePath(ctx), docFile.getAbsolutePath(ctx),docFile.getSimplePath(ctx),docFile.mimeType,docFile.length())
|
||||||
if (existingLocalFile == null) {
|
if (existingLocalFile == null) {
|
||||||
localLibraryItem.localFiles.add(localFile)
|
localLibraryItem.localFiles.add(localFile)
|
||||||
Log.d(tag, "scanLocalLibraryItem new file found ${localFile.filename}")
|
Log.d(tag, "scanLocalLibraryItem new file found ${localFile.filename}")
|
||||||
@@ -420,22 +422,26 @@ class FolderScanner(var ctx: Context) {
|
|||||||
|
|
||||||
if (localFile.isAudioFile()) {
|
if (localFile.isAudioFile()) {
|
||||||
// TODO: Make asynchronous
|
// TODO: Make asynchronous
|
||||||
var audioProbeResult = probeAudioFile(localFile.absolutePath)
|
val audioProbeResult = probeAudioFile(localFile.absolutePath)
|
||||||
|
|
||||||
var existingTrack = existingAudioTracks.find { audioTrack ->
|
val existingTrack = existingAudioTracks.find { audioTrack ->
|
||||||
audioTrack.localFileId == localFile.id
|
audioTrack.localFileId == localFile.id
|
||||||
}
|
}
|
||||||
|
|
||||||
if (existingTrack == null) {
|
if (existingTrack == null) {
|
||||||
// Create new audio track
|
// Create new audio track
|
||||||
var lastTrack = existingAudioTracks.lastOrNull()
|
val lastTrack = existingAudioTracks.lastOrNull()
|
||||||
var startOffset = (lastTrack?.startOffset ?: 0.0) + (lastTrack?.duration ?: 0.0)
|
val startOffset = (lastTrack?.startOffset ?: 0.0) + (lastTrack?.duration ?: 0.0)
|
||||||
var track = AudioTrack(existingAudioTracks.size, startOffset, audioProbeResult.duration, localFile.filename ?: "", localFile.contentUrl, localFile.mimeType ?: "", null, true, localFileId, audioProbeResult, null)
|
val track = AudioTrack(existingAudioTracks.size, startOffset, audioProbeResult?.duration ?: 0.0, localFile.filename ?: "", localFile.contentUrl, localFile.mimeType ?: "", null, true, localFileId, audioProbeResult, null)
|
||||||
localLibraryItem.media.addAudioTrack(track)
|
localLibraryItem.media.addAudioTrack(track)
|
||||||
|
Log.d(tag, "Added New Audio Track ${track.title}")
|
||||||
wasUpdated = true
|
wasUpdated = true
|
||||||
} else {
|
} else {
|
||||||
existingTrack.audioProbeResult = audioProbeResult
|
existingTrack.audioProbeResult = audioProbeResult
|
||||||
// TODO: Update data found from probe
|
// TODO: Update data found from probe
|
||||||
|
|
||||||
|
Log.d(tag, "Updated Audio Track Probe Data ${existingTrack.title}")
|
||||||
|
|
||||||
wasUpdated = true
|
wasUpdated = true
|
||||||
}
|
}
|
||||||
} else { // Check if cover is empty
|
} else { // Check if cover is empty
|
||||||
@@ -458,12 +464,24 @@ class FolderScanner(var ctx: Context) {
|
|||||||
return LocalLibraryItemScanResult(wasUpdated, localLibraryItem)
|
return LocalLibraryItemScanResult(wasUpdated, localLibraryItem)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun probeAudioFile(absolutePath:String):AudioProbeResult {
|
fun probeAudioFile(absolutePath:String):AudioProbeResult? {
|
||||||
var session = FFprobeKit.execute("-i \"${absolutePath}\" -print_format json -show_format -show_streams -select_streams a -show_chapters -loglevel quiet")
|
val session = FFprobeKit.execute("-i \"${absolutePath}\" -print_format json -show_format -show_streams -select_streams a -show_chapters -loglevel quiet")
|
||||||
Log.d(tag, "FFprobe output ${JSObject(session.output)}")
|
|
||||||
|
|
||||||
val audioProbeResult = jacksonMapper.readValue<AudioProbeResult>(session.output)
|
var probeObject:JSObject? = null
|
||||||
Log.d(tag, "Probe Result DATA ${audioProbeResult.duration} | ${audioProbeResult.size} | ${audioProbeResult.title} | ${audioProbeResult.artist}")
|
try {
|
||||||
return audioProbeResult
|
probeObject = JSObject(session.output)
|
||||||
|
} catch(error:JSONException) {
|
||||||
|
Log.e(tag, "Failed to parse probe result $error")
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(tag, "FFprobe output $probeObject")
|
||||||
|
if (probeObject == null || !probeObject.has("streams")) { // Check if output is empty
|
||||||
|
Log.d(tag, "probeAudioFile Probe audio file $absolutePath failed or invalid")
|
||||||
|
return null
|
||||||
|
} else {
|
||||||
|
val audioProbeResult = jacksonMapper.readValue<AudioProbeResult>(session.output)
|
||||||
|
Log.d(tag, "Probe Result DATA ${audioProbeResult.duration} | ${audioProbeResult.size} | ${audioProbeResult.title} | ${audioProbeResult.artist}")
|
||||||
|
return audioProbeResult
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,49 +1,172 @@
|
|||||||
package com.audiobookshelf.app.media
|
package com.audiobookshelf.app.media
|
||||||
|
|
||||||
import android.bluetooth.BluetoothClass
|
import android.app.Activity
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.support.v4.media.MediaBrowserCompat
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.audiobookshelf.app.data.*
|
import com.audiobookshelf.app.data.*
|
||||||
import com.audiobookshelf.app.device.DeviceManager
|
import com.audiobookshelf.app.device.DeviceManager
|
||||||
import com.audiobookshelf.app.player.PlayerNotificationService
|
|
||||||
import com.audiobookshelf.app.server.ApiHandler
|
import com.audiobookshelf.app.server.ApiHandler
|
||||||
|
import com.getcapacitor.JSObject
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import io.paperdb.Paper
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.json.JSONException
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
import kotlin.coroutines.suspendCoroutine
|
||||||
|
|
||||||
class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
|
class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
|
||||||
val tag = "MediaManager"
|
val tag = "MediaManager"
|
||||||
|
|
||||||
var serverLibraryItems = listOf<LibraryItem>()
|
var serverLibraryItems = mutableListOf<LibraryItem>() // Store all items here
|
||||||
var serverLibraryCategories = listOf<LibraryCategory>()
|
var selectedLibraryItems = mutableListOf<LibraryItem>()
|
||||||
var serverLibraries = listOf<Library>()
|
var selectedLibraryId = ""
|
||||||
|
|
||||||
fun initializeAndroidAuto() {
|
var selectedPodcast:Podcast? = null
|
||||||
Log.d(tag, "Android Auto started when MainActivity was never started - initializing Paper")
|
var selectedLibraryItemId:String? = null
|
||||||
Paper.init(ctx)
|
var podcastEpisodeLibraryItemMap = mutableMapOf<String, LibraryItemWithEpisode>()
|
||||||
|
var serverLibraryCategories = listOf<LibraryCategory>()
|
||||||
|
var serverItemsInProgress = listOf<ItemInProgress>()
|
||||||
|
var serverLibraries = listOf<Library>()
|
||||||
|
var serverConfigIdUsed:String? = null
|
||||||
|
var serverConfigLastPing:Long = 0L
|
||||||
|
var serverUserMediaProgress:MutableList<MediaProgress> = mutableListOf()
|
||||||
|
|
||||||
|
var userSettingsPlaybackRate:Float? = null
|
||||||
|
|
||||||
|
fun getIsLibrary(id:String) : Boolean {
|
||||||
|
return serverLibraries.find { it.id == id } != null
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadLibraryCategories(libraryId:String, cb: (List<LibraryCategory>) -> Unit) {
|
fun getSavedPlaybackRate():Float {
|
||||||
if (serverLibraryCategories.isNotEmpty()) {
|
if (userSettingsPlaybackRate != null) {
|
||||||
cb(serverLibraryCategories)
|
return userSettingsPlaybackRate ?: 1f
|
||||||
|
}
|
||||||
|
|
||||||
|
val sharedPrefs = ctx.getSharedPreferences("CapacitorStorage", Activity.MODE_PRIVATE)
|
||||||
|
if (sharedPrefs != null) {
|
||||||
|
val userSettingsPref = sharedPrefs.getString("userSettings", null)
|
||||||
|
if (userSettingsPref != null) {
|
||||||
|
try {
|
||||||
|
val userSettings = JSObject(userSettingsPref)
|
||||||
|
if (userSettings.has("playbackRate")) {
|
||||||
|
userSettingsPlaybackRate = userSettings.getDouble("playbackRate").toFloat()
|
||||||
|
return userSettingsPlaybackRate ?: 1f
|
||||||
|
}
|
||||||
|
} catch(je:JSONException) {
|
||||||
|
Log.e(tag, "Failed to parse userSettings JSON ${je.localizedMessage}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 1f
|
||||||
|
}
|
||||||
|
|
||||||
|
fun checkResetServerItems() {
|
||||||
|
// When opening android auto need to check if still connected to server
|
||||||
|
// and reset any server data already set
|
||||||
|
val serverConnConfig = if (DeviceManager.isConnectedToServer) DeviceManager.serverConnectionConfig else DeviceManager.deviceData.getLastServerConnectionConfig()
|
||||||
|
|
||||||
|
if (!DeviceManager.isConnectedToServer || !apiHandler.isOnline() || serverConnConfig == null || serverConnConfig.id !== serverConfigIdUsed) {
|
||||||
|
podcastEpisodeLibraryItemMap = mutableMapOf()
|
||||||
|
serverLibraryCategories = listOf()
|
||||||
|
serverLibraries = listOf()
|
||||||
|
serverLibraryItems = mutableListOf()
|
||||||
|
selectedLibraryItems = mutableListOf()
|
||||||
|
selectedLibraryId = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadItemsInProgressForAllLibraries(cb: (List<ItemInProgress>) -> Unit) {
|
||||||
|
if (serverItemsInProgress.isNotEmpty()) {
|
||||||
|
cb(serverItemsInProgress)
|
||||||
} else {
|
} else {
|
||||||
apiHandler.getLibraryCategories(libraryId) {
|
apiHandler.getAllItemsInProgress { itemsInProgress ->
|
||||||
serverLibraryCategories = it
|
serverItemsInProgress = itemsInProgress
|
||||||
cb(it)
|
cb(serverItemsInProgress)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadLibraryItems(libraryId:String, cb: (List<LibraryItem>) -> Unit) {
|
fun loadLibraryItemsWithAudio(libraryId:String, cb: (List<LibraryItem>) -> Unit) {
|
||||||
if (serverLibraryItems.isNotEmpty()) {
|
if (selectedLibraryItems.isNotEmpty() && selectedLibraryId == libraryId) {
|
||||||
cb(serverLibraryItems)
|
cb(selectedLibraryItems)
|
||||||
} else {
|
} else {
|
||||||
apiHandler.getLibraryItems(libraryId) { libraryItems ->
|
apiHandler.getLibraryItems(libraryId) { libraryItems ->
|
||||||
serverLibraryItems = libraryItems
|
val libraryItemsWithAudio = libraryItems.filter { li -> li.checkHasTracks() }
|
||||||
cb(libraryItems)
|
if (libraryItemsWithAudio.isNotEmpty()) {
|
||||||
|
selectedLibraryId = libraryId
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedLibraryItems = mutableListOf()
|
||||||
|
libraryItemsWithAudio.forEach { libraryItem ->
|
||||||
|
selectedLibraryItems.add(libraryItem)
|
||||||
|
if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) {
|
||||||
|
serverLibraryItems.add(libraryItem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cb(libraryItemsWithAudio)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun loadLibraryItem(libraryItemId:String, cb: (LibraryItemWrapper?) -> Unit) {
|
||||||
|
if (libraryItemId.startsWith("local")) {
|
||||||
|
cb(DeviceManager.dbManager.getLocalLibraryItem(libraryItemId))
|
||||||
|
} else {
|
||||||
|
Log.d(tag, "loadLibraryItem: $libraryItemId")
|
||||||
|
apiHandler.getLibraryItem(libraryItemId) { libraryItem ->
|
||||||
|
Log.d(tag, "loadLibraryItem: Got library item $libraryItem")
|
||||||
|
cb(libraryItem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadPodcastEpisodeMediaBrowserItems(libraryItemId:String, cb: (MutableList<MediaBrowserCompat.MediaItem>) -> Unit) {
|
||||||
|
loadLibraryItem(libraryItemId) { libraryItemWrapper ->
|
||||||
|
Log.d(tag, "Loaded Podcast library item $libraryItemWrapper")
|
||||||
|
|
||||||
|
libraryItemWrapper?.let {
|
||||||
|
if (libraryItemWrapper is LocalLibraryItem) { // Local podcast episodes
|
||||||
|
if (libraryItemWrapper.mediaType != "podcast" || libraryItemWrapper.media.getAudioTracks().isEmpty()) {
|
||||||
|
cb(mutableListOf())
|
||||||
|
} else {
|
||||||
|
val podcast = libraryItemWrapper.media as Podcast
|
||||||
|
selectedLibraryItemId = libraryItemWrapper.id
|
||||||
|
selectedPodcast = podcast
|
||||||
|
|
||||||
|
val children = podcast.episodes?.map { podcastEpisode ->
|
||||||
|
Log.d(tag, "Local Podcast Episode ${podcastEpisode.title} | ${podcastEpisode.id}")
|
||||||
|
|
||||||
|
val progress = DeviceManager.dbManager.getLocalMediaProgress("${libraryItemWrapper.id}-${podcastEpisode.id}")
|
||||||
|
val description = podcastEpisode.getMediaDescription(libraryItemWrapper, progress)
|
||||||
|
MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
||||||
|
}
|
||||||
|
children?.let { cb(children as MutableList) } ?: cb(mutableListOf())
|
||||||
|
}
|
||||||
|
} else if (libraryItemWrapper is LibraryItem) { // Server podcast episodes
|
||||||
|
if (libraryItemWrapper.mediaType != "podcast" || libraryItemWrapper.media.getAudioTracks().isEmpty()) {
|
||||||
|
cb(mutableListOf())
|
||||||
|
} else {
|
||||||
|
val podcast = libraryItemWrapper.media as Podcast
|
||||||
|
podcast.episodes?.forEach { podcastEpisode ->
|
||||||
|
podcastEpisodeLibraryItemMap[podcastEpisode.id] = LibraryItemWithEpisode(libraryItemWrapper, podcastEpisode)
|
||||||
|
}
|
||||||
|
selectedLibraryItemId = libraryItemWrapper.id
|
||||||
|
selectedPodcast = podcast
|
||||||
|
|
||||||
|
val children = podcast.episodes?.map { podcastEpisode ->
|
||||||
|
|
||||||
|
val progress = serverUserMediaProgress.find { it.libraryItemId == libraryItemWrapper.id && it.episodeId == podcastEpisode.id }
|
||||||
|
val description = podcastEpisode.getMediaDescription(libraryItemWrapper, progress)
|
||||||
|
MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
||||||
|
}
|
||||||
|
children?.let { cb(children as MutableList) } ?: cb(mutableListOf())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun loadLibraries(cb: (List<Library>) -> Unit) {
|
fun loadLibraries(cb: (List<Library>) -> Unit) {
|
||||||
if (serverLibraries.isNotEmpty()) {
|
if (serverLibraries.isNotEmpty()) {
|
||||||
cb(serverLibraries)
|
cb(serverLibraries)
|
||||||
@@ -55,59 +178,129 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Load currently listening category for local items
|
suspend fun checkServerConnection(config:ServerConnectionConfig) : Boolean {
|
||||||
fun loadLocalCategory():List<LibraryCategory> {
|
var successfulPing = false
|
||||||
var localBooks = DeviceManager.dbManager.getLocalLibraryItems("book")
|
suspendCoroutine<Boolean> { cont ->
|
||||||
var localPodcasts = DeviceManager.dbManager.getLocalLibraryItems("podcast")
|
apiHandler.pingServer(config) {
|
||||||
var cats = mutableListOf<LibraryCategory>()
|
Log.d(tag, "checkServerConnection: Checked server conn for ${config.address} result = $it")
|
||||||
if (localBooks.isNotEmpty()) {
|
successfulPing = it
|
||||||
cats.add(LibraryCategory("local-books", "Local Books", "book", localBooks, true))
|
cont.resume(it)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (localPodcasts.isNotEmpty()) {
|
return successfulPing
|
||||||
cats.add(LibraryCategory("local-podcasts", "Local Podcasts", "podcast", localPodcasts, true))
|
|
||||||
}
|
|
||||||
return cats
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadAndroidAutoItems(libraryId:String, cb: (List<LibraryCategory>) -> Unit) {
|
suspend fun authorize(config:ServerConnectionConfig) : MutableList<MediaProgress> {
|
||||||
Log.d(tag, "Load android auto items for library id $libraryId")
|
var mediaProgress:MutableList<MediaProgress> = mutableListOf()
|
||||||
var cats = mutableListOf<LibraryCategory>()
|
suspendCoroutine<MutableList<MediaProgress>> { cont ->
|
||||||
|
apiHandler.authorize(config) {
|
||||||
var localCategories = loadLocalCategory()
|
Log.d(tag, "authorize: Authorized server config ${config.address} result = $it")
|
||||||
cats.addAll(localCategories)
|
if (!it.isNullOrEmpty()) {
|
||||||
|
mediaProgress = it
|
||||||
// Connected to server and has internet - load other cats
|
}
|
||||||
if (apiHandler.isOnline() && (DeviceManager.isConnectedToServer || DeviceManager.hasLastServerConnectionConfig)) {
|
cont.resume(mediaProgress)
|
||||||
if (!DeviceManager.isConnectedToServer) {
|
|
||||||
DeviceManager.serverConnectionConfig = DeviceManager.deviceData.getLastServerConnectionConfig()
|
|
||||||
Log.d(tag, "Not connected to server, set last server \"${DeviceManager.serverAddress}\"")
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
return mediaProgress
|
||||||
|
}
|
||||||
|
|
||||||
loadLibraries { libraries ->
|
fun checkSetValidServerConnectionConfig(cb: (Boolean) -> Unit) = runBlocking {
|
||||||
var library = libraries.find { it.id == libraryId } ?: libraries[0]
|
Log.d(tag, "checkSetValidServerConnectionConfig | $serverConfigIdUsed")
|
||||||
Log.d(tag, "Loading categories for library ${library.name} - ${library.id} - ${library.mediaType}")
|
|
||||||
|
|
||||||
loadLibraryCategories(libraryId) { libraryCategories ->
|
coroutineScope {
|
||||||
|
if (!apiHandler.isOnline()) {
|
||||||
|
serverUserMediaProgress = mutableListOf()
|
||||||
|
cb(false)
|
||||||
|
} else {
|
||||||
|
|
||||||
// Only using book or podcast library categories for now
|
var hasValidConn = false
|
||||||
libraryCategories.forEach {
|
var lookupMediaProgress = true
|
||||||
Log.d(tag, "Found library category ${it.label} with type ${it.type}")
|
|
||||||
if (it.type == library.mediaType) {
|
|
||||||
Log.d(tag, "Using library category ${it.id}")
|
|
||||||
cats.add(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loadLibraryItems(libraryId) { libraryItems ->
|
if (!serverConfigIdUsed.isNullOrEmpty() && serverConfigLastPing > 0L && System.currentTimeMillis() - serverConfigLastPing < 5000) {
|
||||||
var mainCat = LibraryCategory("library", "Library", library.mediaType, libraryItems, false)
|
Log.d(tag, "checkSetValidServerConnectionConfig last ping less than a 5 seconds ago")
|
||||||
cats.add(mainCat)
|
hasValidConn = true
|
||||||
|
lookupMediaProgress = false
|
||||||
|
} else {
|
||||||
|
serverUserMediaProgress = mutableListOf()
|
||||||
|
}
|
||||||
|
|
||||||
cb(cats)
|
if (!hasValidConn) {
|
||||||
|
// First check if the current selected config is pingable
|
||||||
|
DeviceManager.serverConnectionConfig?.let {
|
||||||
|
hasValidConn = checkServerConnection(it)
|
||||||
|
Log.d(
|
||||||
|
tag,
|
||||||
|
"checkSetValidServerConnectionConfig: Current config ${DeviceManager.serverAddress} is pingable? $hasValidConn"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!hasValidConn) {
|
||||||
|
// Loop through available configs and check if can connect
|
||||||
|
for (config: ServerConnectionConfig in DeviceManager.deviceData.serverConnectionConfigs) {
|
||||||
|
val result = checkServerConnection(config)
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
hasValidConn = true
|
||||||
|
DeviceManager.serverConnectionConfig = config
|
||||||
|
Log.d(tag, "checkSetValidServerConnectionConfig: Set server connection config ${DeviceManager.serverConnectionConfigId}")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasValidConn) {
|
||||||
|
serverConfigLastPing = System.currentTimeMillis()
|
||||||
|
|
||||||
|
if (lookupMediaProgress) {
|
||||||
|
Log.d(tag, "Has valid conn now get user media progress")
|
||||||
|
DeviceManager.serverConnectionConfig?.let {
|
||||||
|
serverUserMediaProgress = authorize(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cb(hasValidConn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadAndroidAutoItems(cb: () -> Unit) {
|
||||||
|
Log.d(tag, "Load android auto items")
|
||||||
|
|
||||||
|
// Check if any valid server connection if not use locally downloaded books
|
||||||
|
checkSetValidServerConnectionConfig { isConnected ->
|
||||||
|
if (isConnected) {
|
||||||
|
serverConfigIdUsed = DeviceManager.serverConnectionConfigId
|
||||||
|
|
||||||
|
loadLibraries { libraries ->
|
||||||
|
if (libraries.isEmpty()) {
|
||||||
|
Log.w(tag, "No libraries returned from server request")
|
||||||
|
cb()
|
||||||
|
} else {
|
||||||
|
val library = libraries[0]
|
||||||
|
Log.d(tag, "Loading categories for library ${library.name} - ${library.id} - ${library.mediaType}")
|
||||||
|
|
||||||
|
loadItemsInProgressForAllLibraries { itemsInProgress ->
|
||||||
|
itemsInProgress.forEach {
|
||||||
|
val libraryItem = it.libraryItemWrapper as LibraryItem
|
||||||
|
if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) {
|
||||||
|
serverLibraryItems.add(libraryItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (it.episode != null) {
|
||||||
|
podcastEpisodeLibraryItemMap[it.episode.id] = LibraryItemWithEpisode(it.libraryItemWrapper, it.episode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cb() // Fully loaded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else { // Not connected to server
|
||||||
|
cb()
|
||||||
}
|
}
|
||||||
} else { // Not connected/no internet sent downloaded cats only
|
|
||||||
cb(cats)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,11 +308,19 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
|
|||||||
if (serverLibraryItems.isNotEmpty()) {
|
if (serverLibraryItems.isNotEmpty()) {
|
||||||
return serverLibraryItems[0]
|
return serverLibraryItems[0]
|
||||||
} else {
|
} else {
|
||||||
var localBooks = DeviceManager.dbManager.getLocalLibraryItems("book")
|
val localBooks = DeviceManager.dbManager.getLocalLibraryItems("book")
|
||||||
return if (localBooks.isNotEmpty()) return localBooks[0] else null
|
return if (localBooks.isNotEmpty()) return localBooks[0] else null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getPodcastWithEpisodeByEpisodeId(id:String) : LibraryItemWithEpisode? {
|
||||||
|
if (id.startsWith("local")) {
|
||||||
|
return DeviceManager.dbManager.getLocalLibraryItemWithEpisode(id)
|
||||||
|
} else {
|
||||||
|
return podcastEpisodeLibraryItemMap[id]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun getById(id:String) : LibraryItemWrapper? {
|
fun getById(id:String) : LibraryItemWrapper? {
|
||||||
if (id.startsWith("local")) {
|
if (id.startsWith("local")) {
|
||||||
return DeviceManager.dbManager.getLocalLibraryItem(id)
|
return DeviceManager.dbManager.getLocalLibraryItem(id)
|
||||||
@@ -135,16 +336,19 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun play(libraryItemWrapper:LibraryItemWrapper, mediaPlayer:String, cb: (PlaybackSession) -> Unit) {
|
fun play(libraryItemWrapper:LibraryItemWrapper, episode:PodcastEpisode?, playItemRequestPayload:PlayItemRequestPayload, cb: (PlaybackSession?) -> Unit) {
|
||||||
if (libraryItemWrapper is LocalLibraryItem) {
|
if (libraryItemWrapper is LocalLibraryItem) {
|
||||||
var localLibraryItem = libraryItemWrapper as LocalLibraryItem
|
cb(libraryItemWrapper.getPlaybackSession(episode))
|
||||||
cb(localLibraryItem.getPlaybackSession(null))
|
} else {
|
||||||
} else {
|
val libraryItem = libraryItemWrapper as LibraryItem
|
||||||
var libraryItem = libraryItemWrapper as LibraryItem
|
apiHandler.playLibraryItem(libraryItem.id,episode?.id ?: "", playItemRequestPayload) {
|
||||||
apiHandler.playLibraryItem(libraryItem.id,"",false, mediaPlayer) {
|
if (it == null) {
|
||||||
cb(it)
|
cb(null)
|
||||||
}
|
} else {
|
||||||
}
|
cb(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun levenshtein(lhs : CharSequence, rhs : CharSequence) : Int {
|
private fun levenshtein(lhs : CharSequence, rhs : CharSequence) : Int {
|
||||||
|
|||||||
@@ -5,9 +5,6 @@ import android.graphics.Bitmap
|
|||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.support.v4.media.session.MediaControllerCompat
|
import android.support.v4.media.session.MediaControllerCompat
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.documentfile.provider.DocumentFile
|
|
||||||
import com.anggrayudi.storage.file.getAbsolutePath
|
|
||||||
import com.anggrayudi.storage.file.toRawFile
|
|
||||||
import com.audiobookshelf.app.R
|
import com.audiobookshelf.app.R
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||||
@@ -24,9 +21,6 @@ class AbMediaDescriptionAdapter constructor(private val controller: MediaControl
|
|||||||
var currentIconUri: Uri? = null
|
var currentIconUri: Uri? = null
|
||||||
var currentBitmap: Bitmap? = null
|
var currentBitmap: Bitmap? = null
|
||||||
|
|
||||||
private val glideOptions = RequestOptions()
|
|
||||||
.fallback(R.drawable.icon)
|
|
||||||
.diskCacheStrategy(DiskCacheStrategy.DATA)
|
|
||||||
private val serviceJob = SupervisorJob()
|
private val serviceJob = SupervisorJob()
|
||||||
private val serviceScope = CoroutineScope(Dispatchers.Main + serviceJob)
|
private val serviceScope = CoroutineScope(Dispatchers.Main + serviceJob)
|
||||||
|
|
||||||
@@ -62,26 +56,10 @@ class AbMediaDescriptionAdapter constructor(private val controller: MediaControl
|
|||||||
|
|
||||||
private suspend fun resolveUriAsBitmap(uri: Uri): Bitmap? {
|
private suspend fun resolveUriAsBitmap(uri: Uri): Bitmap? {
|
||||||
return withContext(Dispatchers.IO) {
|
return withContext(Dispatchers.IO) {
|
||||||
// Block on downloading artwork.
|
|
||||||
val context = playerNotificationService.getContext()
|
|
||||||
|
|
||||||
// Fix attempt for #35 local cover crashing
|
|
||||||
// Convert content uri to a file and pass to Glide
|
|
||||||
var urival:Any = uri
|
|
||||||
if (uri.toString().startsWith("content:")) {
|
|
||||||
val imageDocFile = DocumentFile.fromSingleUri(context, uri)
|
|
||||||
Log.d(tag, "Converting local content url $uri to file with path ${imageDocFile?.getAbsolutePath(context)}")
|
|
||||||
val file = imageDocFile?.toRawFile(context)
|
|
||||||
file?.let {
|
|
||||||
Log.d(tag, "Using local file image instead of content uri ${it.absolutePath}")
|
|
||||||
urival = it
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Glide.with(context).applyDefaultRequestOptions(glideOptions)
|
Glide.with(playerNotificationService)
|
||||||
.asBitmap()
|
.asBitmap()
|
||||||
.load(urival)
|
.load(uri)
|
||||||
.placeholder(R.drawable.icon)
|
.placeholder(R.drawable.icon)
|
||||||
.error(R.drawable.icon)
|
.error(R.drawable.icon)
|
||||||
.submit(NOTIFICATION_LARGE_ICON_SIZE, NOTIFICATION_LARGE_ICON_SIZE)
|
.submit(NOTIFICATION_LARGE_ICON_SIZE, NOTIFICATION_LARGE_ICON_SIZE)
|
||||||
@@ -89,7 +67,7 @@ class AbMediaDescriptionAdapter constructor(private val controller: MediaControl
|
|||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
|
|
||||||
Glide.with(context).applyDefaultRequestOptions(glideOptions)
|
Glide.with(playerNotificationService)
|
||||||
.asBitmap()
|
.asBitmap()
|
||||||
.load(Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon))
|
.load(Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon))
|
||||||
.submit(NOTIFICATION_LARGE_ICON_SIZE, NOTIFICATION_LARGE_ICON_SIZE)
|
.submit(NOTIFICATION_LARGE_ICON_SIZE, NOTIFICATION_LARGE_ICON_SIZE)
|
||||||
|
|||||||
@@ -4,17 +4,14 @@ import android.content.ContentResolver
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.support.v4.media.MediaMetadataCompat
|
import android.support.v4.media.MediaMetadataCompat
|
||||||
import android.util.Log
|
|
||||||
import androidx.annotation.AnyRes
|
import androidx.annotation.AnyRes
|
||||||
import com.audiobookshelf.app.R
|
import com.audiobookshelf.app.R
|
||||||
import com.audiobookshelf.app.data.LibraryCategory
|
import com.audiobookshelf.app.data.*
|
||||||
import com.audiobookshelf.app.data.LibraryItem
|
|
||||||
import com.audiobookshelf.app.data.LocalLibraryItem
|
|
||||||
|
|
||||||
|
|
||||||
class BrowseTree(
|
class BrowseTree(
|
||||||
val context: Context,
|
val context: Context,
|
||||||
libraryCategories: List<LibraryCategory>
|
itemsInProgress: List<ItemInProgress>,
|
||||||
|
libraries: List<Library>
|
||||||
) {
|
) {
|
||||||
private val mediaIdToChildren = mutableMapOf<String, MutableList<MediaMetadataCompat>>()
|
private val mediaIdToChildren = mutableMapOf<String, MutableList<MediaMetadataCompat>>()
|
||||||
|
|
||||||
@@ -24,8 +21,7 @@ class BrowseTree(
|
|||||||
* @param drawableId - drawable res id
|
* @param drawableId - drawable res id
|
||||||
* @return - uri
|
* @return - uri
|
||||||
*/
|
*/
|
||||||
fun getUriToDrawable(context: Context,
|
fun getUriToDrawable(@AnyRes drawableId: Int): Uri {
|
||||||
@AnyRes drawableId: Int): Uri {
|
|
||||||
return Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE
|
return Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE
|
||||||
+ "://" + context.resources.getResourcePackageName(drawableId)
|
+ "://" + context.resources.getResourcePackageName(drawableId)
|
||||||
+ '/' + context.resources.getResourceTypeName(drawableId)
|
+ '/' + context.resources.getResourceTypeName(drawableId)
|
||||||
@@ -35,68 +31,41 @@ class BrowseTree(
|
|||||||
init {
|
init {
|
||||||
val rootList = mediaIdToChildren[AUTO_BROWSE_ROOT] ?: mutableListOf()
|
val rootList = mediaIdToChildren[AUTO_BROWSE_ROOT] ?: mutableListOf()
|
||||||
|
|
||||||
val continueReadingMetadata = MediaMetadataCompat.Builder().apply {
|
val continueListeningMetadata = MediaMetadataCompat.Builder().apply {
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, CONTINUE_ROOT)
|
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, CONTINUE_ROOT)
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Listening")
|
putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Listening")
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.exo_icon_localaudio).toString())
|
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(R.drawable.exo_icon_localaudio).toString())
|
||||||
}.build()
|
|
||||||
|
|
||||||
val allMetadata = MediaMetadataCompat.Builder().apply {
|
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, ALL_ROOT)
|
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Library Items")
|
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.exo_icon_books).toString())
|
|
||||||
}.build()
|
}.build()
|
||||||
|
|
||||||
val downloadsMetadata = MediaMetadataCompat.Builder().apply {
|
val downloadsMetadata = MediaMetadataCompat.Builder().apply {
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, DOWNLOADS_ROOT)
|
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, DOWNLOADS_ROOT)
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Downloads")
|
putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Downloads")
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.exo_icon_downloaddone).toString())
|
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(R.drawable.exo_icon_downloaddone).toString())
|
||||||
}.build()
|
}.build()
|
||||||
|
|
||||||
// Server continue Listening cat
|
val librariesMetadata = MediaMetadataCompat.Builder().apply {
|
||||||
libraryCategories.find { it.id == "continue-listening" }?.let { continueListeningCategory ->
|
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, LIBRARIES_ROOT)
|
||||||
var continueListeningMediaMetadata = continueListeningCategory.entities.map { liw ->
|
putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Libraries")
|
||||||
var libraryItem = liw as LibraryItem
|
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(R.drawable.icon_library_folder).toString())
|
||||||
libraryItem.getMediaMetadata()
|
}.build()
|
||||||
}
|
|
||||||
if (continueListeningMediaMetadata.isNotEmpty()) {
|
if (!itemsInProgress.isEmpty()) {
|
||||||
rootList += continueReadingMetadata
|
rootList += continueListeningMetadata
|
||||||
}
|
}
|
||||||
continueListeningMediaMetadata.forEach {
|
|
||||||
val children = mediaIdToChildren[CONTINUE_ROOT] ?: mutableListOf()
|
if (libraries.isNotEmpty()) {
|
||||||
children += it
|
rootList += librariesMetadata
|
||||||
mediaIdToChildren[CONTINUE_ROOT] = children
|
|
||||||
|
libraries.forEach { library ->
|
||||||
|
val libraryMediaMetadata = library.getMediaMetadata()
|
||||||
|
val children = mediaIdToChildren[LIBRARIES_ROOT] ?: mutableListOf()
|
||||||
|
children += libraryMediaMetadata
|
||||||
|
mediaIdToChildren[LIBRARIES_ROOT] = children
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
rootList += allMetadata
|
|
||||||
rootList += downloadsMetadata
|
rootList += downloadsMetadata
|
||||||
|
|
||||||
// Server library cat
|
|
||||||
libraryCategories.find { it.id == "library" }?.let { libraryCategory ->
|
|
||||||
var libraryMediaMetadata = libraryCategory.entities.map { libc ->
|
|
||||||
var libraryItem = libc as LibraryItem
|
|
||||||
libraryItem.getMediaMetadata()
|
|
||||||
}
|
|
||||||
libraryMediaMetadata.forEach {
|
|
||||||
val children = mediaIdToChildren[ALL_ROOT] ?: mutableListOf()
|
|
||||||
children += it
|
|
||||||
mediaIdToChildren[ALL_ROOT] = children
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
libraryCategories.find { it.id == "local-books" }?.let { localBooksCat ->
|
|
||||||
var localMediaMetadata = localBooksCat.entities.map { libc ->
|
|
||||||
var libraryItem = libc as LocalLibraryItem
|
|
||||||
libraryItem.getMediaMetadata()
|
|
||||||
}
|
|
||||||
localMediaMetadata.forEach {
|
|
||||||
val children = mediaIdToChildren[DOWNLOADS_ROOT] ?: mutableListOf()
|
|
||||||
children += it
|
|
||||||
mediaIdToChildren[DOWNLOADS_ROOT] = children
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mediaIdToChildren[AUTO_BROWSE_ROOT] = rootList
|
mediaIdToChildren[AUTO_BROWSE_ROOT] = rootList
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,6 +73,6 @@ class BrowseTree(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const val AUTO_BROWSE_ROOT = "/"
|
const val AUTO_BROWSE_ROOT = "/"
|
||||||
const val ALL_ROOT = "__ALL__"
|
|
||||||
const val CONTINUE_ROOT = "__CONTINUE__"
|
const val CONTINUE_ROOT = "__CONTINUE__"
|
||||||
const val DOWNLOADS_ROOT = "__DOWNLOADS__"
|
const val DOWNLOADS_ROOT = "__DOWNLOADS__"
|
||||||
|
const val LIBRARIES_ROOT = "__LIBRARIES__"
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
|||||||
private var currentMediaItemIndex = 0
|
private var currentMediaItemIndex = 0
|
||||||
private var pendingMediaItemRemovalPosition:PositionInfo? = null
|
private var pendingMediaItemRemovalPosition:PositionInfo? = null
|
||||||
private var pendingSeekCount = 0
|
private var pendingSeekCount = 0
|
||||||
private var pendingSeekWindowIndex = 0
|
private var pendingSeekWindowIndex = C.INDEX_UNSET
|
||||||
private var pendingSeekPositionMs = 0L
|
private var pendingSeekPositionMs = 0L
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ import android.os.Handler
|
|||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.audiobookshelf.app.data.LocalMediaProgress
|
import com.audiobookshelf.app.data.LocalMediaProgress
|
||||||
|
import com.audiobookshelf.app.data.MediaProgress
|
||||||
import com.audiobookshelf.app.data.PlaybackSession
|
import com.audiobookshelf.app.data.PlaybackSession
|
||||||
import com.audiobookshelf.app.device.DeviceManager
|
import com.audiobookshelf.app.device.DeviceManager
|
||||||
import com.audiobookshelf.app.server.ApiHandler
|
import com.audiobookshelf.app.server.ApiHandler
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import kotlin.concurrent.schedule
|
import kotlin.concurrent.schedule
|
||||||
import kotlin.math.roundToInt
|
|
||||||
|
|
||||||
data class MediaProgressSyncData(
|
data class MediaProgressSyncData(
|
||||||
var timeListened:Long, // seconds
|
var timeListened:Long, // seconds
|
||||||
@@ -17,15 +17,15 @@ data class MediaProgressSyncData(
|
|||||||
var currentTime:Double // seconds
|
var currentTime:Double // seconds
|
||||||
)
|
)
|
||||||
|
|
||||||
class MediaProgressSyncer(playerNotificationService:PlayerNotificationService, apiHandler: ApiHandler) {
|
class MediaProgressSyncer(val playerNotificationService:PlayerNotificationService, private val apiHandler: ApiHandler) {
|
||||||
private val tag = "MediaProgressSync"
|
private val tag = "MediaProgressSync"
|
||||||
private val playerNotificationService:PlayerNotificationService = playerNotificationService
|
private val METERED_CONNECTION_SYNC_INTERVAL = 60000
|
||||||
private val apiHandler = apiHandler
|
|
||||||
|
|
||||||
private var listeningTimerTask: TimerTask? = null
|
private var listeningTimerTask: TimerTask? = null
|
||||||
var listeningTimerRunning:Boolean = false
|
var listeningTimerRunning:Boolean = false
|
||||||
|
|
||||||
private var lastSyncTime:Long = 0
|
private var lastSyncTime:Long = 0
|
||||||
|
private var failedSyncs:Int = 0
|
||||||
|
|
||||||
var currentPlaybackSession: PlaybackSession? = null // copy of pb session currently syncing
|
var currentPlaybackSession: PlaybackSession? = null // copy of pb session currently syncing
|
||||||
var currentLocalMediaProgress: LocalMediaProgress? = null
|
var currentLocalMediaProgress: LocalMediaProgress? = null
|
||||||
@@ -43,67 +43,169 @@ class MediaProgressSyncer(playerNotificationService:PlayerNotificationService, a
|
|||||||
currentLocalMediaProgress = null
|
currentLocalMediaProgress = null
|
||||||
listeningTimerTask?.cancel()
|
listeningTimerTask?.cancel()
|
||||||
lastSyncTime = 0L
|
lastSyncTime = 0L
|
||||||
|
Log.d(tag, "start: Set last sync time 0 $lastSyncTime")
|
||||||
|
failedSyncs = 0
|
||||||
} else {
|
} else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
} else if (playerNotificationService.getCurrentPlaybackSessionId() != currentSessionId) {
|
||||||
|
currentLocalMediaProgress = null
|
||||||
}
|
}
|
||||||
|
|
||||||
listeningTimerRunning = true
|
listeningTimerRunning = true
|
||||||
lastSyncTime = System.currentTimeMillis()
|
lastSyncTime = System.currentTimeMillis()
|
||||||
|
Log.d(tag, "start: init last sync time $lastSyncTime")
|
||||||
currentPlaybackSession = playerNotificationService.getCurrentPlaybackSessionCopy()
|
currentPlaybackSession = playerNotificationService.getCurrentPlaybackSessionCopy()
|
||||||
|
|
||||||
listeningTimerTask = Timer("ListeningTimer", false).schedule(0L, 5000L) {
|
listeningTimerTask = Timer("ListeningTimer", false).schedule(0L, 5000L) {
|
||||||
Handler(Looper.getMainLooper()).post() {
|
Handler(Looper.getMainLooper()).post() {
|
||||||
if (playerNotificationService.currentPlayer.isPlaying) {
|
if (playerNotificationService.currentPlayer.isPlaying) {
|
||||||
var currentTime = playerNotificationService.getCurrentTimeSeconds()
|
// Only sync with server on unmetered connection every 5s OR sync with server if last sync time is >= 60s
|
||||||
sync(currentTime)
|
val shouldSyncServer = PlayerNotificationService.isUnmeteredNetwork || System.currentTimeMillis() - lastSyncTime >= METERED_CONNECTION_SYNC_INTERVAL
|
||||||
|
|
||||||
|
val currentTime = playerNotificationService.getCurrentTimeSeconds()
|
||||||
|
if (currentTime > 0) {
|
||||||
|
sync(shouldSyncServer, currentTime) {
|
||||||
|
Log.d(tag, "Sync complete")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun stop() {
|
fun stop(shouldSync:Boolean? = true, cb: () -> Unit) {
|
||||||
if (!listeningTimerRunning) return
|
if (!listeningTimerRunning) return
|
||||||
|
listeningTimerTask?.cancel()
|
||||||
|
listeningTimerTask = null
|
||||||
|
listeningTimerRunning = false
|
||||||
Log.d(tag, "stop: Stopping listening for $currentDisplayTitle")
|
Log.d(tag, "stop: Stopping listening for $currentDisplayTitle")
|
||||||
|
|
||||||
var currentTime = playerNotificationService.getCurrentTimeSeconds()
|
val currentTime = if (shouldSync == true) playerNotificationService.getCurrentTimeSeconds() else 0.0
|
||||||
sync(currentTime)
|
if (currentTime > 0) { // Current time should always be > 0 on stop
|
||||||
reset()
|
sync(true, currentTime) {
|
||||||
|
reset()
|
||||||
|
cb()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
reset()
|
||||||
|
cb()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sync(currentTime:Double) {
|
fun pause(cb: () -> Unit) {
|
||||||
var diffSinceLastSync = System.currentTimeMillis() - lastSyncTime
|
if (!listeningTimerRunning) return
|
||||||
if (diffSinceLastSync < 1000L) {
|
listeningTimerTask?.cancel()
|
||||||
|
listeningTimerTask = null
|
||||||
|
listeningTimerRunning = false
|
||||||
|
Log.d(tag, "pause: Pausing progress syncer for $currentDisplayTitle")
|
||||||
|
Log.d(tag, "pause: Last sync time $lastSyncTime")
|
||||||
|
|
||||||
|
val currentTime = playerNotificationService.getCurrentTimeSeconds()
|
||||||
|
if (currentTime > 0) { // Current time should always be > 0 on pause
|
||||||
|
sync(true, currentTime) {
|
||||||
|
lastSyncTime = 0L
|
||||||
|
Log.d(tag, "pause: Set last sync time 0 $lastSyncTime")
|
||||||
|
failedSyncs = 0
|
||||||
|
cb()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
lastSyncTime = 0L
|
||||||
|
Log.d(tag, "pause: Set last sync time 0 $lastSyncTime (current time < 0)")
|
||||||
|
failedSyncs = 0
|
||||||
|
cb()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
fun syncFromServerProgress(mediaProgress: MediaProgress) {
|
||||||
|
currentPlaybackSession?.let {
|
||||||
|
it.updatedAt = mediaProgress.lastUpdate
|
||||||
|
it.currentTime = mediaProgress.currentTime
|
||||||
|
DeviceManager.dbManager.saveLocalPlaybackSession(it)
|
||||||
|
saveLocalProgress(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sync(shouldSyncServer:Boolean, currentTime:Double, cb: () -> Unit) {
|
||||||
|
if (lastSyncTime <= 0) {
|
||||||
|
Log.e(tag, "Last sync time is not set $lastSyncTime")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var listeningTimeToAdd = diffSinceLastSync / 1000L
|
|
||||||
lastSyncTime = System.currentTimeMillis()
|
|
||||||
|
|
||||||
var syncData = MediaProgressSyncData(listeningTimeToAdd,currentPlaybackDuration,currentTime)
|
val diffSinceLastSync = System.currentTimeMillis() - lastSyncTime
|
||||||
|
if (diffSinceLastSync < 1000L) {
|
||||||
|
return cb()
|
||||||
|
}
|
||||||
|
val listeningTimeToAdd = diffSinceLastSync / 1000L
|
||||||
|
|
||||||
|
val syncData = MediaProgressSyncData(listeningTimeToAdd,currentPlaybackDuration,currentTime)
|
||||||
|
|
||||||
currentPlaybackSession?.syncData(syncData)
|
currentPlaybackSession?.syncData(syncData)
|
||||||
|
|
||||||
|
if (currentPlaybackSession?.progress?.isNaN() == true) {
|
||||||
|
Log.e(tag, "Current Playback Session invalid progress ${currentPlaybackSession?.progress} | Current Time: ${currentPlaybackSession?.currentTime} | Duration: ${currentPlaybackSession?.getTotalDuration()}")
|
||||||
|
return cb()
|
||||||
|
}
|
||||||
|
|
||||||
if (currentIsLocal) {
|
if (currentIsLocal) {
|
||||||
// Save local progress sync
|
// Save local progress sync
|
||||||
currentPlaybackSession?.let {
|
currentPlaybackSession?.let {
|
||||||
DeviceManager.dbManager.saveLocalPlaybackSession(it)
|
DeviceManager.dbManager.saveLocalPlaybackSession(it)
|
||||||
saveLocalProgress(it)
|
saveLocalProgress(it)
|
||||||
|
lastSyncTime = System.currentTimeMillis()
|
||||||
|
|
||||||
|
// Local library item is linked to a server library item
|
||||||
// Send sync to server also if connected to this server and local item belongs to this server
|
// Send sync to server also if connected to this server and local item belongs to this server
|
||||||
if (it.serverConnectionConfigId != null && DeviceManager.serverConnectionConfig?.id == it.serverConnectionConfigId) {
|
if (shouldSyncServer && !it.libraryItemId.isNullOrEmpty() && it.serverConnectionConfigId != null && DeviceManager.serverConnectionConfig?.id == it.serverConnectionConfigId) {
|
||||||
apiHandler.sendLocalProgressSync(it) {
|
apiHandler.sendLocalProgressSync(it) { syncSuccess ->
|
||||||
Log.d(tag, "Local progress sync data sent to server $currentDisplayTitle for time $currentTime")
|
Log.d(
|
||||||
|
tag,
|
||||||
|
"Local progress sync data sent to server $currentDisplayTitle for time $currentTime"
|
||||||
|
)
|
||||||
|
if (syncSuccess) {
|
||||||
|
failedSyncs = 0
|
||||||
|
playerNotificationService.alertSyncSuccess()
|
||||||
|
} else {
|
||||||
|
failedSyncs++
|
||||||
|
if (failedSyncs == 2) {
|
||||||
|
playerNotificationService.alertSyncFailing() // Show alert in client
|
||||||
|
failedSyncs = 0
|
||||||
|
}
|
||||||
|
Log.e(tag, "Local Progress sync failed ($failedSyncs) to send to server $currentDisplayTitle for time $currentTime")
|
||||||
|
}
|
||||||
|
|
||||||
|
cb()
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
cb()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else if (shouldSyncServer) {
|
||||||
apiHandler.sendProgressSync(currentSessionId, syncData) {
|
apiHandler.sendProgressSync(currentSessionId, syncData) {
|
||||||
Log.d(tag, "Progress sync data sent to server $currentDisplayTitle for time $currentTime")
|
if (it) {
|
||||||
|
Log.d(tag, "Progress sync data sent to server $currentDisplayTitle for time $currentTime")
|
||||||
|
failedSyncs = 0
|
||||||
|
playerNotificationService.alertSyncSuccess()
|
||||||
|
lastSyncTime = System.currentTimeMillis()
|
||||||
|
} else {
|
||||||
|
failedSyncs++
|
||||||
|
if (failedSyncs == 2) {
|
||||||
|
playerNotificationService.alertSyncFailing() // Show alert in client
|
||||||
|
failedSyncs = 0
|
||||||
|
}
|
||||||
|
Log.e(tag, "Progress sync failed ($failedSyncs) to send to server $currentDisplayTitle for time $currentTime")
|
||||||
|
}
|
||||||
|
cb()
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
cb()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun saveLocalProgress(playbackSession:PlaybackSession) {
|
private fun saveLocalProgress(playbackSession:PlaybackSession) {
|
||||||
if (currentLocalMediaProgress == null) {
|
if (currentLocalMediaProgress == null) {
|
||||||
var mediaProgress = DeviceManager.dbManager.getLocalMediaProgress(playbackSession.localMediaProgressId)
|
val mediaProgress = DeviceManager.dbManager.getLocalMediaProgress(playbackSession.localMediaProgressId)
|
||||||
if (mediaProgress == null) {
|
if (mediaProgress == null) {
|
||||||
currentLocalMediaProgress = playbackSession.getNewLocalMediaProgress()
|
currentLocalMediaProgress = playbackSession.getNewLocalMediaProgress()
|
||||||
} else {
|
} else {
|
||||||
@@ -113,18 +215,22 @@ class MediaProgressSyncer(playerNotificationService:PlayerNotificationService, a
|
|||||||
currentLocalMediaProgress?.updateFromPlaybackSession(playbackSession)
|
currentLocalMediaProgress?.updateFromPlaybackSession(playbackSession)
|
||||||
}
|
}
|
||||||
currentLocalMediaProgress?.let {
|
currentLocalMediaProgress?.let {
|
||||||
DeviceManager.dbManager.saveLocalMediaProgress(it)
|
if (it.progress.isNaN()) {
|
||||||
playerNotificationService.clientEventEmitter?.onLocalMediaProgressUpdate(it)
|
Log.e(tag, "Invalid progress on local media progress")
|
||||||
Log.d(tag, "Saved Local Progress Current Time: ID ${it.id} | ${it.currentTime} | Duration ${it.duration} | Progress ${(it.progress * 100).roundToInt()}%")
|
} else {
|
||||||
|
DeviceManager.dbManager.saveLocalMediaProgress(it)
|
||||||
|
playerNotificationService.clientEventEmitter?.onLocalMediaProgressUpdate(it)
|
||||||
|
|
||||||
|
Log.d(tag, "Saved Local Progress Current Time: ID ${it.id} | ${it.currentTime} | Duration ${it.duration} | Progress ${it.progressPercent}%")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun reset() {
|
fun reset() {
|
||||||
listeningTimerTask?.cancel()
|
|
||||||
listeningTimerTask = null
|
|
||||||
listeningTimerRunning = false
|
|
||||||
currentPlaybackSession = null
|
currentPlaybackSession = null
|
||||||
currentLocalMediaProgress = null
|
currentLocalMediaProgress = null
|
||||||
lastSyncTime = 0L
|
lastSyncTime = 0L
|
||||||
|
Log.d(tag, "reset: Set last sync time 0 $lastSyncTime")
|
||||||
|
failedSyncs = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,11 +9,8 @@ import android.os.Message
|
|||||||
import android.support.v4.media.session.MediaSessionCompat
|
import android.support.v4.media.session.MediaSessionCompat
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.KeyEvent
|
import android.view.KeyEvent
|
||||||
import com.audiobookshelf.app.data.LibraryItem
|
|
||||||
import com.audiobookshelf.app.data.LibraryItemWrapper
|
import com.audiobookshelf.app.data.LibraryItemWrapper
|
||||||
import kotlinx.coroutines.Dispatchers
|
import com.audiobookshelf.app.data.PodcastEpisode
|
||||||
import kotlinx.coroutines.GlobalScope
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import kotlin.concurrent.schedule
|
import kotlin.concurrent.schedule
|
||||||
|
|
||||||
@@ -22,15 +19,18 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
|||||||
|
|
||||||
private var mediaButtonClickCount: Int = 0
|
private var mediaButtonClickCount: Int = 0
|
||||||
var mediaButtonClickTimeout: Long = 1000 //ms
|
var mediaButtonClickTimeout: Long = 1000 //ms
|
||||||
var seekAmount: Long = 20000 //ms
|
|
||||||
|
|
||||||
override fun onPrepare() {
|
override fun onPrepare() {
|
||||||
Log.d(tag, "ON PREPARE MEDIA SESSION COMPAT")
|
Log.d(tag, "ON PREPARE MEDIA SESSION COMPAT")
|
||||||
playerNotificationService.mediaManager.getFirstItem()?.let { li ->
|
playerNotificationService.mediaManager.getFirstItem()?.let { li ->
|
||||||
playerNotificationService.mediaManager.play(li, playerNotificationService.getMediaPlayer()) {
|
playerNotificationService.mediaManager.play(li, null, playerNotificationService.getPlayItemRequestPayload(false)) {
|
||||||
Log.d(tag, "About to prepare player with ${it.displayTitle}")
|
if (it == null) {
|
||||||
Handler(Looper.getMainLooper()).post() {
|
Log.e(tag, "Failed to play library item")
|
||||||
playerNotificationService.preparePlayer(it,true,null)
|
} else {
|
||||||
|
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
|
||||||
|
Handler(Looper.getMainLooper()).post() {
|
||||||
|
playerNotificationService.preparePlayer(it,true, playbackRate)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -49,10 +49,14 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
|||||||
override fun onPlayFromSearch(query: String?, extras: Bundle?) {
|
override fun onPlayFromSearch(query: String?, extras: Bundle?) {
|
||||||
Log.d(tag, "ON PLAY FROM SEARCH $query")
|
Log.d(tag, "ON PLAY FROM SEARCH $query")
|
||||||
playerNotificationService.mediaManager.getFromSearch(query)?.let { li ->
|
playerNotificationService.mediaManager.getFromSearch(query)?.let { li ->
|
||||||
playerNotificationService.mediaManager.play(li, playerNotificationService.getMediaPlayer()) {
|
playerNotificationService.mediaManager.play(li, null, playerNotificationService.getPlayItemRequestPayload(false)) {
|
||||||
Log.d(tag, "About to prepare player with ${it.displayTitle}")
|
if (it == null) {
|
||||||
Handler(Looper.getMainLooper()).post() {
|
Log.e(tag, "Failed to play library item")
|
||||||
playerNotificationService.preparePlayer(it,true,null)
|
} else {
|
||||||
|
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
|
||||||
|
Handler(Looper.getMainLooper()).post() {
|
||||||
|
playerNotificationService.preparePlayer(it, true, playbackRate)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -68,19 +72,19 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onSkipToPrevious() {
|
override fun onSkipToPrevious() {
|
||||||
playerNotificationService.seekBackward(seekAmount)
|
playerNotificationService.skipToPrevious()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSkipToNext() {
|
override fun onSkipToNext() {
|
||||||
playerNotificationService.seekForward(seekAmount)
|
playerNotificationService.skipToNext()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onFastForward() {
|
override fun onFastForward() {
|
||||||
playerNotificationService.seekForward(seekAmount)
|
playerNotificationService.jumpForward()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onRewind() {
|
override fun onRewind() {
|
||||||
playerNotificationService.seekForward(seekAmount)
|
playerNotificationService.jumpBackward()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSeekTo(pos: Long) {
|
override fun onSeekTo(pos: Long) {
|
||||||
@@ -90,17 +94,31 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
|||||||
override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) {
|
override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) {
|
||||||
Log.d(tag, "ON PLAY FROM MEDIA ID $mediaId")
|
Log.d(tag, "ON PLAY FROM MEDIA ID $mediaId")
|
||||||
var libraryItemWrapper: LibraryItemWrapper? = null
|
var libraryItemWrapper: LibraryItemWrapper? = null
|
||||||
|
var podcastEpisode: PodcastEpisode? = null
|
||||||
|
|
||||||
if (mediaId.isNullOrEmpty()) {
|
if (mediaId.isNullOrEmpty()) {
|
||||||
libraryItemWrapper = playerNotificationService.mediaManager.getFirstItem()
|
libraryItemWrapper = playerNotificationService.mediaManager.getFirstItem()
|
||||||
|
} else if (mediaId.startsWith("ep_") || mediaId.startsWith("local_ep_")) { // Playing podcast episode
|
||||||
|
val libraryItemWithEpisode = playerNotificationService.mediaManager.getPodcastWithEpisodeByEpisodeId(mediaId)
|
||||||
|
libraryItemWrapper = libraryItemWithEpisode?.libraryItemWrapper
|
||||||
|
podcastEpisode = libraryItemWithEpisode?.episode
|
||||||
} else {
|
} else {
|
||||||
libraryItemWrapper = playerNotificationService.mediaManager.getById(mediaId)
|
libraryItemWrapper = playerNotificationService.mediaManager.getById(mediaId)
|
||||||
|
|
||||||
|
if (libraryItemWrapper == null) {
|
||||||
|
Log.e(tag, "onPlayFromMediaId: Media item not found $mediaId")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
libraryItemWrapper?.let { li ->
|
libraryItemWrapper?.let { li ->
|
||||||
playerNotificationService.mediaManager.play(li, playerNotificationService.getMediaPlayer()) {
|
playerNotificationService.mediaManager.play(li, podcastEpisode, playerNotificationService.getPlayItemRequestPayload(false)) {
|
||||||
Log.d(tag, "About to prepare player with ${it.displayTitle}")
|
if (it == null) {
|
||||||
Handler(Looper.getMainLooper()).post() {
|
Log.e(tag, "Failed to play library item")
|
||||||
playerNotificationService.preparePlayer(it,true,null)
|
} else {
|
||||||
|
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
|
||||||
|
Handler(Looper.getMainLooper()).post() {
|
||||||
|
playerNotificationService.preparePlayer(it, true, playbackRate)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -111,11 +129,31 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun handleCallMediaButton(intent: Intent): Boolean {
|
fun handleCallMediaButton(intent: Intent): Boolean {
|
||||||
|
Log.w(tag, "handleCallMediaButton $intent | ${intent.action}")
|
||||||
|
|
||||||
if(Intent.ACTION_MEDIA_BUTTON == intent.action) {
|
if(Intent.ACTION_MEDIA_BUTTON == intent.action) {
|
||||||
var keyEvent = intent.getParcelableExtra<KeyEvent>(Intent.EXTRA_KEY_EVENT)
|
val keyEvent = intent.getParcelableExtra<KeyEvent>(Intent.EXTRA_KEY_EVENT)
|
||||||
if (keyEvent?.getAction() == KeyEvent.ACTION_UP) {
|
Log.d(tag, "handleCallMediaButton keyEvent = $keyEvent | action ${keyEvent?.action}")
|
||||||
when (keyEvent?.getKeyCode()) {
|
|
||||||
|
if (keyEvent?.action == KeyEvent.ACTION_DOWN) {
|
||||||
|
Log.d(tag, "handleCallMediaButton: key action_down for ${keyEvent.keyCode}")
|
||||||
|
when (keyEvent.keyCode) {
|
||||||
|
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> {
|
||||||
|
Log.d(tag, "handleCallMediaButton: Media Play/Pause")
|
||||||
|
if (playerNotificationService.mPlayer.isPlaying) {
|
||||||
|
playerNotificationService.pause()
|
||||||
|
} else {
|
||||||
|
playerNotificationService.play()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keyEvent?.action == KeyEvent.ACTION_UP) {
|
||||||
|
Log.d(tag, "handleCallMediaButton: key action_up for ${keyEvent.keyCode}")
|
||||||
|
when (keyEvent.keyCode) {
|
||||||
KeyEvent.KEYCODE_HEADSETHOOK -> {
|
KeyEvent.KEYCODE_HEADSETHOOK -> {
|
||||||
|
Log.d(tag, "handleCallMediaButton: Headset Hook")
|
||||||
if (0 == mediaButtonClickCount) {
|
if (0 == mediaButtonClickCount) {
|
||||||
if (playerNotificationService.mPlayer.isPlaying)
|
if (playerNotificationService.mPlayer.isPlaying)
|
||||||
playerNotificationService.pause()
|
playerNotificationService.pause()
|
||||||
@@ -125,6 +163,7 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
|||||||
handleMediaButtonClickCount()
|
handleMediaButtonClickCount()
|
||||||
}
|
}
|
||||||
KeyEvent.KEYCODE_MEDIA_PLAY -> {
|
KeyEvent.KEYCODE_MEDIA_PLAY -> {
|
||||||
|
Log.d(tag, "handleCallMediaButton: Media Play")
|
||||||
if (0 == mediaButtonClickCount) {
|
if (0 == mediaButtonClickCount) {
|
||||||
playerNotificationService.play()
|
playerNotificationService.play()
|
||||||
playerNotificationService.sleepTimerManager.checkShouldExtendSleepTimer()
|
playerNotificationService.sleepTimerManager.checkShouldExtendSleepTimer()
|
||||||
@@ -132,19 +171,21 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
|||||||
handleMediaButtonClickCount()
|
handleMediaButtonClickCount()
|
||||||
}
|
}
|
||||||
KeyEvent.KEYCODE_MEDIA_PAUSE -> {
|
KeyEvent.KEYCODE_MEDIA_PAUSE -> {
|
||||||
|
Log.d(tag, "handleCallMediaButton: Media Pause")
|
||||||
if (0 == mediaButtonClickCount) playerNotificationService.pause()
|
if (0 == mediaButtonClickCount) playerNotificationService.pause()
|
||||||
handleMediaButtonClickCount()
|
handleMediaButtonClickCount()
|
||||||
}
|
}
|
||||||
KeyEvent.KEYCODE_MEDIA_NEXT -> {
|
KeyEvent.KEYCODE_MEDIA_NEXT -> {
|
||||||
playerNotificationService.seekForward(seekAmount)
|
playerNotificationService.jumpForward()
|
||||||
}
|
}
|
||||||
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> {
|
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> {
|
||||||
playerNotificationService.seekBackward(seekAmount)
|
playerNotificationService.jumpBackward()
|
||||||
}
|
}
|
||||||
KeyEvent.KEYCODE_MEDIA_STOP -> {
|
KeyEvent.KEYCODE_MEDIA_STOP -> {
|
||||||
playerNotificationService.closePlayback()
|
playerNotificationService.closePlayback()
|
||||||
}
|
}
|
||||||
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> {
|
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> {
|
||||||
|
Log.d(tag, "handleCallMediaButton: Media Play/Pause")
|
||||||
if (playerNotificationService.mPlayer.isPlaying) {
|
if (playerNotificationService.mPlayer.isPlaying) {
|
||||||
if (0 == mediaButtonClickCount) playerNotificationService.pause()
|
if (0 == mediaButtonClickCount) playerNotificationService.pause()
|
||||||
handleMediaButtonClickCount()
|
handleMediaButtonClickCount()
|
||||||
@@ -157,7 +198,7 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
Log.d(tag, "KeyCode:${keyEvent.getKeyCode()}")
|
Log.d(tag, "KeyCode:${keyEvent.keyCode}")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -166,7 +207,7 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
fun handleMediaButtonClickCount() {
|
private fun handleMediaButtonClickCount() {
|
||||||
mediaButtonClickCount++
|
mediaButtonClickCount++
|
||||||
if (1 == mediaButtonClickCount) {
|
if (1 == mediaButtonClickCount) {
|
||||||
Timer().schedule(mediaButtonClickTimeout) {
|
Timer().schedule(mediaButtonClickTimeout) {
|
||||||
@@ -182,14 +223,24 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
|||||||
override fun handleMessage(msg: Message) {
|
override fun handleMessage(msg: Message) {
|
||||||
super.handleMessage(msg)
|
super.handleMessage(msg)
|
||||||
if (2 == msg.what) {
|
if (2 == msg.what) {
|
||||||
playerNotificationService.seekBackward(seekAmount)
|
playerNotificationService.jumpBackward()
|
||||||
playerNotificationService.play()
|
playerNotificationService.play()
|
||||||
}
|
}
|
||||||
else if (msg.what >= 3) {
|
else if (msg.what >= 3) {
|
||||||
playerNotificationService.seekForward(seekAmount)
|
playerNotificationService.jumpForward()
|
||||||
playerNotificationService.play()
|
playerNotificationService.play()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onCustomAction(action: String?, extras: Bundle?) {
|
||||||
|
super.onCustomAction(action, extras)
|
||||||
|
|
||||||
|
when (action) {
|
||||||
|
CUSTOM_ACTION_JUMP_FORWARD -> onFastForward()
|
||||||
|
CUSTOM_ACTION_JUMP_BACKWARD -> onRewind()
|
||||||
|
CUSTOM_ACTION_SKIP_FORWARD -> onSkipToNext()
|
||||||
|
CUSTOM_ACTION_SKIP_BACKWARD -> onSkipToPrevious()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import android.os.Looper
|
|||||||
import android.os.ResultReceiver
|
import android.os.ResultReceiver
|
||||||
import android.support.v4.media.session.PlaybackStateCompat
|
import android.support.v4.media.session.PlaybackStateCompat
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.audiobookshelf.app.data.LibraryItem
|
|
||||||
import com.audiobookshelf.app.data.LibraryItemWrapper
|
import com.audiobookshelf.app.data.LibraryItemWrapper
|
||||||
|
import com.audiobookshelf.app.data.PodcastEpisode
|
||||||
import com.google.android.exoplayer2.Player
|
import com.google.android.exoplayer2.Player
|
||||||
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
|
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
|
||||||
|
|
||||||
@@ -30,9 +30,14 @@ class MediaSessionPlaybackPreparer(var playerNotificationService:PlayerNotificat
|
|||||||
override fun onPrepare(playWhenReady: Boolean) {
|
override fun onPrepare(playWhenReady: Boolean) {
|
||||||
Log.d(tag, "ON PREPARE $playWhenReady")
|
Log.d(tag, "ON PREPARE $playWhenReady")
|
||||||
playerNotificationService.mediaManager.getFirstItem()?.let { li ->
|
playerNotificationService.mediaManager.getFirstItem()?.let { li ->
|
||||||
playerNotificationService.mediaManager.play(li, playerNotificationService.getMediaPlayer()) {
|
playerNotificationService.mediaManager.play(li, null, playerNotificationService.getPlayItemRequestPayload(false)) {
|
||||||
Handler(Looper.getMainLooper()).post() {
|
if (it == null) {
|
||||||
playerNotificationService.preparePlayer(it,playWhenReady,null)
|
Log.e(tag, "Failed to play library item")
|
||||||
|
} else {
|
||||||
|
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
|
||||||
|
Handler(Looper.getMainLooper()).post() {
|
||||||
|
playerNotificationService.preparePlayer(it, playWhenReady, playbackRate)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -41,12 +46,26 @@ class MediaSessionPlaybackPreparer(var playerNotificationService:PlayerNotificat
|
|||||||
override fun onPrepareFromMediaId(mediaId: String, playWhenReady: Boolean, extras: Bundle?) {
|
override fun onPrepareFromMediaId(mediaId: String, playWhenReady: Boolean, extras: Bundle?) {
|
||||||
Log.d(tag, "ON PREPARE FROM MEDIA ID $mediaId $playWhenReady")
|
Log.d(tag, "ON PREPARE FROM MEDIA ID $mediaId $playWhenReady")
|
||||||
|
|
||||||
var libraryItemWrapper: LibraryItemWrapper? = playerNotificationService.mediaManager.getById(mediaId)
|
var libraryItemWrapper: LibraryItemWrapper? = null
|
||||||
|
var podcastEpisode: PodcastEpisode? = null
|
||||||
|
|
||||||
|
if (mediaId.startsWith("ep_") || mediaId.startsWith("local_ep_")) { // Playing podcast episode
|
||||||
|
val libraryItemWithEpisode = playerNotificationService.mediaManager.getPodcastWithEpisodeByEpisodeId(mediaId)
|
||||||
|
libraryItemWrapper = libraryItemWithEpisode?.libraryItemWrapper
|
||||||
|
podcastEpisode = libraryItemWithEpisode?.episode
|
||||||
|
} else {
|
||||||
|
libraryItemWrapper = playerNotificationService.mediaManager.getById(mediaId)
|
||||||
|
}
|
||||||
|
|
||||||
libraryItemWrapper?.let { li ->
|
libraryItemWrapper?.let { li ->
|
||||||
playerNotificationService.mediaManager.play(li, playerNotificationService.getMediaPlayer()) {
|
playerNotificationService.mediaManager.play(li, podcastEpisode, playerNotificationService.getPlayItemRequestPayload(false)) {
|
||||||
Log.d(tag, "About to prepare player with ${it.displayTitle}")
|
if (it == null) {
|
||||||
Handler(Looper.getMainLooper()).post() {
|
Log.e(tag, "Failed to play library item")
|
||||||
playerNotificationService.preparePlayer(it,playWhenReady,null)
|
} else {
|
||||||
|
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
|
||||||
|
Handler(Looper.getMainLooper()).post() {
|
||||||
|
playerNotificationService.preparePlayer(it, playWhenReady, playbackRate)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -55,10 +74,14 @@ class MediaSessionPlaybackPreparer(var playerNotificationService:PlayerNotificat
|
|||||||
override fun onPrepareFromSearch(query: String, playWhenReady: Boolean, extras: Bundle?) {
|
override fun onPrepareFromSearch(query: String, playWhenReady: Boolean, extras: Bundle?) {
|
||||||
Log.d(tag, "ON PREPARE FROM SEARCH $query")
|
Log.d(tag, "ON PREPARE FROM SEARCH $query")
|
||||||
playerNotificationService.mediaManager.getFromSearch(query)?.let { li ->
|
playerNotificationService.mediaManager.getFromSearch(query)?.let { li ->
|
||||||
playerNotificationService.mediaManager.play(li, playerNotificationService.getMediaPlayer()) {
|
playerNotificationService.mediaManager.play(li, null, playerNotificationService.getPlayItemRequestPayload(false)) {
|
||||||
Log.d(tag, "About to prepare player with ${it.displayTitle}")
|
if (it == null) {
|
||||||
Handler(Looper.getMainLooper()).post() {
|
Log.e(tag, "Failed to play library item")
|
||||||
playerNotificationService.preparePlayer(it,playWhenReady,null)
|
} else {
|
||||||
|
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
|
||||||
|
Handler(Looper.getMainLooper()).post() {
|
||||||
|
playerNotificationService.preparePlayer(it, playWhenReady, playbackRate)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package com.audiobookshelf.app.player
|
||||||
|
|
||||||
|
const val CUSTOM_ACTION_JUMP_FORWARD = "com.audiobookshelf.customAction.jump_forward";
|
||||||
|
const val CUSTOM_ACTION_JUMP_BACKWARD = "com.audiobookshelf.customAction.jump_backward";
|
||||||
|
const val CUSTOM_ACTION_SKIP_FORWARD = "com.audiobookshelf.customAction.skip_forward";
|
||||||
|
const val CUSTOM_ACTION_SKIP_BACKWARD = "com.audiobookshelf.customAction.skip_backward";
|
||||||
@@ -2,9 +2,12 @@ package com.audiobookshelf.app.player
|
|||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.audiobookshelf.app.data.PlayerState
|
import com.audiobookshelf.app.data.PlayerState
|
||||||
|
import com.audiobookshelf.app.device.DeviceManager
|
||||||
import com.google.android.exoplayer2.PlaybackException
|
import com.google.android.exoplayer2.PlaybackException
|
||||||
import com.google.android.exoplayer2.Player
|
import com.google.android.exoplayer2.Player
|
||||||
|
|
||||||
|
const val PAUSE_LEN_BEFORE_RECHECK = 30000 // 30 seconds
|
||||||
|
|
||||||
class PlayerListener(var playerNotificationService:PlayerNotificationService) : Player.Listener {
|
class PlayerListener(var playerNotificationService:PlayerNotificationService) : Player.Listener {
|
||||||
var tag = "PlayerListener"
|
var tag = "PlayerListener"
|
||||||
|
|
||||||
@@ -15,13 +18,13 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
|
|||||||
private var onSeekBack: Boolean = false
|
private var onSeekBack: Boolean = false
|
||||||
|
|
||||||
override fun onPlayerError(error: PlaybackException) {
|
override fun onPlayerError(error: PlaybackException) {
|
||||||
var errorMessage = error.message ?: "Unknown Error"
|
val errorMessage = error.message ?: "Unknown Error"
|
||||||
Log.e(tag, "onPlayerError $errorMessage")
|
Log.e(tag, "onPlayerError $errorMessage")
|
||||||
playerNotificationService.handlePlayerPlaybackError(errorMessage) // If was direct playing session, fallback to transcode
|
playerNotificationService.handlePlayerPlaybackError(errorMessage) // If was direct playing session, fallback to transcode
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onEvents(player: Player, events: Player.Events) {
|
override fun onEvents(player: Player, events: Player.Events) {
|
||||||
Log.d(tag, "onEvents ${player.deviceInfo} | ${playerNotificationService.getMediaPlayer()} | ${events.size()}")
|
Log.d(tag, "onEvents ${playerNotificationService.getMediaPlayer()} | ${events.size()}")
|
||||||
|
|
||||||
if (events.contains(Player.EVENT_POSITION_DISCONTINUITY)) {
|
if (events.contains(Player.EVENT_POSITION_DISCONTINUITY)) {
|
||||||
Log.d(tag, "EVENT_POSITION_DISCONTINUITY")
|
Log.d(tag, "EVENT_POSITION_DISCONTINUITY")
|
||||||
@@ -67,20 +70,37 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
|
|||||||
|
|
||||||
if (player.isPlaying) {
|
if (player.isPlaying) {
|
||||||
Log.d(tag, "SeekBackTime: Player is playing")
|
Log.d(tag, "SeekBackTime: Player is playing")
|
||||||
if (lastPauseTime > 0) {
|
if (lastPauseTime > 0 && DeviceManager.deviceData.deviceSettings?.disableAutoRewind != true) {
|
||||||
|
var seekBackTime = 0L
|
||||||
if (onSeekBack) onSeekBack = false
|
if (onSeekBack) onSeekBack = false
|
||||||
else {
|
else {
|
||||||
Log.d(tag, "SeekBackTime: playing started now set seek back time $lastPauseTime")
|
Log.d(tag, "SeekBackTime: playing started now set seek back time $lastPauseTime")
|
||||||
var backTime = calcPauseSeekBackTime()
|
seekBackTime = calcPauseSeekBackTime()
|
||||||
if (backTime > 0) {
|
if (seekBackTime > 0) {
|
||||||
if (backTime >= playerNotificationService.getCurrentTime()) backTime = playerNotificationService.getCurrentTime() - 500
|
// Current chapter is used so that seek back does not go back to the previous chapter
|
||||||
Log.d(tag, "SeekBackTime $backTime")
|
val currentChapter = playerNotificationService.getCurrentBookChapter()
|
||||||
|
val minSeekBackTime = currentChapter?.startMs ?: 0
|
||||||
|
|
||||||
|
val currentTime = playerNotificationService.getCurrentTime()
|
||||||
|
val newTime = currentTime - seekBackTime
|
||||||
|
if (newTime < minSeekBackTime) {
|
||||||
|
seekBackTime = currentTime - minSeekBackTime
|
||||||
|
}
|
||||||
|
Log.d(tag, "SeekBackTime $seekBackTime")
|
||||||
onSeekBack = true
|
onSeekBack = true
|
||||||
playerNotificationService.seekBackward(backTime)
|
|
||||||
} else {
|
|
||||||
Log.d(tag, "SeekBackTime: back time is 0")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if playback session still exists or sync media progress if updated
|
||||||
|
val pauseLength: Long = System.currentTimeMillis() - lastPauseTime
|
||||||
|
if (pauseLength > PAUSE_LEN_BEFORE_RECHECK) {
|
||||||
|
val shouldCarryOn = playerNotificationService.checkCurrentSessionProgress(seekBackTime)
|
||||||
|
if (!shouldCarryOn) return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seekBackTime > 0L) {
|
||||||
|
playerNotificationService.seekBackward(seekBackTime)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Log.d(tag, "SeekBackTime: Player not playing set last pause time")
|
Log.d(tag, "SeekBackTime: Player not playing set last pause time")
|
||||||
@@ -88,23 +108,28 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Start/stop progress sync interval
|
// Start/stop progress sync interval
|
||||||
Log.d(tag, "Playing ${playerNotificationService.getCurrentBookTitle()}")
|
|
||||||
if (player.isPlaying) {
|
if (player.isPlaying) {
|
||||||
|
player.volume = 1F // Volume on sleep timer might have decreased this
|
||||||
playerNotificationService.mediaProgressSyncer.start()
|
playerNotificationService.mediaProgressSyncer.start()
|
||||||
} else {
|
} else {
|
||||||
playerNotificationService.mediaProgressSyncer.stop()
|
playerNotificationService.mediaProgressSyncer.pause {
|
||||||
|
Log.d(tag, "Media Progress Syncer paused and synced")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
playerNotificationService.clientEventEmitter?.onPlayingUpdate(player.isPlaying)
|
playerNotificationService.clientEventEmitter?.onPlayingUpdate(player.isPlaying)
|
||||||
|
|
||||||
|
DeviceManager.widgetUpdater?.onPlayerChanged(playerNotificationService)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun calcPauseSeekBackTime() : Long {
|
private fun calcPauseSeekBackTime() : Long {
|
||||||
if (lastPauseTime <= 0) return 0
|
if (lastPauseTime <= 0) return 0
|
||||||
var time: Long = System.currentTimeMillis() - lastPauseTime
|
val time: Long = System.currentTimeMillis() - lastPauseTime
|
||||||
var seekback: Long
|
val seekback: Long
|
||||||
if (time < 3000) seekback = 0
|
if (time < 10000) seekback = 0 // 10s or less = no seekback
|
||||||
else if (time < 300000) seekback = 10000 // 3s to 5m = jump back 10s
|
else if (time < 60000) seekback = 3000 // 10s to 1m = jump back 3s
|
||||||
|
else if (time < 300000) seekback = 10000 // 1m to 5m = jump back 10s
|
||||||
else if (time < 1800000) seekback = 20000 // 5m to 30m = jump back 20s
|
else if (time < 1800000) seekback = 20000 // 5m to 30m = jump back 20s
|
||||||
else seekback = 29500 // 30m and up = jump back 30s
|
else seekback = 29500 // 30m and up = jump back 30s
|
||||||
return seekback
|
return seekback
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ class PlayerNotificationListener(var playerNotificationService:PlayerNotificatio
|
|||||||
|
|
||||||
// Start foreground service
|
// Start foreground service
|
||||||
Log.d(tag, "Notification Posted $notificationId - Start Foreground | $notification")
|
Log.d(tag, "Notification Posted $notificationId - Start Foreground | $notification")
|
||||||
|
PlayerNotificationService.isClosed = false
|
||||||
playerNotificationService.startForeground(notificationId, notification)
|
playerNotificationService.startForeground(notificationId, notification)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,6 +27,17 @@ class PlayerNotificationListener(var playerNotificationService:PlayerNotificatio
|
|||||||
playerNotificationService.stopSelf()
|
playerNotificationService.stopSelf()
|
||||||
} else {
|
} else {
|
||||||
Log.d(tag, "onNotificationCancelled not dismissed by user")
|
Log.d(tag, "onNotificationCancelled not dismissed by user")
|
||||||
|
|
||||||
|
// When stop button is pressed on the notification I guess it isn't considered "dismissedByUser" so we need to close playback ourselves
|
||||||
|
if (!PlayerNotificationService.isClosed && !PlayerNotificationService.isSwitchingPlayer) {
|
||||||
|
Log.d(tag, "PNS is not closed - closing it now")
|
||||||
|
playerNotificationService.closePlayback()
|
||||||
|
} else if (PlayerNotificationService.isSwitchingPlayer) {
|
||||||
|
// When switching from cast player to exo player and vice versa the notification is cancelled and posted again
|
||||||
|
// so we don't want to cancel the playback during this switch
|
||||||
|
Log.d(tag, "PNS is switching player")
|
||||||
|
PlayerNotificationService.isSwitchingPlayer = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import android.content.Intent
|
|||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.hardware.Sensor
|
import android.hardware.Sensor
|
||||||
import android.hardware.SensorManager
|
import android.hardware.SensorManager
|
||||||
|
import android.net.ConnectivityManager
|
||||||
|
import android.net.Network
|
||||||
|
import android.net.NetworkCapabilities
|
||||||
|
import android.net.NetworkRequest
|
||||||
import android.os.*
|
import android.os.*
|
||||||
import android.support.v4.media.MediaBrowserCompat
|
import android.support.v4.media.MediaBrowserCompat
|
||||||
import android.support.v4.media.MediaDescriptionCompat
|
import android.support.v4.media.MediaDescriptionCompat
|
||||||
@@ -15,15 +19,21 @@ import android.support.v4.media.session.PlaybackStateCompat
|
|||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.media.MediaBrowserServiceCompat
|
import androidx.media.MediaBrowserServiceCompat
|
||||||
import androidx.media.utils.MediaConstants
|
import androidx.media.utils.MediaConstants
|
||||||
|
import com.audiobookshelf.app.BuildConfig
|
||||||
|
import com.audiobookshelf.app.R
|
||||||
import com.audiobookshelf.app.data.*
|
import com.audiobookshelf.app.data.*
|
||||||
|
import com.audiobookshelf.app.data.DeviceInfo
|
||||||
import com.audiobookshelf.app.device.DeviceManager
|
import com.audiobookshelf.app.device.DeviceManager
|
||||||
import com.audiobookshelf.app.media.MediaManager
|
import com.audiobookshelf.app.media.MediaManager
|
||||||
import com.audiobookshelf.app.server.ApiHandler
|
import com.audiobookshelf.app.server.ApiHandler
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||||
import com.google.android.exoplayer2.*
|
import com.google.android.exoplayer2.*
|
||||||
import com.google.android.exoplayer2.audio.AudioAttributes
|
import com.google.android.exoplayer2.audio.AudioAttributes
|
||||||
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
|
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
|
||||||
|
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector.CustomActionProvider
|
||||||
import com.google.android.exoplayer2.ext.mediasession.TimelineQueueNavigator
|
import com.google.android.exoplayer2.ext.mediasession.TimelineQueueNavigator
|
||||||
import com.google.android.exoplayer2.source.MediaSource
|
import com.google.android.exoplayer2.source.MediaSource
|
||||||
import com.google.android.exoplayer2.source.ProgressiveMediaSource
|
import com.google.android.exoplayer2.source.ProgressiveMediaSource
|
||||||
@@ -33,12 +43,18 @@ import com.google.android.exoplayer2.upstream.*
|
|||||||
import java.util.*
|
import java.util.*
|
||||||
import kotlin.concurrent.schedule
|
import kotlin.concurrent.schedule
|
||||||
|
|
||||||
|
|
||||||
const val SLEEP_TIMER_WAKE_UP_EXPIRATION = 120000L // 2m
|
const val SLEEP_TIMER_WAKE_UP_EXPIRATION = 120000L // 2m
|
||||||
|
const val PLAYER_CAST = "cast-player";
|
||||||
|
const val PLAYER_EXO = "exo-player";
|
||||||
|
|
||||||
class PlayerNotificationService : MediaBrowserServiceCompat() {
|
class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
var isStarted = false
|
var isStarted = false
|
||||||
|
var isClosed = false
|
||||||
|
var isUnmeteredNetwork = false
|
||||||
|
var isSwitchingPlayer = false // Used when switching between cast player and exoplayer
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ClientEventEmitter {
|
interface ClientEventEmitter {
|
||||||
@@ -46,12 +62,14 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
fun onPlaybackClosed()
|
fun onPlaybackClosed()
|
||||||
fun onPlayingUpdate(isPlaying: Boolean)
|
fun onPlayingUpdate(isPlaying: Boolean)
|
||||||
fun onMetadata(metadata: PlaybackMetadata)
|
fun onMetadata(metadata: PlaybackMetadata)
|
||||||
fun onPrepare(audiobookId: String, playWhenReady: Boolean)
|
|
||||||
fun onSleepTimerEnded(currentPosition: Long)
|
fun onSleepTimerEnded(currentPosition: Long)
|
||||||
fun onSleepTimerSet(sleepTimeRemaining: Int)
|
fun onSleepTimerSet(sleepTimeRemaining: Int)
|
||||||
fun onLocalMediaProgressUpdate(localMediaProgress: LocalMediaProgress)
|
fun onLocalMediaProgressUpdate(localMediaProgress: LocalMediaProgress)
|
||||||
fun onPlaybackFailed(errorMessage:String)
|
fun onPlaybackFailed(errorMessage:String)
|
||||||
fun onMediaPlayerChanged(mediaPlayer:String)
|
fun onMediaPlayerChanged(mediaPlayer:String)
|
||||||
|
fun onProgressSyncFailing()
|
||||||
|
fun onProgressSyncSuccess()
|
||||||
|
fun onNetworkMeteredChanged(isUnmetered:Boolean)
|
||||||
}
|
}
|
||||||
|
|
||||||
private val tag = "PlayerService"
|
private val tag = "PlayerService"
|
||||||
@@ -62,7 +80,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
private lateinit var ctx:Context
|
private lateinit var ctx:Context
|
||||||
private lateinit var mediaSessionConnector: MediaSessionConnector
|
private lateinit var mediaSessionConnector: MediaSessionConnector
|
||||||
private lateinit var playerNotificationManager: PlayerNotificationManager
|
private lateinit var playerNotificationManager: PlayerNotificationManager
|
||||||
private lateinit var mediaSession: MediaSessionCompat
|
lateinit var mediaSession: MediaSessionCompat
|
||||||
private lateinit var transportControls:MediaControllerCompat.TransportControls
|
private lateinit var transportControls:MediaControllerCompat.TransportControls
|
||||||
|
|
||||||
lateinit var mediaManager: MediaManager
|
lateinit var mediaManager: MediaManager
|
||||||
@@ -75,7 +93,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
lateinit var sleepTimerManager:SleepTimerManager
|
lateinit var sleepTimerManager:SleepTimerManager
|
||||||
lateinit var mediaProgressSyncer:MediaProgressSyncer
|
lateinit var mediaProgressSyncer:MediaProgressSyncer
|
||||||
|
|
||||||
private var notificationId = 10;
|
private var notificationId = 10
|
||||||
private var channelId = "audiobookshelf_channel"
|
private var channelId = "audiobookshelf_channel"
|
||||||
private var channelName = "Audiobookshelf Channel"
|
private var channelName = "Audiobookshelf Channel"
|
||||||
|
|
||||||
@@ -100,7 +118,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
// Android Auto Media Browser Service
|
// Android Auto Media Browser Service
|
||||||
if (SERVICE_INTERFACE == intent.action) {
|
if (SERVICE_INTERFACE == intent.action) {
|
||||||
Log.d(tag, "Is Media Browser Service")
|
Log.d(tag, "Is Media Browser Service")
|
||||||
return super.onBind(intent);
|
return super.onBind(intent)
|
||||||
}
|
}
|
||||||
return binder
|
return binder
|
||||||
}
|
}
|
||||||
@@ -110,11 +128,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
fun getService(): PlayerNotificationService = this@PlayerNotificationService
|
fun getService(): PlayerNotificationService = this@PlayerNotificationService
|
||||||
}
|
}
|
||||||
|
|
||||||
fun stopService(context: Context) {
|
|
||||||
val stopIntent = Intent(context, PlayerNotificationService::class.java)
|
|
||||||
context.stopService(stopIntent)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
isStarted = true
|
isStarted = true
|
||||||
Log.d(tag, "onStartCommand $startId")
|
Log.d(tag, "onStartCommand $startId")
|
||||||
@@ -139,6 +152,14 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
|
|
||||||
// detach player
|
// detach player
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
|
try {
|
||||||
|
val connectivityManager = getSystemService(ConnectivityManager::class.java) as ConnectivityManager
|
||||||
|
connectivityManager.unregisterNetworkCallback(networkCallback)
|
||||||
|
} catch(error:Exception) {
|
||||||
|
Log.e(tag, "Error unregistering network listening callback $error")
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(tag, "onDestroy")
|
||||||
playerNotificationManager.setPlayer(null)
|
playerNotificationManager.setPlayer(null)
|
||||||
mPlayer.release()
|
mPlayer.release()
|
||||||
castPlayer?.release()
|
castPlayer?.release()
|
||||||
@@ -157,30 +178,21 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
stopSelf()
|
stopSelf()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
|
Log.d(tag, "onCreate")
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
ctx = this
|
ctx = this
|
||||||
|
|
||||||
// Initialize player
|
// To listen for network change from metered to unmetered
|
||||||
val customLoadControl:LoadControl = DefaultLoadControl.Builder().setBufferDurationsMs(
|
val networkRequest = NetworkRequest.Builder()
|
||||||
1000 * 20, // 20s min buffer
|
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||||
1000 * 45, // 45s max buffer
|
.addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
|
||||||
1000 * 5, // 5s playback start
|
.addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
|
||||||
1000 * 20 // 20s playback rebuffer
|
|
||||||
).build()
|
|
||||||
|
|
||||||
mPlayer = ExoPlayer.Builder(this)
|
|
||||||
.setLoadControl(customLoadControl)
|
|
||||||
.setSeekBackIncrementMs(10000)
|
|
||||||
.setSeekForwardIncrementMs(10000)
|
|
||||||
.build()
|
.build()
|
||||||
mPlayer.setHandleAudioBecomingNoisy(true)
|
val connectivityManager = getSystemService(ConnectivityManager::class.java) as ConnectivityManager
|
||||||
mPlayer.addListener(PlayerListener(this))
|
connectivityManager.registerNetworkCallback(networkRequest, networkCallback)
|
||||||
val audioAttributes:AudioAttributes = AudioAttributes.Builder().setUsage(C.USAGE_MEDIA).setContentType(C.CONTENT_TYPE_SPEECH).build()
|
|
||||||
mPlayer.setAudioAttributes(audioAttributes, true)
|
|
||||||
|
|
||||||
currentPlayer = mPlayer
|
DbManager.initialize(ctx)
|
||||||
|
|
||||||
// Initialize API
|
// Initialize API
|
||||||
apiHandler = ApiHandler(ctx)
|
apiHandler = ApiHandler(ctx)
|
||||||
@@ -211,6 +223,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
.apply {
|
.apply {
|
||||||
setSessionActivity(sessionActivityPendingIntent)
|
setSessionActivity(sessionActivityPendingIntent)
|
||||||
isActive = true
|
isActive = true
|
||||||
|
setFlags(MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS or MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS)
|
||||||
}
|
}
|
||||||
|
|
||||||
val mediaController = MediaControllerCompat(ctx, mediaSession.sessionToken)
|
val mediaController = MediaControllerCompat(ctx, mediaSession.sessionToken)
|
||||||
@@ -232,6 +245,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
playerNotificationManager.setUseNextAction(false)
|
playerNotificationManager.setUseNextAction(false)
|
||||||
playerNotificationManager.setUsePreviousAction(false)
|
playerNotificationManager.setUsePreviousAction(false)
|
||||||
playerNotificationManager.setUseChronometer(false)
|
playerNotificationManager.setUseChronometer(false)
|
||||||
|
playerNotificationManager.setUseStopAction(true)
|
||||||
playerNotificationManager.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
playerNotificationManager.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||||
playerNotificationManager.setPriority(NotificationCompat.PRIORITY_MAX)
|
playerNotificationManager.setPriority(NotificationCompat.PRIORITY_MAX)
|
||||||
playerNotificationManager.setUseFastForwardActionInCompactView(true)
|
playerNotificationManager.setUseFastForwardActionInCompactView(true)
|
||||||
@@ -244,12 +258,35 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
|
|
||||||
mediaSessionConnector = MediaSessionConnector(mediaSession)
|
mediaSessionConnector = MediaSessionConnector(mediaSession)
|
||||||
val queueNavigator: TimelineQueueNavigator = object : TimelineQueueNavigator(mediaSession) {
|
val queueNavigator: TimelineQueueNavigator = object : TimelineQueueNavigator(mediaSession) {
|
||||||
|
override fun getSupportedQueueNavigatorActions(player: Player): Long {
|
||||||
|
return PlaybackStateCompat.ACTION_PLAY_PAUSE or PlaybackStateCompat.ACTION_PLAY or PlaybackStateCompat.ACTION_PAUSE
|
||||||
|
}
|
||||||
|
|
||||||
override fun getMediaDescription(player: Player, windowIndex: Int): MediaDescriptionCompat {
|
override fun getMediaDescription(player: Player, windowIndex: Int): MediaDescriptionCompat {
|
||||||
|
if (currentPlaybackSession == null) {
|
||||||
|
Log.e(tag,"Playback session is not set - returning blank MediaDescriptionCompat")
|
||||||
|
return MediaDescriptionCompat.Builder().build()
|
||||||
|
}
|
||||||
|
|
||||||
|
val coverUri = currentPlaybackSession!!.getCoverUri()
|
||||||
|
|
||||||
|
// Fix for local images crashing on Android 11 for specific devices
|
||||||
|
// https://stackoverflow.com/questions/64186578/android-11-mediastyle-notification-crash/64232958#64232958
|
||||||
|
try {
|
||||||
|
ctx.grantUriPermission(
|
||||||
|
"com.android.systemui",
|
||||||
|
coverUri,
|
||||||
|
Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||||
|
)
|
||||||
|
} catch(error:Exception) {
|
||||||
|
Log.e(tag, "Grant uri permission error $error")
|
||||||
|
}
|
||||||
|
|
||||||
return MediaDescriptionCompat.Builder()
|
return MediaDescriptionCompat.Builder()
|
||||||
.setMediaId(currentPlaybackSession!!.id)
|
.setMediaId(currentPlaybackSession!!.id)
|
||||||
.setTitle(currentPlaybackSession!!.displayTitle)
|
.setTitle(currentPlaybackSession!!.displayTitle)
|
||||||
.setSubtitle(currentPlaybackSession!!.displayAuthor)
|
.setSubtitle(currentPlaybackSession!!.displayAuthor)
|
||||||
.setIconUri(currentPlaybackSession!!.getCoverUri()).build()
|
.setIconUri(coverUri).build()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,30 +301,77 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
)
|
)
|
||||||
mediaSessionConnector.setQueueNavigator(queueNavigator)
|
mediaSessionConnector.setQueueNavigator(queueNavigator)
|
||||||
mediaSessionConnector.setPlaybackPreparer(MediaSessionPlaybackPreparer(this))
|
mediaSessionConnector.setPlaybackPreparer(MediaSessionPlaybackPreparer(this))
|
||||||
mediaSessionConnector.setPlayer(mPlayer)
|
|
||||||
|
mediaSession.setCallback(MediaSessionCallback(this))
|
||||||
|
|
||||||
|
initializeMPlayer()
|
||||||
|
currentPlayer = mPlayer
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initializeMPlayer() {
|
||||||
|
val customLoadControl:LoadControl = DefaultLoadControl.Builder().setBufferDurationsMs(
|
||||||
|
1000 * 20, // 20s min buffer
|
||||||
|
1000 * 45, // 45s max buffer
|
||||||
|
1000 * 5, // 5s playback start
|
||||||
|
1000 * 20 // 20s playback rebuffer
|
||||||
|
).build()
|
||||||
|
|
||||||
|
mPlayer = ExoPlayer.Builder(this)
|
||||||
|
.setLoadControl(customLoadControl)
|
||||||
|
.setSeekBackIncrementMs(deviceSettings.jumpBackwardsTimeMs)
|
||||||
|
.setSeekForwardIncrementMs(deviceSettings.jumpForwardTimeMs)
|
||||||
|
.build()
|
||||||
|
mPlayer.setHandleAudioBecomingNoisy(true)
|
||||||
|
mPlayer.addListener(PlayerListener(this))
|
||||||
|
val audioAttributes:AudioAttributes = AudioAttributes.Builder().setUsage(C.USAGE_MEDIA).setContentType(C.CONTENT_TYPE_SPEECH).build()
|
||||||
|
mPlayer.setAudioAttributes(audioAttributes, true)
|
||||||
|
|
||||||
//attach player to playerNotificationManager
|
//attach player to playerNotificationManager
|
||||||
playerNotificationManager.setPlayer(mPlayer)
|
playerNotificationManager.setPlayer(mPlayer)
|
||||||
mediaSession.setCallback(MediaSessionCallback(this))
|
|
||||||
|
mediaSessionConnector.setPlayer(mPlayer)
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
User callable methods
|
User callable methods
|
||||||
*/
|
*/
|
||||||
fun preparePlayer(playbackSession: PlaybackSession, playWhenReady:Boolean, playbackRate:Float?) {
|
fun preparePlayer(playbackSession: PlaybackSession, playWhenReady:Boolean, playbackRate:Float?) {
|
||||||
|
if (!isStarted) {
|
||||||
|
Log.i(tag, "preparePlayer: foreground service not started - Starting service --")
|
||||||
|
Intent(ctx, PlayerNotificationService::class.java).also { intent ->
|
||||||
|
ContextCompat.startForegroundService(ctx, intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isClosed = false
|
||||||
|
val customActionProviders = mutableListOf(
|
||||||
|
JumpBackwardCustomActionProvider(),
|
||||||
|
JumpForwardCustomActionProvider(),
|
||||||
|
)
|
||||||
|
val metadata = playbackSession.getMediaMetadataCompat()
|
||||||
|
mediaSession.setMetadata(metadata)
|
||||||
|
val mediaItems = playbackSession.getMediaItems()
|
||||||
val playbackRateToUse = playbackRate ?: initialPlaybackRate ?: 1f
|
val playbackRateToUse = playbackRate ?: initialPlaybackRate ?: 1f
|
||||||
initialPlaybackRate = playbackRate
|
initialPlaybackRate = playbackRate
|
||||||
|
|
||||||
|
if (playbackSession.mediaPlayer != PLAYER_CAST && mediaItems.size > 1) {
|
||||||
|
customActionProviders.addAll(listOf(
|
||||||
|
SkipBackwardCustomActionProvider(),
|
||||||
|
SkipForwardCustomActionProvider(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
mediaSessionConnector.setCustomActionProviders(*customActionProviders.toTypedArray());
|
||||||
|
|
||||||
playbackSession.mediaPlayer = getMediaPlayer()
|
playbackSession.mediaPlayer = getMediaPlayer()
|
||||||
|
|
||||||
if (playbackSession.mediaPlayer == "cast-player" && playbackSession.isLocal) {
|
if (playbackSession.mediaPlayer == PLAYER_CAST && playbackSession.isLocal) {
|
||||||
Log.w(tag, "Cannot cast local media item - switching player")
|
Log.w(tag, "Cannot cast local media item - switching player")
|
||||||
currentPlaybackSession = null
|
currentPlaybackSession = null
|
||||||
switchToPlayer(false)
|
switchToPlayer(false)
|
||||||
playbackSession.mediaPlayer = getMediaPlayer()
|
playbackSession.mediaPlayer = getMediaPlayer()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (playbackSession.mediaPlayer == "cast-player") {
|
if (playbackSession.mediaPlayer == PLAYER_CAST) {
|
||||||
// If cast-player is the first player to be used
|
// If cast-player is the first player to be used
|
||||||
mediaSessionConnector.setPlayer(castPlayer)
|
mediaSessionConnector.setPlayer(castPlayer)
|
||||||
playerNotificationManager.setPlayer(castPlayer)
|
playerNotificationManager.setPlayer(castPlayer)
|
||||||
@@ -298,10 +382,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
|
|
||||||
clientEventEmitter?.onPlaybackSession(playbackSession)
|
clientEventEmitter?.onPlaybackSession(playbackSession)
|
||||||
|
|
||||||
val metadata = playbackSession.getMediaMetadataCompat()
|
|
||||||
mediaSession.setMetadata(metadata)
|
|
||||||
val mediaItems = playbackSession.getMediaItems()
|
|
||||||
|
|
||||||
if (mediaItems.isEmpty()) {
|
if (mediaItems.isEmpty()) {
|
||||||
Log.e(tag, "Invalid playback session no media items to play")
|
Log.e(tag, "Invalid playback session no media items to play")
|
||||||
currentPlaybackSession = null
|
currentPlaybackSession = null
|
||||||
@@ -346,6 +426,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
Log.d(tag, "Prepare complete for session ${currentPlaybackSession?.displayTitle} | ${currentPlayer.mediaItemCount}")
|
Log.d(tag, "Prepare complete for session ${currentPlaybackSession?.displayTitle} | ${currentPlayer.mediaItemCount}")
|
||||||
currentPlayer.playWhenReady = playWhenReady
|
currentPlayer.playWhenReady = playWhenReady
|
||||||
currentPlayer.setPlaybackSpeed(playbackRateToUse)
|
currentPlayer.setPlaybackSpeed(playbackRateToUse)
|
||||||
|
|
||||||
currentPlayer.prepare()
|
currentPlayer.prepare()
|
||||||
} else if (castPlayer != null) {
|
} else if (castPlayer != null) {
|
||||||
val currentTrackIndex = playbackSession.getCurrentTrackIndex()
|
val currentTrackIndex = playbackSession.getCurrentTrackIndex()
|
||||||
@@ -361,19 +442,43 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
// On error and was attempting to direct play - fallback to transcode
|
// On error and was attempting to direct play - fallback to transcode
|
||||||
currentPlaybackSession?.let { playbackSession ->
|
currentPlaybackSession?.let { playbackSession ->
|
||||||
if (playbackSession.isDirectPlay) {
|
if (playbackSession.isDirectPlay) {
|
||||||
val mediaPlayer = getMediaPlayer()
|
val playItemRequestPayload = getPlayItemRequestPayload(true)
|
||||||
Log.d(tag, "Fallback to transcode $mediaPlayer")
|
Log.d(tag, "Fallback to transcode $playItemRequestPayload.mediaPlayer")
|
||||||
|
|
||||||
val libraryItemId = playbackSession.libraryItemId ?: "" // Must be true since direct play
|
val libraryItemId = playbackSession.libraryItemId ?: "" // Must be true since direct play
|
||||||
val episodeId = playbackSession.episodeId
|
val episodeId = playbackSession.episodeId
|
||||||
apiHandler.playLibraryItem(libraryItemId, episodeId, true, mediaPlayer) {
|
apiHandler.playLibraryItem(libraryItemId, episodeId, playItemRequestPayload) {
|
||||||
Handler(Looper.getMainLooper()).post() {
|
if (it == null) { // Play request failed
|
||||||
preparePlayer(it, true, null)
|
clientEventEmitter?.onPlaybackFailed(errorMessage)
|
||||||
|
closePlayback(true)
|
||||||
|
} else {
|
||||||
|
Handler(Looper.getMainLooper()).post {
|
||||||
|
preparePlayer(it, true, null)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
clientEventEmitter?.onPlaybackFailed(errorMessage)
|
clientEventEmitter?.onPlaybackFailed(errorMessage)
|
||||||
closePlayback()
|
closePlayback(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startNewPlaybackSession() {
|
||||||
|
currentPlaybackSession?.let { playbackSession ->
|
||||||
|
val forceTranscode = playbackSession.isHLS // If already HLS then force
|
||||||
|
val playItemRequestPayload = getPlayItemRequestPayload(forceTranscode)
|
||||||
|
|
||||||
|
val libraryItemId = playbackSession.libraryItemId ?: "" // Must be true since direct play
|
||||||
|
val episodeId = playbackSession.episodeId
|
||||||
|
apiHandler.playLibraryItem(libraryItemId, episodeId, playItemRequestPayload) {
|
||||||
|
if (it == null) {
|
||||||
|
Log.e(tag, "Failed to start new playback session")
|
||||||
|
} else {
|
||||||
|
Handler(Looper.getMainLooper()).post {
|
||||||
|
preparePlayer(it, true, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -398,6 +503,18 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (currentPlaybackSession == null) {
|
||||||
|
Log.e(tag, "switchToPlayer: No Current playback session")
|
||||||
|
} else {
|
||||||
|
isSwitchingPlayer = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Playback session in progress syncer is a copy that is up-to-date so replace current here with that
|
||||||
|
// TODO: bad design here implemented to prevent the session in MediaProgressSyncer from changing while syncing
|
||||||
|
if (mediaProgressSyncer.currentPlaybackSession != null) {
|
||||||
|
currentPlaybackSession = mediaProgressSyncer.currentPlaybackSession?.clone()
|
||||||
|
}
|
||||||
|
|
||||||
currentPlayer = if (useCastPlayer) {
|
currentPlayer = if (useCastPlayer) {
|
||||||
Log.d(tag, "switchToPlayer: Using Cast Player " + castPlayer?.deviceInfo)
|
Log.d(tag, "switchToPlayer: Using Cast Player " + castPlayer?.deviceInfo)
|
||||||
mediaSessionConnector.setPlayer(castPlayer)
|
mediaSessionConnector.setPlayer(castPlayer)
|
||||||
@@ -412,15 +529,13 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
|
|
||||||
clientEventEmitter?.onMediaPlayerChanged(getMediaPlayer())
|
clientEventEmitter?.onMediaPlayerChanged(getMediaPlayer())
|
||||||
|
|
||||||
if (currentPlaybackSession == null) {
|
|
||||||
Log.d(tag, "switchToPlayer: No Current playback session")
|
|
||||||
}
|
|
||||||
|
|
||||||
currentPlaybackSession?.let {
|
currentPlaybackSession?.let {
|
||||||
Log.d(tag, "switchToPlayer: Preparing current playback session ${it.displayTitle}")
|
Log.d(tag, "switchToPlayer: Starting new playback session ${it.displayTitle}")
|
||||||
if (wasPlaying) { // media is paused when switching players
|
if (wasPlaying) { // media is paused when switching players
|
||||||
clientEventEmitter?.onPlayingUpdate(false)
|
clientEventEmitter?.onPlayingUpdate(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: Start a new playback session here instead of using the existing
|
||||||
preparePlayer(it, false, null)
|
preparePlayer(it, false, null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -457,10 +572,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
return currentPlaybackSession?.totalDurationMs ?: 0L
|
return currentPlaybackSession?.totalDurationMs ?: 0L
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getCurrentBookTitle() : String? {
|
|
||||||
return currentPlaybackSession?.displayTitle
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getCurrentPlaybackSessionCopy() :PlaybackSession? {
|
fun getCurrentPlaybackSessionCopy() :PlaybackSession? {
|
||||||
return currentPlaybackSession?.clone()
|
return currentPlaybackSession?.clone()
|
||||||
}
|
}
|
||||||
@@ -469,16 +580,102 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
return currentPlaybackSession?.id
|
return currentPlaybackSession?.id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getCurrentBookChapter():BookChapter? {
|
||||||
|
return currentPlaybackSession?.getChapterForTime(this.getCurrentTime())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called from PlayerListener play event
|
||||||
|
// check with server if progress has updated since last play and sync progress update
|
||||||
|
fun checkCurrentSessionProgress(seekBackTime:Long):Boolean {
|
||||||
|
if (currentPlaybackSession == null) return true
|
||||||
|
|
||||||
|
mediaProgressSyncer.currentPlaybackSession?.let { playbackSession ->
|
||||||
|
if (!apiHandler.isOnline() || playbackSession.isLocalLibraryItemOnly) {
|
||||||
|
return true // carry on
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playbackSession.isLocal) {
|
||||||
|
|
||||||
|
// Make sure this connection config exists
|
||||||
|
val serverConnectionConfig = DeviceManager.getServerConnectionConfig(playbackSession.serverConnectionConfigId)
|
||||||
|
if (serverConnectionConfig == null) {
|
||||||
|
Log.d(tag, "checkCurrentSessionProgress: Local library item server connection config is not saved ${playbackSession.serverConnectionConfigId}")
|
||||||
|
return true // carry on
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local playback session check if server has updated media progress
|
||||||
|
Log.d(tag, "checkCurrentSessionProgress: Checking if local media progress was updated on server")
|
||||||
|
apiHandler.getMediaProgress(playbackSession.libraryItemId!!, playbackSession.episodeId, serverConnectionConfig) { mediaProgress ->
|
||||||
|
|
||||||
|
if (mediaProgress != null && mediaProgress.lastUpdate > playbackSession.updatedAt && mediaProgress.currentTime != playbackSession.currentTime) {
|
||||||
|
Log.d(tag, "checkCurrentSessionProgress: Media progress was updated since last play time updating from ${playbackSession.currentTime} to ${mediaProgress.currentTime}")
|
||||||
|
mediaProgressSyncer.syncFromServerProgress(mediaProgress)
|
||||||
|
|
||||||
|
// Update current playback session stored in PNS since MediaProgressSyncer version is a copy
|
||||||
|
mediaProgressSyncer.currentPlaybackSession?.let { updatedPlaybackSession ->
|
||||||
|
currentPlaybackSession = updatedPlaybackSession
|
||||||
|
}
|
||||||
|
|
||||||
|
Handler(Looper.getMainLooper()).post {
|
||||||
|
seekPlayer(playbackSession.currentTimeMs)
|
||||||
|
// Should already be playing
|
||||||
|
currentPlayer.volume = 1F // Volume on sleep timer might have decreased this
|
||||||
|
mediaProgressSyncer.start()
|
||||||
|
clientEventEmitter?.onPlayingUpdate(true)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Handler(Looper.getMainLooper()).post {
|
||||||
|
if (seekBackTime > 0L) {
|
||||||
|
seekBackward(seekBackTime)
|
||||||
|
}
|
||||||
|
// Should already be playing
|
||||||
|
currentPlayer.volume = 1F // Volume on sleep timer might have decreased this
|
||||||
|
mediaProgressSyncer.start()
|
||||||
|
clientEventEmitter?.onPlayingUpdate(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Streaming from server so check if playback session still exists on server
|
||||||
|
Log.d(
|
||||||
|
tag,
|
||||||
|
"checkCurrentSessionProgress: Checking if playback session for server stream is still available"
|
||||||
|
)
|
||||||
|
apiHandler.getPlaybackSession(playbackSession.id) {
|
||||||
|
if (it == null) {
|
||||||
|
Log.d(
|
||||||
|
tag,
|
||||||
|
"checkCurrentSessionProgress: Playback session does not exist on server - start new playback session"
|
||||||
|
)
|
||||||
|
|
||||||
|
Handler(Looper.getMainLooper()).post {
|
||||||
|
currentPlayer.pause()
|
||||||
|
startNewPlaybackSession()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.d(tag, "checkCurrentSessionProgress: Playback session still available on server")
|
||||||
|
Handler(Looper.getMainLooper()).post {
|
||||||
|
if (seekBackTime > 0L) {
|
||||||
|
seekBackward(seekBackTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
currentPlayer.volume = 1F // Volume on sleep timer might have decreased this
|
||||||
|
mediaProgressSyncer.start()
|
||||||
|
clientEventEmitter?.onPlayingUpdate(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
fun play() {
|
fun play() {
|
||||||
if (currentPlayer.isPlaying) {
|
if (currentPlayer.isPlaying) {
|
||||||
Log.d(tag, "Already playing")
|
Log.d(tag, "Already playing")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
currentPlayer.volume = 1F
|
currentPlayer.volume = 1F
|
||||||
if (currentPlayer == castPlayer) {
|
|
||||||
Log.d(tag, "CAST Player set on play ${currentPlayer.isLoading} || ${currentPlayer.duration} | ${currentPlayer.currentPosition}")
|
|
||||||
}
|
|
||||||
|
|
||||||
currentPlayer.play()
|
currentPlayer.play()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -508,26 +705,56 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun skipToPrevious() {
|
||||||
|
currentPlayer.seekToPrevious()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun skipToNext() {
|
||||||
|
currentPlayer.seekToNext()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun jumpForward() {
|
||||||
|
seekForward(deviceSettings.jumpForwardTimeMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun jumpBackward() {
|
||||||
|
seekBackward(deviceSettings.jumpBackwardsTimeMs)
|
||||||
|
}
|
||||||
|
|
||||||
fun seekForward(amount: Long) {
|
fun seekForward(amount: Long) {
|
||||||
seekPlayer(getCurrentTime() + amount)
|
seekPlayer(getCurrentTime() + amount)
|
||||||
// currentPlayer.seekTo(currentPlayer.currentPosition + amount)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun seekBackward(amount: Long) {
|
fun seekBackward(amount: Long) {
|
||||||
seekPlayer(getCurrentTime() - amount)
|
seekPlayer(getCurrentTime() - amount)
|
||||||
// currentPlayer.seekTo(currentPlayer.currentPosition - amount)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setPlaybackSpeed(speed: Float) {
|
fun setPlaybackSpeed(speed: Float) {
|
||||||
currentPlayer.setPlaybackSpeed(speed)
|
currentPlayer.setPlaybackSpeed(speed)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun closePlayback() {
|
fun closePlayback(calledOnError:Boolean? = false) {
|
||||||
currentPlayer.clearMediaItems()
|
Log.d(tag, "closePlayback")
|
||||||
currentPlayer.stop()
|
if (mediaProgressSyncer.listeningTimerRunning) {
|
||||||
|
Log.i(tag, "About to close playback so stopping media progress syncer first")
|
||||||
|
mediaProgressSyncer.stop(calledOnError == false) { // If closing on error then do not sync progress (causes exception)
|
||||||
|
Log.d(tag, "Media Progress syncer stopped")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
currentPlayer.stop()
|
||||||
|
currentPlayer.clearMediaItems()
|
||||||
|
} catch(e:Exception) {
|
||||||
|
Log.e(tag, "Exception clearing exoplayer $e")
|
||||||
|
}
|
||||||
|
|
||||||
currentPlaybackSession = null
|
currentPlaybackSession = null
|
||||||
clientEventEmitter?.onPlaybackClosed()
|
clientEventEmitter?.onPlaybackClosed()
|
||||||
PlayerListener.lastPauseTime = 0
|
PlayerListener.lastPauseTime = 0
|
||||||
|
isClosed = true
|
||||||
|
stopForeground(true)
|
||||||
|
stopSelf()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sendClientMetadata(playerState: PlayerState) {
|
fun sendClientMetadata(playerState: PlayerState) {
|
||||||
@@ -536,13 +763,39 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun getMediaPlayer():String {
|
fun getMediaPlayer():String {
|
||||||
return if(currentPlayer == castPlayer) "cast-player" else "exo-player"
|
return if(currentPlayer == castPlayer) PLAYER_CAST else PLAYER_EXO
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getDeviceInfo(): DeviceInfo {
|
||||||
|
/* EXAMPLE
|
||||||
|
manufacturer: Google
|
||||||
|
model: Pixel 6
|
||||||
|
brand: google
|
||||||
|
sdkVersion: 32
|
||||||
|
appVersion: 0.9.46-beta
|
||||||
|
*/
|
||||||
|
return DeviceInfo(Build.MANUFACTURER, Build.MODEL, Build.BRAND, Build.VERSION.SDK_INT, BuildConfig.VERSION_NAME)
|
||||||
|
}
|
||||||
|
|
||||||
|
@get:JsonIgnore
|
||||||
|
val deviceSettings get() = DeviceManager.deviceData.deviceSettings ?: DeviceSettings.default()
|
||||||
|
|
||||||
|
fun getPlayItemRequestPayload(forceTranscode:Boolean):PlayItemRequestPayload {
|
||||||
|
return PlayItemRequestPayload(getMediaPlayer(), !forceTranscode, forceTranscode, getDeviceInfo())
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getContext():Context {
|
fun getContext():Context {
|
||||||
return ctx
|
return ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun alertSyncFailing() {
|
||||||
|
clientEventEmitter?.onProgressSyncFailing()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun alertSyncSuccess() {
|
||||||
|
clientEventEmitter?.onProgressSyncSuccess()
|
||||||
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
// MEDIA BROWSER STUFF (ANDROID AUTO)
|
// MEDIA BROWSER STUFF (ANDROID AUTO)
|
||||||
//
|
//
|
||||||
@@ -551,10 +804,12 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
private val ANDROID_WEARABLE_PKG_NAME = "com.google.android.wearable.app"
|
private val ANDROID_WEARABLE_PKG_NAME = "com.google.android.wearable.app"
|
||||||
private val ANDROID_GSEARCH_PKG_NAME = "com.google.android.googlequicksearchbox"
|
private val ANDROID_GSEARCH_PKG_NAME = "com.google.android.googlequicksearchbox"
|
||||||
private val ANDROID_AUTOMOTIVE_PKG_NAME = "com.google.android.carassistant"
|
private val ANDROID_AUTOMOTIVE_PKG_NAME = "com.google.android.carassistant"
|
||||||
private val VALID_MEDIA_BROWSERS = mutableListOf<String>(ANDROID_AUTO_PKG_NAME, ANDROID_AUTO_SIMULATOR_PKG_NAME, ANDROID_WEARABLE_PKG_NAME, ANDROID_GSEARCH_PKG_NAME, ANDROID_AUTOMOTIVE_PKG_NAME)
|
private val VALID_MEDIA_BROWSERS = mutableListOf("com.audiobookshelf.app", ANDROID_AUTO_PKG_NAME, ANDROID_AUTO_SIMULATOR_PKG_NAME, ANDROID_WEARABLE_PKG_NAME, ANDROID_GSEARCH_PKG_NAME, ANDROID_AUTOMOTIVE_PKG_NAME)
|
||||||
|
|
||||||
private val AUTO_MEDIA_ROOT = "/"
|
private val AUTO_MEDIA_ROOT = "/"
|
||||||
private val ALL_ROOT = "__ALL__"
|
private val LIBRARIES_ROOT = "__LIBRARIES__"
|
||||||
|
private val DOWNLOADS_ROOT = "__DOWNLOADS__"
|
||||||
|
private val CONTINUE_ROOT = "__CONTINUE__"
|
||||||
private lateinit var browseTree:BrowseTree
|
private lateinit var browseTree:BrowseTree
|
||||||
|
|
||||||
|
|
||||||
@@ -575,11 +830,8 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
// No further calls will be made to other media browsing methods.
|
// No further calls will be made to other media browsing methods.
|
||||||
null
|
null
|
||||||
} else {
|
} else {
|
||||||
if (!isStarted) {
|
isStarted = true
|
||||||
Log.d(tag, "AA Not yet started")
|
mediaManager.checkResetServerItems() // Reset any server items if no longer connected to server
|
||||||
mediaManager.initializeAndroidAuto()
|
|
||||||
isStarted = true
|
|
||||||
}
|
|
||||||
|
|
||||||
isAndroidAuto = true
|
isAndroidAuto = true
|
||||||
|
|
||||||
@@ -600,34 +852,90 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
override fun onLoadChildren(parentMediaId: String, result: Result<MutableList<MediaBrowserCompat.MediaItem>>) {
|
override fun onLoadChildren(parentMediaId: String, result: Result<MutableList<MediaBrowserCompat.MediaItem>>) {
|
||||||
Log.d(tag, "ON LOAD CHILDREN $parentMediaId")
|
Log.d(tag, "ON LOAD CHILDREN $parentMediaId")
|
||||||
|
|
||||||
val flag = if (parentMediaId == AUTO_MEDIA_ROOT) MediaBrowserCompat.MediaItem.FLAG_BROWSABLE else MediaBrowserCompat.MediaItem.FLAG_PLAYABLE
|
|
||||||
|
|
||||||
result.detach()
|
result.detach()
|
||||||
|
|
||||||
mediaManager.loadAndroidAutoItems("main") { libraryCategories ->
|
if (parentMediaId.startsWith("li_") || parentMediaId.startsWith("local_")) { // Show podcast episodes
|
||||||
browseTree = BrowseTree(this, libraryCategories)
|
Log.d(tag, "Loading podcast episodes")
|
||||||
val children = browseTree[parentMediaId]?.map { item ->
|
mediaManager.loadPodcastEpisodeMediaBrowserItems(parentMediaId) {
|
||||||
MediaBrowserCompat.MediaItem(item.description, flag)
|
result.sendResult(it)
|
||||||
}
|
}
|
||||||
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
|
} else if (::browseTree.isInitialized && browseTree[parentMediaId] == null && mediaManager.getIsLibrary(parentMediaId)) { // Load library items for library
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: For using sub menus. Check if this is the root menu:
|
mediaManager.loadLibraryItemsWithAudio(parentMediaId) { libraryItems ->
|
||||||
// if (AUTO_MEDIA_ROOT == parentMediaId) {
|
val children = libraryItems.map { libraryItem ->
|
||||||
// build the MediaItem objects for the top level,
|
if (libraryItem.mediaType == "podcast") { // Podcasts are browseable
|
||||||
// and put them in the mediaItems list
|
val mediaDescription = libraryItem.getMediaDescription(null)
|
||||||
// } else {
|
MediaBrowserCompat.MediaItem(mediaDescription, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
|
||||||
// examine the passed parentMediaId to see which submenu we're at,
|
} else {
|
||||||
// and put the children of that menu in the mediaItems list
|
val progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == libraryItem.id }
|
||||||
// }
|
val description = libraryItem.getMediaDescription(progress)
|
||||||
|
MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
|
||||||
|
}
|
||||||
|
} else if (parentMediaId == DOWNLOADS_ROOT) { // Load downloads
|
||||||
|
|
||||||
|
val localBooks = DeviceManager.dbManager.getLocalLibraryItems("book")
|
||||||
|
val localPodcasts = DeviceManager.dbManager.getLocalLibraryItems("podcast")
|
||||||
|
val localBrowseItems:MutableList<MediaBrowserCompat.MediaItem> = mutableListOf()
|
||||||
|
|
||||||
|
localBooks.forEach { localLibraryItem ->
|
||||||
|
val progress = DeviceManager.dbManager.getLocalMediaProgress(localLibraryItem.id)
|
||||||
|
val description = localLibraryItem.getMediaDescription(progress)
|
||||||
|
|
||||||
|
localBrowseItems += MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
||||||
|
}
|
||||||
|
|
||||||
|
localPodcasts.forEach { localLibraryItem ->
|
||||||
|
val mediaDescription = localLibraryItem.getMediaDescription(null)
|
||||||
|
localBrowseItems += MediaBrowserCompat.MediaItem(mediaDescription, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
|
||||||
|
}
|
||||||
|
|
||||||
|
result.sendResult(localBrowseItems)
|
||||||
|
|
||||||
|
} else if (parentMediaId == CONTINUE_ROOT) {
|
||||||
|
val localBrowseItems:MutableList<MediaBrowserCompat.MediaItem> = mutableListOf()
|
||||||
|
mediaManager.serverItemsInProgress.forEach { itemInProgress ->
|
||||||
|
val progress: MediaProgressWrapper?
|
||||||
|
val mediaDescription:MediaDescriptionCompat
|
||||||
|
if (itemInProgress.episode != null) {
|
||||||
|
if (itemInProgress.isLocal) {
|
||||||
|
progress = DeviceManager.dbManager.getLocalMediaProgress("${itemInProgress.libraryItemWrapper.id}-${itemInProgress.episode.id}")
|
||||||
|
} else {
|
||||||
|
progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == itemInProgress.libraryItemWrapper.id && it.episodeId == itemInProgress.episode.id }
|
||||||
|
}
|
||||||
|
mediaDescription = itemInProgress.episode.getMediaDescription(itemInProgress.libraryItemWrapper,progress)
|
||||||
|
} else {
|
||||||
|
if (itemInProgress.isLocal) {
|
||||||
|
progress = DeviceManager.dbManager.getLocalMediaProgress(itemInProgress.libraryItemWrapper.id)
|
||||||
|
} else {
|
||||||
|
progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == itemInProgress.libraryItemWrapper.id }
|
||||||
|
}
|
||||||
|
mediaDescription = itemInProgress.libraryItemWrapper.getMediaDescription(progress)
|
||||||
|
}
|
||||||
|
localBrowseItems += MediaBrowserCompat.MediaItem(mediaDescription, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
||||||
|
}
|
||||||
|
result.sendResult(localBrowseItems)
|
||||||
|
} else {
|
||||||
|
mediaManager.loadAndroidAutoItems {
|
||||||
|
browseTree = BrowseTree(this, mediaManager.serverItemsInProgress, mediaManager.serverLibraries)
|
||||||
|
|
||||||
|
val children = browseTree[parentMediaId]?.map { item ->
|
||||||
|
Log.d(tag, "Loading Browser Media Item ${item.description.title}")
|
||||||
|
MediaBrowserCompat.MediaItem(item.description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
|
||||||
|
}
|
||||||
|
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSearch(query: String, extras: Bundle?, result: Result<MutableList<MediaBrowserCompat.MediaItem>>) {
|
override fun onSearch(query: String, extras: Bundle?, result: Result<MutableList<MediaBrowserCompat.MediaItem>>) {
|
||||||
result.detach()
|
result.detach()
|
||||||
mediaManager.loadAndroidAutoItems("main") { libraryCategories ->
|
mediaManager.loadAndroidAutoItems {
|
||||||
browseTree = BrowseTree(this, libraryCategories)
|
browseTree = BrowseTree(this, mediaManager.serverItemsInProgress, mediaManager.serverLibraries)
|
||||||
val children = browseTree[ALL_ROOT]?.map { item ->
|
val children = browseTree[LIBRARIES_ROOT]?.map { item ->
|
||||||
MediaBrowserCompat.MediaItem(item.description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
MediaBrowserCompat.MediaItem(item.description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
|
||||||
}
|
}
|
||||||
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
|
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
|
||||||
}
|
}
|
||||||
@@ -680,5 +988,87 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val networkCallback = object : ConnectivityManager.NetworkCallback() {
|
||||||
|
// Network capabilities have changed for the network
|
||||||
|
override fun onCapabilitiesChanged(
|
||||||
|
network: Network,
|
||||||
|
networkCapabilities: NetworkCapabilities
|
||||||
|
) {
|
||||||
|
super.onCapabilitiesChanged(network, networkCapabilities)
|
||||||
|
val unmetered = networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
|
||||||
|
Log.i(tag, "Network capabilities changed is unmetered = $unmetered")
|
||||||
|
isUnmeteredNetwork = unmetered
|
||||||
|
clientEventEmitter?.onNetworkMeteredChanged(unmetered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inner class JumpBackwardCustomActionProvider : CustomActionProvider {
|
||||||
|
override fun onCustomAction(player: Player, action: String, extras: Bundle?) {
|
||||||
|
/*
|
||||||
|
This does not appear to ever get called. Instead, MediaSessionCallback.onCustomAction() is
|
||||||
|
responsible to reacting to a custom action.
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getCustomAction(player: Player): PlaybackStateCompat.CustomAction? {
|
||||||
|
return PlaybackStateCompat.CustomAction.Builder(
|
||||||
|
CUSTOM_ACTION_JUMP_BACKWARD,
|
||||||
|
getContext().getString(R.string.action_jump_backward),
|
||||||
|
R.drawable.exo_icon_rewind
|
||||||
|
).build()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inner class JumpForwardCustomActionProvider : CustomActionProvider {
|
||||||
|
override fun onCustomAction(player: Player, action: String, extras: Bundle?) {
|
||||||
|
/*
|
||||||
|
This does not appear to ever get called. Instead, MediaSessionCallback.onCustomAction() is
|
||||||
|
responsible to reacting to a custom action.
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getCustomAction(player: Player): PlaybackStateCompat.CustomAction? {
|
||||||
|
return PlaybackStateCompat.CustomAction.Builder(
|
||||||
|
CUSTOM_ACTION_JUMP_FORWARD,
|
||||||
|
getContext().getString(R.string.action_jump_forward),
|
||||||
|
R.drawable.exo_icon_fastforward
|
||||||
|
).build()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inner class SkipForwardCustomActionProvider : CustomActionProvider {
|
||||||
|
override fun onCustomAction(player: Player, action: String, extras: Bundle?) {
|
||||||
|
/*
|
||||||
|
This does not appear to ever get called. Instead, MediaSessionCallback.onCustomAction() is
|
||||||
|
responsible to reacting to a custom action.
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getCustomAction(player: Player): PlaybackStateCompat.CustomAction? {
|
||||||
|
return PlaybackStateCompat.CustomAction.Builder(
|
||||||
|
CUSTOM_ACTION_SKIP_FORWARD,
|
||||||
|
getContext().getString(R.string.action_skip_forward),
|
||||||
|
R.drawable.skip_next_24
|
||||||
|
).build()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inner class SkipBackwardCustomActionProvider : CustomActionProvider {
|
||||||
|
override fun onCustomAction(player: Player, action: String, extras: Bundle?) {
|
||||||
|
/*
|
||||||
|
This does not appear to ever get called. Instead, MediaSessionCallback.onCustomAction() is
|
||||||
|
responsible to reacting to a custom action.
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getCustomAction(player: Player): PlaybackStateCompat.CustomAction? {
|
||||||
|
return PlaybackStateCompat.CustomAction.Builder(
|
||||||
|
CUSTOM_ACTION_SKIP_BACKWARD,
|
||||||
|
getContext().getString(R.string.action_skip_backward),
|
||||||
|
R.drawable.skip_previous_24
|
||||||
|
).build()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.audiobookshelf.app.player
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.*
|
import android.os.*
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import com.audiobookshelf.app.device.DeviceManager
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import kotlin.concurrent.schedule
|
import kotlin.concurrent.schedule
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
@@ -203,8 +204,13 @@ class SleepTimerManager constructor(val playerNotificationService:PlayerNotifica
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun handleShake() {
|
fun handleShake() {
|
||||||
Log.d(tag, "HANDLE SHAKE HERE")
|
if (sleepTimerRunning || sleepTimerFinishedAt > 0L) {
|
||||||
if (sleepTimerRunning || sleepTimerFinishedAt > 0L) checkShouldExtendSleepTimer()
|
if (DeviceManager.deviceData.deviceSettings?.disableShakeToResetSleepTimer == true) {
|
||||||
|
Log.d(tag, "Shake to reset sleep timer is disabled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
checkShouldExtendSleepTimer()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun increaseSleepTime(time: Long) {
|
fun increaseSleepTime(time: Long) {
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
package com.audiobookshelf.app.plugins
|
package com.audiobookshelf.app.plugins
|
||||||
|
|
||||||
import android.content.Intent
|
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import com.audiobookshelf.app.MainActivity
|
import com.audiobookshelf.app.MainActivity
|
||||||
import com.audiobookshelf.app.data.*
|
import com.audiobookshelf.app.data.*
|
||||||
import com.audiobookshelf.app.device.DeviceManager
|
import com.audiobookshelf.app.device.DeviceManager
|
||||||
@@ -59,13 +57,6 @@ class AbsAudioPlayer : Plugin() {
|
|||||||
notifyListeners("onMetadata", JSObject(jacksonMapper.writeValueAsString(metadata)))
|
notifyListeners("onMetadata", JSObject(jacksonMapper.writeValueAsString(metadata)))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPrepare(audiobookId: String, playWhenReady: Boolean) {
|
|
||||||
val jsobj = JSObject()
|
|
||||||
jsobj.put("audiobookId", audiobookId)
|
|
||||||
jsobj.put("playWhenReady", playWhenReady)
|
|
||||||
notifyListeners("onPrepareMedia", jsobj)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onSleepTimerEnded(currentPosition: Long) {
|
override fun onSleepTimerEnded(currentPosition: Long) {
|
||||||
emit("onSleepTimerEnded", currentPosition)
|
emit("onSleepTimerEnded", currentPosition)
|
||||||
}
|
}
|
||||||
@@ -85,6 +76,18 @@ class AbsAudioPlayer : Plugin() {
|
|||||||
override fun onMediaPlayerChanged(mediaPlayer:String) {
|
override fun onMediaPlayerChanged(mediaPlayer:String) {
|
||||||
emit("onMediaPlayerChanged", mediaPlayer)
|
emit("onMediaPlayerChanged", mediaPlayer)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onProgressSyncFailing() {
|
||||||
|
emit("onProgressSyncFailing", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onProgressSyncSuccess() {
|
||||||
|
emit("onProgressSyncSuccess", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNetworkMeteredChanged(isUnmetered:Boolean) {
|
||||||
|
emit("onNetworkMeteredChanged", isUnmetered)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
mainActivity.pluginCallback = foregroundServiceReady
|
mainActivity.pluginCallback = foregroundServiceReady
|
||||||
@@ -153,14 +156,6 @@ class AbsAudioPlayer : Plugin() {
|
|||||||
|
|
||||||
@PluginMethod
|
@PluginMethod
|
||||||
fun prepareLibraryItem(call: PluginCall) {
|
fun prepareLibraryItem(call: PluginCall) {
|
||||||
// Need to make sure the player service has been started
|
|
||||||
if (!PlayerNotificationService.isStarted) {
|
|
||||||
Log.w(tag, "prepareLibraryItem: PlayerService not started - Starting foreground service --")
|
|
||||||
Intent(mainActivity, PlayerNotificationService::class.java).also { intent ->
|
|
||||||
ContextCompat.startForegroundService(mainActivity, intent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val libraryItemId = call.getString("libraryItemId", "").toString()
|
val libraryItemId = call.getString("libraryItemId", "").toString()
|
||||||
val episodeId = call.getString("episodeId", "").toString()
|
val episodeId = call.getString("episodeId", "").toString()
|
||||||
val playWhenReady = call.getBoolean("playWhenReady") == true
|
val playWhenReady = call.getBoolean("playWhenReady") == true
|
||||||
@@ -168,7 +163,7 @@ class AbsAudioPlayer : Plugin() {
|
|||||||
|
|
||||||
if (libraryItemId.isEmpty()) {
|
if (libraryItemId.isEmpty()) {
|
||||||
Log.e(tag, "Invalid call to play library item no library item id")
|
Log.e(tag, "Invalid call to play library item no library item id")
|
||||||
return call.resolve()
|
return call.resolve(JSObject("{\"error\":\"Invalid request\"}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (libraryItemId.startsWith("local")) { // Play local media item
|
if (libraryItemId.startsWith("local")) { // Play local media item
|
||||||
@@ -179,28 +174,56 @@ class AbsAudioPlayer : Plugin() {
|
|||||||
episode = podcastMedia.episodes?.find { ep -> ep.id == episodeId }
|
episode = podcastMedia.episodes?.find { ep -> ep.id == episodeId }
|
||||||
if (episode == null) {
|
if (episode == null) {
|
||||||
Log.e(tag, "prepareLibraryItem: Podcast episode not found $episodeId")
|
Log.e(tag, "prepareLibraryItem: Podcast episode not found $episodeId")
|
||||||
return call.resolve(JSObject())
|
return call.resolve(JSObject("{\"error\":\"Podcast episode not found\"}"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Handler(Looper.getMainLooper()).post {
|
Handler(Looper.getMainLooper()).post {
|
||||||
Log.d(tag, "prepareLibraryItem: Preparing Local Media item ${jacksonMapper.writeValueAsString(it)}")
|
Log.d(tag, "prepareLibraryItem: Preparing Local Media item ${jacksonMapper.writeValueAsString(it)}")
|
||||||
val playbackSession = it.getPlaybackSession(episode)
|
val playbackSession = it.getPlaybackSession(episode)
|
||||||
playerNotificationService.preparePlayer(playbackSession, playWhenReady, playbackRate)
|
|
||||||
|
if (playerNotificationService.mediaProgressSyncer.listeningTimerRunning) { // If progress syncing then first stop before preparing next
|
||||||
|
playerNotificationService.mediaProgressSyncer.stop {
|
||||||
|
Log.d(tag, "Media progress syncer was already syncing - stopped")
|
||||||
|
Handler(Looper.getMainLooper()).post { // TODO: This was needed again which is probably a design a flaw
|
||||||
|
playerNotificationService.preparePlayer(
|
||||||
|
playbackSession,
|
||||||
|
playWhenReady,
|
||||||
|
playbackRate
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
playerNotificationService.preparePlayer(playbackSession, playWhenReady, playbackRate)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return call.resolve(JSObject())
|
return call.resolve(JSObject())
|
||||||
}
|
}
|
||||||
} else { // Play library item from server
|
} else { // Play library item from server
|
||||||
val mediaPlayer = playerNotificationService.getMediaPlayer()
|
val playItemRequestPayload = playerNotificationService.getPlayItemRequestPayload(false)
|
||||||
|
|
||||||
apiHandler.playLibraryItem(libraryItemId, episodeId, false, mediaPlayer) {
|
apiHandler.playLibraryItem(libraryItemId, episodeId, playItemRequestPayload) {
|
||||||
|
if (it == null) {
|
||||||
|
call.resolve(JSObject("{\"error\":\"Server play request failed\"}"))
|
||||||
|
} else {
|
||||||
|
|
||||||
Handler(Looper.getMainLooper()).post {
|
Handler(Looper.getMainLooper()).post {
|
||||||
Log.d(tag, "Preparing Player TEST ${jacksonMapper.writeValueAsString(it)}")
|
Log.d(tag, "Preparing Player playback session ${jacksonMapper.writeValueAsString(it)}")
|
||||||
playerNotificationService.preparePlayer(it, playWhenReady, playbackRate)
|
|
||||||
|
if (playerNotificationService.mediaProgressSyncer.listeningTimerRunning) { // If progress syncing then first stop before preparing next
|
||||||
|
playerNotificationService.mediaProgressSyncer.stop {
|
||||||
|
Log.d(tag, "Media progress syncer was already syncing - stopped")
|
||||||
|
Handler(Looper.getMainLooper()).post { // TODO: This was needed again which is probably a design a flaw
|
||||||
|
playerNotificationService.preparePlayer(it, playWhenReady, playbackRate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
playerNotificationService.preparePlayer(it, playWhenReady, playbackRate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
call.resolve(JSObject(jacksonMapper.writeValueAsString(it)))
|
||||||
}
|
}
|
||||||
|
|
||||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(it)))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import com.audiobookshelf.app.device.DeviceManager
|
|||||||
import com.audiobookshelf.app.server.ApiHandler
|
import com.audiobookshelf.app.server.ApiHandler
|
||||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||||
|
import com.fasterxml.jackson.module.kotlin.readValue
|
||||||
import com.getcapacitor.*
|
import com.getcapacitor.*
|
||||||
import com.getcapacitor.annotation.CapacitorPlugin
|
import com.getcapacitor.annotation.CapacitorPlugin
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -24,6 +25,7 @@ class AbsDatabase : Plugin() {
|
|||||||
data class LocalMediaProgressPayload(val value:List<LocalMediaProgress>)
|
data class LocalMediaProgressPayload(val value:List<LocalMediaProgress>)
|
||||||
data class LocalLibraryItemsPayload(val value:List<LocalLibraryItem>)
|
data class LocalLibraryItemsPayload(val value:List<LocalLibraryItem>)
|
||||||
data class LocalFoldersPayload(val value:List<LocalFolder>)
|
data class LocalFoldersPayload(val value:List<LocalFolder>)
|
||||||
|
data class ServerConnConfigPayload(val id:String?, val index:Int, val name:String?, val userId:String, val username:String, val token:String, val address:String?, val customHeaders:Map<String,String>?)
|
||||||
|
|
||||||
override fun load() {
|
override fun load() {
|
||||||
mainActivity = (activity as MainActivity)
|
mainActivity = (activity as MainActivity)
|
||||||
@@ -36,7 +38,7 @@ class AbsDatabase : Plugin() {
|
|||||||
@PluginMethod
|
@PluginMethod
|
||||||
fun getDeviceData(call:PluginCall) {
|
fun getDeviceData(call:PluginCall) {
|
||||||
GlobalScope.launch(Dispatchers.IO) {
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
var deviceData = DeviceManager.dbManager.getDeviceData()
|
val deviceData = DeviceManager.dbManager.getDeviceData()
|
||||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(deviceData)))
|
call.resolve(JSObject(jacksonMapper.writeValueAsString(deviceData)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -44,17 +46,17 @@ class AbsDatabase : Plugin() {
|
|||||||
@PluginMethod
|
@PluginMethod
|
||||||
fun getLocalFolders(call:PluginCall) {
|
fun getLocalFolders(call:PluginCall) {
|
||||||
GlobalScope.launch(Dispatchers.IO) {
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
var folders = DeviceManager.dbManager.getAllLocalFolders()
|
val folders = DeviceManager.dbManager.getAllLocalFolders()
|
||||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(LocalFoldersPayload(folders))))
|
call.resolve(JSObject(jacksonMapper.writeValueAsString(LocalFoldersPayload(folders))))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@PluginMethod
|
@PluginMethod
|
||||||
fun getLocalFolder(call:PluginCall) {
|
fun getLocalFolder(call:PluginCall) {
|
||||||
var folderId = call.getString("folderId", "").toString()
|
val folderId = call.getString("folderId", "").toString()
|
||||||
GlobalScope.launch(Dispatchers.IO) {
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
DeviceManager.dbManager.getLocalFolder(folderId)?.let {
|
DeviceManager.dbManager.getLocalFolder(folderId)?.let {
|
||||||
var folderObj = jacksonMapper.writeValueAsString(it)
|
val folderObj = jacksonMapper.writeValueAsString(it)
|
||||||
call.resolve(JSObject(folderObj))
|
call.resolve(JSObject(folderObj))
|
||||||
} ?: call.resolve()
|
} ?: call.resolve()
|
||||||
}
|
}
|
||||||
@@ -62,10 +64,10 @@ class AbsDatabase : Plugin() {
|
|||||||
|
|
||||||
@PluginMethod
|
@PluginMethod
|
||||||
fun getLocalLibraryItem(call:PluginCall) {
|
fun getLocalLibraryItem(call:PluginCall) {
|
||||||
var id = call.getString("id", "").toString()
|
val id = call.getString("id", "").toString()
|
||||||
|
|
||||||
GlobalScope.launch(Dispatchers.IO) {
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
var localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(id)
|
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(id)
|
||||||
if (localLibraryItem == null) {
|
if (localLibraryItem == null) {
|
||||||
call.resolve()
|
call.resolve()
|
||||||
} else {
|
} else {
|
||||||
@@ -75,10 +77,10 @@ class AbsDatabase : Plugin() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PluginMethod
|
@PluginMethod
|
||||||
fun getLocalLibraryItemByLLId(call:PluginCall) {
|
fun getLocalLibraryItemByLId(call:PluginCall) {
|
||||||
var libraryItemId = call.getString("libraryItemId", "").toString()
|
val libraryItemId = call.getString("libraryItemId", "").toString()
|
||||||
GlobalScope.launch(Dispatchers.IO) {
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
var localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLLId(libraryItemId)
|
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLId(libraryItemId)
|
||||||
if (localLibraryItem == null) {
|
if (localLibraryItem == null) {
|
||||||
call.resolve()
|
call.resolve()
|
||||||
} else {
|
} else {
|
||||||
@@ -89,40 +91,41 @@ class AbsDatabase : Plugin() {
|
|||||||
|
|
||||||
@PluginMethod
|
@PluginMethod
|
||||||
fun getLocalLibraryItems(call:PluginCall) {
|
fun getLocalLibraryItems(call:PluginCall) {
|
||||||
var mediaType = call.getString("mediaType", "").toString()
|
val mediaType = call.getString("mediaType", "").toString()
|
||||||
|
|
||||||
GlobalScope.launch(Dispatchers.IO) {
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
var localLibraryItems = DeviceManager.dbManager.getLocalLibraryItems(mediaType)
|
val localLibraryItems = DeviceManager.dbManager.getLocalLibraryItems(mediaType)
|
||||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(LocalLibraryItemsPayload(localLibraryItems))))
|
call.resolve(JSObject(jacksonMapper.writeValueAsString(LocalLibraryItemsPayload(localLibraryItems))))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@PluginMethod
|
@PluginMethod
|
||||||
fun getLocalLibraryItemsInFolder(call:PluginCall) {
|
fun getLocalLibraryItemsInFolder(call:PluginCall) {
|
||||||
var folderId = call.getString("folderId", "").toString()
|
val folderId = call.getString("folderId", "").toString()
|
||||||
GlobalScope.launch(Dispatchers.IO) {
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
var localLibraryItems = DeviceManager.dbManager.getLocalLibraryItemsInFolder(folderId)
|
val localLibraryItems = DeviceManager.dbManager.getLocalLibraryItemsInFolder(folderId)
|
||||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(LocalLibraryItemsPayload(localLibraryItems))))
|
call.resolve(JSObject(jacksonMapper.writeValueAsString(LocalLibraryItemsPayload(localLibraryItems))))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@PluginMethod
|
@PluginMethod
|
||||||
fun setCurrentServerConnectionConfig(call:PluginCall) {
|
fun setCurrentServerConnectionConfig(call:PluginCall) {
|
||||||
var serverConnectionConfigId = call.getString("id", "").toString()
|
Log.d(tag, "setCurrentServerConnectionConfig ${call.data}")
|
||||||
var serverConnectionConfig = DeviceManager.deviceData.serverConnectionConfigs.find { it.id == serverConnectionConfigId }
|
val serverConfigPayload = jacksonMapper.readValue<ServerConnConfigPayload>(call.data.toString())
|
||||||
|
var serverConnectionConfig = DeviceManager.deviceData.serverConnectionConfigs.find { it.id == serverConfigPayload.id }
|
||||||
|
|
||||||
var userId = call.getString("userId", "").toString()
|
val userId = serverConfigPayload.userId
|
||||||
var username = call.getString("username", "").toString()
|
val username = serverConfigPayload.username
|
||||||
var token = call.getString("token", "").toString()
|
val token = serverConfigPayload.token
|
||||||
|
|
||||||
GlobalScope.launch(Dispatchers.IO) {
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
if (serverConnectionConfig == null) { // New Server Connection
|
if (serverConnectionConfig == null) { // New Server Connection
|
||||||
var serverAddress = call.getString("address", "").toString()
|
val serverAddress = call.getString("address", "").toString()
|
||||||
|
|
||||||
// Create new server connection config
|
// Create new server connection config
|
||||||
var sscId = DeviceManager.getBase64Id("$serverAddress@$username")
|
val sscId = DeviceManager.getBase64Id("$serverAddress@$username")
|
||||||
var sscIndex = DeviceManager.deviceData.serverConnectionConfigs.size
|
val sscIndex = DeviceManager.deviceData.serverConnectionConfigs.size
|
||||||
serverConnectionConfig = ServerConnectionConfig(sscId, sscIndex, "$serverAddress ($username)", serverAddress, userId, username, token)
|
serverConnectionConfig = ServerConnectionConfig(sscId, sscIndex, "$serverAddress ($username)", serverAddress, userId, username, token, serverConfigPayload.customHeaders)
|
||||||
|
|
||||||
// Add and save
|
// Add and save
|
||||||
DeviceManager.deviceData.serverConnectionConfigs.add(serverConnectionConfig!!)
|
DeviceManager.deviceData.serverConnectionConfigs.add(serverConnectionConfig!!)
|
||||||
@@ -139,8 +142,8 @@ class AbsDatabase : Plugin() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set last connection config
|
// Set last connection config
|
||||||
if (DeviceManager.deviceData.lastServerConnectionConfigId != serverConnectionConfigId) {
|
if (DeviceManager.deviceData.lastServerConnectionConfigId != serverConfigPayload.id) {
|
||||||
DeviceManager.deviceData.lastServerConnectionConfigId = serverConnectionConfigId
|
DeviceManager.deviceData.lastServerConnectionConfigId = serverConfigPayload.id
|
||||||
shouldSave = true
|
shouldSave = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,7 +158,7 @@ class AbsDatabase : Plugin() {
|
|||||||
@PluginMethod
|
@PluginMethod
|
||||||
fun removeServerConnectionConfig(call:PluginCall) {
|
fun removeServerConnectionConfig(call:PluginCall) {
|
||||||
GlobalScope.launch(Dispatchers.IO) {
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
var serverConnectionConfigId = call.getString("serverConnectionConfigId", "").toString()
|
val serverConnectionConfigId = call.getString("serverConnectionConfigId", "").toString()
|
||||||
DeviceManager.deviceData.serverConnectionConfigs = DeviceManager.deviceData.serverConnectionConfigs.filter { it.id != serverConnectionConfigId } as MutableList<ServerConnectionConfig>
|
DeviceManager.deviceData.serverConnectionConfigs = DeviceManager.deviceData.serverConnectionConfigs.filter { it.id != serverConnectionConfigId } as MutableList<ServerConnectionConfig>
|
||||||
if (DeviceManager.deviceData.lastServerConnectionConfigId == serverConnectionConfigId) {
|
if (DeviceManager.deviceData.lastServerConnectionConfigId == serverConnectionConfigId) {
|
||||||
DeviceManager.deviceData.lastServerConnectionConfigId = null
|
DeviceManager.deviceData.lastServerConnectionConfigId = null
|
||||||
@@ -181,14 +184,14 @@ class AbsDatabase : Plugin() {
|
|||||||
@PluginMethod
|
@PluginMethod
|
||||||
fun getAllLocalMediaProgress(call:PluginCall) {
|
fun getAllLocalMediaProgress(call:PluginCall) {
|
||||||
GlobalScope.launch(Dispatchers.IO) {
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
var localMediaProgress = DeviceManager.dbManager.getAllLocalMediaProgress()
|
val localMediaProgress = DeviceManager.dbManager.getAllLocalMediaProgress()
|
||||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(LocalMediaProgressPayload(localMediaProgress))))
|
call.resolve(JSObject(jacksonMapper.writeValueAsString(LocalMediaProgressPayload(localMediaProgress))))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@PluginMethod
|
@PluginMethod
|
||||||
fun removeLocalMediaProgress(call:PluginCall) {
|
fun removeLocalMediaProgress(call:PluginCall) {
|
||||||
var localMediaProgressId = call.getString("localMediaProgressId", "").toString()
|
val localMediaProgressId = call.getString("localMediaProgressId", "").toString()
|
||||||
DeviceManager.dbManager.removeLocalMediaProgress(localMediaProgressId)
|
DeviceManager.dbManager.removeLocalMediaProgress(localMediaProgressId)
|
||||||
call.resolve()
|
call.resolve()
|
||||||
}
|
}
|
||||||
@@ -204,6 +207,63 @@ class AbsDatabase : Plugin() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Updates received via web socket
|
||||||
|
// This function doesn't need to sync with the server also because this data is coming from the server
|
||||||
|
// If sending the localMediaProgressId then update existing media progress
|
||||||
|
// If sending localLibraryItemId then save new local media progress
|
||||||
|
@PluginMethod
|
||||||
|
fun syncServerMediaProgressWithLocalMediaProgress(call:PluginCall) {
|
||||||
|
val serverMediaProgress = call.getObject("mediaProgress").toString()
|
||||||
|
val localLibraryItemId = call.getString("localLibraryItemId", "").toString()
|
||||||
|
var localEpisodeId:String? = call.getString("localEpisodeId", "").toString()
|
||||||
|
if (localEpisodeId.isNullOrEmpty()) localEpisodeId = null
|
||||||
|
var localMediaProgressId = call.getString("localMediaProgressId") ?: ""
|
||||||
|
|
||||||
|
val mediaProgress = jacksonMapper.readValue<MediaProgress>(serverMediaProgress)
|
||||||
|
|
||||||
|
if (localMediaProgressId == "") {
|
||||||
|
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
|
||||||
|
if (localLibraryItem != null) {
|
||||||
|
localMediaProgressId = if (localEpisodeId.isNullOrEmpty()) localLibraryItemId else "$localLibraryItemId-$localEpisodeId"
|
||||||
|
|
||||||
|
val localMediaProgress = LocalMediaProgress(
|
||||||
|
id = localMediaProgressId,
|
||||||
|
localLibraryItemId = localLibraryItemId,
|
||||||
|
localEpisodeId = localEpisodeId,
|
||||||
|
duration = mediaProgress.duration,
|
||||||
|
progress = mediaProgress.progress,
|
||||||
|
currentTime = mediaProgress.currentTime,
|
||||||
|
isFinished = mediaProgress.isFinished,
|
||||||
|
lastUpdate = mediaProgress.lastUpdate,
|
||||||
|
startedAt = mediaProgress.startedAt,
|
||||||
|
finishedAt = mediaProgress.finishedAt,
|
||||||
|
serverConnectionConfigId = localLibraryItem.serverConnectionConfigId,
|
||||||
|
serverAddress = localLibraryItem.serverAddress,
|
||||||
|
serverUserId = localLibraryItem.serverUserId,
|
||||||
|
libraryItemId = localLibraryItem.libraryItemId,
|
||||||
|
episodeId = mediaProgress.episodeId)
|
||||||
|
|
||||||
|
Log.d(tag, "syncServerMediaProgressWithLocalMediaProgress: Saving new local media progress $localMediaProgress")
|
||||||
|
DeviceManager.dbManager.saveLocalMediaProgress(localMediaProgress)
|
||||||
|
call.resolve(JSObject(jacksonMapper.writeValueAsString(localMediaProgress)))
|
||||||
|
} else {
|
||||||
|
Log.e(tag, "syncServerMediaProgressWithLocalMediaProgress: Local library item not found")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.d(tag, "syncServerMediaProgressWithLocalMediaProgress $localMediaProgressId")
|
||||||
|
val localMediaProgress = DeviceManager.dbManager.getLocalMediaProgress(localMediaProgressId)
|
||||||
|
|
||||||
|
if (localMediaProgress == null) {
|
||||||
|
Log.w(tag, "syncServerMediaProgressWithLocalMediaProgress Local media progress not found $localMediaProgressId")
|
||||||
|
call.resolve()
|
||||||
|
} else {
|
||||||
|
localMediaProgress.updateFromServerMediaProgress(mediaProgress)
|
||||||
|
DeviceManager.dbManager.saveLocalMediaProgress(localMediaProgress)
|
||||||
|
call.resolve(JSObject(jacksonMapper.writeValueAsString(localMediaProgress)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@PluginMethod
|
@PluginMethod
|
||||||
fun updateLocalMediaProgressFinished(call:PluginCall) {
|
fun updateLocalMediaProgressFinished(call:PluginCall) {
|
||||||
val localLibraryItemId = call.getString("localLibraryItemId", "").toString()
|
val localLibraryItemId = call.getString("localLibraryItemId", "").toString()
|
||||||
@@ -273,13 +333,13 @@ class AbsDatabase : Plugin() {
|
|||||||
// Send update to server media progress is linked to a server and user is logged into that server
|
// Send update to server media progress is linked to a server and user is logged into that server
|
||||||
localMediaProgress.serverConnectionConfigId?.let { configId ->
|
localMediaProgress.serverConnectionConfigId?.let { configId ->
|
||||||
if (DeviceManager.serverConnectionConfigId == configId) {
|
if (DeviceManager.serverConnectionConfigId == configId) {
|
||||||
var libraryItemId = localMediaProgress.libraryItemId ?: ""
|
val libraryItemId = localMediaProgress.libraryItemId ?: ""
|
||||||
var episodeId = localMediaProgress.episodeId ?: ""
|
val episodeId = localMediaProgress.episodeId ?: ""
|
||||||
var updatePayload = JSObject()
|
val updatePayload = JSObject()
|
||||||
updatePayload.put("isFinished", isFinished)
|
updatePayload.put("isFinished", isFinished)
|
||||||
apiHandler.updateMediaProgress(libraryItemId,episodeId,updatePayload) {
|
apiHandler.updateMediaProgress(libraryItemId,episodeId,updatePayload) {
|
||||||
Log.d(tag, "updateLocalMediaProgressFinished: Updated media progress isFinished on server")
|
Log.d(tag, "updateLocalMediaProgressFinished: Updated media progress isFinished on server")
|
||||||
var jsobj = JSObject()
|
val jsobj = JSObject()
|
||||||
jsobj.put("local", true)
|
jsobj.put("local", true)
|
||||||
jsobj.put("server", true)
|
jsobj.put("server", true)
|
||||||
jsobj.put("localMediaProgress", JSObject(lmpstring))
|
jsobj.put("localMediaProgress", JSObject(lmpstring))
|
||||||
@@ -288,7 +348,7 @@ class AbsDatabase : Plugin() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (localMediaProgress.serverConnectionConfigId == null || DeviceManager.serverConnectionConfigId != localMediaProgress.serverConnectionConfigId) {
|
if (localMediaProgress.serverConnectionConfigId == null || DeviceManager.serverConnectionConfigId != localMediaProgress.serverConnectionConfigId) {
|
||||||
var jsobj = JSObject()
|
val jsobj = JSObject()
|
||||||
jsobj.put("local", true)
|
jsobj.put("local", true)
|
||||||
jsobj.put("server", false)
|
jsobj.put("server", false)
|
||||||
jsobj.put("localMediaProgress", JSObject(lmpstring))
|
jsobj.put("localMediaProgress", JSObject(lmpstring))
|
||||||
@@ -298,25 +358,25 @@ class AbsDatabase : Plugin() {
|
|||||||
|
|
||||||
@PluginMethod
|
@PluginMethod
|
||||||
fun updateLocalTrackOrder(call:PluginCall) {
|
fun updateLocalTrackOrder(call:PluginCall) {
|
||||||
var localLibraryItemId = call.getString("localLibraryItemId", "") ?: ""
|
val localLibraryItemId = call.getString("localLibraryItemId", "") ?: ""
|
||||||
var localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
|
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
|
||||||
if (localLibraryItem == null) {
|
if (localLibraryItem == null) {
|
||||||
call.resolve()
|
call.resolve()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var audioTracks = localLibraryItem.media.getAudioTracks() as MutableList
|
val audioTracks = localLibraryItem.media.getAudioTracks() as MutableList
|
||||||
|
|
||||||
var tracks:JSArray = call.getArray("tracks") ?: JSArray()
|
val tracks:JSArray = call.getArray("tracks") ?: JSArray()
|
||||||
Log.d(tag, "updateLocalTrackOrder $tracks")
|
Log.d(tag, "updateLocalTrackOrder $tracks")
|
||||||
|
|
||||||
var index = 1
|
var index = 1
|
||||||
var hasUpdates = false
|
var hasUpdates = false
|
||||||
for (i in 0 until tracks.length()) {
|
for (i in 0 until tracks.length()) {
|
||||||
var track = tracks.getJSONObject(i)
|
val track = tracks.getJSONObject(i)
|
||||||
var localFileId = track.getString("localFileId")
|
val localFileId = track.getString("localFileId")
|
||||||
|
|
||||||
var existingTrack = audioTracks.find{ it.localFileId == localFileId }
|
val existingTrack = audioTracks.find{ it.localFileId == localFileId }
|
||||||
if (existingTrack != null) {
|
if (existingTrack != null) {
|
||||||
Log.d(tag, "Found existing track ${existingTrack.localFileId} that has index ${existingTrack.index} should be index $index")
|
Log.d(tag, "Found existing track ${existingTrack.localFileId} that has index ${existingTrack.index} should be index $index")
|
||||||
if (existingTrack.index != index) hasUpdates = true
|
if (existingTrack.index != index) hasUpdates = true
|
||||||
@@ -336,4 +396,15 @@ class AbsDatabase : Plugin() {
|
|||||||
call.resolve()
|
call.resolve()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PluginMethod
|
||||||
|
fun updateDeviceSettings(call:PluginCall) { // Returns device data
|
||||||
|
Log.d(tag, "updateDeviceSettings ${call.data}")
|
||||||
|
val newDeviceSettings = jacksonMapper.readValue<DeviceSettings>(call.data.toString())
|
||||||
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
|
DeviceManager.deviceData.deviceSettings = newDeviceSettings
|
||||||
|
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
|
||||||
|
call.resolve(JSObject(jacksonMapper.writeValueAsString(DeviceManager.deviceData)))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,31 +143,35 @@ class AbsDownloader : Plugin() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
apiHandler.getLibraryItemWithProgress(libraryItemId, episodeId) { libraryItem ->
|
apiHandler.getLibraryItemWithProgress(libraryItemId, episodeId) { libraryItem ->
|
||||||
Log.d(tag, "Got library item from server ${libraryItem.id}")
|
if (libraryItem == null) {
|
||||||
|
call.resolve(JSObject("{\"error\":\"Server request failed\"}"))
|
||||||
|
} else {
|
||||||
|
Log.d(tag, "Got library item from server ${libraryItem.id}")
|
||||||
|
|
||||||
val localFolder = DeviceManager.dbManager.getLocalFolder(localFolderId)
|
val localFolder = DeviceManager.dbManager.getLocalFolder(localFolderId)
|
||||||
if (localFolder != null) {
|
if (localFolder != null) {
|
||||||
|
|
||||||
if (episodeId.isNotEmpty() && libraryItem.mediaType != "podcast") {
|
if (episodeId.isNotEmpty() && libraryItem.mediaType != "podcast") {
|
||||||
Log.e(tag, "Library item is not a podcast but episode was requested")
|
Log.e(tag, "Library item is not a podcast but episode was requested")
|
||||||
call.resolve(JSObject("{\"error\":\"Invalid library item not a podcast\"}"))
|
call.resolve(JSObject("{\"error\":\"Invalid library item not a podcast\"}"))
|
||||||
} else if (episodeId.isNotEmpty()) {
|
} else if (episodeId.isNotEmpty()) {
|
||||||
val podcast = libraryItem.media as Podcast
|
val podcast = libraryItem.media as Podcast
|
||||||
val episode = podcast.episodes?.find { podcastEpisode ->
|
val episode = podcast.episodes?.find { podcastEpisode ->
|
||||||
podcastEpisode.id == episodeId
|
podcastEpisode.id == episodeId
|
||||||
}
|
}
|
||||||
if (episode == null) {
|
if (episode == null) {
|
||||||
call.resolve(JSObject("{\"error\":\"Invalid podcast episode not found\"}"))
|
call.resolve(JSObject("{\"error\":\"Invalid podcast episode not found\"}"))
|
||||||
|
} else {
|
||||||
|
startLibraryItemDownload(libraryItem, localFolder, episode)
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
startLibraryItemDownload(libraryItem, localFolder, episode)
|
startLibraryItemDownload(libraryItem, localFolder, null)
|
||||||
call.resolve()
|
call.resolve()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
startLibraryItemDownload(libraryItem, localFolder, null)
|
call.resolve(JSObject("{\"error\":\"Local Folder Not Found\"}"))
|
||||||
call.resolve()
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
call.resolve(JSObject("{\"error\":\"Local Folder Not Found\"}"))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -277,7 +281,7 @@ class AbsDownloader : Plugin() {
|
|||||||
finalDestinationFile.delete()
|
finalDestinationFile.delete()
|
||||||
}
|
}
|
||||||
|
|
||||||
var downloadItemPart = DownloadItemPart.make(destinationFilename,destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,audioTrack,null)
|
var downloadItemPart = DownloadItemPart.make(destinationFilename,destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,audioTrack,episode)
|
||||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||||
|
|
||||||
var dlRequest = downloadItemPart.getDownloadRequest()
|
var dlRequest = downloadItemPart.getDownloadRequest()
|
||||||
@@ -294,7 +298,7 @@ class AbsDownloader : Plugin() {
|
|||||||
if (finalDestinationFile.exists()) {
|
if (finalDestinationFile.exists()) {
|
||||||
Log.d(tag, "Podcast cover already exists - not downloading cover again")
|
Log.d(tag, "Podcast cover already exists - not downloading cover again")
|
||||||
} else {
|
} else {
|
||||||
downloadItemPart = DownloadItemPart.make(destinationFilename,destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,audioTrack,null)
|
downloadItemPart = DownloadItemPart.make(destinationFilename,destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,null,null)
|
||||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||||
|
|
||||||
dlRequest = downloadItemPart.getDownloadRequest()
|
dlRequest = downloadItemPart.getDownloadRequest()
|
||||||
@@ -397,9 +401,13 @@ class AbsDownloader : Plugin() {
|
|||||||
|
|
||||||
Log.d(tag, "DOWNLOAD: Move file to final destination path: ${downloadItemPart.finalDestinationPath}")
|
Log.d(tag, "DOWNLOAD: Move file to final destination path: ${downloadItemPart.finalDestinationPath}")
|
||||||
val localFolderFile = DocumentFileCompat.fromUri(mainActivity,Uri.parse(downloadItemPart.localFolderUrl))
|
val localFolderFile = DocumentFileCompat.fromUri(mainActivity,Uri.parse(downloadItemPart.localFolderUrl))
|
||||||
val mimetype = if (downloadItemPart.audioTrack != null) MimeType.AUDIO else MimeType.IMAGE
|
if (localFolderFile == null) {
|
||||||
val fileDescription = FileDescription(downloadItemPart.filename, downloadItemPart.itemTitle, mimetype)
|
Log.e(tag, "Local Folder File from uri is null")
|
||||||
file?.moveFileTo(mainActivity,localFolderFile!!,fileDescription,fcb)
|
} else {
|
||||||
|
val mimetype = if (downloadItemPart.audioTrack != null) MimeType.AUDIO else MimeType.IMAGE
|
||||||
|
val fileDescription = FileDescription(downloadItemPart.filename, downloadItemPart.itemTitle, mimetype)
|
||||||
|
file?.moveFileTo(mainActivity,localFolderFile,fileDescription,fcb)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Why is kotlin requiring an else here..
|
// Why is kotlin requiring an else here..
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.audiobookshelf.app.plugins
|
package com.audiobookshelf.app.plugins
|
||||||
|
|
||||||
|
import android.app.AlertDialog
|
||||||
import android.database.Cursor
|
import android.database.Cursor
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
@@ -65,39 +66,59 @@ class AbsFileSystem : Plugin() {
|
|||||||
|
|
||||||
@PluginMethod
|
@PluginMethod
|
||||||
fun selectFolder(call: PluginCall) {
|
fun selectFolder(call: PluginCall) {
|
||||||
var mediaType = call.data.getString("mediaType", "book").toString()
|
val mediaType = call.data.getString("mediaType", "book").toString()
|
||||||
|
val REQUEST_CODE_SELECT_FOLDER = 6
|
||||||
|
val REQUEST_CODE_SDCARD_ACCESS = 7
|
||||||
|
|
||||||
mainActivity.storage.folderPickerCallback = object : FolderPickerCallback {
|
mainActivity.storage.folderPickerCallback = object : FolderPickerCallback {
|
||||||
override fun onFolderSelected(requestCode: Int, folder: DocumentFile) {
|
override fun onFolderSelected(requestCode: Int, folder: DocumentFile) {
|
||||||
Log.d(TAG, "ON FOLDER SELECTED ${folder.uri} ${folder.name}")
|
Log.d(TAG, "ON FOLDER SELECTED ${folder.uri} ${folder.name}")
|
||||||
var absolutePath = folder.getAbsolutePath(activity)
|
val absolutePath = folder.getAbsolutePath(activity)
|
||||||
var storageType = folder.getStorageType(activity)
|
val storageType = folder.getStorageType(activity)
|
||||||
var simplePath = folder.getSimplePath(activity)
|
val simplePath = folder.getSimplePath(activity)
|
||||||
var basePath = folder.getBasePath(activity)
|
val basePath = folder.getBasePath(activity)
|
||||||
var folderId = android.util.Base64.encodeToString(folder.id.toByteArray(), android.util.Base64.DEFAULT)
|
val folderId = android.util.Base64.encodeToString(folder.id.toByteArray(), android.util.Base64.DEFAULT)
|
||||||
|
|
||||||
var 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, simplePath, storageType.toString(), mediaType)
|
||||||
|
|
||||||
DeviceManager.dbManager.saveLocalFolder(localFolder)
|
DeviceManager.dbManager.saveLocalFolder(localFolder)
|
||||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(localFolder)))
|
call.resolve(JSObject(jacksonMapper.writeValueAsString(localFolder)))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStorageAccessDenied(requestCode: Int, folder: DocumentFile?, storageType: StorageType) {
|
override fun onStorageAccessDenied(requestCode: Int, folder: DocumentFile?, storageType: StorageType) {
|
||||||
Log.e(TAG, "STORAGE ACCESS DENIED")
|
val jsobj = JSObject()
|
||||||
var jsobj = JSObject()
|
if (requestCode == REQUEST_CODE_SELECT_FOLDER) {
|
||||||
jsobj.put("error", "Access Denied")
|
|
||||||
call.resolve(jsobj)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
builder.setPositiveButton("Allow.") { _, _ -> mainActivity.storageHelper.requestStorageAccess(REQUEST_CODE_SDCARD_ACCESS, storageType) }
|
||||||
|
builder.show()
|
||||||
|
} else {
|
||||||
|
Log.d(TAG, "STORAGE ACCESS DENIED $requestCode")
|
||||||
|
jsobj.put("error", "Access Denied")
|
||||||
|
call.resolve(jsobj)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
override fun onStoragePermissionDenied(requestCode: Int) {
|
override fun onStoragePermissionDenied(requestCode: Int) {
|
||||||
Log.d(TAG, "STORAGE PERMISSION DENIED $requestCode")
|
Log.d(TAG, "STORAGE PERMISSION DENIED $requestCode")
|
||||||
var jsobj = JSObject()
|
val jsobj = JSObject()
|
||||||
jsobj.put("error", "Permission Denied")
|
jsobj.put("error", "Permission Denied")
|
||||||
call.resolve(jsobj)
|
call.resolve(jsobj)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
mainActivity.storage.openFolderPicker(6)
|
mainActivity.storage.openFolderPicker(REQUEST_CODE_SELECT_FOLDER)
|
||||||
}
|
}
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.R)
|
@RequiresApi(Build.VERSION_CODES.R)
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package com.audiobookshelf.app.server
|
package com.audiobookshelf.app.server
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.SharedPreferences
|
|
||||||
import android.net.ConnectivityManager
|
import android.net.ConnectivityManager
|
||||||
import android.net.NetworkCapabilities
|
import android.net.NetworkCapabilities
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
@@ -17,36 +16,43 @@ import com.getcapacitor.JSObject
|
|||||||
import okhttp3.*
|
import okhttp3.*
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONException
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
class ApiHandler(var ctx:Context) {
|
class ApiHandler(var ctx:Context) {
|
||||||
val tag = "ApiHandler"
|
val tag = "ApiHandler"
|
||||||
|
|
||||||
private var client = OkHttpClient()
|
private var defaultClient = OkHttpClient()
|
||||||
|
private var pingClient = OkHttpClient.Builder().callTimeout(3, TimeUnit.SECONDS).build()
|
||||||
var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||||
|
|
||||||
var storageSharedPreferences: SharedPreferences? = null
|
|
||||||
|
|
||||||
data class LocalMediaProgressSyncPayload(val localMediaProgress:List<LocalMediaProgress>)
|
data class LocalMediaProgressSyncPayload(val localMediaProgress:List<LocalMediaProgress>)
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
data class MediaProgressSyncResponsePayload(val numServerProgressUpdates:Int, val localProgressUpdates:List<LocalMediaProgress>)
|
data class MediaProgressSyncResponsePayload(val numServerProgressUpdates:Int, val localProgressUpdates:List<LocalMediaProgress>)
|
||||||
data class LocalMediaProgressSyncResultsPayload(var numLocalMediaProgressForServer:Int, var numServerProgressUpdates:Int, var numLocalProgressUpdates:Int)
|
data class LocalMediaProgressSyncResultsPayload(var numLocalMediaProgressForServer:Int, var numServerProgressUpdates:Int, var numLocalProgressUpdates:Int)
|
||||||
|
|
||||||
fun getRequest(endpoint:String, cb: (JSObject) -> Unit) {
|
fun getRequest(endpoint:String, httpClient:OkHttpClient?, config:ServerConnectionConfig?, cb: (JSObject) -> Unit) {
|
||||||
|
val address = config?.address ?: DeviceManager.serverAddress
|
||||||
|
val token = config?.token ?: DeviceManager.token
|
||||||
|
|
||||||
val request = Request.Builder()
|
val request = Request.Builder()
|
||||||
.url("${DeviceManager.serverAddress}$endpoint").addHeader("Authorization", "Bearer ${DeviceManager.token}")
|
.url("${address}$endpoint").addHeader("Authorization", "Bearer $token")
|
||||||
.build()
|
.build()
|
||||||
makeRequest(request, cb)
|
makeRequest(request, httpClient, cb)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun postRequest(endpoint:String, payload: JSObject, cb: (JSObject) -> Unit) {
|
fun postRequest(endpoint:String, payload: JSObject, config:ServerConnectionConfig?, cb: (JSObject) -> Unit) {
|
||||||
|
val address = config?.address ?: DeviceManager.serverAddress
|
||||||
|
val token = config?.token ?: DeviceManager.token
|
||||||
val mediaType = "application/json; charset=utf-8".toMediaType()
|
val mediaType = "application/json; charset=utf-8".toMediaType()
|
||||||
val requestBody = payload.toString().toRequestBody(mediaType)
|
val requestBody = payload.toString().toRequestBody(mediaType)
|
||||||
val request = Request.Builder().post(requestBody)
|
val request = Request.Builder().post(requestBody)
|
||||||
.url("${DeviceManager.serverAddress}$endpoint").addHeader("Authorization", "Bearer ${DeviceManager.token}")
|
.url("${address}$endpoint").addHeader("Authorization", "Bearer ${token}")
|
||||||
.build()
|
.build()
|
||||||
makeRequest(request, cb)
|
makeRequest(request, null, cb)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun patchRequest(endpoint:String, payload: JSObject, cb: (JSObject) -> Unit) {
|
fun patchRequest(endpoint:String, payload: JSObject, cb: (JSObject) -> Unit) {
|
||||||
@@ -55,7 +61,7 @@ class ApiHandler(var ctx:Context) {
|
|||||||
val request = Request.Builder().patch(requestBody)
|
val request = Request.Builder().patch(requestBody)
|
||||||
.url("${DeviceManager.serverAddress}$endpoint").addHeader("Authorization", "Bearer ${DeviceManager.token}")
|
.url("${DeviceManager.serverAddress}$endpoint").addHeader("Authorization", "Bearer ${DeviceManager.token}")
|
||||||
.build()
|
.build()
|
||||||
makeRequest(request, cb)
|
makeRequest(request, null, cb)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isOnline(): Boolean {
|
fun isOnline(): Boolean {
|
||||||
@@ -76,30 +82,52 @@ class ApiHandler(var ctx:Context) {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
fun makeRequest(request:Request, cb: (JSObject) -> Unit) {
|
fun isUsingCellularData(): Boolean {
|
||||||
|
val connectivityManager = ctx.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||||
|
val capabilities = connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork)
|
||||||
|
return capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun makeRequest(request:Request, httpClient:OkHttpClient?, cb: (JSObject) -> Unit) {
|
||||||
|
val client = httpClient ?: defaultClient
|
||||||
client.newCall(request).enqueue(object : Callback {
|
client.newCall(request).enqueue(object : Callback {
|
||||||
override fun onFailure(call: Call, e: IOException) {
|
override fun onFailure(call: Call, e: IOException) {
|
||||||
Log.d(tag, "FAILURE TO CONNECT")
|
Log.d(tag, "FAILURE TO CONNECT")
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
cb(JSObject())
|
|
||||||
|
val jsobj = JSObject()
|
||||||
|
jsobj.put("error", "Failed to connect")
|
||||||
|
cb(jsobj)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResponse(call: Call, response: Response) {
|
override fun onResponse(call: Call, response: Response) {
|
||||||
response.use {
|
response.use {
|
||||||
if (!it.isSuccessful) throw IOException("Unexpected code $response")
|
if (!it.isSuccessful) {
|
||||||
|
val jsobj = JSObject()
|
||||||
|
jsobj.put("error", "Unexpected code $response")
|
||||||
|
cb(jsobj)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
val bodyString = it.body!!.string()
|
val bodyString = it.body!!.string()
|
||||||
if (bodyString == "OK") {
|
if (bodyString == "OK") {
|
||||||
cb(JSObject())
|
cb(JSObject())
|
||||||
} else {
|
} else {
|
||||||
var jsonObj = JSObject()
|
try {
|
||||||
if (bodyString.startsWith("[")) {
|
var jsonObj = JSObject()
|
||||||
val array = JSArray(bodyString)
|
if (bodyString.startsWith("[")) {
|
||||||
jsonObj.put("value", array)
|
val array = JSArray(bodyString)
|
||||||
} else {
|
jsonObj.put("value", array)
|
||||||
jsonObj = JSObject(bodyString)
|
} else {
|
||||||
|
jsonObj = JSObject(bodyString)
|
||||||
|
}
|
||||||
|
cb(jsonObj)
|
||||||
|
} catch(je:JSONException) {
|
||||||
|
Log.e(tag, "Invalid JSON response ${je.localizedMessage} from body $bodyString")
|
||||||
|
val jsobj = JSObject()
|
||||||
|
jsobj.put("error", "Invalid response body")
|
||||||
|
cb(jsobj)
|
||||||
}
|
}
|
||||||
cb(jsonObj)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -108,7 +136,7 @@ class ApiHandler(var ctx:Context) {
|
|||||||
|
|
||||||
fun getLibraries(cb: (List<Library>) -> Unit) {
|
fun getLibraries(cb: (List<Library>) -> Unit) {
|
||||||
val mapper = jacksonMapper
|
val mapper = jacksonMapper
|
||||||
getRequest("/api/libraries") {
|
getRequest("/api/libraries", null,null) {
|
||||||
val libraries = mutableListOf<Library>()
|
val libraries = mutableListOf<Library>()
|
||||||
if (it.has("value")) {
|
if (it.has("value")) {
|
||||||
val array = it.getJSONArray("value")
|
val array = it.getJSONArray("value")
|
||||||
@@ -121,24 +149,34 @@ class ApiHandler(var ctx:Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getLibraryItem(libraryItemId:String, cb: (LibraryItem) -> Unit) {
|
fun getLibraryItem(libraryItemId:String, cb: (LibraryItem?) -> Unit) {
|
||||||
getRequest("/api/items/$libraryItemId?expanded=1") {
|
getRequest("/api/items/$libraryItemId?expanded=1", null, null) {
|
||||||
val libraryItem = jacksonMapper.readValue<LibraryItem>(it.toString())
|
if (it.has("error")) {
|
||||||
cb(libraryItem)
|
Log.e(tag, it.getString("error") ?: "getLibraryItem Failed")
|
||||||
|
cb(null)
|
||||||
|
} else {
|
||||||
|
val libraryItem = jacksonMapper.readValue<LibraryItem>(it.toString())
|
||||||
|
cb(libraryItem)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getLibraryItemWithProgress(libraryItemId:String, episodeId:String?, cb: (LibraryItem) -> Unit) {
|
fun getLibraryItemWithProgress(libraryItemId:String, episodeId:String?, cb: (LibraryItem?) -> Unit) {
|
||||||
var requestUrl = "/api/items/$libraryItemId?expanded=1&include=progress"
|
var requestUrl = "/api/items/$libraryItemId?expanded=1&include=progress"
|
||||||
if (!episodeId.isNullOrEmpty()) requestUrl += "&episode=$episodeId"
|
if (!episodeId.isNullOrEmpty()) requestUrl += "&episode=$episodeId"
|
||||||
getRequest(requestUrl) {
|
getRequest(requestUrl, null, null) {
|
||||||
val libraryItem = jacksonMapper.readValue<LibraryItem>(it.toString())
|
if (it.has("error")) {
|
||||||
cb(libraryItem)
|
Log.e(tag, it.getString("error") ?: "getLibraryItemWithProgress Failed")
|
||||||
|
cb(null)
|
||||||
|
} else {
|
||||||
|
val libraryItem = jacksonMapper.readValue<LibraryItem>(it.toString())
|
||||||
|
cb(libraryItem)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getLibraryItems(libraryId:String, cb: (List<LibraryItem>) -> Unit) {
|
fun getLibraryItems(libraryId:String, cb: (List<LibraryItem>) -> Unit) {
|
||||||
getRequest("/api/libraries/$libraryId/items?limit=100&minified=1") {
|
getRequest("/api/libraries/$libraryId/items?limit=100&minified=1", null, null) {
|
||||||
val items = mutableListOf<LibraryItem>()
|
val items = mutableListOf<LibraryItem>()
|
||||||
if (it.has("results")) {
|
if (it.has("results")) {
|
||||||
val array = it.getJSONArray("results")
|
val array = it.getJSONArray("results")
|
||||||
@@ -151,57 +189,60 @@ class ApiHandler(var ctx:Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getLibraryCategories(libraryId:String, cb: (List<LibraryCategory>) -> Unit) {
|
fun getAllItemsInProgress(cb: (List<ItemInProgress>) -> Unit) {
|
||||||
getRequest("/api/libraries/$libraryId/personalized") {
|
getRequest("/api/me/items-in-progress", null, null) {
|
||||||
val items = mutableListOf<LibraryCategory>()
|
val items = mutableListOf<ItemInProgress>()
|
||||||
if (it.has("value")) {
|
if (it.has("libraryItems")) {
|
||||||
val array = it.getJSONArray("value")
|
val array = it.getJSONArray("libraryItems")
|
||||||
for (i in 0 until array.length()) {
|
for (i in 0 until array.length()) {
|
||||||
val jsobj = array.get(i) as JSONObject
|
val jsobj = array.get(i) as JSONObject
|
||||||
|
|
||||||
val type = jsobj.get("type").toString()
|
val itemInProgress = ItemInProgress.makeFromServerObject(jsobj)
|
||||||
// Only support for podcast and book in android auto
|
items.add(itemInProgress)
|
||||||
if (type == "podcast" || type == "book") {
|
|
||||||
jsobj.put("isLocal", false)
|
|
||||||
val item = jacksonMapper.readValue<LibraryCategory>(jsobj.toString())
|
|
||||||
items.add(item)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cb(items)
|
cb(items)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun playLibraryItem(libraryItemId:String, episodeId:String?, forceTranscode:Boolean, mediaPlayer:String, cb: (PlaybackSession) -> Unit) {
|
fun playLibraryItem(libraryItemId:String, episodeId:String?, playItemRequestPayload:PlayItemRequestPayload, cb: (PlaybackSession?) -> Unit) {
|
||||||
val payload = JSObject()
|
val payload = JSObject(jacksonMapper.writeValueAsString(playItemRequestPayload))
|
||||||
payload.put("mediaPlayer", mediaPlayer)
|
|
||||||
|
|
||||||
// Only if direct play fails do we force transcode
|
|
||||||
if (!forceTranscode) payload.put("forceDirectPlay", true)
|
|
||||||
else payload.put("forceTranscode", true)
|
|
||||||
|
|
||||||
val endpoint = if (episodeId.isNullOrEmpty()) "/api/items/$libraryItemId/play" else "/api/items/$libraryItemId/play/$episodeId"
|
val endpoint = if (episodeId.isNullOrEmpty()) "/api/items/$libraryItemId/play" else "/api/items/$libraryItemId/play/$episodeId"
|
||||||
postRequest(endpoint, payload) {
|
postRequest(endpoint, payload, null) {
|
||||||
it.put("serverConnectionConfigId", DeviceManager.serverConnectionConfig?.id)
|
if (it.has("error")) {
|
||||||
it.put("serverAddress", DeviceManager.serverAddress)
|
Log.e(tag, it.getString("error") ?: "Play Library Item Failed")
|
||||||
val playbackSession = jacksonMapper.readValue<PlaybackSession>(it.toString())
|
cb(null)
|
||||||
cb(playbackSession)
|
} else {
|
||||||
|
it.put("serverConnectionConfigId", DeviceManager.serverConnectionConfig?.id)
|
||||||
|
it.put("serverAddress", DeviceManager.serverAddress)
|
||||||
|
val playbackSession = jacksonMapper.readValue<PlaybackSession>(it.toString())
|
||||||
|
cb(playbackSession)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sendProgressSync(sessionId:String, syncData: MediaProgressSyncData, cb: () -> Unit) {
|
fun sendProgressSync(sessionId:String, syncData: MediaProgressSyncData, cb: (Boolean) -> Unit) {
|
||||||
val payload = JSObject(jacksonMapper.writeValueAsString(syncData))
|
val payload = JSObject(jacksonMapper.writeValueAsString(syncData))
|
||||||
|
|
||||||
postRequest("/api/session/$sessionId/sync", payload) {
|
postRequest("/api/session/$sessionId/sync", payload, null) {
|
||||||
cb()
|
if (!it.getString("error").isNullOrEmpty()) {
|
||||||
|
cb(false)
|
||||||
|
} else {
|
||||||
|
cb(true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sendLocalProgressSync(playbackSession:PlaybackSession, cb: () -> Unit) {
|
fun sendLocalProgressSync(playbackSession:PlaybackSession, cb: (Boolean) -> Unit) {
|
||||||
val payload = JSObject(jacksonMapper.writeValueAsString(playbackSession))
|
val payload = JSObject(jacksonMapper.writeValueAsString(playbackSession))
|
||||||
|
|
||||||
postRequest("/api/session/local", payload) {
|
postRequest("/api/session/local", payload, null) {
|
||||||
cb()
|
if (!it.getString("error").isNullOrEmpty()) {
|
||||||
|
cb(false)
|
||||||
|
} else {
|
||||||
|
cb(true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,11 +263,13 @@ class ApiHandler(var ctx:Context) {
|
|||||||
if (localMediaProgress.isNotEmpty()) {
|
if (localMediaProgress.isNotEmpty()) {
|
||||||
Log.d(tag, "Sending sync local progress request with ${localMediaProgress.size} progress items")
|
Log.d(tag, "Sending sync local progress request with ${localMediaProgress.size} progress items")
|
||||||
val payload = JSObject(jacksonMapper.writeValueAsString(LocalMediaProgressSyncPayload(localMediaProgress)))
|
val payload = JSObject(jacksonMapper.writeValueAsString(LocalMediaProgressSyncPayload(localMediaProgress)))
|
||||||
postRequest("/api/me/sync-local-progress", payload) {
|
postRequest("/api/me/sync-local-progress", payload, null) {
|
||||||
Log.d(tag, "Media Progress Sync payload $payload - response ${it.toString()}")
|
Log.d(tag, "Media Progress Sync payload $payload - response ${it}")
|
||||||
|
|
||||||
if (it.toString() == "{}") {
|
if (it.toString() == "{}") {
|
||||||
Log.e(tag, "Progress sync received empty object")
|
Log.e(tag, "Progress sync received empty object")
|
||||||
|
} else if (it.has("error")) {
|
||||||
|
Log.e(tag, it.getString("error") ?: "Progress sync error")
|
||||||
} else {
|
} else {
|
||||||
val progressSyncResponsePayload = jacksonMapper.readValue<MediaProgressSyncResponsePayload>(it.toString())
|
val progressSyncResponsePayload = jacksonMapper.readValue<MediaProgressSyncResponsePayload>(it.toString())
|
||||||
|
|
||||||
@@ -257,4 +300,66 @@ class ApiHandler(var ctx:Context) {
|
|||||||
cb()
|
cb()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getMediaProgress(libraryItemId:String, episodeId:String?, serverConnectionConfig:ServerConnectionConfig?, cb: (MediaProgress?) -> Unit) {
|
||||||
|
val endpoint = if(episodeId.isNullOrEmpty()) "/api/me/progress/$libraryItemId" else "/api/me/progress/$libraryItemId/$episodeId"
|
||||||
|
|
||||||
|
// TODO: Using ping client here allows for shorter timeout (3 seconds), maybe rename or make diff client for requests requiring quicker response
|
||||||
|
getRequest(endpoint, pingClient, serverConnectionConfig) {
|
||||||
|
if (it.has("error")) {
|
||||||
|
Log.e(tag, "getMediaProgress: Failed to get progress")
|
||||||
|
cb(null)
|
||||||
|
} else {
|
||||||
|
val progress = jacksonMapper.readValue<MediaProgress>(it.toString())
|
||||||
|
cb(progress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getPlaybackSession(playbackSessionId:String, cb: (PlaybackSession?) -> Unit) {
|
||||||
|
val endpoint = "/api/session/$playbackSessionId"
|
||||||
|
getRequest(endpoint, null, null) {
|
||||||
|
val err = it.getString("error")
|
||||||
|
if (!err.isNullOrEmpty()) {
|
||||||
|
cb(null)
|
||||||
|
} else {
|
||||||
|
cb(jacksonMapper.readValue<PlaybackSession>(it.toString()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun pingServer(config:ServerConnectionConfig, cb: (Boolean) -> Unit) {
|
||||||
|
Log.d(tag, "pingServer: Pinging ${config.address}")
|
||||||
|
getRequest("/ping", pingClient, config) {
|
||||||
|
val success = it.getString("success")
|
||||||
|
if (success.isNullOrEmpty()) {
|
||||||
|
Log.d(tag, "pingServer: Ping ${config.address} Failed")
|
||||||
|
cb(false)
|
||||||
|
} else {
|
||||||
|
Log.d(tag, "pingServer: Ping ${config.address} Successful")
|
||||||
|
cb(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun authorize(config:ServerConnectionConfig, cb: (MutableList<MediaProgress>?) -> Unit) {
|
||||||
|
Log.d(tag, "authorize: Authorizing ${config.address}")
|
||||||
|
postRequest("/api/authorize", JSObject(), config) {
|
||||||
|
val error = it.getString("error")
|
||||||
|
if (!error.isNullOrEmpty()) {
|
||||||
|
Log.d(tag, "authorize: Authorize ${config.address} Failed: $error")
|
||||||
|
cb(null)
|
||||||
|
} else {
|
||||||
|
val mediaProgressList:MutableList<MediaProgress> = mutableListOf()
|
||||||
|
val user = it.getJSObject("user")
|
||||||
|
val mediaProgress = user?.getJSONArray("mediaProgress") ?: JSONArray()
|
||||||
|
for (i in 0 until mediaProgress.length()) {
|
||||||
|
val mediaProg = jacksonMapper.readValue<MediaProgress>(mediaProgress.getJSONObject(i).toString())
|
||||||
|
mediaProgressList.add(mediaProg)
|
||||||
|
}
|
||||||
|
Log.d(tag, "authorize: Authorize ${config.address} Successful")
|
||||||
|
cb(mediaProgressList)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,11 +11,5 @@
|
|||||||
<path
|
<path
|
||||||
android:fillColor="@android:color/white"
|
android:fillColor="@android:color/white"
|
||||||
android:pathData="M18,13c0,3.31 -2.69,6 -6,6s-6,-2.69 -6,-6s2.69,-6 6,-6v4l5,-5l-5,-5v4c-4.42,0 -8,3.58 -8,8c0,4.42 3.58,8 8,8s8,-3.58 8,-8H18z"/>
|
android:pathData="M18,13c0,3.31 -2.69,6 -6,6s-6,-2.69 -6,-6s2.69,-6 6,-6v4l5,-5l-5,-5v4c-4.42,0 -8,3.58 -8,8c0,4.42 3.58,8 8,8s8,-3.58 8,-8H18z"/>
|
||||||
<path
|
|
||||||
android:fillColor="@android:color/white"
|
|
||||||
android:pathData="M10.86,15.94l0,-4.27l-0.09,0l-1.77,0.63l0,0.69l1.01,-0.31l0,3.26z"/>
|
|
||||||
<path
|
|
||||||
android:fillColor="@android:color/white"
|
|
||||||
android:pathData="M12.25,13.44v0.74c0,1.9 1.31,1.82 1.44,1.82c0.14,0 1.44,0.09 1.44,-1.82v-0.74c0,-1.9 -1.31,-1.82 -1.44,-1.82C13.55,11.62 12.25,11.53 12.25,13.44zM14.29,13.32v0.97c0,0.77 -0.21,1.03 -0.59,1.03c-0.38,0 -0.6,-0.26 -0.6,-1.03v-0.97c0,-0.75 0.22,-1.01 0.59,-1.01C14.07,12.3 14.29,12.57 14.29,13.32z"/>
|
|
||||||
</group>
|
</group>
|
||||||
</vector>
|
</vector>
|
||||||
|
|||||||
@@ -11,11 +11,5 @@
|
|||||||
<path
|
<path
|
||||||
android:fillColor="@android:color/white"
|
android:fillColor="@android:color/white"
|
||||||
android:pathData="M11.99,5V1l-5,5l5,5V7c3.31,0 6,2.69 6,6s-2.69,6 -6,6s-6,-2.69 -6,-6h-2c0,4.42 3.58,8 8,8s8,-3.58 8,-8S16.41,5 11.99,5z"/>
|
android:pathData="M11.99,5V1l-5,5l5,5V7c3.31,0 6,2.69 6,6s-2.69,6 -6,6s-6,-2.69 -6,-6h-2c0,4.42 3.58,8 8,8s8,-3.58 8,-8S16.41,5 11.99,5z"/>
|
||||||
<path
|
|
||||||
android:fillColor="@android:color/white"
|
|
||||||
android:pathData="M10.89,16h-0.85v-3.26l-1.01,0.31v-0.69l1.77,-0.63h0.09V16z"/>
|
|
||||||
<path
|
|
||||||
android:fillColor="@android:color/white"
|
|
||||||
android:pathData="M15.17,14.24c0,0.32 -0.03,0.6 -0.1,0.82s-0.17,0.42 -0.29,0.57s-0.28,0.26 -0.45,0.33s-0.37,0.1 -0.59,0.1s-0.41,-0.03 -0.59,-0.1s-0.33,-0.18 -0.46,-0.33s-0.23,-0.34 -0.3,-0.57s-0.11,-0.5 -0.11,-0.82V13.5c0,-0.32 0.03,-0.6 0.1,-0.82s0.17,-0.42 0.29,-0.57s0.28,-0.26 0.45,-0.33s0.37,-0.1 0.59,-0.1s0.41,0.03 0.59,0.1c0.18,0.07 0.33,0.18 0.46,0.33s0.23,0.34 0.3,0.57s0.11,0.5 0.11,0.82V14.24zM14.32,13.38c0,-0.19 -0.01,-0.35 -0.04,-0.48s-0.07,-0.23 -0.12,-0.31s-0.11,-0.14 -0.19,-0.17s-0.16,-0.05 -0.25,-0.05s-0.18,0.02 -0.25,0.05s-0.14,0.09 -0.19,0.17s-0.09,0.18 -0.12,0.31s-0.04,0.29 -0.04,0.48v0.97c0,0.19 0.01,0.35 0.04,0.48s0.07,0.24 0.12,0.32s0.11,0.14 0.19,0.17s0.16,0.05 0.25,0.05s0.18,-0.02 0.25,-0.05s0.14,-0.09 0.19,-0.17s0.09,-0.19 0.11,-0.32s0.04,-0.29 0.04,-0.48V13.38z"/>
|
|
||||||
</group>
|
</group>
|
||||||
</vector>
|
</vector>
|
||||||
|
|||||||
@@ -10,9 +10,6 @@
|
|||||||
android:translateY="-1.5294118">
|
android:translateY="-1.5294118">
|
||||||
<path
|
<path
|
||||||
android:fillColor="@android:color/white"
|
android:fillColor="@android:color/white"
|
||||||
android:pathData="M4,6H2v14c0,1.1 0.9,2 2,2h14v-2H4V6z"/>
|
android:pathData="M10,4H4c-1.1,0 -1.99,0.9 -1.99,2L2,18c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2V8c0,-1.1 -0.9,-2 -2,-2h-8l-2,-2z"/>
|
||||||
<path
|
|
||||||
android:fillColor="@android:color/white"
|
|
||||||
android:pathData="M20,2L8,2c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2L22,4c0,-1.1 -0.9,-2 -2,-2zM20,12l-2.5,-1.5L15,12L15,4h5v8z"/>
|
|
||||||
</group>
|
</group>
|
||||||
</vector>
|
</vector>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="#FFFFFF">
|
||||||
|
<group android:scaleX="1.127451"
|
||||||
|
android:scaleY="1.127451"
|
||||||
|
android:translateX="-1.5294118"
|
||||||
|
android:translateY="-1.5294118">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M6,18l8.5,-6L6,6v12zM16,6v12h2V6h-2z"/>
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="#FFFFFF">
|
||||||
|
<group android:scaleX="1.127451"
|
||||||
|
android:scaleY="1.127451"
|
||||||
|
android:translateX="-1.5294118"
|
||||||
|
android:translateY="-1.5294118">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M6,6h2v12L6,18zM9.5,12l8.5,6L18,6z"/>
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
|
Before Width: | Height: | Size: 276 B |
|
After Width: | Height: | Size: 216 B |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 199 B |
|
After Width: | Height: | Size: 164 B |
|
After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 101 KiB |
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Background for widgets to make the rounded corners based on the
|
||||||
|
appWidgetRadius attribute value
|
||||||
|
-->
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
|
||||||
|
<corners android:radius="?attr/appWidgetRadius" />
|
||||||
|
<solid android:color="?android:attr/colorBackground" />
|
||||||
|
</shape>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Background for views inside widgets to make the rounded corners based on the
|
||||||
|
appWidgetInnerRadius attribute value
|
||||||
|
-->
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
|
||||||
|
<corners android:radius="?attr/appWidgetInnerRadius" />
|
||||||
|
<solid android:color="?android:attr/colorPrimaryDark" />
|
||||||
|
</shape>
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
xmlns:aapt="http://schemas.android.com/aapt"
|
|
||||||
android:width="108dp"
|
|
||||||
android:height="108dp"
|
|
||||||
android:viewportHeight="108"
|
|
||||||
android:viewportWidth="108">
|
|
||||||
<path
|
|
||||||
android:fillType="evenOdd"
|
|
||||||
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
|
|
||||||
android:strokeColor="#00000000"
|
|
||||||
android:strokeWidth="1">
|
|
||||||
<aapt:attr name="android:fillColor">
|
|
||||||
<gradient
|
|
||||||
android:endX="78.5885"
|
|
||||||
android:endY="90.9159"
|
|
||||||
android:startX="48.7653"
|
|
||||||
android:startY="61.0927"
|
|
||||||
android:type="linear">
|
|
||||||
<item
|
|
||||||
android:color="#44000000"
|
|
||||||
android:offset="0.0" />
|
|
||||||
<item
|
|
||||||
android:color="#00000000"
|
|
||||||
android:offset="1.0" />
|
|
||||||
</gradient>
|
|
||||||
</aapt:attr>
|
|
||||||
</path>
|
|
||||||
<path
|
|
||||||
android:fillColor="#FFFFFF"
|
|
||||||
android:fillType="nonZero"
|
|
||||||
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
|
|
||||||
android:strokeColor="#00000000"
|
|
||||||
android:strokeWidth="1" />
|
|
||||||
</vector>
|
|
||||||
|
Before Width: | Height: | Size: 309 B |
|
After Width: | Height: | Size: 254 B |
|
Before Width: | Height: | Size: 430 B |
|
After Width: | Height: | Size: 466 B |
@@ -2,6 +2,4 @@
|
|||||||
android:viewportHeight="24" android:viewportWidth="24"
|
android:viewportHeight="24" android:viewportWidth="24"
|
||||||
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<path android:fillColor="@android:color/white" android:pathData="M18,13c0,3.31 -2.69,6 -6,6s-6,-2.69 -6,-6s2.69,-6 6,-6v4l5,-5l-5,-5v4c-4.42,0 -8,3.58 -8,8c0,4.42 3.58,8 8,8s8,-3.58 8,-8H18z"/>
|
<path android:fillColor="@android:color/white" android:pathData="M18,13c0,3.31 -2.69,6 -6,6s-6,-2.69 -6,-6s2.69,-6 6,-6v4l5,-5l-5,-5v4c-4.42,0 -8,3.58 -8,8c0,4.42 3.58,8 8,8s8,-3.58 8,-8H18z"/>
|
||||||
<path android:fillColor="@android:color/white" android:pathData="M10.86,15.94l0,-4.27l-0.09,0l-1.77,0.63l0,0.69l1.01,-0.31l0,3.26z"/>
|
|
||||||
<path android:fillColor="@android:color/white" android:pathData="M12.25,13.44v0.74c0,1.9 1.31,1.82 1.44,1.82c0.14,0 1.44,0.09 1.44,-1.82v-0.74c0,-1.9 -1.31,-1.82 -1.44,-1.82C13.55,11.62 12.25,11.53 12.25,13.44zM14.29,13.32v0.97c0,0.77 -0.21,1.03 -0.59,1.03c-0.38,0 -0.6,-0.26 -0.6,-1.03v-0.97c0,-0.75 0.22,-1.01 0.59,-1.01C14.07,12.3 14.29,12.57 14.29,13.32z"/>
|
|
||||||
</vector>
|
</vector>
|
||||||
|
|||||||
@@ -2,6 +2,4 @@
|
|||||||
android:viewportHeight="24" android:viewportWidth="24"
|
android:viewportHeight="24" android:viewportWidth="24"
|
||||||
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<path android:fillColor="@android:color/white" android:pathData="M11.99,5V1l-5,5l5,5V7c3.31,0 6,2.69 6,6s-2.69,6 -6,6s-6,-2.69 -6,-6h-2c0,4.42 3.58,8 8,8s8,-3.58 8,-8S16.41,5 11.99,5z"/>
|
<path android:fillColor="@android:color/white" android:pathData="M11.99,5V1l-5,5l5,5V7c3.31,0 6,2.69 6,6s-2.69,6 -6,6s-6,-2.69 -6,-6h-2c0,4.42 3.58,8 8,8s8,-3.58 8,-8S16.41,5 11.99,5z"/>
|
||||||
<path android:fillColor="@android:color/white" android:pathData="M10.89,16h-0.85v-3.26l-1.01,0.31v-0.69l1.77,-0.63h0.09V16z"/>
|
|
||||||
<path android:fillColor="@android:color/white" android:pathData="M15.17,14.24c0,0.32 -0.03,0.6 -0.1,0.82s-0.17,0.42 -0.29,0.57s-0.28,0.26 -0.45,0.33s-0.37,0.1 -0.59,0.1s-0.41,-0.03 -0.59,-0.1s-0.33,-0.18 -0.46,-0.33s-0.23,-0.34 -0.3,-0.57s-0.11,-0.5 -0.11,-0.82V13.5c0,-0.32 0.03,-0.6 0.1,-0.82s0.17,-0.42 0.29,-0.57s0.28,-0.26 0.45,-0.33s0.37,-0.1 0.59,-0.1s0.41,0.03 0.59,0.1c0.18,0.07 0.33,0.18 0.46,0.33s0.23,0.34 0.3,0.57s0.11,0.5 0.11,0.82V14.24zM14.32,13.38c0,-0.19 -0.01,-0.35 -0.04,-0.48s-0.07,-0.23 -0.12,-0.31s-0.11,-0.14 -0.19,-0.17s-0.16,-0.05 -0.25,-0.05s-0.18,0.02 -0.25,0.05s-0.14,0.09 -0.19,0.17s-0.09,0.18 -0.12,0.31s-0.04,0.29 -0.04,0.48v0.97c0,0.19 0.01,0.35 0.04,0.48s0.07,0.24 0.12,0.32s0.11,0.14 0.19,0.17s0.16,0.05 0.25,0.05s0.18,-0.02 0.25,-0.05s0.14,-0.09 0.19,-0.17s0.09,-0.19 0.11,-0.32s0.04,-0.29 0.04,-0.48V13.38z"/>
|
|
||||||
</vector>
|
</vector>
|
||||||
|
|||||||
@@ -1,170 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="108dp"
|
|
||||||
android:height="108dp"
|
|
||||||
android:viewportHeight="108"
|
|
||||||
android:viewportWidth="108">
|
|
||||||
<path
|
|
||||||
android:fillColor="#26A69A"
|
|
||||||
android:pathData="M0,0h108v108h-108z" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M9,0L9,108"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,0L19,108"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M29,0L29,108"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M39,0L39,108"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M49,0L49,108"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M59,0L59,108"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M69,0L69,108"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M79,0L79,108"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M89,0L89,108"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M99,0L99,108"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,9L108,9"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,19L108,19"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,29L108,29"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,39L108,39"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,49L108,49"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,59L108,59"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,69L108,69"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,79L108,79"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,89L108,89"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,99L108,99"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,29L89,29"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,39L89,39"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,49L89,49"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,59L89,59"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,69L89,69"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,79L89,79"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M29,19L29,89"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M39,19L39,89"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M49,19L49,89"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M59,19L59,89"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M69,19L69,89"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M79,19L79,89"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
</vector>
|
|
||||||
|
Before Width: | Height: | Size: 28 KiB |
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="#FFFFFF">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M6,18l8.5,-6L6,6v12zM16,6v12h2V6h-2z"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="#FFFFFF">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M6,6h2v12L6,18zM9.5,12l8.5,6L18,6z"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
style="@style/Widget.Android.AppWidget.Container"
|
||||||
|
android:id="@+id/appWidget"
|
||||||
|
android:theme="@style/AppTheme.AppWidgetContainer"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/imageView"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_centerInParent="true"
|
||||||
|
android:adjustViewBounds="true"
|
||||||
|
android:alpha="0.75"
|
||||||
|
android:contentDescription="Cover image"
|
||||||
|
android:visibility="visible" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/playPauseIcon"
|
||||||
|
android:layout_width="71dp"
|
||||||
|
android:layout_height="55dp"
|
||||||
|
android:layout_centerInParent="true"
|
||||||
|
app:srcCompat="@drawable/cast_ic_expanded_controller_play" />
|
||||||
|
|
||||||
|
</RelativeLayout>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<resources>
|
||||||
|
<!-- Base application theme. -->
|
||||||
|
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
|
||||||
|
<!-- Customize your theme here. -->
|
||||||
|
<item name="colorPrimary">@color/colorPrimary</item>
|
||||||
|
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
||||||
|
<item name="colorAccent">@color/colorAccent</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.NoActionBar">
|
||||||
|
<item name="windowActionBar">false</item>
|
||||||
|
<item name="windowNoTitle">true</item>
|
||||||
|
<item name="android:background">@null</item>
|
||||||
|
<item name="android:statusBarColor">@color/background_dark</item>
|
||||||
|
<item name="android:navigationBarColor">@color/background_dark</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
<style name="AppTheme.NoActionBarLaunch" parent="AppTheme.NoActionBar">
|
||||||
|
<!-- <item name="android:background">@drawable/screen</item>-->
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style name="Widget.Android.AppWidget.Container" parent="android:Widget">
|
||||||
|
<item name="android:padding">?attr/appWidgetPadding</item>
|
||||||
|
<item name="android:background">@drawable/app_widget_background</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style name="Widget.Android.AppWidget.InnerView" parent="android:Widget">
|
||||||
|
<item name="android:padding">?attr/appWidgetPadding</item>
|
||||||
|
<item name="android:background">@drawable/app_widget_inner_view_background</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<resources>
|
||||||
|
<declare-styleable name="AppWidgetAttrs">
|
||||||
|
<attr name="appWidgetPadding" format="dimension" />
|
||||||
|
<attr name="appWidgetInnerRadius" format="dimension" />
|
||||||
|
<attr name="appWidgetRadius" format="dimension" />
|
||||||
|
</declare-styleable>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<resources>
|
||||||
|
<color name="light_blue_50">#FFE1F5FE</color>
|
||||||
|
<color name="light_blue_200">#FF81D4FA</color>
|
||||||
|
<color name="light_blue_600">#FF039BE5</color>
|
||||||
|
<color name="light_blue_900">#FF01579B</color>
|
||||||
|
<color name="background_dark">#262626</color>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Refer to App Widget Documentation for margin information
|
||||||
|
http://developer.android.com/guide/topics/appwidgets/index.html#CreatingLayout
|
||||||
|
-->
|
||||||
|
<dimen name="widget_margin">0dp</dimen>
|
||||||
|
|
||||||
|
</resources>
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<color name="ic_launcher_background">#FFFFFF</color>
|
|
||||||
</resources>
|
|
||||||
@@ -4,4 +4,10 @@
|
|||||||
<string name="title_activity_main">audiobookshelf</string>
|
<string name="title_activity_main">audiobookshelf</string>
|
||||||
<string name="package_name">com.audiobookshelf.app</string>
|
<string name="package_name">com.audiobookshelf.app</string>
|
||||||
<string name="custom_url_scheme">com.audiobookshelf.app</string>
|
<string name="custom_url_scheme">com.audiobookshelf.app</string>
|
||||||
|
<string name="add_widget">Add widget</string>
|
||||||
|
<string name="app_widget_description">Simple widget for audiobookshelf playback</string>
|
||||||
|
<string name="action_jump_forward">Jump Forward</string>
|
||||||
|
<string name="action_jump_backward">Jump Backward</string>
|
||||||
|
<string name="action_skip_forward">Skip Forward</string>
|
||||||
|
<string name="action_skip_backward">Skip Backward</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -13,10 +13,21 @@
|
|||||||
<item name="windowActionBar">false</item>
|
<item name="windowActionBar">false</item>
|
||||||
<item name="windowNoTitle">true</item>
|
<item name="windowNoTitle">true</item>
|
||||||
<item name="android:background">@null</item>
|
<item name="android:background">@null</item>
|
||||||
|
<item name="android:statusBarColor">@color/background_dark</item>
|
||||||
|
<item name="android:navigationBarColor">@color/background_dark</item>
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|
||||||
<style name="AppTheme.NoActionBarLaunch" parent="AppTheme.NoActionBar">
|
<style name="AppTheme.NoActionBarLaunch" parent="AppTheme.NoActionBar">
|
||||||
<item name="android:background">@drawable/screen</item>
|
<!-- <item name="android:background">@drawable/screen</item>-->
|
||||||
|
</style>
|
||||||
|
<style name="Widget.Android.AppWidget.Container" parent="android:Widget">
|
||||||
|
<item name="android:id">@android:id/background</item>
|
||||||
|
<item name="android:background">?android:attr/colorBackground</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style name="Widget.Android.AppWidget.InnerView" parent="android:Widget">
|
||||||
|
<item name="android:background">?android:attr/colorBackground</item>
|
||||||
|
<item name="android:textColor">?android:attr/textColorPrimary</item>
|
||||||
</style>
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<resources>
|
||||||
|
<style name="AppTheme.AppWidgetContainerParent" parent="@android:style/Theme.DeviceDefault">
|
||||||
|
<!-- Radius of the outer bound of widgets to make the rounded corners -->
|
||||||
|
<item name="appWidgetRadius">16dp</item>
|
||||||
|
<!--
|
||||||
|
Radius of the inner view's bound of widgets to make the rounded corners.
|
||||||
|
It needs to be 8dp or less than the value of appWidgetRadius
|
||||||
|
-->
|
||||||
|
<item name="appWidgetInnerRadius">8dp</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style name="AppTheme.AppWidgetContainer"
|
||||||
|
parent="AppTheme.AppWidgetContainerParent">
|
||||||
|
<!-- Apply padding to avoid the content of the widget colliding with the rounded corners -->
|
||||||
|
<item name="appWidgetPadding">16dp</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:minWidth="110dp"
|
||||||
|
android:minHeight="40dp"
|
||||||
|
android:updatePeriodMillis="86400000"
|
||||||
|
android:previewImage="@drawable/example_appwidget_preview"
|
||||||
|
android:initialLayout="@layout/new_app_widget"
|
||||||
|
android:description="@string/app_widget_description"
|
||||||
|
android:resizeMode="horizontal|vertical"
|
||||||
|
android:widgetCategory="home_screen"
|
||||||
|
android:initialKeyguardLayout="@layout/new_app_widget"
|
||||||
|
/>
|
||||||
@@ -8,8 +8,8 @@ buildscript {
|
|||||||
mavenCentral()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
dependencies {
|
dependencies {
|
||||||
classpath 'com.google.gms:google-services:4.3.5'
|
classpath 'com.google.gms:google-services:4.3.10'
|
||||||
classpath 'com.android.tools.build:gradle:7.2.0-beta04'
|
classpath 'com.android.tools.build:gradle:7.2.2'
|
||||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||||
|
|
||||||
// NOTE: Do not place your application dependencies here; they belong
|
// NOTE: Do not place your application dependencies here; they belong
|
||||||
|
|||||||