mirror of
https://github.com/advplyr/audiobookshelf-app.git
synced 2026-07-25 14:08:35 +02:00
Compare commits
323
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81d7b1cfd7 | ||
|
|
e15c1bcb32 | ||
|
|
826d414d9f | ||
|
|
0da6fca727 | ||
|
|
2327bc1705 | ||
|
|
3bb76cecac | ||
|
|
e3f82bb237 | ||
|
|
fcd6270f2d | ||
|
|
d5411703ca | ||
|
|
8fc2058db8 | ||
|
|
d03949f5d1 | ||
|
|
ff01786063 | ||
|
|
bb117ab4da | ||
|
|
f5ec247b47 | ||
|
|
6e6dd00523 | ||
|
|
c9fb98f033 | ||
|
|
75ecff5e6d | ||
|
|
91a9f918fb | ||
|
|
03305f90ca | ||
|
|
9428bc07c5 | ||
|
|
f38f9c3502 | ||
|
|
64a796aece | ||
|
|
63039fe8c9 | ||
|
|
3f2b723d0f | ||
|
|
ffd7fb1cf9 | ||
|
|
b049b206cf | ||
|
|
35f56dc8a9 | ||
|
|
a71cd326cc | ||
|
|
cc841d1c7c | ||
|
|
16ecdcf96e | ||
|
|
96ec56d1ad | ||
|
|
9b15bafc9f | ||
|
|
aa02f2e465 | ||
|
|
15464617ef | ||
|
|
60ecceb59d | ||
|
|
3fe943e989 | ||
|
|
5e7d039454 | ||
|
|
f928df146a | ||
|
|
6e57cb49c4 | ||
|
|
2f8494b013 | ||
|
|
6f1b93bd9f | ||
|
|
19b928d7f6 | ||
|
|
b849be6f5e | ||
|
|
86096df1a4 | ||
|
|
98a465c7b4 | ||
|
|
3315f61ae4 | ||
|
|
f977445d7a | ||
|
|
095d5e1cec | ||
|
|
69fbfa4b33 | ||
|
|
1c89d33726 | ||
|
|
acee907932 | ||
|
|
08ad216fe2 | ||
|
|
3cc7a0937c | ||
|
|
322cad5548 | ||
|
|
73d70dd480 | ||
|
|
56bc569d4a | ||
|
|
981ae58f6a | ||
|
|
b2bfa4c002 | ||
|
|
8b6003c5e3 | ||
|
|
017b6237ca | ||
|
|
d5496c2e9a | ||
|
|
5ef8961c33 | ||
|
|
e8bea48577 | ||
|
|
6cae78f5c2 | ||
|
|
d9d31a0620 | ||
|
|
3682c5fca2 | ||
|
|
14b04a14b6 | ||
|
|
f218d90bfb | ||
|
|
020f936ad4 | ||
|
|
224e5efe48 | ||
|
|
439ab2c8c0 | ||
|
|
26d0209763 | ||
|
|
29086fc783 | ||
|
|
bb656a265f | ||
|
|
b11ad9dc5b | ||
|
|
2b1667e532 | ||
|
|
228a198598 | ||
|
|
2843308fb8 | ||
|
|
793f0c05f7 | ||
|
|
cf7271dc57 | ||
|
|
54664b0c32 | ||
|
|
ecf910dc55 | ||
|
|
460daec72e | ||
|
|
4dd67adcab | ||
|
|
023c83ee78 | ||
|
|
2b8ed16616 | ||
|
|
6ae4d72b68 | ||
|
|
c6bd93ef5c | ||
|
|
e1c02ce74c | ||
|
|
efc6d68403 | ||
|
|
7412d0899f | ||
|
|
42eb0a8384 | ||
|
|
27e83e8ed9 | ||
|
|
dd66042766 | ||
|
|
dc790d5450 | ||
|
|
01ac844024 | ||
|
|
c8064ac7bc | ||
|
|
f5da44dc6f | ||
|
|
cbdff29684 | ||
|
|
19d6c84113 | ||
|
|
d9f4ed3d54 | ||
|
|
fdb26b7700 | ||
|
|
94cb7a65a2 | ||
|
|
9298065934 | ||
|
|
1cf01a5d12 | ||
|
|
e3aa96cbd5 | ||
|
|
c7678da664 | ||
|
|
52701048ad | ||
|
|
776c02828f | ||
|
|
69308413f6 | ||
|
|
84e376a7cf | ||
|
|
1822b21af8 | ||
|
|
8b222435e6 | ||
|
|
fdd251ec2c | ||
|
|
78170d4e10 | ||
|
|
837981b8b6 | ||
|
|
be23c6703f | ||
|
|
0c72889917 | ||
|
|
02746bc35f | ||
|
|
85a5c33bd8 | ||
|
|
5067c0f6d7 | ||
|
|
89938ab120 | ||
|
|
9a32396daa | ||
|
|
ddb1796891 | ||
|
|
76253b7b9d | ||
|
|
74fef1d2f4 | ||
|
|
7306ffa709 | ||
|
|
6f289293cc | ||
|
|
783f924bb9 | ||
|
|
044a35aacf | ||
|
|
78448894d2 | ||
|
|
aca348e783 | ||
|
|
e6c1874cbb | ||
|
|
1b06dbd78c | ||
|
|
a26f37aa49 | ||
|
|
8af3c09458 | ||
|
|
fed6579e08 | ||
|
|
6621f8b2ee | ||
|
|
737b6d7c26 | ||
|
|
59c71b71fb | ||
|
|
f89148b92e | ||
|
|
88fbebe5c4 | ||
|
|
e8abb18350 | ||
|
|
cc9dead1eb | ||
|
|
f9dd8188ee | ||
|
|
579785d2c6 | ||
|
|
257a1ceb51 | ||
|
|
5c60cd136c | ||
|
|
2bfe548c52 | ||
|
|
164fd92a18 | ||
|
|
0036a19659 | ||
|
|
ef4d7ba1bf | ||
|
|
8dbffa7993 | ||
|
|
17ff2ed5f4 | ||
|
|
6fe470cfc1 | ||
|
|
301e9b213f | ||
|
|
2b533524fc | ||
|
|
632b1f526b | ||
|
|
81b11bac93 | ||
|
|
a88e404ccf | ||
|
|
a7cbdbfbbb | ||
|
|
ad5146e2ee | ||
|
|
36be91962c | ||
|
|
ff5a1bb09f | ||
|
|
5060554640 | ||
|
|
93cb319e97 | ||
|
|
a42dfa5649 | ||
|
|
6a938b8da1 | ||
|
|
4f994072ad | ||
|
|
7c0ef179ec | ||
|
|
5fdd0c6672 | ||
|
|
e9f523d7cb | ||
|
|
c1b13dcd24 | ||
|
|
0daa043f14 | ||
|
|
1a6b716046 | ||
|
|
945baa24f0 | ||
|
|
30d4e709f0 | ||
|
|
8d8782a5a9 | ||
|
|
087e8553c4 | ||
|
|
2984398051 | ||
|
|
e9251db647 | ||
|
|
6c88337180 | ||
|
|
74b488ad0f | ||
|
|
fc7af6d1fc | ||
|
|
737d8f19b3 | ||
|
|
d613d8954d | ||
|
|
04e468b43d | ||
|
|
dc8178769b | ||
|
|
8b631f7860 | ||
|
|
e521ddfab6 | ||
|
|
6b164bdb27 | ||
|
|
2eb399b8a7 | ||
|
|
46f558d6e0 | ||
|
|
00bed39733 | ||
|
|
2048795c92 | ||
|
|
8c9bd53934 | ||
|
|
01178d00bf | ||
|
|
7541e4b660 | ||
|
|
2366c938b5 | ||
|
|
59480ad114 | ||
|
|
ce7779607f | ||
|
|
04bc74babe | ||
|
|
ac2b674ba5 | ||
|
|
e53901e7a4 | ||
|
|
3ede41f496 | ||
|
|
0adeaaa04d | ||
|
|
57d1fbfa83 | ||
|
|
27bbe40874 | ||
|
|
60da6093f3 | ||
|
|
259c9cef20 | ||
|
|
6d61b2acaf | ||
|
|
72f687b079 | ||
|
|
0ce3c8bcbd | ||
|
|
f9621d2f60 | ||
|
|
e634132a39 | ||
|
|
59b428100b | ||
|
|
36e0c91e27 | ||
|
|
03225957aa | ||
|
|
77f70e48de | ||
|
|
cbdc3513c7 | ||
|
|
394479bea0 | ||
|
|
b9592e46a7 | ||
|
|
5810a83c66 | ||
|
|
389c7d23ca | ||
|
|
defc7d216a | ||
|
|
852702112a | ||
|
|
dd91bc6667 | ||
|
|
cdbbd56a58 | ||
|
|
d6a1eba657 | ||
|
|
da899bfddd | ||
|
|
b7b746fb2a | ||
|
|
c8053355df | ||
|
|
1ddb6eca00 | ||
|
|
f42c624cba | ||
|
|
f8fbeef9b7 | ||
|
|
46b82a81ce | ||
|
|
48342e5bd3 | ||
|
|
147f89f870 | ||
|
|
8b39ba9c92 | ||
|
|
13d32dc1f7 | ||
|
|
0d1312e497 | ||
|
|
ff4f8324e7 | ||
|
|
b4bf10d409 | ||
|
|
ae37861f7d | ||
|
|
1da03cdd65 | ||
|
|
a7443d0992 | ||
|
|
5a953464ab | ||
|
|
953bde5a42 | ||
|
|
481d0d0285 | ||
|
|
7b3a005ad6 | ||
|
|
4f9d341f69 | ||
|
|
9e7d1d1617 | ||
|
|
4f03c2a35d | ||
|
|
db3c5396e9 | ||
|
|
455af239a1 | ||
|
|
d8bc26f5f8 | ||
|
|
543ac209e4 | ||
|
|
c8b5cefeb5 | ||
|
|
f1411011e6 | ||
|
|
f3964dda17 | ||
|
|
d207e88e18 | ||
|
|
2643e98fc0 | ||
|
|
410648e316 | ||
|
|
373221703d | ||
|
|
fbcb8620f9 | ||
|
|
a8ac4970cc | ||
|
|
195e33ae32 | ||
|
|
9acf695802 | ||
|
|
e6aaccfc74 | ||
|
|
87c74fe78b | ||
|
|
7dc319c1bd | ||
|
|
c093548b71 | ||
|
|
b067a2d3d5 | ||
|
|
a9ae7bfdb8 | ||
|
|
9dcf22614c | ||
|
|
c7564d439e | ||
|
|
157dc01673 | ||
|
|
cce6e1d0ab | ||
|
|
b935cb5cf1 | ||
|
|
9ab37ac525 | ||
|
|
7031c895b5 | ||
|
|
7c739bf3bf | ||
|
|
facf56930e | ||
|
|
2c3dff3544 | ||
|
|
b1bf68b8cd | ||
|
|
78aade7694 | ||
|
|
83a4474375 | ||
|
|
1b47c38efa | ||
|
|
922d9f22ce | ||
|
|
d7feb52dd8 | ||
|
|
fafaad8b5f | ||
|
|
928f33005a | ||
|
|
0448d20583 | ||
|
|
f9d408b33f | ||
|
|
9386ae95a3 | ||
|
|
c530e0f7e3 | ||
|
|
e39a5e1ba3 | ||
|
|
cb9e317e74 | ||
|
|
8061ac0ced | ||
|
|
daff8355d7 | ||
|
|
231289e9ac | ||
|
|
87df022af0 | ||
|
|
07fdac9746 | ||
|
|
0d482aaa42 | ||
|
|
d48d1fdde3 | ||
|
|
65b69cc020 | ||
|
|
a3794a6a6a | ||
|
|
695ab84940 | ||
|
|
5399ec67d7 | ||
|
|
d9a6ec65df | ||
|
|
e842de78dd | ||
|
|
260363bf4f | ||
|
|
6d7dffd4de | ||
|
|
ef1d8a8eb4 | ||
|
|
cdd7a1a636 | ||
|
|
6584cd1522 | ||
|
|
d01b602306 | ||
|
|
c90f2c136a | ||
|
|
15ccc7192e | ||
|
|
e4345a4438 | ||
|
|
3f4d564bad | ||
|
|
3311f66da8 | ||
|
|
e711ada0b3 |
@@ -16,10 +16,16 @@ jobs:
|
||||
- name: checkout sources
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: use Node.js 16.x
|
||||
- name: use Node.js 20.x
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16
|
||||
node-version: 20
|
||||
|
||||
- name: Set up Java
|
||||
uses: actions/setup-java@v2
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: 17
|
||||
|
||||
- name: install dependencies
|
||||
run: npm ci
|
||||
|
||||
@@ -15,10 +15,16 @@ jobs:
|
||||
- name: checkout sources
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: use Node.js 16.x
|
||||
- name: use Node.js 20.x
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16
|
||||
node-version: 20
|
||||
|
||||
- name: Set up Java
|
||||
uses: actions/setup-java@v2
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: 17
|
||||
|
||||
- name: install dependencies
|
||||
run: npm ci
|
||||
|
||||
+10
-15
@@ -21,8 +21,6 @@ kotlin {
|
||||
}
|
||||
|
||||
android {
|
||||
|
||||
|
||||
namespace 'com.audiobookshelf.app'
|
||||
buildFeatures {
|
||||
viewBinding true
|
||||
@@ -35,9 +33,12 @@ android {
|
||||
applicationId "com.audiobookshelf.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 94
|
||||
versionName "0.9.63-beta"
|
||||
versionCode 103
|
||||
versionName "0.9.72-beta"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
manifestPlaceholders = [
|
||||
"appAuthRedirectScheme": "com.audiobookshelf.app"
|
||||
]
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
|
||||
@@ -63,14 +64,15 @@ repositories {
|
||||
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
|
||||
}
|
||||
mavenCentral()
|
||||
// TODO: Temporarily using SNAPSHOT version of Simple Storage that resolves https://github.com/anggrayudi/SimpleStorage/issues/133
|
||||
maven { url "https://oss.sonatype.org/content/repositories/snapshots" }
|
||||
}
|
||||
|
||||
configurations.all {
|
||||
configurations.configureEach {
|
||||
resolutionStrategy {
|
||||
force("com.google.android.gms:play-services-base:18.0.1")
|
||||
force("androidx.appcompat:appcompat:$androidxAppCompatVersion")
|
||||
force("androidx.core:core-ktx:$androidx_core_ktx_version")
|
||||
force("org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version")
|
||||
force("androidx.media:media:$androidx_media_version")
|
||||
force("org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlin_coroutines_version")
|
||||
force("org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version")
|
||||
@@ -83,12 +85,9 @@ dependencies {
|
||||
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
||||
implementation project(':capacitor-android')
|
||||
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.3'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
||||
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
||||
|
||||
// testImplementation "junit:junit:$junitVersion"
|
||||
// androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
||||
// androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
||||
implementation project(':capacitor-cordova-android-plugins')
|
||||
|
||||
implementation "androidx.core:core-ktx:$androidx_core_ktx_version"
|
||||
@@ -116,22 +115,18 @@ dependencies {
|
||||
|
||||
// Glide for images
|
||||
implementation "com.github.bumptech.glide:glide:$glide_version"
|
||||
kapt "com.github.bumptech.glide:compiler:$glide_version"
|
||||
|
||||
// Paper NoSQL Db
|
||||
implementation 'io.github.pilgr:paperdb:2.7.2'
|
||||
|
||||
// Simple Storage
|
||||
implementation "com.anggrayudi:storage:1.5.4"
|
||||
implementation "com.anggrayudi:storage:1.5.5-SNAPSHOT"
|
||||
|
||||
// OK HTTP
|
||||
implementation 'com.squareup.okhttp3:okhttp:4.9.2'
|
||||
|
||||
// Jackson for JSON
|
||||
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.12.2'
|
||||
|
||||
// FFMPEG-Kit
|
||||
implementation 'com.arthenica:ffmpeg-kit-min:4.5.1'
|
||||
}
|
||||
|
||||
apply from: 'capacitor.build.gradle'
|
||||
|
||||
@@ -2,14 +2,17 @@
|
||||
|
||||
android {
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_11
|
||||
targetCompatibility JavaVersion.VERSION_11
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
}
|
||||
|
||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||
dependencies {
|
||||
implementation project(':byteowls-capacitor-filesharer')
|
||||
implementation project(':capacitor-app')
|
||||
implementation project(':capacitor-browser')
|
||||
implementation project(':capacitor-clipboard')
|
||||
implementation project(':capacitor-dialog')
|
||||
implementation project(':capacitor-haptics')
|
||||
implementation project(':capacitor-network')
|
||||
|
||||
@@ -2,4 +2,5 @@
|
||||
<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>
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
||||
@@ -2,4 +2,5 @@
|
||||
<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>
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<resources>
|
||||
<string name="app_name">Audiobookshelf</string>
|
||||
<string name="title_activity_main">Audiobookshelf</string>
|
||||
<string name="package_name">com.audiobookshelf.app</string>
|
||||
<string name="custom_url_scheme">audiobookshelf</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>
|
||||
<string name="action_change_speed">Change Playback Speed</string>
|
||||
</resources>
|
||||
@@ -15,13 +15,14 @@
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:label="@string/app_name"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:requestLegacyExternalStorage="true"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme"
|
||||
android:usesCleartextTraffic="true" >
|
||||
android:usesCleartextTraffic="true"
|
||||
android:largeHeap="true" >
|
||||
|
||||
<!-- Android app widget -->
|
||||
<receiver
|
||||
@@ -91,6 +92,16 @@
|
||||
<action android:name="android.media.browse.MediaBrowserService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:grantUriPermissions="true"
|
||||
android:exported="false">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -2,5 +2,10 @@
|
||||
"appId": "com.audiobookshelf.app",
|
||||
"appName": "audiobookshelf-app",
|
||||
"webDir": "dist",
|
||||
"bundledWebRuntime": false
|
||||
"bundledWebRuntime": false,
|
||||
"plugins": {
|
||||
"CapacitorHttp": {
|
||||
"enabled": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
[
|
||||
{
|
||||
"pkg": "@byteowls/capacitor-filesharer",
|
||||
"classpath": "com.byteowls.capacitor.filesharer.FileSharerPlugin"
|
||||
},
|
||||
{
|
||||
"pkg": "@capacitor/app",
|
||||
"classpath": "com.capacitorjs.plugins.app.AppPlugin"
|
||||
},
|
||||
{
|
||||
"pkg": "@capacitor/browser",
|
||||
"classpath": "com.capacitorjs.plugins.browser.BrowserPlugin"
|
||||
},
|
||||
{
|
||||
"pkg": "@capacitor/clipboard",
|
||||
"classpath": "com.capacitorjs.plugins.clipboard.ClipboardPlugin"
|
||||
},
|
||||
{
|
||||
"pkg": "@capacitor/dialog",
|
||||
"classpath": "com.capacitorjs.plugins.dialog.DialogPlugin"
|
||||
|
||||
@@ -12,10 +12,10 @@ import android.util.Log
|
||||
import androidx.core.app.ActivityCompat
|
||||
import com.anggrayudi.storage.SimpleStorage
|
||||
import com.anggrayudi.storage.SimpleStorageHelper
|
||||
import com.audiobookshelf.app.plugins.AbsDatabase
|
||||
import com.audiobookshelf.app.managers.DbManager
|
||||
import com.audiobookshelf.app.player.PlayerNotificationService
|
||||
import com.audiobookshelf.app.plugins.AbsAudioPlayer
|
||||
import com.audiobookshelf.app.plugins.AbsDatabase
|
||||
import com.audiobookshelf.app.plugins.AbsDownloader
|
||||
import com.audiobookshelf.app.plugins.AbsFileSystem
|
||||
import com.getcapacitor.BridgeActivity
|
||||
@@ -116,6 +116,7 @@ class MainActivity : BridgeActivity() {
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
storageHelper.onSaveInstanceState(outState)
|
||||
super.onSaveInstanceState(outState)
|
||||
outState.clear()
|
||||
}
|
||||
|
||||
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
|
||||
|
||||
@@ -15,8 +15,7 @@ import android.widget.RemoteViews
|
||||
import androidx.media.session.MediaButtonReceiver
|
||||
import com.audiobookshelf.app.data.PlaybackSession
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.audiobookshelf.app.device.WidgetEventEmitter
|
||||
import com.audiobookshelf.app.player.PlayerNotificationService
|
||||
import com.audiobookshelf.app.managers.DbManager
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.request.RequestOptions
|
||||
import com.bumptech.glide.request.target.AppWidgetTarget
|
||||
@@ -27,16 +26,15 @@ import com.bumptech.glide.request.transition.Transition
|
||||
*/
|
||||
class MediaPlayerWidget : AppWidgetProvider() {
|
||||
val tag = "MediaPlayerWidget"
|
||||
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, PlayerNotificationService.isClosed)
|
||||
}
|
||||
}
|
||||
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
|
||||
Log.d(tag, "onUpdate $appWidgetIds")
|
||||
}
|
||||
|
||||
override fun onEnabled(context: Context) {
|
||||
Log.i(tag, "onEnabled check context ${context.packageName}")
|
||||
|
||||
DbManager.initialize(context)
|
||||
|
||||
DeviceManager.deviceData.lastPlaybackSession?.let {
|
||||
val appWidgetManager = AppWidgetManager.getInstance(context)
|
||||
val componentName = ComponentName(context, MediaPlayerWidget::class.java)
|
||||
@@ -48,32 +46,14 @@ class MediaPlayerWidget : AppWidgetProvider() {
|
||||
}
|
||||
|
||||
// Enter relevant functionality for when the first widget is created
|
||||
DeviceManager.widgetUpdater = (object : WidgetEventEmitter {
|
||||
override fun onPlayerChanged(pns: PlayerNotificationService) {
|
||||
val isPlaying = pns.currentPlayer.isPlaying
|
||||
|
||||
val appWidgetManager = AppWidgetManager.getInstance(context)
|
||||
val componentName = ComponentName(context, MediaPlayerWidget::class.java)
|
||||
val ids = appWidgetManager.getAppWidgetIds(componentName)
|
||||
|
||||
val playbackSession = pns.getCurrentPlaybackSessionCopy()
|
||||
|
||||
for (widgetId in ids) {
|
||||
updateAppWidget(context, appWidgetManager, widgetId, playbackSession, isPlaying, PlayerNotificationService.isClosed)
|
||||
}
|
||||
}
|
||||
})
|
||||
DeviceManager.initializeWidgetUpdater(context)
|
||||
}
|
||||
|
||||
override fun onDisabled(context: Context) {
|
||||
// Enter relevant functionality for when the last widget is disabled
|
||||
}
|
||||
}
|
||||
|
||||
internal fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int, playbackSession: PlaybackSession?, isPlaying:Boolean, isAppClosed:Boolean) {
|
||||
val tag = "MediaPlayerWidget"
|
||||
val views = RemoteViews(context.packageName, R.layout.media_player_widget)
|
||||
|
||||
Log.i(tag, "updateAppWidget ${playbackSession?.displayTitle ?: "No Title"} isPlaying=$isPlaying isAppClosed=$isAppClosed")
|
||||
val wholeWidgetClickI = Intent(context, MainActivity::class.java)
|
||||
wholeWidgetClickI.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
val wholeWidgetClickPI = PendingIntent.getActivity(
|
||||
@@ -96,8 +76,8 @@ internal fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManage
|
||||
views.setViewVisibility(R.id.widgetButtonContainer, if (isAppClosed) View.GONE else View.VISIBLE)
|
||||
|
||||
views.setOnClickPendingIntent(R.id.widgetBackground, wholeWidgetClickPI)
|
||||
|
||||
val imageUri = playbackSession?.getCoverUri() ?: Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
|
||||
|
||||
val imageUri = playbackSession?.getCoverUri(context) ?: Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
|
||||
val awt: AppWidgetTarget = object : AppWidgetTarget(context.applicationContext, R.id.widgetAlbumArt, views, appWidgetId) {
|
||||
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
|
||||
super.onResourceReady(resource, transition)
|
||||
@@ -115,7 +95,7 @@ internal fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManage
|
||||
|
||||
Log.i(tag, "Update App Widget | Is Playing=$isPlaying | isAppClosed=$isAppClosed")
|
||||
|
||||
val playPauseResource = if (isPlaying) R.drawable.ic_media_pause_dark else R.drawable.ic_media_play_dark
|
||||
val playPauseResource = if (isPlaying) androidx.mediarouter.R.drawable.ic_media_pause_dark else androidx.mediarouter.R.drawable.ic_media_play_dark
|
||||
views.setImageViewResource(R.id.widgetPlayPauseButton, playPauseResource)
|
||||
|
||||
// Instruct the widget manager to update the widget
|
||||
|
||||
@@ -24,7 +24,7 @@ open class MediaType(var metadata:MediaTypeMetadata, var coverPath:String?) {
|
||||
@JsonIgnore
|
||||
open fun removeAudioTrack(localFileId:String) { }
|
||||
@JsonIgnore
|
||||
open fun getLocalCopy():MediaType { return MediaType(MediaTypeMetadata(""),null) }
|
||||
open fun getLocalCopy():MediaType { return MediaType(MediaTypeMetadata("", false),null) }
|
||||
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ class Podcast(
|
||||
audioTracks.forEach { at ->
|
||||
if (episodes?.find{ it.audioTrack?.localFileId == at.localFileId } == null) {
|
||||
val localEpisodeId = "local_ep_" + at.localFileId
|
||||
val newEpisode = PodcastEpisode(localEpisodeId,(episodes?.size ?: 0) + 1,null,null,at.title,null,null,null, null, null, at,at.duration,0, null, localEpisodeId)
|
||||
val newEpisode = PodcastEpisode(localEpisodeId,(episodes?.size ?: 0) + 1,null,null,at.title,null,null,null, null, null, at,null,at.duration,0, null, localEpisodeId)
|
||||
episodes?.add(newEpisode)
|
||||
}
|
||||
}
|
||||
@@ -67,7 +67,7 @@ class Podcast(
|
||||
@JsonIgnore
|
||||
override fun addAudioTrack(audioTrack:AudioTrack) {
|
||||
val localEpisodeId = "local_ep_" + audioTrack.localFileId
|
||||
val newEpisode = PodcastEpisode(localEpisodeId,(episodes?.size ?: 0) + 1,null,null,audioTrack.title,null,null,null, null, null,audioTrack,audioTrack.duration,0, null, localEpisodeId)
|
||||
val newEpisode = PodcastEpisode(localEpisodeId,(episodes?.size ?: 0) + 1,null,null,audioTrack.title,null,null,null, null, null,audioTrack,null,audioTrack.duration,0, null, localEpisodeId)
|
||||
episodes?.add(newEpisode)
|
||||
|
||||
var index = 1
|
||||
@@ -96,7 +96,7 @@ class Podcast(
|
||||
@JsonIgnore
|
||||
fun addEpisode(audioTrack:AudioTrack, episode:PodcastEpisode):PodcastEpisode {
|
||||
val localEpisodeId = "local_ep_" + episode.id
|
||||
val newEpisode = PodcastEpisode(localEpisodeId,(episodes?.size ?: 0) + 1,episode.episode,episode.episodeType,episode.title,episode.subtitle,episode.description,null,null,null,audioTrack,audioTrack.duration,0, episode.id, localEpisodeId)
|
||||
val newEpisode = PodcastEpisode(localEpisodeId,(episodes?.size ?: 0) + 1,episode.episode,episode.episodeType,episode.title,episode.subtitle,episode.description,null,null,null,audioTrack,episode.chapters,audioTrack.duration,0, episode.id, localEpisodeId)
|
||||
episodes?.add(newEpisode)
|
||||
|
||||
var index = 1
|
||||
@@ -126,6 +126,7 @@ class Book(
|
||||
var audioFiles:List<AudioFile>?,
|
||||
var chapters:List<BookChapter>?,
|
||||
var tracks:MutableList<AudioTrack>?,
|
||||
var ebookFile: EBookFile?,
|
||||
var size:Long?,
|
||||
var duration:Double?,
|
||||
var numTracks:Int?
|
||||
@@ -179,7 +180,7 @@ class Book(
|
||||
}
|
||||
@JsonIgnore
|
||||
override fun getLocalCopy(): Book {
|
||||
return Book(metadata as BookMetadata,coverPath,tags, mutableListOf(),chapters,mutableListOf(),null,null, 0)
|
||||
return Book(metadata as BookMetadata,coverPath,tags, mutableListOf(),chapters,mutableListOf(), ebookFile, null,null, 0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,7 +190,7 @@ class Book(
|
||||
JsonSubTypes.Type(BookMetadata::class),
|
||||
JsonSubTypes.Type(PodcastMetadata::class)
|
||||
)
|
||||
open class MediaTypeMetadata(var title:String) {
|
||||
open class MediaTypeMetadata(var title:String, var explicit:Boolean) {
|
||||
@JsonIgnore
|
||||
open fun getAuthorDisplayName():String { return "Unknown" }
|
||||
}
|
||||
@@ -208,13 +209,13 @@ class BookMetadata(
|
||||
var isbn:String?,
|
||||
var asin:String?,
|
||||
var language:String?,
|
||||
var explicit:Boolean,
|
||||
explicit:Boolean,
|
||||
// In toJSONExpanded
|
||||
var authorName:String?,
|
||||
var authorNameLF:String?,
|
||||
var narratorName:String?,
|
||||
var seriesName:String?
|
||||
) : MediaTypeMetadata(title) {
|
||||
) : MediaTypeMetadata(title, explicit) {
|
||||
@JsonIgnore
|
||||
override fun getAuthorDisplayName():String { return authorName ?: "Unknown" }
|
||||
}
|
||||
@@ -224,8 +225,9 @@ class PodcastMetadata(
|
||||
title:String,
|
||||
var author:String?,
|
||||
var feedUrl:String?,
|
||||
var genres:MutableList<String>
|
||||
) : MediaTypeMetadata(title) {
|
||||
var genres:MutableList<String>,
|
||||
explicit:Boolean
|
||||
) : MediaTypeMetadata(title, explicit) {
|
||||
@JsonIgnore
|
||||
override fun getAuthorDisplayName():String { return author ?: "Unknown" }
|
||||
}
|
||||
@@ -250,15 +252,16 @@ data class PodcastEpisode(
|
||||
var publishedAt:Long?,
|
||||
var audioFile:AudioFile?,
|
||||
var audioTrack:AudioTrack?,
|
||||
var chapters:List<BookChapter>?,
|
||||
var duration:Double?,
|
||||
var size:Long?,
|
||||
var serverEpisodeId:String?, // For local podcasts to match with server podcasts
|
||||
var localEpisodeId:String? // For Android Auto server episodes with local copy
|
||||
) {
|
||||
@JsonIgnore
|
||||
fun getMediaDescription(libraryItem:LibraryItemWrapper, progress:MediaProgressWrapper?, ctx: Context?): MediaDescriptionCompat {
|
||||
fun getMediaDescription(libraryItem:LibraryItemWrapper, progress:MediaProgressWrapper?, ctx: Context): MediaDescriptionCompat {
|
||||
val coverUri = if (libraryItem is LocalLibraryItem) {
|
||||
libraryItem.getCoverUri()
|
||||
libraryItem.getCoverUri(ctx)
|
||||
} else {
|
||||
(libraryItem as LibraryItem).getCoverUri()
|
||||
}
|
||||
|
||||
@@ -48,6 +48,20 @@ data class LocalFile(
|
||||
if (mimeType == "video/mp4") return true
|
||||
return mimeType?.startsWith("audio") == true
|
||||
}
|
||||
@JsonIgnore
|
||||
fun isEBookFile():Boolean {
|
||||
return getEBookFormat() != null
|
||||
}
|
||||
@JsonIgnore
|
||||
fun getEBookFormat():String? {
|
||||
if (mimeType == "application/epub+zip") return "epub"
|
||||
if (mimeType == "application/pdf") return "pdf"
|
||||
if (mimeType == "application/x-mobipocket-ebook") return "mobi"
|
||||
if (mimeType == "application/vnd.comicbook+zip") return "cbz"
|
||||
if (mimeType == "application/vnd.comicbook-rar") return "cbr"
|
||||
if (mimeType == "application/vnd.amazon.mobi8-ebook") return "azw3"
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
@@ -69,14 +83,14 @@ data class LocalFolder(
|
||||
)
|
||||
open class LibraryItemWrapper(var id:String) {
|
||||
@JsonIgnore
|
||||
open fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context?): MediaDescriptionCompat { return MediaDescriptionCompat.Builder().build() }
|
||||
open fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context): MediaDescriptionCompat { return MediaDescriptionCompat.Builder().build() }
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class DeviceInfo(
|
||||
var deviceId:String,
|
||||
var manufacturer:String,
|
||||
var model:String,
|
||||
var brand:String,
|
||||
var sdkVersion:Int,
|
||||
var clientVersion: String
|
||||
)
|
||||
@@ -95,6 +109,7 @@ data class DeviceSettings(
|
||||
var enableAltView:Boolean,
|
||||
var jumpBackwardsTime:Int,
|
||||
var jumpForwardTime:Int,
|
||||
var enableMp3IndexSeeking:Boolean,
|
||||
var disableShakeToResetSleepTimer:Boolean,
|
||||
var shakeSensitivity: ShakeSensitivitySetting,
|
||||
var lockOrientation: LockOrientationSetting,
|
||||
@@ -102,9 +117,12 @@ data class DeviceSettings(
|
||||
var autoSleepTimer: Boolean,
|
||||
var autoSleepTimerStartTime: String,
|
||||
var autoSleepTimerEndTime: String,
|
||||
var autoSleepTimerAutoRewind: Boolean,
|
||||
var autoSleepTimerAutoRewindTime: Long, //Time in milliseconds
|
||||
var sleepTimerLength: Long, // Time in milliseconds
|
||||
var disableSleepTimerFadeOut: Boolean,
|
||||
var disableSleepTimerResetFeedback: Boolean
|
||||
var disableSleepTimerResetFeedback: Boolean,
|
||||
var languageCode: String
|
||||
) {
|
||||
companion object {
|
||||
// Static method to get default device settings
|
||||
@@ -114,6 +132,7 @@ data class DeviceSettings(
|
||||
enableAltView = true,
|
||||
jumpBackwardsTime = 10,
|
||||
jumpForwardTime = 10,
|
||||
enableMp3IndexSeeking = false,
|
||||
disableShakeToResetSleepTimer = false,
|
||||
shakeSensitivity = ShakeSensitivitySetting.MEDIUM,
|
||||
lockOrientation = LockOrientationSetting.NONE,
|
||||
@@ -122,8 +141,11 @@ data class DeviceSettings(
|
||||
autoSleepTimerStartTime = "22:00",
|
||||
autoSleepTimerEndTime = "06:00",
|
||||
sleepTimerLength = 900000L, // 15 minutes
|
||||
autoSleepTimerAutoRewind = false,
|
||||
autoSleepTimerAutoRewindTime = 300000L, // 5 minutes
|
||||
disableSleepTimerFadeOut = false,
|
||||
disableSleepTimerResetFeedback = false
|
||||
disableSleepTimerResetFeedback = false,
|
||||
languageCode = "en-us"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -141,6 +163,7 @@ data class DeviceSettings(
|
||||
@get:JsonIgnore
|
||||
val autoSleepTimerEndMinute get() = autoSleepTimerEndTime.split(":")[1].toInt()
|
||||
|
||||
|
||||
@JsonIgnore
|
||||
fun getShakeThresholdGravity() : Float { // Used in ShakeDetector
|
||||
return if (shakeSensitivity == ShakeSensitivitySetting.VERY_HIGH) 1.2f
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.audiobookshelf.app.data
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class EBookFile(
|
||||
var ino:String,
|
||||
var metadata:FileMetadata?,
|
||||
var ebookFormat:String,
|
||||
var isLocal:Boolean,
|
||||
var localFileId:String?,
|
||||
var contentUrl:String?
|
||||
)
|
||||
@@ -58,7 +58,7 @@ class LibraryItem(
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
override fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context?): MediaDescriptionCompat {
|
||||
override fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context): MediaDescriptionCompat {
|
||||
val extras = Bundle()
|
||||
|
||||
if (localLibraryItemId != null) {
|
||||
@@ -90,6 +90,10 @@ class LibraryItem(
|
||||
)
|
||||
}
|
||||
|
||||
if (media.metadata.explicit) {
|
||||
extras.putLong(MediaConstants.METADATA_KEY_IS_EXPLICIT, MediaConstants.METADATA_VALUE_ATTRIBUTE_PRESENT)
|
||||
}
|
||||
|
||||
val mediaId = localLibraryItemId ?: id
|
||||
return MediaDescriptionCompat.Builder()
|
||||
.setMediaId(mediaId)
|
||||
|
||||
@@ -9,6 +9,8 @@ import android.os.Bundle
|
||||
import android.provider.MediaStore
|
||||
import android.support.v4.media.MediaDescriptionCompat
|
||||
import android.util.Log
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.net.toFile
|
||||
import androidx.media.utils.MediaConstants
|
||||
import com.audiobookshelf.app.BuildConfig
|
||||
import com.audiobookshelf.app.R
|
||||
@@ -46,7 +48,10 @@ class LocalLibraryItem(
|
||||
val isPodcast get() = mediaType == "podcast"
|
||||
|
||||
@JsonIgnore
|
||||
fun getCoverUri(): Uri {
|
||||
fun getCoverUri(ctx:Context): Uri {
|
||||
if (coverContentUrl?.startsWith("file:") == true) {
|
||||
return FileProvider.getUriForFile(ctx, "${BuildConfig.APPLICATION_ID}.fileprovider", Uri.parse(coverContentUrl).toFile())
|
||||
}
|
||||
return if (coverContentUrl != null) Uri.parse(coverContentUrl) else Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
|
||||
}
|
||||
|
||||
@@ -74,9 +79,9 @@ class LocalLibraryItem(
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getPlaybackSession(episode:PodcastEpisode?):PlaybackSession {
|
||||
fun getPlaybackSession(episode:PodcastEpisode?, deviceInfo:DeviceInfo):PlaybackSession {
|
||||
val localEpisodeId = episode?.id
|
||||
val sessionId = "play_local_${UUID.randomUUID()}"
|
||||
val sessionId = "${UUID.randomUUID()}"
|
||||
|
||||
// Get current progress for local media
|
||||
val mediaProgressId = if (localEpisodeId.isNullOrEmpty()) id else "$id-$localEpisodeId"
|
||||
@@ -85,19 +90,20 @@ class LocalLibraryItem(
|
||||
|
||||
|
||||
val mediaMetadata = media.metadata
|
||||
val chapters = if (mediaType == "book") (media as Book).chapters else mutableListOf()
|
||||
var chapters = if (mediaType == "book") (media as Book).chapters else mutableListOf()
|
||||
var audioTracks = media.getAudioTracks() as MutableList<AudioTrack>
|
||||
val authorName = mediaMetadata.getAuthorDisplayName()
|
||||
val displayTitle = episode?.title ?: mediaMetadata.title
|
||||
var duration = getDuration()
|
||||
if (episode != null) { // Get podcast episode audio track
|
||||
episode.audioTrack?.let { at -> mutableListOf(at) }?.let { tracks -> audioTracks = tracks }
|
||||
chapters = episode.chapters
|
||||
duration = episode.audioTrack?.duration ?: 0.0
|
||||
Log.d("LocalLibraryItem", "getPlaybackSession: Got podcast episode audio track ${audioTracks.size}")
|
||||
}
|
||||
|
||||
val dateNow = System.currentTimeMillis()
|
||||
return PlaybackSession(sessionId,serverUserId,libraryItemId,episode?.serverEpisodeId, mediaType, mediaMetadata, chapters ?: mutableListOf(), displayTitle, authorName,null,duration,PLAYMETHOD_LOCAL,dateNow,0L,0L, audioTracks,currentTime,null,this,localEpisodeId,serverConnectionConfigId, serverAddress, "exo-player")
|
||||
return PlaybackSession(sessionId,serverUserId,libraryItemId,episode?.serverEpisodeId, mediaType, mediaMetadata, deviceInfo,chapters ?: mutableListOf(), displayTitle, authorName,null,duration,PLAYMETHOD_LOCAL,dateNow,0L,0L, audioTracks,currentTime,null,this,localEpisodeId,serverConnectionConfigId, serverAddress, "exo-player")
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
@@ -106,18 +112,16 @@ class LocalLibraryItem(
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
override fun getMediaDescription(progress:MediaProgressWrapper?, ctx:Context?): MediaDescriptionCompat {
|
||||
val coverUri = getCoverUri()
|
||||
override fun getMediaDescription(progress:MediaProgressWrapper?, ctx:Context): MediaDescriptionCompat {
|
||||
val coverUri = getCoverUri(ctx)
|
||||
|
||||
var bitmap:Bitmap? = null
|
||||
if (coverContentUrl != null) {
|
||||
ctx?.let {
|
||||
bitmap = if (Build.VERSION.SDK_INT < 28) {
|
||||
MediaStore.Images.Media.getBitmap(it.contentResolver, coverUri)
|
||||
} else {
|
||||
val source: ImageDecoder.Source = ImageDecoder.createSource(it.contentResolver, coverUri)
|
||||
ImageDecoder.decodeBitmap(source)
|
||||
}
|
||||
bitmap = if (Build.VERSION.SDK_INT < 28) {
|
||||
MediaStore.Images.Media.getBitmap(ctx.contentResolver, coverUri)
|
||||
} else {
|
||||
val source: ImageDecoder.Source = ImageDecoder.createSource(ctx.contentResolver, coverUri)
|
||||
ImageDecoder.decodeBitmap(source)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,6 +152,10 @@ class LocalLibraryItem(
|
||||
)
|
||||
}
|
||||
|
||||
if (media.metadata.explicit) {
|
||||
extras.putLong(MediaConstants.METADATA_KEY_IS_EXPLICIT, MediaConstants.METADATA_VALUE_ATTRIBUTE_PRESENT)
|
||||
}
|
||||
|
||||
val mediaDescriptionBuilder = MediaDescriptionCompat.Builder()
|
||||
.setMediaId(id)
|
||||
.setTitle(title)
|
||||
|
||||
@@ -18,6 +18,7 @@ data class LocalMediaItem(
|
||||
var basePath:String,
|
||||
var absolutePath:String,
|
||||
var audioTracks:MutableList<AudioTrack>,
|
||||
var ebookFile:EBookFile?,
|
||||
var localFiles:MutableList<LocalFile>,
|
||||
var coverContentUrl:String?,
|
||||
var coverAbsolutePath:String?
|
||||
@@ -42,7 +43,7 @@ data class LocalMediaItem(
|
||||
return if (mediaType == "book") {
|
||||
BookMetadata(name,null, mutableListOf(), mutableListOf(), mutableListOf(),null,null,null,null,null,null,null,false,null,null,null,null)
|
||||
} else {
|
||||
PodcastMetadata(name,null,null, mutableListOf())
|
||||
PodcastMetadata(name,null,null, mutableListOf(), false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +62,7 @@ data class LocalMediaItem(
|
||||
val mediaMetadata = getMediaMetadata()
|
||||
if (mediaType == "book") {
|
||||
val chapters = getAudiobookChapters()
|
||||
val book = Book(mediaMetadata as BookMetadata, coverAbsolutePath, mutableListOf(), mutableListOf(), chapters,audioTracks,getTotalSize(),getDuration(),audioTracks.size)
|
||||
val book = Book(mediaMetadata as BookMetadata, coverAbsolutePath, mutableListOf(), mutableListOf(), chapters,audioTracks,ebookFile,getTotalSize(),getDuration(),audioTracks.size)
|
||||
return LocalLibraryItem(id, folderId, basePath,absolutePath, contentUrl, false,mediaType, book, localFiles, coverContentUrl, coverAbsolutePath,true,null,null,null,null)
|
||||
} else {
|
||||
val podcast = Podcast(mediaMetadata as PodcastMetadata, coverAbsolutePath, mutableListOf(), mutableListOf(), false, 0)
|
||||
|
||||
@@ -13,6 +13,8 @@ class LocalMediaProgress(
|
||||
progress:Double, // 0 to 1
|
||||
currentTime:Double,
|
||||
isFinished:Boolean,
|
||||
var ebookLocation:String?, // cfi tag
|
||||
var ebookProgress:Double?, // 0 to 1
|
||||
var lastUpdate:Long,
|
||||
var startedAt:Long,
|
||||
var finishedAt:Long?,
|
||||
@@ -58,11 +60,20 @@ class LocalMediaProgress(
|
||||
finishedAt = if (isFinished) lastUpdate else null
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun updateEbookProgress(ebookLocation:String, ebookProgress:Double) {
|
||||
lastUpdate = System.currentTimeMillis()
|
||||
this.ebookProgress = ebookProgress
|
||||
this.ebookLocation = ebookLocation
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun updateFromServerMediaProgress(serverMediaProgress:MediaProgress) {
|
||||
isFinished = serverMediaProgress.isFinished
|
||||
progress = serverMediaProgress.progress
|
||||
currentTime = serverMediaProgress.currentTime
|
||||
ebookProgress = serverMediaProgress.ebookProgress
|
||||
ebookLocation = serverMediaProgress.ebookLocation
|
||||
duration = serverMediaProgress.duration
|
||||
lastUpdate = serverMediaProgress.lastUpdate
|
||||
finishedAt = serverMediaProgress.finishedAt
|
||||
|
||||
@@ -12,6 +12,8 @@ class MediaProgress(
|
||||
progress:Double, // 0 to 1
|
||||
currentTime:Double,
|
||||
isFinished:Boolean,
|
||||
var ebookLocation:String?, // cfi tag
|
||||
var ebookProgress:Double?, // 0 to 1
|
||||
var lastUpdate:Long,
|
||||
var startedAt:Long,
|
||||
var finishedAt:Long?
|
||||
|
||||
@@ -6,6 +6,8 @@ import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import android.support.v4.media.MediaMetadataCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.net.toFile
|
||||
import com.audiobookshelf.app.BuildConfig
|
||||
import com.audiobookshelf.app.R
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
@@ -27,6 +29,7 @@ class PlaybackSession(
|
||||
var episodeId:String?,
|
||||
var mediaType:String,
|
||||
var mediaMetadata:MediaTypeMetadata,
|
||||
var deviceInfo:DeviceInfo,
|
||||
var chapters:List<BookChapter>,
|
||||
var displayTitle: String?,
|
||||
var displayAuthor: String?,
|
||||
@@ -137,8 +140,15 @@ class PlaybackSession(
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getCoverUri(): Uri {
|
||||
if (localLibraryItem?.coverContentUrl != null) return Uri.parse(localLibraryItem?.coverContentUrl) ?: Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
|
||||
fun getCoverUri(ctx:Context): Uri {
|
||||
if (localLibraryItem?.coverContentUrl != null) {
|
||||
var coverUri = Uri.parse(localLibraryItem?.coverContentUrl.toString())
|
||||
if (coverUri.toString().startsWith("file:")) {
|
||||
coverUri = FileProvider.getUriForFile(ctx, "${BuildConfig.APPLICATION_ID}.fileprovider", coverUri.toFile())
|
||||
}
|
||||
|
||||
return coverUri ?: Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
|
||||
}
|
||||
|
||||
if (coverPath == null) return Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
|
||||
return Uri.parse("$serverAddress/api/items/$libraryItemId/cover?token=${DeviceManager.token}")
|
||||
@@ -152,6 +162,8 @@ class PlaybackSession(
|
||||
|
||||
@JsonIgnore
|
||||
fun getMediaMetadataCompat(ctx: Context): MediaMetadataCompat {
|
||||
val coverUri = getCoverUri(ctx)
|
||||
|
||||
val metadataBuilder = MediaMetadataCompat.Builder()
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, displayTitle)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, displayTitle)
|
||||
@@ -162,16 +174,16 @@ class PlaybackSession(
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST, displayAuthor)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION, displayAuthor)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getCoverUri().toString())
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ART_URI, getCoverUri().toString())
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, getCoverUri().toString())
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, coverUri.toString())
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ART_URI, coverUri.toString())
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, coverUri.toString())
|
||||
|
||||
// Local covers get bitmap
|
||||
if (localLibraryItem?.coverContentUrl != null) {
|
||||
val bitmap = if (Build.VERSION.SDK_INT < 28) {
|
||||
MediaStore.Images.Media.getBitmap(ctx.contentResolver, getCoverUri())
|
||||
MediaStore.Images.Media.getBitmap(ctx.contentResolver, coverUri)
|
||||
} else {
|
||||
val source: ImageDecoder.Source = ImageDecoder.createSource(ctx.contentResolver, getCoverUri())
|
||||
val source: ImageDecoder.Source = ImageDecoder.createSource(ctx.contentResolver, coverUri)
|
||||
ImageDecoder.decodeBitmap(source)
|
||||
}
|
||||
metadataBuilder.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, bitmap)
|
||||
@@ -182,7 +194,9 @@ class PlaybackSession(
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getExoMediaMetadata(audioTrack:AudioTrack): MediaMetadata {
|
||||
fun getExoMediaMetadata(ctx:Context): MediaMetadata {
|
||||
val coverUri = getCoverUri(ctx)
|
||||
|
||||
val metadataBuilder = MediaMetadata.Builder()
|
||||
.setTitle(displayTitle)
|
||||
.setDisplayTitle(displayTitle)
|
||||
@@ -191,20 +205,18 @@ class PlaybackSession(
|
||||
.setSubtitle(displayAuthor)
|
||||
.setAlbumTitle(displayAuthor)
|
||||
.setDescription(displayAuthor)
|
||||
.setArtworkUri(getCoverUri())
|
||||
|
||||
val contentUri = this.getContentUri(audioTrack)
|
||||
metadataBuilder.setMediaUri(contentUri)
|
||||
.setArtworkUri(coverUri)
|
||||
.setMediaType(MediaMetadata.MEDIA_TYPE_AUDIO_BOOK)
|
||||
|
||||
return metadataBuilder.build()
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getMediaItems():List<MediaItem> {
|
||||
fun getMediaItems(ctx:Context):List<MediaItem> {
|
||||
val mediaItems:MutableList<MediaItem> = mutableListOf()
|
||||
|
||||
for (audioTrack in audioTracks) {
|
||||
val mediaMetadata = this.getExoMediaMetadata(audioTrack)
|
||||
val mediaMetadata = this.getExoMediaMetadata(ctx)
|
||||
val mediaUri = this.getContentUri(audioTrack)
|
||||
val mimeType = audioTrack.mimeType
|
||||
|
||||
@@ -252,7 +264,7 @@ class PlaybackSession(
|
||||
|
||||
@JsonIgnore
|
||||
fun clone():PlaybackSession {
|
||||
return PlaybackSession(id,userId,libraryItemId,episodeId,mediaType,mediaMetadata,chapters,displayTitle,displayAuthor,coverPath,duration,playMethod,startedAt,updatedAt,timeListening,audioTracks,currentTime,libraryItem,localLibraryItem,localEpisodeId,serverConnectionConfigId,serverAddress, mediaPlayer)
|
||||
return PlaybackSession(id,userId,libraryItemId,episodeId,mediaType,mediaMetadata,deviceInfo,chapters,displayTitle,displayAuthor,coverPath,duration,playMethod,startedAt,updatedAt,timeListening,audioTracks,currentTime,libraryItem,localLibraryItem,localEpisodeId,serverConnectionConfigId,serverAddress, mediaPlayer)
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
@@ -264,6 +276,6 @@ class PlaybackSession(
|
||||
|
||||
@JsonIgnore
|
||||
fun getNewLocalMediaProgress():LocalMediaProgress {
|
||||
return LocalMediaProgress(localMediaProgressId,localLibraryItemId,localEpisodeId,getTotalDuration(),progress,currentTime,false,updatedAt,startedAt,null,serverConnectionConfigId,serverAddress,userId,libraryItemId,episodeId)
|
||||
return LocalMediaProgress(localMediaProgressId,localLibraryItemId,localEpisodeId,getTotalDuration(),progress,currentTime,false,null,null,updatedAt,startedAt,null,serverConnectionConfigId,serverAddress,userId,libraryItemId,episodeId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
package com.audiobookshelf.app.device
|
||||
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.MediaPlayerWidget
|
||||
import com.audiobookshelf.app.data.*
|
||||
import com.audiobookshelf.app.managers.DbManager
|
||||
import com.audiobookshelf.app.player.PlayerNotificationService
|
||||
import com.audiobookshelf.app.updateAppWidget
|
||||
|
||||
interface WidgetEventEmitter {
|
||||
fun onPlayerChanged(pns:PlayerNotificationService)
|
||||
fun onPlayerClosed()
|
||||
}
|
||||
|
||||
object DeviceManager {
|
||||
@@ -39,6 +44,15 @@ object DeviceManager {
|
||||
if (deviceData.deviceSettings?.shakeSensitivity == null) {
|
||||
deviceData.deviceSettings?.shakeSensitivity = ShakeSensitivitySetting.MEDIUM
|
||||
}
|
||||
// Initialize auto sleep timer auto rewind added in v0.9.64
|
||||
if (deviceData.deviceSettings?.autoSleepTimerAutoRewindTime == null) {
|
||||
deviceData.deviceSettings?.autoSleepTimerAutoRewindTime = 300000L // 5 minutes
|
||||
}
|
||||
|
||||
// Language added in v0.9.69
|
||||
if (deviceData.deviceSettings?.languageCode == null) {
|
||||
deviceData.deviceSettings?.languageCode = "en-us"
|
||||
}
|
||||
}
|
||||
|
||||
fun getBase64Id(id:String):String {
|
||||
@@ -72,4 +86,31 @@ object DeviceManager {
|
||||
deviceData.lastPlaybackSession = playbackSession
|
||||
dbManager.saveDeviceData(deviceData)
|
||||
}
|
||||
|
||||
fun initializeWidgetUpdater(context:Context) {
|
||||
Log.d(tag, "Initializing widget updater")
|
||||
widgetUpdater = (object : WidgetEventEmitter {
|
||||
override fun onPlayerChanged(pns: PlayerNotificationService) {
|
||||
|
||||
val isPlaying = pns.currentPlayer.isPlaying
|
||||
|
||||
val appWidgetManager = AppWidgetManager.getInstance(context)
|
||||
val componentName = ComponentName(context, MediaPlayerWidget::class.java)
|
||||
val ids = appWidgetManager.getAppWidgetIds(componentName)
|
||||
val playbackSession = pns.getCurrentPlaybackSessionCopy()
|
||||
|
||||
for (widgetId in ids) {
|
||||
updateAppWidget(context, appWidgetManager, widgetId, playbackSession, isPlaying, PlayerNotificationService.isClosed)
|
||||
}
|
||||
}
|
||||
override fun onPlayerClosed() {
|
||||
val appWidgetManager = AppWidgetManager.getInstance(context)
|
||||
val componentName = ComponentName(context, MediaPlayerWidget::class.java)
|
||||
val ids = appWidgetManager.getAppWidgetIds(componentName)
|
||||
for (widgetId in ids) {
|
||||
updateAppWidget(context, appWidgetManager, widgetId, deviceData.lastPlaybackSession, false, PlayerNotificationService.isClosed)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,16 +5,11 @@ import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import com.anggrayudi.storage.file.*
|
||||
import com.arthenica.ffmpegkit.FFmpegKitConfig
|
||||
import com.arthenica.ffmpegkit.FFprobeKit
|
||||
import com.arthenica.ffmpegkit.Level
|
||||
import com.audiobookshelf.app.data.*
|
||||
import com.audiobookshelf.app.models.DownloadItem
|
||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import com.getcapacitor.JSObject
|
||||
import org.json.JSONException
|
||||
import java.io.File
|
||||
|
||||
class FolderScanner(var ctx: Context) {
|
||||
private val tag = "FolderScanner"
|
||||
@@ -26,203 +21,189 @@ class FolderScanner(var ctx: Context) {
|
||||
return "local_" + DeviceManager.getBase64Id(mediaItemId)
|
||||
}
|
||||
|
||||
enum class ItemScanResult {
|
||||
ADDED, REMOVED, UPDATED, UPTODATE
|
||||
}
|
||||
private fun scanInternalDownloadItem(downloadItem:DownloadItem, cb: (DownloadItemScanResult?) -> Unit) {
|
||||
val localLibraryItemId = "local_${downloadItem.libraryItemId}"
|
||||
|
||||
// TODO: CLEAN this monster! Divide into bite-size methods
|
||||
fun scanForMediaItems(localFolder:LocalFolder, forceAudioProbe:Boolean):FolderScanResult? {
|
||||
FFmpegKitConfig.enableLogCallback { log ->
|
||||
if (log.level != Level.AV_LOG_STDERR) { // STDERR is filled with junk
|
||||
Log.d(tag, "FFmpeg-Kit Log: (${log.level}) ${log.message}")
|
||||
}
|
||||
}
|
||||
|
||||
val df: DocumentFile? = DocumentFileCompat.fromUri(ctx, Uri.parse(localFolder.contentUrl))
|
||||
|
||||
if (df == null) {
|
||||
Log.e(tag, "Folder Doc File Invalid $localFolder.contentUrl")
|
||||
return null
|
||||
}
|
||||
|
||||
var mediaItemsUpdated = 0
|
||||
var mediaItemsAdded = 0
|
||||
var mediaItemsRemoved = 0
|
||||
var mediaItemsUpToDate = 0
|
||||
|
||||
// Search for files in media item folder
|
||||
val foldersFound = df.search(true, DocumentFileType.FOLDER)
|
||||
|
||||
// Match folders found with local library items already saved in db
|
||||
var existingLocalLibraryItems = DeviceManager.dbManager.getLocalLibraryItemsInFolder(localFolder.id)
|
||||
|
||||
// Remove existing items no longer there
|
||||
existingLocalLibraryItems = existingLocalLibraryItems.filter { lli ->
|
||||
Log.d(tag, "scanForMediaItems Checking Existing LLI ${lli.id}")
|
||||
val fileFound = foldersFound.find { f -> lli.id == getLocalLibraryItemId(f.id) }
|
||||
if (fileFound == null) {
|
||||
Log.d(tag, "Existing local library item is no longer in file system ${lli.media.metadata.title}")
|
||||
DeviceManager.dbManager.removeLocalLibraryItem(lli.id)
|
||||
mediaItemsRemoved++
|
||||
}
|
||||
fileFound != null
|
||||
}
|
||||
|
||||
foldersFound.forEach { itemFolder ->
|
||||
Log.d(tag, "Iterating over Folder Found ${itemFolder.name} | ${itemFolder.getSimplePath(ctx)} | URI: ${itemFolder.uri}")
|
||||
val existingItem = existingLocalLibraryItems.find { emi -> emi.id == getLocalLibraryItemId(itemFolder.id) }
|
||||
|
||||
val filesInFolder = itemFolder.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4", "application/octet-stream"))
|
||||
|
||||
// Do not scan folders that have no media items and not an existing item already
|
||||
if (existingItem != null || filesInFolder.isNotEmpty()) {
|
||||
when (scanLibraryItemFolder(itemFolder, filesInFolder, localFolder, existingItem, forceAudioProbe)) {
|
||||
ItemScanResult.REMOVED -> mediaItemsRemoved++
|
||||
ItemScanResult.UPDATED -> mediaItemsUpdated++
|
||||
ItemScanResult.ADDED -> mediaItemsAdded++
|
||||
else -> mediaItemsUpToDate++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Log.d(tag, "Folder $${localFolder.name} scan Results: $mediaItemsAdded Added | $mediaItemsUpdated Updated | $mediaItemsRemoved Removed | $mediaItemsUpToDate Up-to-date")
|
||||
|
||||
return if (mediaItemsAdded > 0 || mediaItemsUpdated > 0 || mediaItemsRemoved > 0) {
|
||||
val folderLibraryItems = DeviceManager.dbManager.getLocalLibraryItemsInFolder(localFolder.id) // Get all local media items
|
||||
FolderScanResult(mediaItemsAdded, mediaItemsUpdated, mediaItemsRemoved, mediaItemsUpToDate, localFolder, folderLibraryItems)
|
||||
} else {
|
||||
Log.d(tag, "No Media Items to save")
|
||||
FolderScanResult(mediaItemsAdded, mediaItemsUpdated, mediaItemsRemoved, mediaItemsUpToDate, localFolder, mutableListOf())
|
||||
}
|
||||
}
|
||||
|
||||
private fun scanLibraryItemFolder(itemFolder:DocumentFile, filesInFolder:List<DocumentFile>, localFolder:LocalFolder, existingItem:LocalLibraryItem?, forceAudioProbe:Boolean):ItemScanResult {
|
||||
val itemFolderName = itemFolder.name ?: ""
|
||||
val itemId = getLocalLibraryItemId(itemFolder.id)
|
||||
|
||||
val existingLocalFiles = existingItem?.localFiles ?: mutableListOf()
|
||||
val existingAudioTracks = existingItem?.media?.getAudioTracks() ?: mutableListOf()
|
||||
var isNewOrUpdated = existingItem == null
|
||||
|
||||
val audioTracks = mutableListOf<AudioTrack>()
|
||||
val localFiles = mutableListOf<LocalFile>()
|
||||
var index = 1
|
||||
var startOffset = 0.0
|
||||
var coverContentUrl:String? = null
|
||||
var coverAbsolutePath:String? = null
|
||||
|
||||
val existingLocalFilesRemoved = existingLocalFiles.filter { elf ->
|
||||
filesInFolder.find { fif -> DeviceManager.getBase64Id(fif.id) == elf.id } == null // File was not found in media item folder
|
||||
}
|
||||
if (existingLocalFilesRemoved.isNotEmpty()) {
|
||||
Log.d(tag, "${existingLocalFilesRemoved.size} Local files were removed from local media item ${existingItem?.media?.metadata?.title}")
|
||||
isNewOrUpdated = true
|
||||
}
|
||||
|
||||
filesInFolder.forEach { file ->
|
||||
val mimeType = file.mimeType ?: ""
|
||||
val filename = file.name ?: ""
|
||||
val isAudio = mimeType.startsWith("audio") || mimeType == "video/mp4"
|
||||
Log.d(tag, "Found $mimeType file $filename in folder $itemFolderName")
|
||||
|
||||
val localFileId = DeviceManager.getBase64Id(file.id)
|
||||
|
||||
val localFile = LocalFile(localFileId,filename,file.uri.toString(),file.getBasePath(ctx), file.getAbsolutePath(ctx),file.getSimplePath(ctx),mimeType,file.length())
|
||||
localFiles.add(localFile)
|
||||
|
||||
Log.d(tag, "File attributes Id:${localFileId}|ContentUrl:${localFile.contentUrl}|isDownloadsDocument:${file.isDownloadsDocument}")
|
||||
|
||||
if (isAudio) {
|
||||
val audioTrackToAdd:AudioTrack?
|
||||
|
||||
val existingAudioTrack = existingAudioTracks.find { eat -> eat.localFileId == localFileId }
|
||||
if (existingAudioTrack != null) { // Update existing audio track
|
||||
if (existingAudioTrack.index != index) {
|
||||
Log.d(tag, "scanLibraryItemFolder Updating Audio track index from ${existingAudioTrack.index} to $index")
|
||||
existingAudioTrack.index = index
|
||||
isNewOrUpdated = true
|
||||
}
|
||||
if (existingAudioTrack.startOffset != startOffset) {
|
||||
Log.d(tag, "scanLibraryItemFolder Updating Audio track startOffset ${existingAudioTrack.startOffset} to $startOffset")
|
||||
existingAudioTrack.startOffset = startOffset
|
||||
isNewOrUpdated = true
|
||||
}
|
||||
}
|
||||
|
||||
if (existingAudioTrack == null || forceAudioProbe) {
|
||||
Log.d(tag, "scanLibraryItemFolder Scanning Audio File Path ${localFile.absolutePath} | ForceAudioProbe=${forceAudioProbe}")
|
||||
|
||||
// TODO: Make asynchronous
|
||||
val audioProbeResult = probeAudioFile(localFile.absolutePath)
|
||||
|
||||
if (existingAudioTrack != null) {
|
||||
// Update audio probe data on existing audio track
|
||||
existingAudioTrack.audioProbeResult = audioProbeResult
|
||||
audioTrackToAdd = existingAudioTrack
|
||||
} else {
|
||||
// Create new audio track
|
||||
val track = AudioTrack(index, startOffset, audioProbeResult?.duration ?: 0.0, filename, localFile.contentUrl, mimeType, null, true, localFileId, audioProbeResult, null)
|
||||
audioTrackToAdd = track
|
||||
}
|
||||
|
||||
startOffset += audioProbeResult?.duration ?: 0.0
|
||||
isNewOrUpdated = true
|
||||
} else {
|
||||
audioTrackToAdd = existingAudioTrack
|
||||
}
|
||||
|
||||
startOffset += audioTrackToAdd.duration
|
||||
index++
|
||||
audioTracks.add(audioTrackToAdd)
|
||||
} else {
|
||||
val existingLocalFile = existingLocalFiles.find { elf -> elf.id == localFileId }
|
||||
|
||||
if (existingLocalFile == null) {
|
||||
Log.d(tag, "scanLibraryItemFolder new local file found ${localFile.absolutePath}")
|
||||
isNewOrUpdated = true
|
||||
}
|
||||
if (existingItem != null && existingItem.coverContentUrl == null) {
|
||||
// Existing media item did not have a cover - cover found on scan
|
||||
Log.d(tag, "scanLibraryItemFolder setting cover ${localFile.absolutePath}")
|
||||
isNewOrUpdated = true
|
||||
existingItem.coverAbsolutePath = localFile.absolutePath
|
||||
existingItem.coverContentUrl = localFile.contentUrl
|
||||
existingItem.media.coverPath = localFile.absolutePath
|
||||
}
|
||||
|
||||
// First image file use as cover path
|
||||
if (coverContentUrl == null) {
|
||||
coverContentUrl = localFile.contentUrl
|
||||
coverAbsolutePath = localFile.absolutePath
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (existingItem != null && audioTracks.isEmpty()) {
|
||||
Log.d(tag, "Local library item ${existingItem.media.metadata.title} no longer has audio tracks - removing item")
|
||||
DeviceManager.dbManager.removeLocalLibraryItem(existingItem.id)
|
||||
return ItemScanResult.REMOVED
|
||||
} else if (existingItem != null && !isNewOrUpdated) {
|
||||
Log.d(tag, "Local library item ${existingItem.media.metadata.title} has no updates")
|
||||
return ItemScanResult.UPTODATE
|
||||
} else if (existingItem != null) {
|
||||
Log.d(tag, "Updating local library item ${existingItem.media.metadata.title}")
|
||||
existingItem.updateFromScan(audioTracks,localFiles)
|
||||
DeviceManager.dbManager.saveLocalLibraryItem(existingItem)
|
||||
return ItemScanResult.UPDATED
|
||||
} else if (audioTracks.isNotEmpty()) {
|
||||
Log.d(tag, "Found local media item named $itemFolderName with ${audioTracks.size} tracks and ${localFiles.size} local files")
|
||||
val localMediaItem = LocalMediaItem(itemId, itemFolderName, localFolder.mediaType, localFolder.id, itemFolder.uri.toString(), itemFolder.getSimplePath(ctx), itemFolder.getBasePath(ctx), itemFolder.getAbsolutePath(ctx),audioTracks,localFiles,coverContentUrl,coverAbsolutePath)
|
||||
val localLibraryItem = localMediaItem.getLocalLibraryItem()
|
||||
DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem)
|
||||
return ItemScanResult.ADDED
|
||||
var localEpisodeId:String? = null
|
||||
var localLibraryItem:LocalLibraryItem?
|
||||
if (downloadItem.mediaType == "book") {
|
||||
localLibraryItem = LocalLibraryItem(localLibraryItemId, downloadItem.localFolder.id, downloadItem.itemFolderPath, downloadItem.itemFolderPath, "", false, downloadItem.mediaType, downloadItem.media.getLocalCopy(), mutableListOf(), null, null, true, downloadItem.serverConnectionConfigId, downloadItem.serverAddress, downloadItem.serverUserId, downloadItem.libraryItemId)
|
||||
} else {
|
||||
return ItemScanResult.UPTODATE
|
||||
// Lookup or create podcast local library item
|
||||
localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
|
||||
if (localLibraryItem == null) {
|
||||
Log.d(tag, "[FolderScanner] Podcast local library item not created yet for ${downloadItem.media.metadata.title}")
|
||||
localLibraryItem = LocalLibraryItem(localLibraryItemId, downloadItem.localFolder.id, downloadItem.itemFolderPath, downloadItem.itemFolderPath, "", false, downloadItem.mediaType, downloadItem.media.getLocalCopy(), mutableListOf(), null, null, true,downloadItem.serverConnectionConfigId,downloadItem.serverAddress,downloadItem.serverUserId,downloadItem.libraryItemId)
|
||||
}
|
||||
}
|
||||
|
||||
val audioTracks:MutableList<AudioTrack> = mutableListOf()
|
||||
var foundEBookFile = false
|
||||
|
||||
downloadItem.downloadItemParts.forEach { downloadItemPart ->
|
||||
Log.d(tag, "Scan internal storage item with finalDestinationUri=${downloadItemPart.finalDestinationUri}")
|
||||
|
||||
val file = File(downloadItemPart.finalDestinationPath)
|
||||
Log.d(tag, "Scan internal storage item created file ${file.name}")
|
||||
|
||||
if (file == null) {
|
||||
Log.e(tag, "scanInternalDownloadItem: Null docFile for path ${downloadItemPart.finalDestinationPath}")
|
||||
} else {
|
||||
if (downloadItemPart.audioTrack != null) {
|
||||
val audioTrackFromServer = downloadItemPart.audioTrack
|
||||
Log.d(
|
||||
tag,
|
||||
"scanInternalDownloadItem: Audio Track from Server index = ${audioTrackFromServer.index}"
|
||||
)
|
||||
|
||||
val localFileId = DeviceManager.getBase64Id(file.name)
|
||||
Log.d(tag, "Scan internal file localFileId=$localFileId")
|
||||
val localFile = LocalFile(
|
||||
localFileId,
|
||||
file.name,
|
||||
downloadItemPart.finalDestinationUri.toString(),
|
||||
file.getBasePath(ctx),
|
||||
file.absolutePath,
|
||||
file.getSimplePath(ctx),
|
||||
file.mimeType,
|
||||
file.length()
|
||||
)
|
||||
localLibraryItem.localFiles.add(localFile)
|
||||
|
||||
// Create new audio track
|
||||
val track = AudioTrack(
|
||||
audioTrackFromServer.index,
|
||||
audioTrackFromServer.startOffset,
|
||||
audioTrackFromServer.duration,
|
||||
localFile.filename ?: "",
|
||||
localFile.contentUrl,
|
||||
localFile.mimeType ?: "",
|
||||
null,
|
||||
true,
|
||||
localFileId,
|
||||
null,
|
||||
audioTrackFromServer.index
|
||||
)
|
||||
audioTracks.add(track)
|
||||
|
||||
Log.d(
|
||||
tag,
|
||||
"scanInternalDownloadItem: Created Audio Track with index ${track.index} from local file ${localFile.absolutePath}"
|
||||
)
|
||||
|
||||
// Add podcast episodes to library
|
||||
downloadItemPart.episode?.let { podcastEpisode ->
|
||||
val podcast = localLibraryItem.media as Podcast
|
||||
val newEpisode = podcast.addEpisode(track, podcastEpisode)
|
||||
localEpisodeId = newEpisode.id
|
||||
Log.d(
|
||||
tag,
|
||||
"scanInternalDownloadItem: Added episode to podcast ${podcastEpisode.title} ${track.title} | Track index: ${podcastEpisode.audioTrack?.index}"
|
||||
)
|
||||
}
|
||||
|
||||
} else if (downloadItemPart.ebookFile != null) {
|
||||
foundEBookFile = true
|
||||
Log.d(tag, "scanInternalDownloadItem: Ebook file found with mimetype=${file.mimeType}")
|
||||
val localFileId = DeviceManager.getBase64Id(file.name)
|
||||
val localFile = LocalFile(
|
||||
localFileId,
|
||||
file.name,
|
||||
Uri.fromFile(file).toString(),
|
||||
file.getBasePath(ctx),
|
||||
file.absolutePath,
|
||||
file.getSimplePath(ctx),
|
||||
file.mimeType,
|
||||
file.length()
|
||||
)
|
||||
localLibraryItem.localFiles.add(localFile)
|
||||
|
||||
val ebookFile = EBookFile(
|
||||
downloadItemPart.ebookFile.ino,
|
||||
downloadItemPart.ebookFile.metadata,
|
||||
downloadItemPart.ebookFile.ebookFormat,
|
||||
true,
|
||||
localFileId,
|
||||
localFile.contentUrl
|
||||
)
|
||||
(localLibraryItem.media as Book).ebookFile = ebookFile
|
||||
Log.d(tag, "scanInternalDownloadItem: Ebook file added to lli ${localFile.contentUrl}")
|
||||
} else {
|
||||
val localFileId = DeviceManager.getBase64Id(file.name)
|
||||
val localFile = LocalFile(localFileId,file.name,Uri.fromFile(file).toString(),file.getBasePath(ctx),file.absolutePath,file.getSimplePath(ctx),file.mimeType,file.length())
|
||||
|
||||
localLibraryItem.coverAbsolutePath = localFile.absolutePath
|
||||
localLibraryItem.coverContentUrl = localFile.contentUrl
|
||||
localLibraryItem.localFiles.add(localFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (audioTracks.isEmpty() && !foundEBookFile) {
|
||||
Log.d(tag, "scanDownloadItem did not find any audio tracks or ebook file in folder for ${downloadItem.itemFolderPath}")
|
||||
return cb(null)
|
||||
}
|
||||
|
||||
// For books sort audio tracks then set
|
||||
if (downloadItem.mediaType == "book") {
|
||||
audioTracks.sortBy { it.index }
|
||||
|
||||
var indexCheck = 1
|
||||
var startOffset = 0.0
|
||||
audioTracks.forEach { audioTrack ->
|
||||
if (audioTrack.index != indexCheck || audioTrack.startOffset != startOffset) {
|
||||
audioTrack.index = indexCheck
|
||||
audioTrack.startOffset = startOffset
|
||||
}
|
||||
indexCheck++
|
||||
startOffset += audioTrack.duration
|
||||
}
|
||||
|
||||
localLibraryItem.media.setAudioTracks(audioTracks)
|
||||
}
|
||||
|
||||
val downloadItemScanResult = DownloadItemScanResult(localLibraryItem,null)
|
||||
|
||||
// If library item had media progress then make local media progress and save
|
||||
downloadItem.userMediaProgress?.let { mediaProgress ->
|
||||
val localMediaProgressId = if (downloadItem.episodeId.isNullOrEmpty()) localLibraryItemId else "$localLibraryItemId-$localEpisodeId"
|
||||
val newLocalMediaProgress = LocalMediaProgress(
|
||||
id = localMediaProgressId,
|
||||
localLibraryItemId = localLibraryItemId,
|
||||
localEpisodeId = localEpisodeId,
|
||||
duration = mediaProgress.duration,
|
||||
progress = mediaProgress.progress,
|
||||
currentTime = mediaProgress.currentTime,
|
||||
isFinished = mediaProgress.isFinished,
|
||||
ebookLocation = mediaProgress.ebookLocation,
|
||||
ebookProgress = mediaProgress.ebookProgress,
|
||||
lastUpdate = mediaProgress.lastUpdate,
|
||||
startedAt = mediaProgress.startedAt,
|
||||
finishedAt = mediaProgress.finishedAt,
|
||||
serverConnectionConfigId = downloadItem.serverConnectionConfigId,
|
||||
serverAddress = downloadItem.serverAddress,
|
||||
serverUserId = downloadItem.serverUserId,
|
||||
libraryItemId = downloadItem.libraryItemId,
|
||||
episodeId = downloadItem.episodeId)
|
||||
Log.d(tag, "scanLibraryItemFolder: Saving local media progress ${newLocalMediaProgress.id} at progress ${newLocalMediaProgress.progress}")
|
||||
DeviceManager.dbManager.saveLocalMediaProgress(newLocalMediaProgress)
|
||||
|
||||
downloadItemScanResult.localMediaProgress = newLocalMediaProgress
|
||||
}
|
||||
|
||||
DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem)
|
||||
|
||||
cb(downloadItemScanResult)
|
||||
}
|
||||
|
||||
// Scan item after download and create local library item
|
||||
fun scanDownloadItem(downloadItem: DownloadItem, cb: (DownloadItemScanResult?) -> Unit) {
|
||||
// If downloading to internal storage handle separately
|
||||
if (downloadItem.isInternalStorage) {
|
||||
scanInternalDownloadItem(downloadItem, cb)
|
||||
return
|
||||
}
|
||||
|
||||
val folderDf = DocumentFileCompat.fromUri(ctx, Uri.parse(downloadItem.localFolder.contentUrl))
|
||||
val foldersFound = folderDf?.search(true, DocumentFileType.FOLDER) ?: mutableListOf()
|
||||
|
||||
@@ -257,7 +238,7 @@ class FolderScanner(var ctx: Context) {
|
||||
|
||||
// Search for files in media item folder
|
||||
// m4b files showing as mimeType application/octet-stream on Android 10 and earlier see #154
|
||||
val filesFound = df.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4", "application/octet-stream"))
|
||||
val filesFound = df.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4", "application/*"))
|
||||
Log.d(tag, "scanDownloadItem ${filesFound.size} files found in ${downloadItem.itemFolderPath}")
|
||||
|
||||
var localEpisodeId:String? = null
|
||||
@@ -274,6 +255,7 @@ class FolderScanner(var ctx: Context) {
|
||||
}
|
||||
|
||||
val audioTracks:MutableList<AudioTrack> = mutableListOf()
|
||||
var foundEBookFile = false
|
||||
|
||||
filesFound.forEach { docFile ->
|
||||
val itemPart = downloadItem.downloadItemParts.find { itemPart ->
|
||||
@@ -304,6 +286,16 @@ class FolderScanner(var ctx: Context) {
|
||||
localEpisodeId = newEpisode.id
|
||||
Log.d(tag, "scanDownloadItem: Added episode to podcast ${podcastEpisode.title} ${track.title} | Track index: ${podcastEpisode.audioTrack?.index}")
|
||||
}
|
||||
} else if (itemPart.ebookFile != null) { // Ebook
|
||||
foundEBookFile = true
|
||||
Log.d(tag, "scanDownloadItem: Ebook file found with mimetype=${docFile.mimeType}")
|
||||
val localFileId = DeviceManager.getBase64Id(docFile.id)
|
||||
val localFile = LocalFile(localFileId,docFile.name,docFile.uri.toString(),docFile.getBasePath(ctx),docFile.getAbsolutePath(ctx),docFile.getSimplePath(ctx),docFile.mimeType,docFile.length())
|
||||
localLibraryItem.localFiles.add(localFile)
|
||||
|
||||
val ebookFile = EBookFile(itemPart.ebookFile.ino, itemPart.ebookFile.metadata, itemPart.ebookFile.ebookFormat, true, localFileId, localFile.contentUrl)
|
||||
(localLibraryItem.media as Book).ebookFile = ebookFile
|
||||
Log.d(tag, "scanDownloadItem: Ebook file added to lli ${localFile.contentUrl}")
|
||||
} else { // Cover image
|
||||
val localFileId = DeviceManager.getBase64Id(docFile.id)
|
||||
val localFile = LocalFile(localFileId,docFile.name,docFile.uri.toString(),docFile.getBasePath(ctx),docFile.getAbsolutePath(ctx),docFile.getSimplePath(ctx),docFile.mimeType,docFile.length())
|
||||
@@ -314,8 +306,8 @@ class FolderScanner(var ctx: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
if (audioTracks.isEmpty()) {
|
||||
Log.d(tag, "scanDownloadItem did not find any audio tracks in folder for ${downloadItem.itemFolderPath}")
|
||||
if (audioTracks.isEmpty() && !foundEBookFile) {
|
||||
Log.d(tag, "scanDownloadItem did not find any audio tracks or ebook file in folder for ${downloadItem.itemFolderPath}")
|
||||
return cb(null)
|
||||
}
|
||||
|
||||
@@ -349,7 +341,9 @@ class FolderScanner(var ctx: Context) {
|
||||
duration = mediaProgress.duration,
|
||||
progress = mediaProgress.progress,
|
||||
currentTime = mediaProgress.currentTime,
|
||||
isFinished = false,
|
||||
isFinished = mediaProgress.isFinished,
|
||||
ebookLocation = mediaProgress.ebookLocation,
|
||||
ebookProgress = mediaProgress.ebookProgress,
|
||||
lastUpdate = mediaProgress.lastUpdate,
|
||||
startedAt = mediaProgress.startedAt,
|
||||
finishedAt = mediaProgress.finishedAt,
|
||||
@@ -359,6 +353,7 @@ class FolderScanner(var ctx: Context) {
|
||||
libraryItemId = downloadItem.libraryItemId,
|
||||
episodeId = downloadItem.episodeId)
|
||||
Log.d(tag, "scanLibraryItemFolder: Saving local media progress ${newLocalMediaProgress.id} at progress ${newLocalMediaProgress.progress}")
|
||||
|
||||
DeviceManager.dbManager.saveLocalMediaProgress(newLocalMediaProgress)
|
||||
|
||||
downloadItemScanResult.localMediaProgress = newLocalMediaProgress
|
||||
@@ -368,122 +363,4 @@ class FolderScanner(var ctx: Context) {
|
||||
|
||||
cb(downloadItemScanResult)
|
||||
}
|
||||
|
||||
fun scanLocalLibraryItem(localLibraryItem:LocalLibraryItem, forceAudioProbe:Boolean):LocalLibraryItemScanResult? {
|
||||
val df: DocumentFile? = DocumentFileCompat.fromUri(ctx, Uri.parse(localLibraryItem.contentUrl))
|
||||
|
||||
if (df == null) {
|
||||
Log.e(tag, "Item Folder Doc File Invalid ${localLibraryItem.absolutePath}")
|
||||
return null
|
||||
}
|
||||
Log.d(tag, "scanLocalLibraryItem starting for ${localLibraryItem.absolutePath} | ${df.uri}")
|
||||
|
||||
var wasUpdated = false
|
||||
|
||||
// Search for files in media item folder
|
||||
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}")
|
||||
|
||||
filesFound.forEach {
|
||||
try {
|
||||
Log.d(tag, "Checking file found ${it.name} | ${it.id}")
|
||||
}catch(e:Exception) {
|
||||
Log.d(tag, "Check file found exception", e)
|
||||
}
|
||||
}
|
||||
|
||||
val existingAudioTracks = localLibraryItem.media.getAudioTracks()
|
||||
|
||||
// Remove any files no longer found in library item folder
|
||||
val existingLocalFileIds = localLibraryItem.localFiles.map { it.id }
|
||||
existingLocalFileIds.forEach { localFileId ->
|
||||
Log.d(tag, "Checking local file id is there $localFileId")
|
||||
if (filesFound.find { DeviceManager.getBase64Id(it.id) == localFileId } == null) {
|
||||
Log.d(tag, "scanLocalLibraryItem file $localFileId was removed from ${localLibraryItem.absolutePath}")
|
||||
localLibraryItem.localFiles.removeIf { it.id == localFileId }
|
||||
|
||||
if (existingAudioTracks.find { it.localFileId == localFileId } != null) {
|
||||
Log.d(tag, "scanLocalLibraryItem audio track file $localFileId was removed from ${localLibraryItem.absolutePath}")
|
||||
localLibraryItem.media.removeAudioTrack(localFileId)
|
||||
}
|
||||
wasUpdated = true
|
||||
}
|
||||
}
|
||||
|
||||
filesFound.forEach { docFile ->
|
||||
val localFileId = DeviceManager.getBase64Id(docFile.id)
|
||||
val existingLocalFile = localLibraryItem.localFiles.find { it.id == localFileId }
|
||||
|
||||
if (existingLocalFile == null || (existingLocalFile.isAudioFile() && forceAudioProbe)) {
|
||||
|
||||
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) {
|
||||
localLibraryItem.localFiles.add(localFile)
|
||||
Log.d(tag, "scanLocalLibraryItem new file found ${localFile.filename}")
|
||||
}
|
||||
|
||||
if (localFile.isAudioFile()) {
|
||||
// TODO: Make asynchronous
|
||||
val audioProbeResult = probeAudioFile(localFile.absolutePath)
|
||||
|
||||
val existingTrack = existingAudioTracks.find { audioTrack ->
|
||||
audioTrack.localFileId == localFile.id
|
||||
}
|
||||
|
||||
if (existingTrack == null) {
|
||||
// Create new audio track
|
||||
val lastTrack = existingAudioTracks.lastOrNull()
|
||||
val startOffset = (lastTrack?.startOffset ?: 0.0) + (lastTrack?.duration ?: 0.0)
|
||||
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)
|
||||
Log.d(tag, "Added New Audio Track ${track.title}")
|
||||
wasUpdated = true
|
||||
} else {
|
||||
existingTrack.audioProbeResult = audioProbeResult
|
||||
// TODO: Update data found from probe
|
||||
|
||||
Log.d(tag, "Updated Audio Track Probe Data ${existingTrack.title}")
|
||||
|
||||
wasUpdated = true
|
||||
}
|
||||
} else { // Check if cover is empty
|
||||
if (localLibraryItem.coverContentUrl == null) {
|
||||
Log.d(tag, "scanLocalLibraryItem setting cover for ${localLibraryItem.media.metadata.title}")
|
||||
localLibraryItem.coverContentUrl = localFile.contentUrl
|
||||
localLibraryItem.coverAbsolutePath = localFile.absolutePath
|
||||
wasUpdated = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (wasUpdated) {
|
||||
Log.d(tag, "Local library item was updated - saving it")
|
||||
DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem)
|
||||
} else {
|
||||
Log.d(tag, "Local library item was up-to-date")
|
||||
}
|
||||
return LocalLibraryItemScanResult(wasUpdated, localLibraryItem)
|
||||
}
|
||||
|
||||
private fun probeAudioFile(absolutePath:String):AudioProbeResult? {
|
||||
val session = FFprobeKit.execute("-i \"${absolutePath}\" -print_format json -show_format -show_streams -select_streams a -show_chapters -loglevel quiet")
|
||||
|
||||
var probeObject:JSObject? = null
|
||||
try {
|
||||
probeObject = JSObject(session.output)
|
||||
} catch(error:JSONException) {
|
||||
Log.e(tag, "Failed to parse probe result $error")
|
||||
}
|
||||
|
||||
Log.d(tag, "FFprobe output $probeObject")
|
||||
return if (probeObject == null || !probeObject.has("streams")) { // Check if output is empty
|
||||
Log.d(tag, "probeAudioFile Probe audio file $absolutePath failed or invalid")
|
||||
null
|
||||
} else {
|
||||
val audioProbeResult = jacksonMapper.readValue<AudioProbeResult>(session.output)
|
||||
Log.d(tag, "Probe Result DATA ${audioProbeResult.duration} | ${audioProbeResult.size} | ${audioProbeResult.title} | ${audioProbeResult.artist}")
|
||||
audioProbeResult
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.util.*
|
||||
|
||||
class DownloadItemManager(var downloadManager:DownloadManager, private var folderScanner: FolderScanner, var mainActivity: MainActivity, private var clientEventEmitter:DownloadEventEmitter) {
|
||||
@@ -44,6 +46,11 @@ class DownloadItemManager(var downloadManager:DownloadManager, private var folde
|
||||
fun onDownloadItemComplete(jsobj:JSObject)
|
||||
}
|
||||
|
||||
interface InternalProgressCallback {
|
||||
fun onProgress(totalBytesWritten:Long, progress: Long)
|
||||
fun onComplete(failed: Boolean)
|
||||
}
|
||||
|
||||
companion object {
|
||||
var isDownloading:Boolean = false
|
||||
}
|
||||
@@ -65,11 +72,33 @@ class DownloadItemManager(var downloadManager:DownloadManager, private var folde
|
||||
|
||||
if (nextDownloadItemParts.size > 0) {
|
||||
nextDownloadItemParts.forEach {
|
||||
val dlRequest = it.getDownloadRequest()
|
||||
val downloadId = downloadManager.enqueue(dlRequest)
|
||||
it.downloadId = downloadId
|
||||
Log.d(tag, "checkUpdateDownloadQueue: Starting download item part, downloadId=$downloadId")
|
||||
currentDownloadItemParts.add(it)
|
||||
if (it.isInternalStorage) {
|
||||
val file = File(it.finalDestinationPath)
|
||||
file.parentFile?.mkdirs()
|
||||
|
||||
val fileOutputStream = FileOutputStream(it.finalDestinationPath)
|
||||
val internalProgressCallback = (object : InternalProgressCallback {
|
||||
override fun onProgress(totalBytesWritten:Long, progress: Long) {
|
||||
it.bytesDownloaded = totalBytesWritten
|
||||
it.progress = progress
|
||||
}
|
||||
override fun onComplete(failed:Boolean) {
|
||||
it.failed = failed
|
||||
it.completed = true
|
||||
}
|
||||
})
|
||||
|
||||
Log.d(tag, "Start internal download to destination path ${it.finalDestinationPath} from ${it.serverUrl}")
|
||||
InternalDownloadManager(fileOutputStream, internalProgressCallback).download(it.serverUrl)
|
||||
it.downloadId = 1
|
||||
currentDownloadItemParts.add(it)
|
||||
} else {
|
||||
val dlRequest = it.getDownloadRequest()
|
||||
val downloadId = downloadManager.enqueue(dlRequest)
|
||||
it.downloadId = downloadId
|
||||
Log.d(tag, "checkUpdateDownloadQueue: Starting download item part, downloadId=$downloadId")
|
||||
currentDownloadItemParts.add(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,14 +118,25 @@ class DownloadItemManager(var downloadManager:DownloadManager, private var folde
|
||||
isDownloading = true
|
||||
|
||||
while (currentDownloadItemParts.size > 0) {
|
||||
|
||||
val itemParts = currentDownloadItemParts.filter { !it.isMoving }.map { it }
|
||||
for (downloadItemPart in itemParts) {
|
||||
val downloadCheckStatus = checkDownloadItemPart(downloadItemPart)
|
||||
clientEventEmitter.onDownloadItemPartUpdate(downloadItemPart)
|
||||
if (downloadItemPart.isInternalStorage) {
|
||||
clientEventEmitter.onDownloadItemPartUpdate(downloadItemPart)
|
||||
|
||||
// Will move to final destination, remove current item parts, and check if download item is finished
|
||||
handleDownloadItemPartCheck(downloadCheckStatus, downloadItemPart)
|
||||
if (downloadItemPart.completed) {
|
||||
val downloadItem = downloadItemQueue.find { it.id == downloadItemPart.downloadItemId }
|
||||
downloadItem?.let {
|
||||
checkDownloadItemFinished(it)
|
||||
}
|
||||
currentDownloadItemParts.remove(downloadItemPart)
|
||||
}
|
||||
} else {
|
||||
val downloadCheckStatus = checkDownloadItemPart(downloadItemPart)
|
||||
clientEventEmitter.onDownloadItemPartUpdate(downloadItemPart)
|
||||
|
||||
// Will move to final destination, remove current item parts, and check if download item is finished
|
||||
handleDownloadItemPartCheck(downloadCheckStatus, downloadItemPart)
|
||||
}
|
||||
}
|
||||
|
||||
delay(500)
|
||||
@@ -166,7 +206,6 @@ class DownloadItemManager(var downloadManager:DownloadManager, private var folde
|
||||
Log.e(tag, "Download item part finished but download item not found ${downloadItemPart.filename}")
|
||||
currentDownloadItemParts.remove(downloadItemPart)
|
||||
} else if (downloadCheckStatus == DownloadCheckStatus.Successful) {
|
||||
|
||||
val file = DocumentFileCompat.fromUri(mainActivity, downloadItemPart.destinationUri)
|
||||
Log.d(tag, "DOWNLOAD: DESTINATION URI ${downloadItemPart.destinationUri}")
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.audiobookshelf.app.managers
|
||||
|
||||
import android.util.Log
|
||||
import com.google.common.net.HttpHeaders.CONTENT_LENGTH
|
||||
import okhttp3.*
|
||||
import java.io.*
|
||||
import java.util.*
|
||||
|
||||
class InternalDownloadManager(outputStream:FileOutputStream, private val progressCallback: DownloadItemManager.InternalProgressCallback) : AutoCloseable {
|
||||
private val tag = "InternalDownloadManager"
|
||||
|
||||
private val client: OkHttpClient = OkHttpClient()
|
||||
private val writer = BinaryFileWriter(outputStream, progressCallback)
|
||||
|
||||
@Throws(IOException::class)
|
||||
fun download(url:String) {
|
||||
val request: Request = Request.Builder().url(url).build()
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
Log.e(tag, "download URL $url FAILED")
|
||||
progressCallback.onComplete(true)
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
val responseBody: ResponseBody = response.body
|
||||
?: throw IllegalStateException("Response doesn't contain a file")
|
||||
|
||||
val length: Long = (response.header(CONTENT_LENGTH, "1") ?: "0").toLong()
|
||||
writer.write(responseBody.byteStream(), length)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@Throws(Exception::class)
|
||||
override fun close() {
|
||||
writer.close()
|
||||
}
|
||||
}
|
||||
|
||||
class BinaryFileWriter(outputStream: OutputStream, progressCallback: DownloadItemManager.InternalProgressCallback) :
|
||||
AutoCloseable {
|
||||
private val outputStream: OutputStream
|
||||
private val progressCallback: DownloadItemManager.InternalProgressCallback
|
||||
|
||||
init {
|
||||
this.outputStream = outputStream
|
||||
this.progressCallback = progressCallback
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
fun write(inputStream: InputStream?, length: Long): Long {
|
||||
BufferedInputStream(inputStream).use { input ->
|
||||
val dataBuffer = ByteArray(CHUNK_SIZE)
|
||||
var readBytes: Int
|
||||
var totalBytes: Long = 0
|
||||
while (input.read(dataBuffer).also { readBytes = it } != -1) {
|
||||
totalBytes += readBytes.toLong()
|
||||
outputStream.write(dataBuffer, 0, readBytes)
|
||||
progressCallback.onProgress(totalBytes, (totalBytes * 100L) / length)
|
||||
}
|
||||
progressCallback.onComplete(false)
|
||||
return totalBytes
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun close() {
|
||||
outputStream.close()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CHUNK_SIZE = 1024
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.audiobookshelf.app.managers
|
||||
|
||||
import android.content.Context
|
||||
import android.media.metrics.PlaybackSession
|
||||
import android.os.*
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
@@ -21,6 +22,8 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
||||
private var sleepTimerElapsed:Long = 0L
|
||||
private var sleepTimerFinishedAt:Long = 0L
|
||||
private var isAutoSleepTimer:Boolean = false // When timer was auto-set
|
||||
private var isFirstAutoSleepTimer: Boolean = true
|
||||
private var sleepTimerSessionId:String = ""
|
||||
|
||||
private fun getCurrentTime():Long {
|
||||
return playerNotificationService.getCurrentTime()
|
||||
@@ -61,6 +64,7 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
||||
sleepTimerRunning = true
|
||||
sleepTimerFinishedAt = 0L
|
||||
sleepTimerElapsed = 0L
|
||||
setVolume(1f)
|
||||
|
||||
// Register shake sensor
|
||||
playerNotificationService.registerSensor()
|
||||
@@ -80,10 +84,6 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
||||
} else {
|
||||
sleepTimerLength = time
|
||||
sleepTimerEndTime = 0L
|
||||
|
||||
if (sleepTimerLength + getCurrentTime() > getDuration()) {
|
||||
sleepTimerLength = getDuration() - getCurrentTime()
|
||||
}
|
||||
}
|
||||
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds(), isAutoSleepTimer)
|
||||
@@ -107,17 +107,14 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerEnded(getCurrentTime())
|
||||
clearSleepTimer()
|
||||
sleepTimerFinishedAt = System.currentTimeMillis()
|
||||
} else if (sleepTimeSecondsRemaining <= 60) {
|
||||
if (DeviceManager.deviceData.deviceSettings?.disableSleepTimerFadeOut == true) {
|
||||
// Set volume to 1 in case setting was enabled while fading
|
||||
setVolume(1f)
|
||||
} else {
|
||||
// Start fading out audio down to 10% volume
|
||||
val percentToReduce = 1 - (sleepTimeSecondsRemaining / 60F)
|
||||
val volume = 1f - (percentToReduce * 0.9f)
|
||||
Log.d(tag, "SLEEP VOLUME FADE $volume | ${sleepTimeSecondsRemaining}s remaining")
|
||||
setVolume(volume)
|
||||
}
|
||||
} else if (sleepTimeSecondsRemaining <= 60 && DeviceManager.deviceData.deviceSettings?.disableSleepTimerFadeOut != true) {
|
||||
// Start fading out audio down to 10% volume
|
||||
val percentToReduce = 1 - (sleepTimeSecondsRemaining / 60F)
|
||||
val volume = 1f - (percentToReduce * 0.9f)
|
||||
Log.d(tag, "SLEEP VOLUME FADE $volume | ${sleepTimeSecondsRemaining}s remaining")
|
||||
setVolume(volume)
|
||||
} else {
|
||||
setVolume(1f)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -125,7 +122,8 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
||||
return true
|
||||
}
|
||||
|
||||
fun setManualSleepTimer(time: Long, isChapterTime:Boolean):Boolean {
|
||||
fun setManualSleepTimer(playbackSessionId:String, time: Long, isChapterTime:Boolean):Boolean {
|
||||
sleepTimerSessionId = playbackSessionId
|
||||
isAutoSleepTimer = false
|
||||
return setSleepTimer(time, isChapterTime)
|
||||
}
|
||||
@@ -135,7 +133,6 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
||||
sleepTimerTask = null
|
||||
sleepTimerEndTime = 0
|
||||
sleepTimerRunning = false
|
||||
isAutoSleepTimer = false
|
||||
playerNotificationService.unregisterSensor()
|
||||
|
||||
setVolume(1f)
|
||||
@@ -229,6 +226,17 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
||||
return
|
||||
}
|
||||
|
||||
// Automatically Rewind in the book if settings is enabled
|
||||
if (isAutoSleepTimer) {
|
||||
DeviceManager.deviceData.deviceSettings?.let { deviceSettings ->
|
||||
if (deviceSettings.autoSleepTimerAutoRewind && !isFirstAutoSleepTimer) {
|
||||
Log.i(tag, "Auto sleep timer auto rewind seeking back ${deviceSettings.autoSleepTimerAutoRewindTime}ms")
|
||||
playerNotificationService.seekBackward(deviceSettings.autoSleepTimerAutoRewindTime)
|
||||
}
|
||||
isFirstAutoSleepTimer = false
|
||||
}
|
||||
}
|
||||
|
||||
// Set sleep timer
|
||||
// When sleepTimerLength is 0 then use end of chapter/track time
|
||||
if (sleepTimerLength == 0L) {
|
||||
@@ -327,18 +335,31 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
||||
endCalendar.set(Calendar.HOUR_OF_DAY, deviceSettings.autoSleepTimerEndHour)
|
||||
endCalendar.set(Calendar.MINUTE, deviceSettings.autoSleepTimerEndMinute)
|
||||
|
||||
// In cases where end time is earlier then start time then we add a day to end time
|
||||
// e.g. start time 22:00 and end time 06:00. End time will be treated as 6am the next day.
|
||||
// e.g. start time 08:00 and end time 22:00. Start and end time will be the same day.
|
||||
val currentCalendar = Calendar.getInstance()
|
||||
|
||||
// In cases where end time is before start time then we shift the time window forward or backward based on the current time.
|
||||
// e.g. start time 22:00 and end time 06:00.
|
||||
// If current time is less than start time (e.g. 00:30) then start time will be the previous day.
|
||||
// If current time is greater than start time (e.g. 23:00) then end time will be the next day.
|
||||
if (endCalendar.before(startCalendar)) {
|
||||
endCalendar.add(Calendar.DAY_OF_MONTH, 1)
|
||||
if (currentCalendar.before(startCalendar)) { // Shift start back a day
|
||||
startCalendar.add(Calendar.DAY_OF_MONTH, -1)
|
||||
} else { // Shift end forward a day
|
||||
endCalendar.add(Calendar.DAY_OF_MONTH, 1)
|
||||
}
|
||||
}
|
||||
|
||||
val currentCalendar = Calendar.getInstance()
|
||||
val currentHour = SimpleDateFormat("HH:mm", Locale.getDefault()).format(currentCalendar.time)
|
||||
if (currentCalendar.after(startCalendar) && currentCalendar.before(endCalendar)) {
|
||||
Log.i(tag, "Current hour $currentHour is between ${deviceSettings.autoSleepTimerStartTime} and ${deviceSettings.autoSleepTimerEndTime} - starting sleep timer")
|
||||
|
||||
// Automatically Rewind in the book if settings is enabled
|
||||
if (deviceSettings.autoSleepTimerAutoRewind && !isFirstAutoSleepTimer) {
|
||||
Log.i(tag, "Auto sleep timer auto rewind seeking back ${deviceSettings.autoSleepTimerAutoRewindTime}ms")
|
||||
playerNotificationService.seekBackward(deviceSettings.autoSleepTimerAutoRewindTime)
|
||||
}
|
||||
isFirstAutoSleepTimer = false
|
||||
|
||||
// Set sleep timer
|
||||
// When sleepTimerLength is 0 then use end of chapter/track time
|
||||
if (deviceSettings.sleepTimerLength == 0L) {
|
||||
@@ -354,13 +375,23 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
||||
setSleepTimer(deviceSettings.sleepTimerLength, false)
|
||||
}
|
||||
} else {
|
||||
isFirstAutoSleepTimer = true
|
||||
Log.d(tag, "Current hour $currentHour is NOT between ${deviceSettings.autoSleepTimerStartTime} and ${deviceSettings.autoSleepTimerEndTime}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun handleMediaPlayEvent() {
|
||||
checkShouldResetSleepTimer()
|
||||
fun handleMediaPlayEvent(playbackSessionId:String) {
|
||||
// Check if the playback session has changed
|
||||
// If it hasn't changed OR the sleep timer is running then check reset the timer
|
||||
// e.g. You set a manual sleep timer for 10 mins, then decide to change books, the sleep timer will stay on and reset to 10 mins
|
||||
if (sleepTimerSessionId == playbackSessionId || sleepTimerRunning) {
|
||||
checkShouldResetSleepTimer()
|
||||
} else {
|
||||
isFirstAutoSleepTimer = true
|
||||
}
|
||||
sleepTimerSessionId = playbackSessionId
|
||||
|
||||
checkAutoSleepTimer()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,7 +111,10 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
|
||||
cb(serverItemsInProgress)
|
||||
} else {
|
||||
apiHandler.getAllItemsInProgress { itemsInProgress ->
|
||||
serverItemsInProgress = itemsInProgress
|
||||
serverItemsInProgress = itemsInProgress.filter {
|
||||
val libraryItem = it.libraryItemWrapper as LibraryItem
|
||||
libraryItem.checkHasTracks()
|
||||
}
|
||||
cb(serverItemsInProgress)
|
||||
}
|
||||
}
|
||||
@@ -250,8 +253,10 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
|
||||
if (!DeviceManager.checkConnectivity(ctx)) {
|
||||
serverUserMediaProgress = mutableListOf()
|
||||
cb(false)
|
||||
} else if (DeviceManager.serverConnectionConfigId == "") { // If in offline mode server connection config is unset
|
||||
serverUserMediaProgress = mutableListOf()
|
||||
cb(false)
|
||||
} else {
|
||||
|
||||
var hasValidConn = false
|
||||
var lookupMediaProgress = true
|
||||
|
||||
@@ -394,7 +399,7 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
|
||||
|
||||
fun play(libraryItemWrapper:LibraryItemWrapper, episode:PodcastEpisode?, playItemRequestPayload:PlayItemRequestPayload, cb: (PlaybackSession?) -> Unit) {
|
||||
if (libraryItemWrapper is LocalLibraryItem) {
|
||||
cb(libraryItemWrapper.getPlaybackSession(episode))
|
||||
cb(libraryItemWrapper.getPlaybackSession(episode, playItemRequestPayload.deviceInfo))
|
||||
} else {
|
||||
val libraryItem = libraryItemWrapper as LibraryItem
|
||||
apiHandler.playLibraryItem(libraryItem.id,episode?.id ?: "", playItemRequestPayload) {
|
||||
|
||||
@@ -38,8 +38,8 @@ class MediaProgressSyncer(val playerNotificationService: PlayerNotificationServi
|
||||
var currentLocalMediaProgress: LocalMediaProgress? = null
|
||||
|
||||
private val currentDisplayTitle get() = currentPlaybackSession?.displayTitle ?: "Unset"
|
||||
private val currentIsLocal get() = currentPlaybackSession?.isLocal == true
|
||||
private val currentSessionId get() = currentPlaybackSession?.id ?: ""
|
||||
val currentIsLocal get() = currentPlaybackSession?.isLocal == true
|
||||
val currentSessionId get() = currentPlaybackSession?.id ?: ""
|
||||
private val currentPlaybackDuration get() = currentPlaybackSession?.duration ?: 0.0
|
||||
|
||||
fun start(playbackSession:PlaybackSession) {
|
||||
@@ -215,7 +215,6 @@ class MediaProgressSyncer(val playerNotificationService: PlayerNotificationServi
|
||||
val listeningTimeToAdd = diffSinceLastSync / 1000L
|
||||
|
||||
val syncData = MediaProgressSyncData(listeningTimeToAdd,currentPlaybackDuration,currentTime)
|
||||
|
||||
currentPlaybackSession?.syncData(syncData)
|
||||
|
||||
if (currentPlaybackSession?.progress?.isNaN() == true) {
|
||||
@@ -239,15 +238,12 @@ class MediaProgressSyncer(val playerNotificationService: PlayerNotificationServi
|
||||
saveLocalProgress(it)
|
||||
lastSyncTime = System.currentTimeMillis()
|
||||
|
||||
Log.d(tag, "Sync local device current serverConnectionConfigId=${DeviceManager.serverConnectionConfig?.id}")
|
||||
|
||||
// Local library item is linked to a server library item
|
||||
// Send sync to server also if connected to this server and local item belongs to this server
|
||||
if (hasNetworkConnection && shouldSyncServer && !it.libraryItemId.isNullOrEmpty() && it.serverConnectionConfigId != null && DeviceManager.serverConnectionConfig?.id == it.serverConnectionConfigId) {
|
||||
apiHandler.sendLocalProgressSync(it) { syncSuccess, errorMsg ->
|
||||
Log.d(
|
||||
tag,
|
||||
"Local progress sync data sent to server $currentDisplayTitle for time $currentTime"
|
||||
)
|
||||
|
||||
if (syncSuccess) {
|
||||
failedSyncs = 0
|
||||
playerNotificationService.alertSyncSuccess()
|
||||
|
||||
@@ -21,6 +21,9 @@ data class DownloadItem(
|
||||
val media: MediaType,
|
||||
val downloadItemParts: MutableList<DownloadItemPart>
|
||||
) {
|
||||
@get:JsonIgnore
|
||||
val isInternalStorage get() = localFolder.id.startsWith("internal-")
|
||||
|
||||
@get:JsonIgnore
|
||||
val isDownloadFinished get() = !downloadItemParts.any { !it.completed || it.isMoving }
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.app.DownloadManager
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.data.AudioTrack
|
||||
import com.audiobookshelf.app.data.EBookFile
|
||||
import com.audiobookshelf.app.data.LocalFolder
|
||||
import com.audiobookshelf.app.data.PodcastEpisode
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
@@ -20,6 +21,7 @@ data class DownloadItemPart(
|
||||
val localFolderName: String,
|
||||
val localFolderUrl: String,
|
||||
val localFolderId: String,
|
||||
val ebookFile: EBookFile?,
|
||||
val audioTrack: AudioTrack?,
|
||||
val episode: PodcastEpisode?,
|
||||
var completed:Boolean,
|
||||
@@ -35,12 +37,12 @@ data class DownloadItemPart(
|
||||
var bytesDownloaded: Long
|
||||
) {
|
||||
companion object {
|
||||
fun make(downloadItemId:String, filename:String, fileSize: Long, destinationFile: File, finalDestinationFile: File, subfolder:String, serverPath:String, localFolder: LocalFolder, audioTrack: AudioTrack?, episode: PodcastEpisode?) :DownloadItemPart {
|
||||
fun make(downloadItemId:String, filename:String, fileSize: Long, destinationFile: File, finalDestinationFile: File, subfolder:String, serverPath:String, localFolder: LocalFolder, ebookFile: EBookFile?, audioTrack: AudioTrack?, episode: PodcastEpisode?) :DownloadItemPart {
|
||||
val destinationUri = Uri.fromFile(destinationFile)
|
||||
val finalDestinationUri = Uri.fromFile(finalDestinationFile)
|
||||
|
||||
var downloadUrl = "${DeviceManager.serverAddress}${serverPath}?token=${DeviceManager.token}"
|
||||
if (serverPath.endsWith("/cover")) downloadUrl += "&format=jpeg" // For cover images force to jpeg
|
||||
if (serverPath.endsWith("/cover")) downloadUrl += "&format=jpeg&raw=1" // For cover images force to jpeg
|
||||
val downloadUri = Uri.parse(downloadUrl)
|
||||
Log.d("DownloadItemPart", "Audio File Destination Uri: $destinationUri | Final Destination Uri: $finalDestinationUri | Download URI $downloadUri")
|
||||
return DownloadItemPart(
|
||||
@@ -53,6 +55,7 @@ data class DownloadItemPart(
|
||||
localFolderName = localFolder.name,
|
||||
localFolderUrl = localFolder.contentUrl,
|
||||
localFolderId = localFolder.id,
|
||||
ebookFile = ebookFile,
|
||||
audioTrack = audioTrack,
|
||||
episode = episode,
|
||||
completed = false,
|
||||
@@ -70,6 +73,12 @@ data class DownloadItemPart(
|
||||
}
|
||||
}
|
||||
|
||||
@get:JsonIgnore
|
||||
val isInternalStorage get() = localFolderId.startsWith("internal-")
|
||||
|
||||
@get:JsonIgnore
|
||||
val serverUrl get() = "${DeviceManager.serverAddress}${serverPath}?token=${DeviceManager.token}"
|
||||
|
||||
@JsonIgnore
|
||||
fun getDownloadRequest(): DownloadManager.Request {
|
||||
val dlRequest = DownloadManager.Request(uri)
|
||||
|
||||
@@ -123,7 +123,7 @@ class CastManager constructor(val mainActivity:Activity) {
|
||||
}
|
||||
|
||||
fun startRouteScan(connListener:ChromecastListener) {
|
||||
var callback = object : ScanCallback() {
|
||||
val callback = object : ScanCallback() {
|
||||
override fun onRouteUpdate(routes: List<MediaRouter.RouteInfo>?) {
|
||||
Log.d(tag, "CAST On ROUTE UPDATED ${routes?.size} | ${getContext().castState}")
|
||||
// if the routes have changed, we may have an available device
|
||||
@@ -136,7 +136,7 @@ class CastManager constructor(val mainActivity:Activity) {
|
||||
// Let the client know a receiver is available
|
||||
connListener.onReceiverAvailableUpdate(true)
|
||||
// Since we have a receiver we may also have an active session
|
||||
var session = getSessionManager()?.currentCastSession
|
||||
val session = getSessionManager()?.currentCastSession
|
||||
// If we do have a session
|
||||
if (session != null) {
|
||||
// Let the client know
|
||||
@@ -246,15 +246,15 @@ class CastManager constructor(val mainActivity:Activity) {
|
||||
onRouteUpdate(outRoutes)
|
||||
}
|
||||
|
||||
override fun onRouteAdded(router: MediaRouter?, route: MediaRouter.RouteInfo?) {
|
||||
override fun onRouteAdded(router: MediaRouter, route: MediaRouter.RouteInfo) {
|
||||
onFilteredRouteUpdate()
|
||||
}
|
||||
|
||||
override fun onRouteChanged(router: MediaRouter?, route: MediaRouter.RouteInfo?) {
|
||||
override fun onRouteChanged(router: MediaRouter, route: MediaRouter.RouteInfo) {
|
||||
onFilteredRouteUpdate()
|
||||
}
|
||||
|
||||
override fun onRouteRemoved(router: MediaRouter?, route: MediaRouter.RouteInfo?) {
|
||||
override fun onRouteRemoved(router: MediaRouter, route: MediaRouter.RouteInfo) {
|
||||
onFilteredRouteUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,12 +11,12 @@ import com.google.android.exoplayer2.*
|
||||
import com.google.android.exoplayer2.C.TrackType
|
||||
import com.google.android.exoplayer2.MediaMetadata
|
||||
import com.google.android.exoplayer2.Player.*
|
||||
import com.google.android.exoplayer2.TracksInfo.TrackGroupInfo
|
||||
import com.google.android.exoplayer2.Tracks.Group
|
||||
import com.google.android.exoplayer2.audio.AudioAttributes
|
||||
import com.google.android.exoplayer2.ext.cast.SessionAvailabilityListener
|
||||
import com.google.android.exoplayer2.source.TrackGroup
|
||||
import com.google.android.exoplayer2.source.TrackGroupArray
|
||||
import com.google.android.exoplayer2.text.Cue
|
||||
import com.google.android.exoplayer2.text.CueGroup
|
||||
import com.google.android.exoplayer2.trackselection.TrackSelection
|
||||
import com.google.android.exoplayer2.trackselection.TrackSelectionArray
|
||||
import com.google.android.exoplayer2.trackselection.TrackSelectionParameters
|
||||
@@ -49,7 +49,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
||||
private var myPlayWhenReady:Boolean = false
|
||||
private var playbackParameters:PlaybackParameters = PlaybackParameters.DEFAULT
|
||||
private var myAvailableCommands: Commands
|
||||
private var myCurrentTracksInfo: TracksInfo
|
||||
private var myCurrentTracksInfo: Tracks
|
||||
private var myCurrentTrackGroups: TrackGroupArray
|
||||
private var myCurrentTrackSelections: TrackSelectionArray
|
||||
|
||||
@@ -76,7 +76,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
||||
COMMAND_GET_MEDIA_ITEMS_METADATA,
|
||||
COMMAND_SET_MEDIA_ITEMS_METADATA,
|
||||
COMMAND_CHANGE_MEDIA_ITEMS,
|
||||
COMMAND_GET_TRACK_INFOS)
|
||||
COMMAND_GET_TRACKS)
|
||||
.build()
|
||||
|
||||
var currentPlaybackState = Player.STATE_IDLE
|
||||
@@ -95,7 +95,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
||||
Log.d(tag, "Init CastPlayer")
|
||||
myCurrentTrackGroups = TrackGroupArray.EMPTY
|
||||
myCurrentTrackSelections = EMPTY_TRACK_SELECTION_ARRAY
|
||||
myCurrentTracksInfo = TracksInfo.EMPTY
|
||||
myCurrentTracksInfo = Tracks.EMPTY
|
||||
statusListener = StatusListener()
|
||||
timelineTracker = CastTimelineTracker()
|
||||
myCurrentTimeline = CastTimeline.EMPTY_CAST_TIMELINE
|
||||
@@ -385,7 +385,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
||||
}
|
||||
if (updateTracksAndSelectionsAndNotifyIfChanged()) {
|
||||
listeners.queueEvent(
|
||||
EVENT_TRACKS_CHANGED) { listener: Listener -> listener.onTracksInfoChanged(currentTracksInfo) }
|
||||
EVENT_TRACKS_CHANGED) { listener: Listener -> listener.onTracksChanged(currentTracks) }
|
||||
}
|
||||
updateAvailableCommandsAndNotifyIfChanged()
|
||||
listeners.flushEvents()
|
||||
@@ -405,7 +405,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
||||
val hasChanged = !myCurrentTrackGroups.isEmpty
|
||||
myCurrentTrackGroups = TrackGroupArray.EMPTY
|
||||
myCurrentTrackSelections = EMPTY_TRACK_SELECTION_ARRAY
|
||||
myCurrentTracksInfo = TracksInfo.EMPTY
|
||||
myCurrentTracksInfo = Tracks.EMPTY
|
||||
return hasChanged
|
||||
}
|
||||
var activeTrackIds = mediaStatus.activeTrackIds
|
||||
@@ -414,7 +414,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
||||
}
|
||||
val trackGroups = arrayOfNulls<TrackGroup>(castMediaTracks.size)
|
||||
val trackSelections = arrayOfNulls<TrackSelection>(RENDERER_COUNT)
|
||||
val trackGroupInfos = arrayOfNulls<TrackGroupInfo>(castMediaTracks.size)
|
||||
val trackGroupInfos = arrayOfNulls<Group>(castMediaTracks.size)
|
||||
for (i in castMediaTracks.indices) {
|
||||
val mediaTrack = castMediaTracks[i]
|
||||
trackGroups[i] = TrackGroup( /* id= */i.toString(), mediaTrackToFormat(mediaTrack))
|
||||
@@ -428,18 +428,16 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
||||
}
|
||||
val trackSupport = intArrayOf(if (supported) C.FORMAT_HANDLED else C.FORMAT_UNSUPPORTED_TYPE)
|
||||
val trackSelected = booleanArrayOf(selected)
|
||||
trackGroupInfos[i] = TrackGroupInfo(trackGroups[i]!!, trackSupport, trackType, trackSelected)
|
||||
trackGroupInfos[i] = Group(trackGroups[i]!!, false, trackSupport, trackSelected)
|
||||
}
|
||||
|
||||
val tg = trackGroups.filterNotNull().toTypedArray()
|
||||
var ts = trackSelections.filterNotNull().toTypedArray()
|
||||
var tgi = trackGroupInfos.filterNotNull().toMutableList()
|
||||
val ts = trackSelections.filterNotNull().toTypedArray()
|
||||
val tgi = trackGroupInfos.filterNotNull().toMutableList()
|
||||
val newTrackGroups = TrackGroupArray(*tg)
|
||||
val newTrackSelections = TrackSelectionArray(*ts)
|
||||
val newTracksInfo = TracksInfo(tgi)
|
||||
if (newTrackGroups != currentTrackGroups
|
||||
|| newTrackSelections != currentTrackSelections
|
||||
|| newTracksInfo != currentTracksInfo) {
|
||||
val newTracksInfo = Tracks(tgi)
|
||||
if (newTracksInfo != currentTracks) {
|
||||
myCurrentTrackSelections = newTrackSelections
|
||||
myCurrentTrackGroups = newTrackGroups
|
||||
myCurrentTracksInfo = newTracksInfo
|
||||
@@ -593,7 +591,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
||||
listeners.flushEvents()
|
||||
val pendingResult: PendingResult<RemoteMediaClient.MediaChannelResult> = if (playWhenReady) remoteMediaClient!!.play() else remoteMediaClient!!.pause()
|
||||
|
||||
var resultCb = ResultCallback<RemoteMediaClient.MediaChannelResult?> {
|
||||
val resultCb = ResultCallback<RemoteMediaClient.MediaChannelResult?> {
|
||||
updatePlayerStateAndNotifyIfChanged()
|
||||
}
|
||||
|
||||
@@ -622,10 +620,10 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
||||
return false
|
||||
}
|
||||
|
||||
override fun seekTo(mediaItemIndex: Int, positionMs: Long) {
|
||||
override fun seekTo(mediaItemIndex: Int, positionMs: Long, seekCommand: Int, isRepeatingCurrentItem: Boolean) {
|
||||
Log.d(tag, "seekTo $mediaItemIndex position $positionMs")
|
||||
|
||||
var resultCb = ResultCallback<RemoteMediaClient.MediaChannelResult?> {
|
||||
val resultCb = ResultCallback<RemoteMediaClient.MediaChannelResult?> {
|
||||
val statusCode: Int = it.status.statusCode
|
||||
if (statusCode != CastStatusCodes.SUCCESS && statusCode != CastStatusCodes.REPLACED) {
|
||||
Log.e(tag, "Seek failed. Error code $statusCode")
|
||||
@@ -645,14 +643,14 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
||||
|
||||
// We assume the default position is 0. There is no support for seeking to the default position
|
||||
// in RemoteMediaClient.
|
||||
var positionMsFinal = if (positionMs != C.TIME_UNSET) positionMs else 0
|
||||
val positionMsFinal = if (positionMs != C.TIME_UNSET) positionMs else 0
|
||||
if (mediaStatus != null) {
|
||||
if (currentMediaItemIndex != mediaItemIndex) {
|
||||
Log.d(tag, "seekTo: Changing media item index from $currentMediaItemIndex to $mediaItemIndex")
|
||||
remoteMediaClient?.queueJumpToItem(myCurrentTimeline.getPeriod(mediaItemIndex, period).uid as Int, positionMsFinal, JSONObject())?.setResultCallback(resultCb)
|
||||
} else {
|
||||
Log.d(tag, "seekTo: Same media index seek to position $positionMsFinal")
|
||||
var mediaSeekOptions = MediaSeekOptions.Builder().setPosition(positionMsFinal).build()
|
||||
val mediaSeekOptions = MediaSeekOptions.Builder().setPosition(positionMsFinal).build()
|
||||
remoteMediaClient?.seek(mediaSeekOptions)?.setResultCallback(resultCb)
|
||||
}
|
||||
val oldPosition = getCurrentPositionInfo()
|
||||
@@ -709,7 +707,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
||||
|
||||
val pendingResult: PendingResult<RemoteMediaClient.MediaChannelResult>? = remoteMediaClient?.setPlaybackRate(actualPlaybackParameters.speed.toDouble(), /* customData= */null)
|
||||
|
||||
var resultCb = ResultCallback<RemoteMediaClient.MediaChannelResult?> {
|
||||
val resultCb = ResultCallback<RemoteMediaClient.MediaChannelResult?> {
|
||||
updatePlaybackRateAndNotifyIfChanged()
|
||||
listeners.flushEvents()
|
||||
}
|
||||
@@ -736,15 +734,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
||||
sessionManager.endCurrentSession(false)
|
||||
}
|
||||
|
||||
override fun getCurrentTrackGroups(): TrackGroupArray {
|
||||
return this.myCurrentTrackGroups
|
||||
}
|
||||
|
||||
override fun getCurrentTrackSelections(): TrackSelectionArray {
|
||||
return this.myCurrentTrackSelections
|
||||
}
|
||||
|
||||
override fun getCurrentTracksInfo(): TracksInfo {
|
||||
override fun getCurrentTracks(): Tracks {
|
||||
return this.myCurrentTracksInfo
|
||||
}
|
||||
|
||||
@@ -858,8 +848,12 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
||||
return VideoSize.UNKNOWN
|
||||
}
|
||||
|
||||
override fun getCurrentCues(): MutableList<Cue> {
|
||||
return mutableListOf()
|
||||
override fun getSurfaceSize(): Size {
|
||||
return Size.UNKNOWN
|
||||
}
|
||||
|
||||
override fun getCurrentCues(): CueGroup {
|
||||
return CueGroup.EMPTY_TIME_ZERO
|
||||
}
|
||||
|
||||
override fun getDeviceInfo(): DeviceInfo {
|
||||
|
||||
@@ -86,7 +86,8 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
||||
}
|
||||
|
||||
override fun onSeekTo(pos: Long) {
|
||||
playerNotificationService.seekPlayer(pos)
|
||||
val currentTrackStartOffset = playerNotificationService.getCurrentTrackStartOffsetMs()
|
||||
playerNotificationService.seekPlayer(currentTrackStartOffset + pos)
|
||||
}
|
||||
|
||||
private fun onChangeSpeed() {
|
||||
@@ -115,15 +116,16 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
||||
|
||||
if (mediaId.isNullOrEmpty()) {
|
||||
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 {
|
||||
libraryItemWrapper = playerNotificationService.mediaManager.getById(mediaId)
|
||||
|
||||
if (libraryItemWrapper == null) {
|
||||
Log.e(tag, "onPlayFromMediaId: Media item not found $mediaId")
|
||||
val libraryItemWithEpisode = playerNotificationService.mediaManager.getPodcastWithEpisodeByEpisodeId(mediaId)
|
||||
if (libraryItemWithEpisode != null) {
|
||||
libraryItemWrapper = libraryItemWithEpisode.libraryItemWrapper
|
||||
podcastEpisode = libraryItemWithEpisode.episode
|
||||
} else {
|
||||
libraryItemWrapper = playerNotificationService.mediaManager.getById(mediaId)
|
||||
if (libraryItemWrapper == null) {
|
||||
Log.e(tag, "onPlayFromMediaId: Media item not found $mediaId")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
-4
@@ -49,10 +49,10 @@ class MediaSessionPlaybackPreparer(var playerNotificationService:PlayerNotificat
|
||||
val libraryItemWrapper: LibraryItemWrapper?
|
||||
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
|
||||
val libraryItemWithEpisode = playerNotificationService.mediaManager.getPodcastWithEpisodeByEpisodeId(mediaId)
|
||||
if (libraryItemWithEpisode != null) {
|
||||
libraryItemWrapper = libraryItemWithEpisode.libraryItemWrapper
|
||||
podcastEpisode = libraryItemWithEpisode.episode
|
||||
} else {
|
||||
libraryItemWrapper = playerNotificationService.mediaManager.getById(mediaId)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.google.android.exoplayer2.PlaybackException
|
||||
import com.google.android.exoplayer2.Player
|
||||
|
||||
const val PAUSE_LEN_BEFORE_RECHECK = 30000 // 30 seconds
|
||||
//const val PAUSE_LEN_BEFORE_RECHECK = 30000 // 30 seconds
|
||||
|
||||
class PlayerListener(var playerNotificationService:PlayerNotificationService) : Player.Listener {
|
||||
var tag = "PlayerListener"
|
||||
@@ -56,6 +56,9 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
|
||||
|
||||
lazyIsPlaying = isPlaying
|
||||
|
||||
// Update widget
|
||||
DeviceManager.widgetUpdater?.onPlayerChanged(playerNotificationService)
|
||||
|
||||
if (isPlaying) {
|
||||
Log.d(tag, "SeekBackTime: Player is playing")
|
||||
if (lastPauseTime > 0 && DeviceManager.deviceData.deviceSettings?.disableAutoRewind != true) {
|
||||
@@ -74,12 +77,13 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
|
||||
Log.d(tag, "SeekBackTime $seekBackTime")
|
||||
}
|
||||
|
||||
// TODO: this needs to be reworked so that the audio doesn't start playing before it checks for updated progress
|
||||
// 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
|
||||
}
|
||||
// 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)
|
||||
@@ -92,12 +96,15 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
|
||||
|
||||
// Start/stop progress sync interval
|
||||
if (isPlaying) {
|
||||
// Handles auto-starting sleep timer and resetting sleep timer
|
||||
playerNotificationService.sleepTimerManager.handleMediaPlayEvent()
|
||||
|
||||
player.volume = 1F // Volume on sleep timer might have decreased this
|
||||
val playbackSession: PlaybackSession? = playerNotificationService.mediaProgressSyncer.currentPlaybackSession ?: playerNotificationService.currentPlaybackSession
|
||||
playbackSession?.let { playerNotificationService.mediaProgressSyncer.play(it) }
|
||||
playbackSession?.let {
|
||||
// Handles auto-starting sleep timer and resetting sleep timer
|
||||
playerNotificationService.sleepTimerManager.handleMediaPlayEvent(it.id)
|
||||
|
||||
player.volume = 1F // Volume on sleep timer might have decreased this
|
||||
|
||||
playerNotificationService.mediaProgressSyncer.play(it)
|
||||
}
|
||||
} else {
|
||||
playerNotificationService.mediaProgressSyncer.pause {
|
||||
Log.d(tag, "Media Progress Syncer paused and synced")
|
||||
@@ -105,8 +112,6 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
|
||||
}
|
||||
|
||||
playerNotificationService.clientEventEmitter?.onPlayingUpdate(isPlaying)
|
||||
|
||||
DeviceManager.widgetUpdater?.onPlayerChanged(playerNotificationService)
|
||||
}
|
||||
|
||||
override fun onEvents(player: Player, events: Player.Events) {
|
||||
|
||||
+1
-5
@@ -44,11 +44,7 @@ class PlayerNotificationListener(var playerNotificationService:PlayerNotificatio
|
||||
} else {
|
||||
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) {
|
||||
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")
|
||||
|
||||
+111
-67
@@ -1,8 +1,7 @@
|
||||
package com.audiobookshelf.app.player
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.*
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
@@ -10,12 +9,10 @@ import android.graphics.Color
|
||||
import android.graphics.ImageDecoder
|
||||
import android.hardware.Sensor
|
||||
import android.hardware.SensorManager
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import android.net.*
|
||||
import android.os.*
|
||||
import android.provider.MediaStore
|
||||
import android.provider.Settings
|
||||
import android.support.v4.media.MediaBrowserCompat
|
||||
import android.support.v4.media.MediaDescriptionCompat
|
||||
import android.support.v4.media.MediaMetadataCompat
|
||||
@@ -23,15 +20,11 @@ import android.support.v4.media.session.MediaControllerCompat
|
||||
import android.support.v4.media.session.MediaSessionCompat
|
||||
import android.support.v4.media.session.PlaybackStateCompat
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.widget.RemoteViews
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.media.MediaBrowserServiceCompat
|
||||
import androidx.media.utils.MediaConstants
|
||||
import com.audiobookshelf.app.BuildConfig
|
||||
import com.audiobookshelf.app.MediaPlayerWidget
|
||||
import com.audiobookshelf.app.R
|
||||
import com.audiobookshelf.app.data.*
|
||||
import com.audiobookshelf.app.data.DeviceInfo
|
||||
@@ -46,6 +39,8 @@ import com.google.android.exoplayer2.audio.AudioAttributes
|
||||
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.extractor.DefaultExtractorsFactory
|
||||
import com.google.android.exoplayer2.extractor.mp3.Mp3Extractor
|
||||
import com.google.android.exoplayer2.source.MediaSource
|
||||
import com.google.android.exoplayer2.source.ProgressiveMediaSource
|
||||
import com.google.android.exoplayer2.source.hls.HlsMediaSource
|
||||
@@ -69,7 +64,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
var isSwitchingPlayer = false // Used when switching between cast player and exoplayer
|
||||
}
|
||||
|
||||
private val tag = "PlayerNotificationService"
|
||||
private val tag = "PlayerNotificationServ"
|
||||
|
||||
interface ClientEventEmitter {
|
||||
fun onPlaybackSession(playbackSession:PlaybackSession)
|
||||
@@ -202,6 +197,12 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
super.onCreate()
|
||||
ctx = this
|
||||
|
||||
// Initialize Paper
|
||||
DbManager.initialize(ctx)
|
||||
|
||||
// Initialize widget
|
||||
DeviceManager.initializeWidgetUpdater(ctx)
|
||||
|
||||
// To listen for network change from metered to unmetered
|
||||
val networkRequest = NetworkRequest.Builder()
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
@@ -263,7 +264,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
playerNotificationManager.setUseNextAction(false)
|
||||
playerNotificationManager.setUsePreviousAction(false)
|
||||
playerNotificationManager.setUseChronometer(false)
|
||||
playerNotificationManager.setUseStopAction(true)
|
||||
playerNotificationManager.setUseStopAction(false)
|
||||
playerNotificationManager.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
playerNotificationManager.setPriority(NotificationCompat.PRIORITY_MAX)
|
||||
playerNotificationManager.setUseFastForwardActionInCompactView(true)
|
||||
@@ -287,10 +288,10 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
return MediaDescriptionCompat.Builder().build()
|
||||
}
|
||||
|
||||
val coverUri = currentPlaybackSession!!.getCoverUri()
|
||||
val coverUri = currentPlaybackSession!!.getCoverUri(ctx)
|
||||
|
||||
var bitmap:Bitmap? = null
|
||||
// Local covers get bitmap
|
||||
var bitmap: Bitmap? = null
|
||||
// Local covers get bitmap
|
||||
if (currentPlaybackSession!!.localLibraryItem?.coverContentUrl != null) {
|
||||
bitmap = if (Build.VERSION.SDK_INT < 28) {
|
||||
MediaStore.Images.Media.getBitmap(ctx.contentResolver, coverUri)
|
||||
@@ -361,7 +362,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
.build()
|
||||
mPlayer.setHandleAudioBecomingNoisy(true)
|
||||
mPlayer.addListener(PlayerListener(this))
|
||||
val audioAttributes:AudioAttributes = AudioAttributes.Builder().setUsage(C.USAGE_MEDIA).setContentType(C.CONTENT_TYPE_SPEECH).build()
|
||||
val audioAttributes:AudioAttributes = AudioAttributes.Builder().setUsage(C.USAGE_MEDIA).setContentType(C.AUDIO_CONTENT_TYPE_SPEECH).build()
|
||||
mPlayer.setAudioAttributes(audioAttributes, true)
|
||||
|
||||
//attach player to playerNotificationManager
|
||||
@@ -393,7 +394,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
|
||||
val metadata = playbackSession.getMediaMetadataCompat(ctx)
|
||||
mediaSession.setMetadata(metadata)
|
||||
val mediaItems = playbackSession.getMediaItems()
|
||||
val mediaItems = playbackSession.getMediaItems(ctx)
|
||||
val playbackRateToUse = playbackRate ?: initialPlaybackRate ?: 1f
|
||||
initialPlaybackRate = playbackRate
|
||||
|
||||
@@ -437,12 +438,26 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
if (playbackSession.isLocal) {
|
||||
Log.d(tag, "Playing Local Item")
|
||||
val dataSourceFactory = DefaultDataSource.Factory(ctx)
|
||||
mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItems[0])
|
||||
|
||||
val extractorsFactory = DefaultExtractorsFactory()
|
||||
if (DeviceManager.deviceData.deviceSettings?.enableMp3IndexSeeking == true) {
|
||||
// @see https://exoplayer.dev/troubleshooting.html#why-is-seeking-inaccurate-in-some-mp3-files
|
||||
extractorsFactory.setMp3ExtractorFlags(Mp3Extractor.FLAG_ENABLE_INDEX_SEEKING)
|
||||
}
|
||||
|
||||
mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory, extractorsFactory).createMediaSource(mediaItems[0])
|
||||
} else if (!playbackSession.isHLS) {
|
||||
Log.d(tag, "Direct Playing Item")
|
||||
val dataSourceFactory = DefaultHttpDataSource.Factory()
|
||||
|
||||
val extractorsFactory = DefaultExtractorsFactory()
|
||||
if (DeviceManager.deviceData.deviceSettings?.enableMp3IndexSeeking == true) {
|
||||
// @see https://exoplayer.dev/troubleshooting.html#why-is-seeking-inaccurate-in-some-mp3-files
|
||||
extractorsFactory.setMp3ExtractorFlags(Mp3Extractor.FLAG_ENABLE_INDEX_SEEKING)
|
||||
}
|
||||
|
||||
dataSourceFactory.setUserAgent(channelId)
|
||||
mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItems[0])
|
||||
mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory, extractorsFactory).createMediaSource(mediaItems[0])
|
||||
} else {
|
||||
Log.d(tag, "Playing HLS Item")
|
||||
val dataSourceFactory = DefaultHttpDataSource.Factory()
|
||||
@@ -482,7 +497,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
}
|
||||
|
||||
private fun setMediaSessionConnectorCustomActions(playbackSession:PlaybackSession) {
|
||||
val mediaItems = playbackSession.getMediaItems()
|
||||
val mediaItems = playbackSession.getMediaItems(ctx)
|
||||
val customActionProviders = mutableListOf(
|
||||
JumpBackwardCustomActionProvider(),
|
||||
JumpForwardCustomActionProvider(),
|
||||
@@ -637,16 +652,20 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
}
|
||||
}
|
||||
|
||||
fun getCurrentTime() : Long {
|
||||
fun getCurrentTrackStartOffsetMs() : Long {
|
||||
return if (currentPlayer.mediaItemCount > 1) {
|
||||
val windowIndex = currentPlayer.currentMediaItemIndex
|
||||
val currentTrackStartOffset = currentPlaybackSession?.getTrackStartOffsetMs(windowIndex) ?: 0L
|
||||
currentPlayer.currentPosition + currentTrackStartOffset
|
||||
currentTrackStartOffset
|
||||
} else {
|
||||
currentPlayer.currentPosition
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
fun getCurrentTime() : Long {
|
||||
return currentPlayer.currentPosition + getCurrentTrackStartOffsetMs()
|
||||
}
|
||||
|
||||
fun getCurrentTimeSeconds() : Double {
|
||||
return getCurrentTime() / 1000.0
|
||||
}
|
||||
@@ -862,10 +881,26 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
|
||||
fun closePlayback(calledOnError:Boolean? = false) {
|
||||
Log.d(tag, "closePlayback")
|
||||
val isLocal = mediaProgressSyncer.currentIsLocal
|
||||
val currentSessionId = mediaProgressSyncer.currentSessionId
|
||||
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")
|
||||
|
||||
// If not local session then close on server
|
||||
if (!isLocal && currentSessionId != "") {
|
||||
apiHandler.closePlaybackSession(currentSessionId) {
|
||||
Log.d(tag, "Closed playback session $currentSessionId")
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// If not local session then close on server
|
||||
if (!isLocal && currentSessionId != "") {
|
||||
apiHandler.closePlaybackSession(currentSessionId) {
|
||||
Log.d(tag, "Closed playback session $currentSessionId")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -879,8 +914,10 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
currentPlaybackSession = null
|
||||
mediaProgressSyncer.reset()
|
||||
clientEventEmitter?.onPlaybackClosed()
|
||||
|
||||
PlayerListener.lastPauseTime = 0
|
||||
isClosed = true
|
||||
DeviceManager.widgetUpdater?.onPlayerClosed()
|
||||
stopForeground(Service.STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
@@ -894,7 +931,8 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
return if(currentPlayer == castPlayer) PLAYER_CAST else PLAYER_EXO
|
||||
}
|
||||
|
||||
private fun getDeviceInfo(): DeviceInfo {
|
||||
@SuppressLint("HardwareIds")
|
||||
fun getDeviceInfo(): DeviceInfo {
|
||||
/* EXAMPLE
|
||||
manufacturer: Google
|
||||
model: Pixel 6
|
||||
@@ -902,7 +940,8 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
sdkVersion: 32
|
||||
appVersion: 0.9.46-beta
|
||||
*/
|
||||
return DeviceInfo(Build.MANUFACTURER, Build.MODEL, Build.BRAND, Build.VERSION.SDK_INT, BuildConfig.VERSION_NAME)
|
||||
val deviceId = Settings.Secure.getString(ctx.contentResolver, Settings.Secure.ANDROID_ID)
|
||||
return DeviceInfo(deviceId, Build.MANUFACTURER, Build.MODEL, Build.VERSION.SDK_INT, Build.VERSION.RELEASE)
|
||||
}
|
||||
|
||||
private val deviceSettings get() = DeviceManager.deviceData.deviceSettings ?: DeviceSettings.default()
|
||||
@@ -959,13 +998,16 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
|
||||
val extras = Bundle()
|
||||
extras.putBoolean(
|
||||
MediaConstants.BROWSER_SERVICE_EXTRAS_KEY_SEARCH_SUPPORTED, true)
|
||||
MediaConstants.BROWSER_SERVICE_EXTRAS_KEY_SEARCH_SUPPORTED, true
|
||||
)
|
||||
extras.putInt(
|
||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_BROWSABLE,
|
||||
MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM)
|
||||
MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM
|
||||
)
|
||||
extras.putInt(
|
||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_PLAYABLE,
|
||||
MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM)
|
||||
MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM
|
||||
)
|
||||
|
||||
BrowserRoot(AUTO_MEDIA_ROOT, extras)
|
||||
}
|
||||
@@ -976,39 +1018,19 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
|
||||
result.detach()
|
||||
|
||||
if (parentMediaId.startsWith("li_") || parentMediaId.startsWith("local_")) { // Show podcast episodes
|
||||
Log.d(tag, "Loading podcast episodes")
|
||||
mediaManager.loadPodcastEpisodeMediaBrowserItems(parentMediaId, ctx) {
|
||||
result.sendResult(it)
|
||||
}
|
||||
} else if (::browseTree.isInitialized && browseTree[parentMediaId] == null && mediaManager.getIsLibrary(parentMediaId)) { // Load library items for library
|
||||
|
||||
mediaManager.loadLibraryItemsWithAudio(parentMediaId) { libraryItems ->
|
||||
val children = libraryItems.map { libraryItem ->
|
||||
if (libraryItem.mediaType == "podcast") { // Podcasts are browseable
|
||||
val mediaDescription = libraryItem.getMediaDescription(null, ctx)
|
||||
MediaBrowserCompat.MediaItem(mediaDescription, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
|
||||
} else {
|
||||
val progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == libraryItem.id }
|
||||
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLId(libraryItem.id)
|
||||
libraryItem.localLibraryItemId = localLibraryItem?.id
|
||||
val description = libraryItem.getMediaDescription(progress, ctx)
|
||||
MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
||||
}
|
||||
}
|
||||
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
|
||||
}
|
||||
} else if (parentMediaId == DOWNLOADS_ROOT) { // Load downloads
|
||||
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, ctx)
|
||||
if (localLibraryItem.media.getAudioTracks().isNotEmpty()) {
|
||||
val progress = DeviceManager.dbManager.getLocalMediaProgress(localLibraryItem.id)
|
||||
val description = localLibraryItem.getMediaDescription(progress, ctx)
|
||||
|
||||
localBrowseItems += MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
||||
localBrowseItems += MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
||||
}
|
||||
}
|
||||
|
||||
localPodcasts.forEach { localLibraryItem ->
|
||||
@@ -1024,19 +1046,19 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
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 }
|
||||
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 }
|
||||
|
||||
// to show download icon
|
||||
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLId(itemInProgress.libraryItemWrapper.id)
|
||||
localLibraryItem?.let { lli ->
|
||||
val localEpisode = (lli.media as Podcast).episodes?.find { it.serverEpisodeId == itemInProgress.episode.id }
|
||||
itemInProgress.episode.localEpisodeId = localEpisode?.id
|
||||
}
|
||||
// to show download icon
|
||||
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLId(itemInProgress.libraryItemWrapper.id)
|
||||
localLibraryItem?.let { lli ->
|
||||
val localEpisode = (lli.media as Podcast).episodes?.find { it.serverEpisodeId == itemInProgress.episode.id }
|
||||
itemInProgress.episode.localEpisodeId = localEpisode?.id
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
mediaDescription = itemInProgress.episode.getMediaDescription(itemInProgress.libraryItemWrapper, progress, ctx)
|
||||
} else {
|
||||
if (itemInProgress.isLocal) {
|
||||
@@ -1052,16 +1074,38 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
localBrowseItems += MediaBrowserCompat.MediaItem(mediaDescription, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
||||
}
|
||||
result.sendResult(localBrowseItems)
|
||||
} else {
|
||||
} else if (parentMediaId == LIBRARIES_ROOT || parentMediaId == AUTO_MEDIA_ROOT) {
|
||||
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)
|
||||
MediaBrowserCompat.MediaItem(item.description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
|
||||
}
|
||||
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
|
||||
}
|
||||
} else if (mediaManager.getIsLibrary(parentMediaId)) { // Load library items for library
|
||||
Log.d(tag, "Loading items for library $parentMediaId")
|
||||
mediaManager.loadLibraryItemsWithAudio(parentMediaId) { libraryItems ->
|
||||
val children = libraryItems.map { libraryItem ->
|
||||
if (libraryItem.mediaType == "podcast") { // Podcasts are browseable
|
||||
val mediaDescription = libraryItem.getMediaDescription(null, ctx)
|
||||
MediaBrowserCompat.MediaItem(mediaDescription, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
|
||||
} else {
|
||||
val progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == libraryItem.id }
|
||||
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLId(libraryItem.id)
|
||||
libraryItem.localLibraryItemId = localLibraryItem?.id
|
||||
val description = libraryItem.getMediaDescription(progress, ctx)
|
||||
MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
||||
}
|
||||
}
|
||||
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
|
||||
}
|
||||
} else {
|
||||
Log.d(tag, "Loading podcast episodes for podcast $parentMediaId")
|
||||
mediaManager.loadPodcastEpisodeMediaBrowserItems(parentMediaId, ctx) {
|
||||
result.sendResult(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,11 @@ class AbsAudioPlayer : Plugin() {
|
||||
mainActivity = (activity as MainActivity)
|
||||
apiHandler = ApiHandler(mainActivity)
|
||||
|
||||
initCastManager()
|
||||
try {
|
||||
initCastManager()
|
||||
} catch(e:Exception) {
|
||||
Log.e(tag, "initCastManager exception ${e.printStackTrace()}")
|
||||
}
|
||||
|
||||
val foregroundServiceReady : () -> Unit = {
|
||||
playerNotificationService = mainActivity.foregroundService
|
||||
@@ -176,7 +180,7 @@ class AbsAudioPlayer : Plugin() {
|
||||
val playWhenReady = call.getBoolean("playWhenReady") == true
|
||||
val playbackRate = call.getFloat("playbackRate",1f) ?: 1f
|
||||
val startTimeOverride = call.getDouble("startTime")
|
||||
Log.d(tag, "prepareLibraryItem lid=$libraryItemId, startTimeOverride=$startTimeOverride")
|
||||
Log.d(tag, "prepareLibraryItem lid=$libraryItemId, startTimeOverride=$startTimeOverride, playbackRate=$playbackRate")
|
||||
|
||||
if (libraryItemId.isEmpty()) {
|
||||
Log.e(tag, "Invalid call to play library item no library item id")
|
||||
@@ -197,7 +201,7 @@ class AbsAudioPlayer : Plugin() {
|
||||
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
Log.d(tag, "prepareLibraryItem: Preparing Local Media item ${jacksonMapper.writeValueAsString(it)}")
|
||||
val playbackSession = it.getPlaybackSession(episode)
|
||||
val playbackSession = it.getPlaybackSession(episode, playerNotificationService.getDeviceInfo())
|
||||
if (startTimeOverride != null) {
|
||||
Log.d(tag, "prepareLibraryItem: Using start time override $startTimeOverride")
|
||||
playbackSession.currentTime = startTimeOverride
|
||||
@@ -337,7 +341,8 @@ class AbsAudioPlayer : Plugin() {
|
||||
val isChapterTime:Boolean = call.getBoolean("isChapterTime", false) == true
|
||||
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
val success:Boolean = playerNotificationService.sleepTimerManager.setManualSleepTimer(time, isChapterTime)
|
||||
val playbackSession: PlaybackSession? = playerNotificationService.mediaProgressSyncer.currentPlaybackSession ?: playerNotificationService.currentPlaybackSession
|
||||
val success:Boolean = playerNotificationService.sleepTimerManager.setManualSleepTimer(playbackSession?.id ?: "", time, isChapterTime)
|
||||
val ret = JSObject()
|
||||
ret.put("success", success)
|
||||
call.resolve(ret)
|
||||
|
||||
@@ -231,9 +231,15 @@ class AbsDatabase : Plugin() {
|
||||
if (!success) {
|
||||
call.resolve(JSObject("{\"error\":\"$errorMsg\"}"))
|
||||
} else {
|
||||
// Remove all local sessions
|
||||
savedSessions.forEach {
|
||||
DeviceManager.dbManager.removePlaybackSession(it.id)
|
||||
}
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -265,6 +271,8 @@ class AbsDatabase : Plugin() {
|
||||
progress = mediaProgress.progress,
|
||||
currentTime = mediaProgress.currentTime,
|
||||
isFinished = mediaProgress.isFinished,
|
||||
ebookLocation = mediaProgress.ebookLocation,
|
||||
ebookProgress = mediaProgress.ebookProgress,
|
||||
lastUpdate = mediaProgress.lastUpdate,
|
||||
startedAt = mediaProgress.startedAt,
|
||||
finishedAt = mediaProgress.finishedAt,
|
||||
@@ -345,6 +353,8 @@ class AbsDatabase : Plugin() {
|
||||
progress = if (isFinished) 1.0 else 0.0,
|
||||
currentTime = 0.0,
|
||||
isFinished = isFinished,
|
||||
ebookLocation = null,
|
||||
ebookProgress = null,
|
||||
lastUpdate = currentTime,
|
||||
startedAt = if (isFinished) currentTime else 0L,
|
||||
finishedAt = if (isFinished) currentTime else null,
|
||||
@@ -389,6 +399,55 @@ class AbsDatabase : Plugin() {
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun updateLocalEbookProgress(call:PluginCall) {
|
||||
val localLibraryItemId = call.getString("localLibraryItemId", "").toString()
|
||||
val ebookLocation = call.getString("ebookLocation", "").toString()
|
||||
val ebookProgress = call.getDouble("ebookProgress") ?: 0.0
|
||||
|
||||
val localMediaProgressId = localLibraryItemId
|
||||
var localMediaProgress = DeviceManager.dbManager.getLocalMediaProgress(localMediaProgressId)
|
||||
|
||||
if (localMediaProgress == null) {
|
||||
Log.d(tag, "updateLocalEbookProgress Local Media Progress not found $localMediaProgressId - Creating new")
|
||||
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
|
||||
?: return call.resolve(JSObject("{\"error\":\"Library Item not found\"}"))
|
||||
|
||||
val book = localLibraryItem.media as Book
|
||||
|
||||
localMediaProgress = LocalMediaProgress(
|
||||
id = localMediaProgressId,
|
||||
localLibraryItemId = localLibraryItemId,
|
||||
localEpisodeId = null,
|
||||
duration = book.duration ?: 0.0,
|
||||
progress = 0.0,
|
||||
currentTime = 0.0,
|
||||
isFinished = false,
|
||||
ebookLocation = ebookLocation,
|
||||
ebookProgress = ebookProgress,
|
||||
lastUpdate = System.currentTimeMillis(),
|
||||
startedAt = 0L,
|
||||
finishedAt = null,
|
||||
serverConnectionConfigId = localLibraryItem.serverConnectionConfigId,
|
||||
serverAddress = localLibraryItem.serverAddress,
|
||||
serverUserId = localLibraryItem.serverUserId,
|
||||
libraryItemId = localLibraryItem.libraryItemId,
|
||||
episodeId = null)
|
||||
} else {
|
||||
localMediaProgress.updateEbookProgress(ebookLocation, ebookProgress)
|
||||
}
|
||||
|
||||
// Save local media progress locally
|
||||
DeviceManager.dbManager.saveLocalMediaProgress(localMediaProgress)
|
||||
|
||||
val lmpstring = jacksonMapper.writeValueAsString(localMediaProgress)
|
||||
Log.d(tag, "updateLocalEbookProgress: Local Media Progress String $lmpstring")
|
||||
|
||||
val jsobj = JSObject()
|
||||
jsobj.put("localMediaProgress", JSObject(lmpstring))
|
||||
call.resolve(jsobj)
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun updateLocalTrackOrder(call:PluginCall) {
|
||||
val localLibraryItemId = call.getString("localLibraryItemId", "") ?: ""
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.audiobookshelf.app.plugins
|
||||
|
||||
import android.app.DownloadManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.MainActivity
|
||||
@@ -58,7 +57,7 @@ class AbsDownloader : Plugin() {
|
||||
val libraryItemId = call.data.getString("libraryItemId").toString()
|
||||
var episodeId = call.data.getString("episodeId").toString()
|
||||
if (episodeId == "null") episodeId = ""
|
||||
val localFolderId = call.data.getString("localFolderId").toString()
|
||||
var localFolderId = call.data.getString("localFolderId", "").toString()
|
||||
Log.d(tag, "Download library item $libraryItemId to folder $localFolderId / episode: $episodeId")
|
||||
|
||||
val downloadId = if (episodeId.isEmpty()) libraryItemId else "$libraryItemId-$episodeId"
|
||||
@@ -73,9 +72,18 @@ class AbsDownloader : Plugin() {
|
||||
} else {
|
||||
Log.d(tag, "Got library item from server ${libraryItem.id}")
|
||||
|
||||
val localFolder = DeviceManager.dbManager.getLocalFolder(localFolderId)
|
||||
if (localFolder != null) {
|
||||
if (localFolderId == "") {
|
||||
localFolderId = "internal-${libraryItem.mediaType}"
|
||||
}
|
||||
var localFolder = DeviceManager.dbManager.getLocalFolder(localFolderId)
|
||||
|
||||
if (localFolder == null && localFolderId.startsWith("internal-")) {
|
||||
Log.d(tag, "Creating new App Storage internal LocalFolder $localFolderId")
|
||||
localFolder = LocalFolder(localFolderId, "Internal App Storage", "", "", "", "", "internal", libraryItem.mediaType)
|
||||
DeviceManager.dbManager.saveLocalFolder(localFolder)
|
||||
}
|
||||
|
||||
if (localFolder != null) {
|
||||
if (episodeId.isNotEmpty() && libraryItem.mediaType != "podcast") {
|
||||
Log.e(tag, "Library item is not a podcast but episode was requested")
|
||||
call.resolve(JSObject("{\"error\":\"Invalid library item not a podcast\"}"))
|
||||
@@ -101,12 +109,6 @@ class AbsDownloader : Plugin() {
|
||||
}
|
||||
}
|
||||
|
||||
// Clean folder path so it can be used in URL
|
||||
private fun cleanRelPath(relPath: String): String {
|
||||
val cleanedRelPath = relPath.replace("\\", "/").replace("%", "%25").replace("#", "%23")
|
||||
return if (cleanedRelPath.startsWith("/")) cleanedRelPath.substring(1) else cleanedRelPath
|
||||
}
|
||||
|
||||
// Item filenames could be the same if they are in sub-folders, this will make them unique
|
||||
private fun getFilenameFromRelPath(relPath: String): String {
|
||||
var cleanedRelPath = relPath.replace("\\", "_").replace("/", "_")
|
||||
@@ -128,7 +130,9 @@ class AbsDownloader : Plugin() {
|
||||
}
|
||||
|
||||
private fun startLibraryItemDownload(libraryItem: LibraryItem, localFolder: LocalFolder, episode:PodcastEpisode?) {
|
||||
val tempFolderPath = mainActivity.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
|
||||
val isInternal = localFolder.id.startsWith("internal-")
|
||||
|
||||
val tempFolderPath = if (isInternal) "${mainActivity.filesDir}/downloads/${libraryItem.id}" else mainActivity.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
|
||||
|
||||
Log.d(tag, "downloadCacheDirectory=$tempFolderPath")
|
||||
|
||||
@@ -139,13 +143,40 @@ class AbsDownloader : Plugin() {
|
||||
val tracks = libraryItem.media.getAudioTracks()
|
||||
Log.d(tag, "Starting library item download with ${tracks.size} tracks")
|
||||
val itemSubfolder = "$bookAuthor/$bookTitle"
|
||||
val itemFolderPath = "${localFolder.absolutePath}/$itemSubfolder"
|
||||
val itemFolderPath = if (isInternal) "$tempFolderPath" else "${localFolder.absolutePath}/$itemSubfolder"
|
||||
val downloadItem = DownloadItem(libraryItem.id, libraryItem.id, null, libraryItem.userMediaProgress,DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, bookTitle, itemSubfolder, libraryItem.media, mutableListOf())
|
||||
|
||||
val book = libraryItem.media as Book
|
||||
book.ebookFile?.let { ebookFile ->
|
||||
val fileSize = ebookFile.metadata?.size ?: 0
|
||||
val serverPath = "/api/items/${libraryItem.id}/file/${ebookFile.ino}/download"
|
||||
val destinationFilename = getFilenameFromRelPath(ebookFile.metadata?.relPath ?: "")
|
||||
val finalDestinationFile = File("$itemFolderPath/$destinationFilename")
|
||||
val destinationFile = File("$tempFolderPath/$destinationFilename")
|
||||
|
||||
if (destinationFile.exists()) {
|
||||
Log.d(tag, "TEMP ebook file already exists, removing it from ${destinationFile.absolutePath}")
|
||||
destinationFile.delete()
|
||||
}
|
||||
|
||||
if (finalDestinationFile.exists()) {
|
||||
Log.d(tag, "ebook file already exists, removing it from ${finalDestinationFile.absolutePath}")
|
||||
finalDestinationFile.delete()
|
||||
}
|
||||
|
||||
val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, fileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,ebookFile,null,null)
|
||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||
}
|
||||
|
||||
// Create download item part for each audio track
|
||||
val audioFiles = (libraryItem.media as Book).audioFiles ?: mutableListOf()
|
||||
tracks.forEach { audioTrack ->
|
||||
val fileSize = audioTrack.metadata?.size ?: 0
|
||||
val serverPath = "/s/item/${libraryItem.id}/${cleanRelPath(audioTrack.relPath)}"
|
||||
|
||||
// TODO: Currently file ino is only stored on AudioFile. This should be updated server side to be in FileMetadata or on the AudioTrack
|
||||
val audioFileIno = audioFiles.find { it.metadata.path == audioTrack.metadata?.path }?.ino
|
||||
|
||||
val serverPath = "/api/items/${libraryItem.id}/file/${audioFileIno}/download"
|
||||
val destinationFilename = getFilenameFromRelPath(audioTrack.relPath)
|
||||
Log.d(tag, "Audio File Server Path $serverPath | AF RelPath ${audioTrack.relPath} | LocalFolder Path ${localFolder.absolutePath} | DestName $destinationFilename")
|
||||
|
||||
@@ -162,7 +193,7 @@ class AbsDownloader : Plugin() {
|
||||
finalDestinationFile.delete()
|
||||
}
|
||||
|
||||
val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, fileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,audioTrack,null)
|
||||
val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, fileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,null,audioTrack,null)
|
||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||
}
|
||||
|
||||
@@ -187,7 +218,7 @@ class AbsDownloader : Plugin() {
|
||||
finalDestinationFile.delete()
|
||||
}
|
||||
|
||||
val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, coverFileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,null,null)
|
||||
val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, coverFileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,null,null,null)
|
||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||
}
|
||||
|
||||
@@ -198,14 +229,15 @@ class AbsDownloader : Plugin() {
|
||||
val podcastTitle = cleanStringForFileSystem(libraryItem.media.metadata.title)
|
||||
|
||||
val audioTrack = episode?.audioTrack
|
||||
val audioFileIno = episode?.audioFile?.ino
|
||||
val fileSize = audioTrack?.metadata?.size ?: 0
|
||||
|
||||
Log.d(tag, "Starting podcast episode download")
|
||||
val itemFolderPath = localFolder.absolutePath + "/" + podcastTitle
|
||||
val itemFolderPath = if (isInternal) "$tempFolderPath" else "${localFolder.absolutePath}/$podcastTitle"
|
||||
val downloadItemId = "${libraryItem.id}-${episode?.id}"
|
||||
val downloadItem = DownloadItem(downloadItemId, libraryItem.id, episode?.id, libraryItem.userMediaProgress, DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, podcastTitle, podcastTitle, libraryItem.media, mutableListOf())
|
||||
|
||||
var serverPath = "/s/item/${libraryItem.id}/${cleanRelPath(audioTrack?.relPath ?: "")}"
|
||||
var serverPath = "/api/items/${libraryItem.id}/file/${audioFileIno}/download"
|
||||
var destinationFilename = getFilenameFromRelPath(audioTrack?.relPath ?: "")
|
||||
Log.d(tag, "Audio File Server Path $serverPath | AF RelPath ${audioTrack?.relPath} | LocalFolder Path ${localFolder.absolutePath} | DestName $destinationFilename")
|
||||
|
||||
@@ -216,7 +248,7 @@ class AbsDownloader : Plugin() {
|
||||
finalDestinationFile.delete()
|
||||
}
|
||||
|
||||
var downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,fileSize, destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,audioTrack,episode)
|
||||
var downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,fileSize, destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,null,audioTrack,episode)
|
||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||
|
||||
if (libraryItem.media.coverPath != null && libraryItem.media.coverPath?.isNotEmpty() == true) {
|
||||
@@ -232,7 +264,7 @@ class AbsDownloader : Plugin() {
|
||||
if (finalDestinationFile.exists()) {
|
||||
Log.d(tag, "Podcast cover already exists - not downloading cover again")
|
||||
} else {
|
||||
downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,coverFileSize,destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,null,null)
|
||||
downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,coverFileSize,destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,null,null,null)
|
||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.audiobookshelf.app.plugins
|
||||
|
||||
import android.app.AlertDialog
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
@@ -13,16 +12,12 @@ import com.anggrayudi.storage.callback.StorageAccessCallback
|
||||
import com.anggrayudi.storage.file.*
|
||||
import com.audiobookshelf.app.MainActivity
|
||||
import com.audiobookshelf.app.data.LocalFolder
|
||||
import com.audiobookshelf.app.data.LocalLibraryItem
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.audiobookshelf.app.device.FolderScanner
|
||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.getcapacitor.*
|
||||
import com.getcapacitor.annotation.CapacitorPlugin
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
|
||||
@CapacitorPlugin(name = "AbsFileSystem")
|
||||
class AbsFileSystem : Plugin() {
|
||||
@@ -165,23 +160,10 @@ class AbsFileSystem : Plugin() {
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun scanFolder(call: PluginCall) {
|
||||
val folderId = call.data.getString("folderId", "").toString()
|
||||
val forceAudioProbe = call.data.getBoolean("forceAudioProbe")
|
||||
Log.d(TAG, "Scan Folder $folderId | Force Audio Probe $forceAudioProbe")
|
||||
|
||||
val folder: LocalFolder? = DeviceManager.dbManager.getLocalFolder(folderId)
|
||||
folder?.let {
|
||||
val folderScanner = FolderScanner(context)
|
||||
val folderScanResult = folderScanner.scanForMediaItems(it, forceAudioProbe)
|
||||
if (folderScanResult == null) {
|
||||
Log.d(TAG, "NO Scan DATA")
|
||||
return call.resolve(JSObject())
|
||||
} else {
|
||||
Log.d(TAG, "Scan DATA ${jacksonMapper.writeValueAsString(folderScanResult)}")
|
||||
return call.resolve(JSObject(jacksonMapper.writeValueAsString(folderScanResult)))
|
||||
}
|
||||
} ?: call.resolve(JSObject())
|
||||
fun getSDKVersion(call: PluginCall) {
|
||||
val jsObject = JSObject()
|
||||
jsObject.put("version", Build.VERSION.SDK_INT)
|
||||
call.resolve(jsObject)
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
@@ -198,27 +180,6 @@ class AbsFileSystem : Plugin() {
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun scanLocalLibraryItem(call: PluginCall) {
|
||||
val localLibraryItemId = call.data.getString("localLibraryItemId", "").toString()
|
||||
val forceAudioProbe = call.data.getBoolean("forceAudioProbe")
|
||||
Log.d(TAG, "Scan Local library item $localLibraryItemId | Force Audio Probe $forceAudioProbe")
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
val localLibraryItem: LocalLibraryItem? = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
|
||||
localLibraryItem?.let {
|
||||
val folderScanner = FolderScanner(context)
|
||||
val scanResult = folderScanner.scanLocalLibraryItem(it, forceAudioProbe)
|
||||
if (scanResult == null) {
|
||||
Log.d(TAG, "NO Scan DATA")
|
||||
call.resolve(JSObject())
|
||||
} else {
|
||||
Log.d(TAG, "Scan DATA ${jacksonMapper.writeValueAsString(scanResult)}")
|
||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(scanResult)))
|
||||
}
|
||||
} ?: call.resolve(JSObject())
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun deleteItem(call: PluginCall) {
|
||||
val localLibraryItemId = call.data.getString("id", "").toString()
|
||||
@@ -226,8 +187,46 @@ class AbsFileSystem : Plugin() {
|
||||
val contentUrl = call.data.getString("contentUrl", "").toString()
|
||||
Log.d(tag, "deleteItem $absolutePath | $contentUrl")
|
||||
|
||||
val docfile = DocumentFileCompat.fromUri(mainActivity, Uri.parse(contentUrl))
|
||||
val success = docfile?.delete() == true
|
||||
// Check if should delete subfolder
|
||||
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
|
||||
|
||||
val success: Boolean
|
||||
|
||||
// If internal library item use File to delete
|
||||
if (localLibraryItem?.folderId?.startsWith("internal-") == true) {
|
||||
Log.d(tag, "Deleting internal library item at absolutePath $absolutePath")
|
||||
val file = File(absolutePath)
|
||||
success = file.deleteRecursively()
|
||||
} else {
|
||||
var subfolderPathToDelete = ""
|
||||
localLibraryItem?.folderId?.let { folderId ->
|
||||
val folder = DeviceManager.dbManager.getLocalFolder(folderId)
|
||||
folder?.absolutePath?.let { folderPath ->
|
||||
val splitAbsolutePath = absolutePath.split("/")
|
||||
val fullSubDir = splitAbsolutePath.subList(0, splitAbsolutePath.size - 1).joinToString("/")
|
||||
if (fullSubDir != folderPath) {
|
||||
val subdirHasAnItem = DeviceManager.dbManager.getLocalLibraryItems().any { _localLibraryItem ->
|
||||
if (_localLibraryItem.id == localLibraryItemId) {
|
||||
false
|
||||
} else {
|
||||
_localLibraryItem.absolutePath.startsWith(fullSubDir)
|
||||
}
|
||||
}
|
||||
subfolderPathToDelete = if (subdirHasAnItem) "" else fullSubDir
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val docfile = DocumentFileCompat.fromUri(mainActivity, Uri.parse(contentUrl))
|
||||
success = docfile?.delete() == true
|
||||
|
||||
if (subfolderPathToDelete != "") {
|
||||
Log.d(tag, "Deleting empty subfolder at $subfolderPathToDelete")
|
||||
val docfilesub = DocumentFileCompat.fromFullPath(mainActivity, subfolderPathToDelete)
|
||||
docfilesub?.delete()
|
||||
}
|
||||
}
|
||||
|
||||
if (success) {
|
||||
DeviceManager.dbManager.removeLocalLibraryItem(localLibraryItemId)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package com.audiobookshelf.app.server
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.data.*
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
@@ -17,6 +20,7 @@ import com.getcapacitor.JSObject
|
||||
import okhttp3.*
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.internal.EMPTY_REQUEST
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
@@ -30,7 +34,7 @@ class ApiHandler(var ctx:Context) {
|
||||
private var pingClient = OkHttpClient.Builder().callTimeout(3, TimeUnit.SECONDS).build()
|
||||
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||
|
||||
data class LocalSessionsSyncRequestPayload(val sessions:List<PlaybackSession>)
|
||||
data class LocalSessionsSyncRequestPayload(val sessions:List<PlaybackSession>, val deviceInfo:DeviceInfo)
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class LocalSessionSyncResult(val id:String, val success:Boolean, val progressSynced:Boolean?, val error:String?)
|
||||
data class LocalSessionsSyncResponsePayload(val results:List<LocalSessionSyncResult>)
|
||||
@@ -45,11 +49,11 @@ class ApiHandler(var ctx:Context) {
|
||||
makeRequest(request, httpClient, cb)
|
||||
}
|
||||
|
||||
private fun postRequest(endpoint:String, payload: JSObject, config:ServerConnectionConfig?, cb: (JSObject) -> Unit) {
|
||||
private 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 requestBody = payload.toString().toRequestBody(mediaType)
|
||||
val requestBody = payload?.toString()?.toRequestBody(mediaType) ?: EMPTY_REQUEST
|
||||
val requestUrl = "${address}$endpoint"
|
||||
Log.d(tag, "postRequest to $requestUrl")
|
||||
val request = Request.Builder().post(requestBody)
|
||||
@@ -292,6 +296,13 @@ class ApiHandler(var ctx:Context) {
|
||||
}
|
||||
}
|
||||
|
||||
fun closePlaybackSession(playbackSessionId:String, cb: (Boolean) -> Unit) {
|
||||
Log.d(tag, "closePlaybackSession: playbackSessionId=$playbackSessionId")
|
||||
postRequest("/api/session/$playbackSessionId/close", null, null) {
|
||||
cb(true)
|
||||
}
|
||||
}
|
||||
|
||||
fun authorize(config:ServerConnectionConfig, cb: (MutableList<MediaProgress>?) -> Unit) {
|
||||
Log.d(tag, "authorize: Authorizing ${config.address}")
|
||||
postRequest("/api/authorize", JSObject(), config) {
|
||||
@@ -314,19 +325,24 @@ class ApiHandler(var ctx:Context) {
|
||||
}
|
||||
|
||||
fun sendSyncLocalSessions(playbackSessions:List<PlaybackSession>, cb: (Boolean, String?) -> Unit) {
|
||||
val payload = JSObject(jacksonMapper.writeValueAsString(LocalSessionsSyncRequestPayload(playbackSessions)))
|
||||
@SuppressLint("HardwareIds")
|
||||
val deviceId = Settings.Secure.getString(ctx.contentResolver, Settings.Secure.ANDROID_ID)
|
||||
val deviceInfo = DeviceInfo(deviceId, Build.MANUFACTURER, Build.MODEL, Build.VERSION.SDK_INT, Build.VERSION.RELEASE)
|
||||
|
||||
val payload = JSObject(jacksonMapper.writeValueAsString(LocalSessionsSyncRequestPayload(playbackSessions, deviceInfo)))
|
||||
Log.d(tag, "Sending ${playbackSessions.size} saved local playback sessions to server")
|
||||
postRequest("/api/session/local-all", payload, null) {
|
||||
if (!it.getString("error").isNullOrEmpty()) {
|
||||
Log.e(tag, "Failed to sync local sessions")
|
||||
cb(false, it.getString("error"))
|
||||
} else {
|
||||
val response = jacksonMapper.readValue<LocalSessionsSyncResponsePayload>(it.toString())
|
||||
response.results.forEach { localSessionSyncResult ->
|
||||
Log.d(tag, "Synced session result ${localSessionSyncResult.id}|${localSessionSyncResult.progressSynced}|${localSessionSyncResult.success}")
|
||||
playbackSessions.find { ps -> ps.id == localSessionSyncResult.id }?.let { session ->
|
||||
if (localSessionSyncResult.progressSynced == true) {
|
||||
val syncResult = SyncResult(true, true, "Progress synced on server")
|
||||
MediaEventManager.saveEvent(session, syncResult)
|
||||
DeviceManager.dbManager.removePlaybackSession(session.id)
|
||||
Log.i(tag, "Successfully synced session ${session.displayTitle} with server")
|
||||
} else if (!localSessionSyncResult.success) {
|
||||
Log.e(tag, "Failed to sync session ${session.displayTitle} with server. Error: ${localSessionSyncResult.error}")
|
||||
@@ -356,6 +372,17 @@ class ApiHandler(var ctx:Context) {
|
||||
Log.d(tag, "Server progress for media item id=\"${mediaProgress.mediaItemId}\" is more recent then local. Updating local current time ${localMediaProgress.currentTime} to ${mediaProgress.currentTime}")
|
||||
localMediaProgress.updateFromServerMediaProgress(mediaProgress)
|
||||
MediaEventManager.syncEvent(mediaProgress, "Sync on server connection")
|
||||
DeviceManager.dbManager.saveLocalMediaProgress(localMediaProgress)
|
||||
} else if (localMediaProgress.lastUpdate > mediaProgress.lastUpdate && localMediaProgress.ebookLocation != null && localMediaProgress.ebookLocation != mediaProgress.ebookLocation) {
|
||||
// Patch ebook progress to server
|
||||
val endpoint = "/api/me/progress/${localMediaProgress.libraryItemId}"
|
||||
val updatePayload = JSObject()
|
||||
updatePayload.put("ebookLocation", localMediaProgress.ebookLocation)
|
||||
updatePayload.put("ebookProgress", localMediaProgress.ebookProgress)
|
||||
updatePayload.put("lastUpdate", localMediaProgress.lastUpdate)
|
||||
patchRequest(endpoint,updatePayload) {
|
||||
Log.d(tag, "syncLocalMediaProgressForUser patched ebook progress")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,4 +2,5 @@
|
||||
<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>
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
||||
@@ -2,4 +2,5 @@
|
||||
<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>
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<resources>
|
||||
<string name="app_name">audiobookshelf</string>
|
||||
<string name="title_activity_main">audiobookshelf</string>
|
||||
<string name="app_name">Audiobookshelf</string>
|
||||
<string name="title_activity_main">Audiobookshelf</string>
|
||||
<string name="package_name">com.audiobookshelf.app</string>
|
||||
<string name="custom_url_scheme">com.audiobookshelf.app</string>
|
||||
<string name="custom_url_scheme">audiobookshelf</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>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<external-path name="my_images" path="." />
|
||||
<cache-path name="my_cache_images" path="." />
|
||||
</paths>
|
||||
<paths>
|
||||
<files-path name="downloads" path="downloads/" />
|
||||
</paths>
|
||||
|
||||
@@ -8,8 +8,8 @@ buildscript {
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.google.gms:google-services:4.3.13'
|
||||
classpath 'com.android.tools.build:gradle:7.4.0'
|
||||
classpath 'com.google.gms:google-services:4.4.0'
|
||||
classpath 'com.android.tools.build:gradle:8.1.1'
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||
|
||||
// NOTE: Do not place your application dependencies here; they belong
|
||||
|
||||
@@ -2,9 +2,18 @@
|
||||
include ':capacitor-android'
|
||||
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
|
||||
|
||||
include ':byteowls-capacitor-filesharer'
|
||||
project(':byteowls-capacitor-filesharer').projectDir = new File('../node_modules/@byteowls/capacitor-filesharer/android')
|
||||
|
||||
include ':capacitor-app'
|
||||
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
|
||||
|
||||
include ':capacitor-browser'
|
||||
project(':capacitor-browser').projectDir = new File('../node_modules/@capacitor/browser/android')
|
||||
|
||||
include ':capacitor-clipboard'
|
||||
project(':capacitor-clipboard').projectDir = new File('../node_modules/@capacitor/clipboard/android')
|
||||
|
||||
include ':capacitor-dialog'
|
||||
project(':capacitor-dialog').projectDir = new File('../node_modules/@capacitor/dialog/android')
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ org.gradle.jvmargs=-Dfile.encoding=UTF-8 \
|
||||
# Android operating system, and which are packaged with your app's APK
|
||||
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||
android.useAndroidX=true
|
||||
# Automatically convert third-party libraries to use AndroidX
|
||||
android.enableJetifier=true
|
||||
|
||||
kapt.use.worker.api=false
|
||||
|
||||
android.defaults.buildfeatures.buildconfig=true
|
||||
|
||||
BIN
Binary file not shown.
+3
-3
@@ -1,6 +1,6 @@
|
||||
#Sun Apr 17 13:28:55 CDT 2022
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-all.zip
|
||||
distributionPath=wrapper/dists
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.0.2-all.zip
|
||||
networkTimeout=10000
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
|
||||
Vendored
+172
-113
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env sh
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright 2015 the original author or authors.
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -17,67 +17,101 @@
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
##
|
||||
## Gradle start up script for UN*X
|
||||
##
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
# Resolve links: $0 may be a link
|
||||
PRG="$0"
|
||||
# Need this for relative symlinks.
|
||||
while [ -h "$PRG" ] ; do
|
||||
ls=`ls -ld "$PRG"`
|
||||
link=`expr "$ls" : '.*-> \(.*\)$'`
|
||||
if expr "$link" : '/.*' > /dev/null; then
|
||||
PRG="$link"
|
||||
else
|
||||
PRG=`dirname "$PRG"`"/$link"
|
||||
fi
|
||||
done
|
||||
SAVED="`pwd`"
|
||||
cd "`dirname \"$PRG\"`/" >/dev/null
|
||||
APP_HOME="`pwd -P`"
|
||||
cd "$SAVED" >/dev/null
|
||||
|
||||
APP_NAME="Gradle"
|
||||
APP_BASE_NAME=`basename "$0"`
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD="maximum"
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
}
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
}
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "`uname`" in
|
||||
CYGWIN* )
|
||||
cygwin=true
|
||||
;;
|
||||
Darwin* )
|
||||
darwin=true
|
||||
;;
|
||||
MINGW* )
|
||||
msys=true
|
||||
;;
|
||||
NONSTOP* )
|
||||
nonstop=true
|
||||
;;
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
@@ -87,9 +121,9 @@ CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD="$JAVA_HOME/bin/java"
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
@@ -98,7 +132,7 @@ Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD="java"
|
||||
JAVACMD=java
|
||||
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
@@ -106,80 +140,105 @@ location of your Java installation."
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
|
||||
MAX_FD_LIMIT=`ulimit -H -n`
|
||||
if [ $? -eq 0 ] ; then
|
||||
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
|
||||
MAX_FD="$MAX_FD_LIMIT"
|
||||
fi
|
||||
ulimit -n $MAX_FD
|
||||
if [ $? -ne 0 ] ; then
|
||||
warn "Could not set maximum file descriptor limit: $MAX_FD"
|
||||
fi
|
||||
else
|
||||
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
|
||||
fi
|
||||
fi
|
||||
|
||||
# For Darwin, add options to specify how the application appears in the dock
|
||||
if $darwin; then
|
||||
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
|
||||
fi
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
|
||||
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
|
||||
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
|
||||
|
||||
JAVACMD=`cygpath --unix "$JAVACMD"`
|
||||
|
||||
# We build the pattern for arguments to be converted via cygpath
|
||||
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
|
||||
SEP=""
|
||||
for dir in $ROOTDIRSRAW ; do
|
||||
ROOTDIRS="$ROOTDIRS$SEP$dir"
|
||||
SEP="|"
|
||||
done
|
||||
OURCYGPATTERN="(^($ROOTDIRS))"
|
||||
# Add a user-defined pattern to the cygpath arguments
|
||||
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
|
||||
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
|
||||
fi
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
i=0
|
||||
for arg in "$@" ; do
|
||||
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
|
||||
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
|
||||
|
||||
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
|
||||
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
|
||||
else
|
||||
eval `echo args$i`="\"$arg\""
|
||||
fi
|
||||
i=`expr $i + 1`
|
||||
done
|
||||
case $i in
|
||||
0) set -- ;;
|
||||
1) set -- "$args0" ;;
|
||||
2) set -- "$args0" "$args1" ;;
|
||||
3) set -- "$args0" "$args1" "$args2" ;;
|
||||
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
|
||||
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
|
||||
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
|
||||
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
|
||||
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
|
||||
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Escape application args
|
||||
save () {
|
||||
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
|
||||
echo " "
|
||||
}
|
||||
APP_ARGS=`save "$@"`
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# Collect all arguments for the java command, following the shell quoting and substitution rules
|
||||
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command;
|
||||
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
|
||||
# shell script including quotes and variable substitutions, so put them in
|
||||
# double quotes to make sure that they get re-expanded; and
|
||||
# * put everything else in single quotes, so that it's not re-expanded.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
org.gradle.wrapper.GradleWrapperMain \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
|
||||
Vendored
+9
-6
@@ -14,7 +14,7 @@
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%" == "" @echo off
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@@ -25,7 +25,8 @@
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%" == "" set DIRNAME=.
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@@ -40,7 +41,7 @@ if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if "%ERRORLEVEL%" == "0" goto execute
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
@@ -75,13 +76,15 @@ set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if "%ERRORLEVEL%"=="0" goto mainEnd
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
|
||||
exit /b 1
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
+14
-14
@@ -1,39 +1,39 @@
|
||||
ext {
|
||||
minSdkVersion = 24
|
||||
compileSdkVersion = 33
|
||||
targetSdkVersion = 32
|
||||
androidxActivityVersion = '1.4.0'
|
||||
androidxAppCompatVersion = '1.4.2'
|
||||
compileSdkVersion = 34
|
||||
targetSdkVersion = 33
|
||||
androidxActivityVersion = '1.7.0'
|
||||
androidxAppCompatVersion = '1.6.1'
|
||||
androidxCoordinatorLayoutVersion = '1.2.0'
|
||||
androidxCoreVersion = '1.8.0'
|
||||
androidxCoreVersion = '1.10.0'
|
||||
androidPlayCore = '1.9.0'
|
||||
androidxFragmentVersion = '1.4.1'
|
||||
androidxFragmentVersion = '1.5.6'
|
||||
junitVersion = '4.13.2'
|
||||
androidxJunitVersion = '1.1.3'
|
||||
androidxEspressoCoreVersion = '3.4.0'
|
||||
androidxJunitVersion = '1.1.5'
|
||||
androidxEspressoCoreVersion = '3.5.1'
|
||||
cordovaAndroidVersion = '10.1.1'
|
||||
androidx_car_version = '1.0.0-alpha7'
|
||||
androidx_core_ktx_version = '1.7.0'
|
||||
androidx_core_ktx_version = '1.12.0'
|
||||
androidx_media_version = '1.6.0'
|
||||
androidx_preference_version = '1.1.1'
|
||||
androidx_test_runner_version = '1.3.0'
|
||||
arch_lifecycle_version = '2.2.0'
|
||||
constraint_layout_version = '2.0.1'
|
||||
espresso_version = '3.3.0'
|
||||
exoplayer_version = '2.17.0'
|
||||
exoplayer_version = '2.18.7'
|
||||
fragment_version = '1.2.5'
|
||||
glide_version = '4.11.0'
|
||||
gms_strict_version_matcher_version = '1.0.3'
|
||||
gradle_version = '3.1.4'
|
||||
gson_version = '2.8.5'
|
||||
junit_version = '4.13'
|
||||
kotlin_version = '1.7.20'
|
||||
kotlin_coroutines_version = '1.1.0'
|
||||
kotlin_version = '1.8.10'
|
||||
kotlin_coroutines_version = '1.6.4'
|
||||
multidex_version = '1.0.3'
|
||||
play_services_auth_version = '18.1.0'
|
||||
recycler_view_version = '1.1.0'
|
||||
robolectric_version = '4.2'
|
||||
test_runner_version = '1.1.0'
|
||||
coreSplashScreenVersion = '1.0.0-rc01'
|
||||
androidxWebkitVersion = '1.4.0'
|
||||
coreSplashScreenVersion = '1.0.1'
|
||||
androidxWebkitVersion = '1.6.1'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
class WrappingMarquee {
|
||||
#scrollDelay = 2000
|
||||
#scrollSpeed = 30
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} el
|
||||
*/
|
||||
constructor(el) {
|
||||
this.el = el
|
||||
/** @type {HTMLElement} */
|
||||
this.pEl = el?.firstElementChild
|
||||
|
||||
this.innerText = ''
|
||||
this.isScrolling = false
|
||||
|
||||
/** @type {NodeJS.Timeout} */
|
||||
this.timer = null
|
||||
/** @type {number} */
|
||||
this.animationId = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Transparent gradient mask shown when text is scrolling left and overflowing right
|
||||
*
|
||||
* @param {boolean} showLeft
|
||||
*/
|
||||
setMask(showLeft) {
|
||||
if (!this.el) return
|
||||
this.el.style.maskImage = showLeft ? 'linear-gradient(90deg, transparent 0%, #fff 10%, #000 90%, transparent)' : 'linear-gradient(90deg, #000 90%, transparent)'
|
||||
}
|
||||
|
||||
startScroll() {
|
||||
if (this.isScrolling) {
|
||||
console.warn('Already scrolling')
|
||||
return
|
||||
}
|
||||
|
||||
this.isScrolling = true
|
||||
this.setMask(true)
|
||||
|
||||
let textScrollAmount = this.el.scrollWidth
|
||||
this.pEl.innerHTML = this.innerText + ' '.repeat(15)
|
||||
let totalScrollAmount = this.el.scrollWidth
|
||||
let scrollDuration = totalScrollAmount * this.#scrollSpeed
|
||||
|
||||
this.pEl.innerHTML = this.pEl.innerHTML + this.innerText
|
||||
|
||||
let done = false
|
||||
let start, previousTimeStamp
|
||||
|
||||
const step = (timeStamp) => {
|
||||
if (start === undefined) {
|
||||
start = timeStamp
|
||||
}
|
||||
const elapsed = timeStamp - start
|
||||
|
||||
if (this.isScrolling && previousTimeStamp !== timeStamp) {
|
||||
const amountToMove = Math.min(elapsed / scrollDuration * totalScrollAmount, totalScrollAmount)
|
||||
this.pEl.style.transform = `translateX(-${amountToMove}px)`
|
||||
if (amountToMove === totalScrollAmount) done = true
|
||||
if (amountToMove > textScrollAmount) this.setMask(false)
|
||||
}
|
||||
|
||||
if (!this.isScrolling || done) { // canceled or done
|
||||
this.isScrolling = false
|
||||
this.pEl.style.transform = 'translateX(0px)'
|
||||
this.pEl.innerText = this.innerText
|
||||
this.setMask(false)
|
||||
if (done) {
|
||||
this.startTimer()
|
||||
}
|
||||
} else if (elapsed < scrollDuration) { // step
|
||||
previousTimeStamp = timeStamp
|
||||
this.animationId = window.requestAnimationFrame(step)
|
||||
}
|
||||
}
|
||||
this.animationId = window.requestAnimationFrame(step)
|
||||
}
|
||||
|
||||
startTimer() {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = setTimeout(() => {
|
||||
this.startScroll()
|
||||
}, this.#scrollDelay)
|
||||
}
|
||||
|
||||
reset() {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = null
|
||||
this.isScrolling = false
|
||||
window.cancelAnimationFrame(this.animationId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize and start marquee if text overflows container
|
||||
* resets the marquee if already active
|
||||
*
|
||||
* @param {string} innerText
|
||||
*/
|
||||
init(innerText) {
|
||||
if (!this.el || !this.pEl) return
|
||||
|
||||
this.reset()
|
||||
|
||||
this.innerText = innerText
|
||||
this.pEl.innerText = innerText
|
||||
this.pEl.style.transform = 'translateX(0px)'
|
||||
|
||||
if (this.el.scrollWidth > this.el.clientWidth) {
|
||||
this.setMask(false)
|
||||
this.startTimer()
|
||||
} else {
|
||||
this.el.style.maskImage = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
export default WrappingMarquee
|
||||
+39
-4
@@ -1,6 +1,8 @@
|
||||
@import "./tailwind.css";
|
||||
@import "./fonts.css";
|
||||
@import './defaultStyles.css';
|
||||
@import './absicons.css';
|
||||
@import './transitions.css';
|
||||
|
||||
* {
|
||||
-webkit-touch-callout: none;
|
||||
@@ -15,10 +17,6 @@ textarea {
|
||||
user-select: auto;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #262626;
|
||||
}
|
||||
|
||||
.layout-wrapper {
|
||||
height: calc(100vh - env(safe-area-inset-top));
|
||||
min-height: calc(100vh - env(safe-area-inset-top));
|
||||
@@ -117,6 +115,20 @@ Bookshelf Label
|
||||
filter: blur(20px);
|
||||
}
|
||||
|
||||
.episode-subtitle {
|
||||
word-break: break-word;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
line-height: 16px;
|
||||
/* fallback */
|
||||
max-height: 32px;
|
||||
/* fallback */
|
||||
-webkit-line-clamp: 2;
|
||||
/* number of lines to show */
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.line-clamp-2 {
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
@@ -129,4 +141,27 @@ Bookshelf Label
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 4;
|
||||
}
|
||||
|
||||
.tracksTable {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
border: 1px solid rgb(var(--color-secondary));
|
||||
}
|
||||
|
||||
.tracksTable tr:nth-child(even) {
|
||||
background-color: rgb(var(--color-secondary));
|
||||
}
|
||||
|
||||
.tracksTable tr {
|
||||
background-color: rgb(var(--color-bg));
|
||||
}
|
||||
|
||||
.tracksTable td {
|
||||
padding: 8px 8px;
|
||||
}
|
||||
|
||||
.tracksTable th {
|
||||
padding: 4px 8px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
(function (global, factory) {
|
||||
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
|
||||
typeof define === 'function' && define.amd ? define(['exports'], factory) :
|
||||
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global['fast-sort'] = {}));
|
||||
}(this, (function (exports) {
|
||||
'use strict';
|
||||
|
||||
// >>> INTERFACES <<<
|
||||
// >>> HELPERS <<<
|
||||
var castComparer = function (comparer) { return function (a, b, order) { return comparer(a, b, order) * order; }; };
|
||||
var throwInvalidConfigErrorIfTrue = function (condition, context) {
|
||||
if (condition)
|
||||
throw Error("Invalid sort config: " + context);
|
||||
};
|
||||
var unpackObjectSorter = function (sortByObj) {
|
||||
var _a = sortByObj || {}, asc = _a.asc, desc = _a.desc;
|
||||
var order = asc ? 1 : -1;
|
||||
var sortBy = (asc || desc);
|
||||
// Validate object config
|
||||
throwInvalidConfigErrorIfTrue(!sortBy, 'Expected `asc` or `desc` property');
|
||||
throwInvalidConfigErrorIfTrue(asc && desc, 'Ambiguous object with `asc` and `desc` config properties');
|
||||
var comparer = sortByObj.comparer && castComparer(sortByObj.comparer);
|
||||
return { order: order, sortBy: sortBy, comparer: comparer };
|
||||
};
|
||||
// >>> SORTERS <<<
|
||||
var multiPropertySorterProvider = function (defaultComparer) {
|
||||
return function multiPropertySorter(sortBy, sortByArr, depth, order, comparer, a, b) {
|
||||
var valA;
|
||||
var valB;
|
||||
if (typeof sortBy === 'string') {
|
||||
valA = a[sortBy];
|
||||
valB = b[sortBy];
|
||||
}
|
||||
else if (typeof sortBy === 'function') {
|
||||
valA = sortBy(a);
|
||||
valB = sortBy(b);
|
||||
}
|
||||
else {
|
||||
var objectSorterConfig = unpackObjectSorter(sortBy);
|
||||
return multiPropertySorter(objectSorterConfig.sortBy, sortByArr, depth, objectSorterConfig.order, objectSorterConfig.comparer || defaultComparer, a, b);
|
||||
}
|
||||
var equality = comparer(valA, valB, order);
|
||||
if ((equality === 0 || (valA == null && valB == null)) &&
|
||||
sortByArr.length > depth) {
|
||||
return multiPropertySorter(sortByArr[depth], sortByArr, depth + 1, order, comparer, a, b);
|
||||
}
|
||||
return equality;
|
||||
};
|
||||
};
|
||||
function getSortStrategy(sortBy, comparer, order) {
|
||||
// Flat array sorter
|
||||
if (sortBy === undefined || sortBy === true) {
|
||||
return function (a, b) { return comparer(a, b, order); };
|
||||
}
|
||||
// Sort list of objects by single object key
|
||||
if (typeof sortBy === 'string') {
|
||||
throwInvalidConfigErrorIfTrue(sortBy.includes('.'), 'String syntax not allowed for nested properties.');
|
||||
return function (a, b) { return comparer(a[sortBy], b[sortBy], order); };
|
||||
}
|
||||
// Sort list of objects by single function sorter
|
||||
if (typeof sortBy === 'function') {
|
||||
return function (a, b) { return comparer(sortBy(a), sortBy(b), order); };
|
||||
}
|
||||
// Sort by multiple properties
|
||||
if (Array.isArray(sortBy)) {
|
||||
var multiPropSorter_1 = multiPropertySorterProvider(comparer);
|
||||
return function (a, b) { return multiPropSorter_1(sortBy[0], sortBy, 1, order, comparer, a, b); };
|
||||
}
|
||||
// Unpack object config to get actual sorter strategy
|
||||
var objectSorterConfig = unpackObjectSorter(sortBy);
|
||||
return getSortStrategy(objectSorterConfig.sortBy, objectSorterConfig.comparer || comparer, objectSorterConfig.order);
|
||||
}
|
||||
var sortArray = function (order, ctx, sortBy, comparer) {
|
||||
var _a;
|
||||
if (!Array.isArray(ctx)) {
|
||||
return ctx;
|
||||
}
|
||||
// Unwrap sortBy if array with only 1 value to get faster sort strategy
|
||||
if (Array.isArray(sortBy) && sortBy.length < 2) {
|
||||
_a = sortBy, sortBy = _a[0];
|
||||
}
|
||||
return ctx.sort(getSortStrategy(sortBy, comparer, order));
|
||||
};
|
||||
// >>> Public <<<
|
||||
var createNewSortInstance = function (opts) {
|
||||
var comparer = castComparer(opts.comparer);
|
||||
return function (_ctx) {
|
||||
var ctx = Array.isArray(_ctx) && !opts.inPlaceSorting
|
||||
? _ctx.slice()
|
||||
: _ctx;
|
||||
return {
|
||||
/**
|
||||
* Sort array in ascending order.
|
||||
* @example
|
||||
* sort([3, 1, 4]).asc();
|
||||
* sort(users).asc(u => u.firstName);
|
||||
* sort(users).asc([
|
||||
* U => u.firstName
|
||||
* u => u.lastName,
|
||||
* ]);
|
||||
*/
|
||||
asc: function (sortBy) {
|
||||
return sortArray(1, ctx, sortBy, comparer);
|
||||
},
|
||||
/**
|
||||
* Sort array in descending order.
|
||||
* @example
|
||||
* sort([3, 1, 4]).desc();
|
||||
* sort(users).desc(u => u.firstName);
|
||||
* sort(users).desc([
|
||||
* U => u.firstName
|
||||
* u => u.lastName,
|
||||
* ]);
|
||||
*/
|
||||
desc: function (sortBy) {
|
||||
return sortArray(-1, ctx, sortBy, comparer);
|
||||
},
|
||||
/**
|
||||
* Sort array in ascending or descending order. It allows sorting on multiple props
|
||||
* in different order for each of them.
|
||||
* @example
|
||||
* sort(users).by([
|
||||
* { asc: u => u.score }
|
||||
* { desc: u => u.age }
|
||||
* ]);
|
||||
*/
|
||||
by: function (sortBy) {
|
||||
return sortArray(1, ctx, sortBy, comparer);
|
||||
},
|
||||
};
|
||||
};
|
||||
};
|
||||
var defaultComparer = function (a, b, order) {
|
||||
if (a == null)
|
||||
return order;
|
||||
if (b == null)
|
||||
return -order;
|
||||
if (a < b)
|
||||
return -1;
|
||||
if (a === b)
|
||||
return 0;
|
||||
return 1;
|
||||
};
|
||||
var sort = createNewSortInstance({
|
||||
comparer: defaultComparer,
|
||||
});
|
||||
var inPlaceSort = createNewSortInstance({
|
||||
comparer: defaultComparer,
|
||||
inPlaceSorting: true,
|
||||
});
|
||||
|
||||
exports.createNewSortInstance = createNewSortInstance;
|
||||
exports.inPlaceSort = inPlaceSort;
|
||||
exports.sort = sort;
|
||||
|
||||
Object.defineProperty(exports, '__esModule', { value: true });
|
||||
|
||||
})));
|
||||
@@ -0,0 +1,47 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
color: white;
|
||||
--color-bg: 56 56 56;
|
||||
--color-bg-hover: 102 104 107;
|
||||
--color-fg: 230 237 243;
|
||||
--color-fg-muted: 142 147 153;
|
||||
--color-primary: 35 35 35;
|
||||
--color-secondary: 47 48 48;
|
||||
--color-border: 75 85 89;
|
||||
--color-bg-toggle: 56 56 56;
|
||||
--color-bg-toggle-selected: 81 82 84;
|
||||
--color-track-cursor: 229 231 235;
|
||||
--color-track: 107 114 128;
|
||||
--color-track-buffered: 75 85 99;
|
||||
--gradient-item-page: linear-gradient(169deg, rgba(0, 0, 0, 0.4) 0%, rgba(55, 56, 56, 1) 80%);
|
||||
--gradient-audio-player: linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, rgba(38, 38, 38, 1) 80%);
|
||||
--gradient-minimized-audio-player: linear-gradient(145deg, rgba(38, 38, 38, 0.5) 0%, rgba(38, 38, 38, 0.9) 20%, rgb(38, 38, 38) 60%);
|
||||
}
|
||||
|
||||
html[data-theme='light'] {
|
||||
color: black;
|
||||
--color-bg: 255 255 255;
|
||||
--color-bg-hover: 208 210 212;
|
||||
--color-fg: 37 37 37;
|
||||
--color-fg-muted: 101 109 118;
|
||||
--color-primary: 222 222 222;
|
||||
--color-secondary: 246 248 250;
|
||||
--color-border: 189 191 191;
|
||||
--color-bg-toggle: 222 222 222;
|
||||
--color-bg-toggle-selected: 255 255 255;
|
||||
--color-track-cursor: 101 109 118;
|
||||
--color-track: 189 191 191;
|
||||
--color-track-buffered: 129 131 131;
|
||||
--gradient-item-page: linear-gradient(169deg, rgba(255, 255, 255, 0.4) 0%, rgba(255, 255, 255, 1) 80%);
|
||||
--gradient-audio-player: linear-gradient(180deg, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 1) 80%);
|
||||
--gradient-minimized-audio-player: linear-gradient(145deg, rgba(255, 255, 255, 0.5) 0%, rgba(255, 255, 255, 0.9) 20%, rgb(255, 255, 255) 60%);
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-primary
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
.slide-enter-active {
|
||||
-moz-transition-duration: 0.1s;
|
||||
-webkit-transition-duration: 0.1s;
|
||||
-o-transition-duration: 0.1s;
|
||||
transition-duration: 0.1s;
|
||||
-moz-transition-timing-function: ease-in;
|
||||
-webkit-transition-timing-function: ease-in;
|
||||
-o-transition-timing-function: ease-in;
|
||||
transition-timing-function: ease-in;
|
||||
}
|
||||
|
||||
.slide-leave-active {
|
||||
-moz-transition-duration: 0.2s;
|
||||
-webkit-transition-duration: 0.2s;
|
||||
-o-transition-duration: 0.2s;
|
||||
transition-duration: 0.2s;
|
||||
-moz-transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
|
||||
-webkit-transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
|
||||
-o-transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
|
||||
transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
|
||||
}
|
||||
|
||||
.slide-enter-to,
|
||||
.slide-leave {
|
||||
max-height: 600px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.slide-enter,
|
||||
.slide-leave-to {
|
||||
overflow: hidden;
|
||||
max-height: 0;
|
||||
}
|
||||
|
||||
.menu-enter,
|
||||
.menu-leave-active {
|
||||
transform: translateY(-15px);
|
||||
}
|
||||
|
||||
.menu-enter-active {
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.menu-leave-active {
|
||||
transition: all 0.1s;
|
||||
}
|
||||
|
||||
.menu-enter,
|
||||
.menu-leave-active {
|
||||
opacity: 0;
|
||||
}
|
||||
@@ -2,5 +2,10 @@
|
||||
"appId": "com.audiobookshelf.app",
|
||||
"appName": "audiobookshelf-app",
|
||||
"webDir": "dist",
|
||||
"bundledWebRuntime": false
|
||||
"bundledWebRuntime": false,
|
||||
"plugins": {
|
||||
"CapacitorHttp": {
|
||||
"enabled": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,13 +4,13 @@
|
||||
<nuxt-link v-show="!showBack" to="/" class="mr-3">
|
||||
<img src="/Logo.png" class="h-10 w-10" />
|
||||
</nuxt-link>
|
||||
<a v-if="showBack" @click="back" class="rounded-full h-10 w-10 flex items-center justify-center hover:bg-white hover:bg-opacity-10 mr-2 cursor-pointer">
|
||||
<span class="material-icons text-3xl text-white">arrow_back</span>
|
||||
<a v-if="showBack" @click="back" class="rounded-full h-10 w-10 flex items-center justify-center mr-2 cursor-pointer">
|
||||
<span class="material-icons text-3xl text-fg">arrow_back</span>
|
||||
</a>
|
||||
<div v-if="user && currentLibrary">
|
||||
<div v-if="user && currentLibrary && networkConnected">
|
||||
<div class="pl-1.5 pr-2.5 py-2 bg-bg bg-opacity-30 rounded-md flex items-center" @click="clickShowLibraryModal">
|
||||
<ui-library-icon :icon="currentLibraryIcon" :size="4" font-size="base" />
|
||||
<p class="text-smleading-4 ml-2 mt-0.5 max-w-24 truncate">{{ currentLibraryName }}</p>
|
||||
<p class="text-sm leading-4 ml-2 mt-0.5 max-w-24 truncate">{{ currentLibraryName }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -54,14 +54,17 @@ export default {
|
||||
this.$store.commit('setCastAvailable', val)
|
||||
}
|
||||
},
|
||||
networkConnected() {
|
||||
return this.$store.state.networkConnected
|
||||
},
|
||||
currentLibrary() {
|
||||
return this.$store.getters['libraries/getCurrentLibrary']
|
||||
},
|
||||
currentLibraryName() {
|
||||
return this.currentLibrary ? this.currentLibrary.name : ''
|
||||
return this.currentLibrary?.name || ''
|
||||
},
|
||||
currentLibraryIcon() {
|
||||
return this.currentLibrary ? this.currentLibrary.icon : 'database'
|
||||
return this.currentLibrary?.icon || 'database'
|
||||
},
|
||||
showBack() {
|
||||
if (!this.$route.name) return true
|
||||
@@ -71,7 +74,7 @@ export default {
|
||||
return this.$store.state.user.user
|
||||
},
|
||||
username() {
|
||||
return this.user ? this.user.username : 'err'
|
||||
return this.user?.username || 'err'
|
||||
},
|
||||
isCasting() {
|
||||
return this.$store.state.isCasting
|
||||
@@ -79,7 +82,7 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
castClick() {
|
||||
if (this.$store.state.playerIsLocal) {
|
||||
if (this.$store.getters['getIsCurrentSessionLocal']) {
|
||||
this.$eventBus.$emit('cast-local-item')
|
||||
return
|
||||
}
|
||||
|
||||
+195
-103
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div v-if="playbackSession" id="streamContainer" class="fixed top-0 left-0 layout-wrapper right-0 z-50 pointer-events-none" :class="{ fullscreen: showFullscreen, 'ios-player': $platform === 'ios', 'web-player': $platform === 'web' }">
|
||||
<div v-if="showFullscreen" class="w-full h-full z-10 absolute top-0 left-0 pointer-events-auto" :style="{ backgroundColor: coverRgb }">
|
||||
<div class="w-full h-full absolute top-0 left-0 pointer-events-none" style="background: linear-gradient(169deg, rgba(0, 0, 0, 0) 0%, rgba(38, 38, 38, 1) 80%)" />
|
||||
<div class="w-full h-full absolute top-0 left-0 pointer-events-none" style="background: var(--gradient-audio-player)" />
|
||||
|
||||
<div class="top-4 left-4 absolute cursor-pointer">
|
||||
<span class="material-icons text-5xl" :class="{ 'text-black text-opacity-75': coverBgIsLight }" @click="collapseFullscreen">expand_more</span>
|
||||
@@ -12,20 +12,20 @@
|
||||
<div class="top-6 right-4 absolute cursor-pointer">
|
||||
<span class="material-icons text-3xl" :class="{ 'text-black text-opacity-75': coverBgIsLight }" @click="showMoreMenuDialog = true">more_vert</span>
|
||||
</div>
|
||||
<p class="top-4 absolute left-0 right-0 mx-auto text-center uppercase tracking-widest text-opacity-75" style="font-size: 10px" :class="{ 'text-success': isLocalPlayMethod, 'text-accent': !isLocalPlayMethod }">{{ isDirectPlayMethod ? 'Direct' : isLocalPlayMethod ? 'Local' : 'Transcode' }}</p>
|
||||
<p class="top-4 absolute left-0 right-0 mx-auto text-center uppercase tracking-widest text-opacity-75" :class="{ 'text-black text-opacity-75': coverBgIsLight }" style="font-size: 10px">{{ isDirectPlayMethod ? $strings.LabelPlaybackDirect : isLocalPlayMethod ? $strings.LabelPlaybackLocal : $strings.LabelPlaybackTranscode }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="useChapterTrack && useTotalTrack && showFullscreen" class="absolute total-track w-full z-30 px-6">
|
||||
<div v-if="playerSettings.useChapterTrack && playerSettings.useTotalTrack && showFullscreen" class="absolute total-track w-full z-30 px-6">
|
||||
<div class="flex">
|
||||
<p class="font-mono text-white text-opacity-90" style="font-size: 0.8rem">{{ currentTimePretty }}</p>
|
||||
<p class="font-mono text-fg" style="font-size: 0.8rem">{{ currentTimePretty }}</p>
|
||||
<div class="flex-grow" />
|
||||
<p class="font-mono text-white text-opacity-90" style="font-size: 0.8rem">{{ totalTimeRemainingPretty }}</p>
|
||||
<p class="font-mono text-fg" style="font-size: 0.8rem">{{ totalTimeRemainingPretty }}</p>
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<div class="h-1 w-full bg-gray-500 bg-opacity-50 relative rounded-full">
|
||||
<div ref="totalReadyTrack" class="h-full bg-gray-600 absolute top-0 left-0 pointer-events-none rounded-full" />
|
||||
<div ref="totalBufferedTrack" class="h-full bg-gray-500 absolute top-0 left-0 pointer-events-none rounded-full" />
|
||||
<div ref="totalPlayedTrack" class="h-full bg-gray-200 absolute top-0 left-0 pointer-events-none rounded-full" />
|
||||
<div class="h-1 w-full bg-track/50 relative rounded-full">
|
||||
<div ref="totalReadyTrack" class="h-full bg-track-buffered absolute top-0 left-0 pointer-events-none rounded-full" />
|
||||
<div ref="totalBufferedTrack" class="h-full bg-track absolute top-0 left-0 pointer-events-none rounded-full" />
|
||||
<div ref="totalPlayedTrack" class="h-full bg-track-cursor absolute top-0 left-0 pointer-events-none rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -41,62 +41,66 @@
|
||||
</div>
|
||||
|
||||
<div class="title-author-texts absolute z-30 left-0 right-0 overflow-hidden" @click="clickTitleAndAuthor">
|
||||
<p class="title-text truncate">{{ title }}</p>
|
||||
<p class="author-text text-white text-opacity-75 truncate">{{ authorName }}</p>
|
||||
<div ref="titlewrapper" class="overflow-hidden relative">
|
||||
<p class="title-text whitespace-nowrap"></p>
|
||||
</div>
|
||||
<p class="author-text text-fg text-opacity-75 truncate">{{ authorName }}</p>
|
||||
</div>
|
||||
|
||||
<div id="playerContent" class="playerContainer w-full z-20 absolute bottom-0 left-0 right-0 p-2 pointer-events-auto transition-all" :style="{ backgroundColor: showFullscreen ? '' : coverRgb }" @click="clickContainer">
|
||||
<div v-if="showFullscreen" class="absolute bottom-4 left-0 right-0 w-full pb-4 pt-2 mx-auto px-6" style="max-width: 414px">
|
||||
<div class="flex items-center justify-between pointer-events-auto">
|
||||
<span v-if="!isPodcast && isServerItem && networkConnected" class="material-icons text-3xl text-white text-opacity-75 cursor-pointer" @click="$emit('showBookmarks')">{{ bookmarks.length ? 'bookmark' : 'bookmark_border' }}</span>
|
||||
<span v-if="!isPodcast && serverLibraryItemId && networkConnected" class="material-icons text-3xl text-fg-muted cursor-pointer" @click="$emit('showBookmarks')">{{ bookmarks.length ? 'bookmark' : 'bookmark_border' }}</span>
|
||||
<!-- hidden for podcasts but still using this as a placeholder -->
|
||||
<span v-else class="material-icons text-3xl text-white text-opacity-0">bookmark</span>
|
||||
|
||||
<span class="font-mono text-white text-opacity-75 cursor-pointer" style="font-size: 1.35rem" @click="$emit('selectPlaybackSpeed')">{{ currentPlaybackRate }}x</span>
|
||||
<svg v-if="!sleepTimerRunning" xmlns="http://www.w3.org/2000/svg" class="h-7 w-7 text-white text-opacity-75 cursor-pointer" fill="none" viewBox="0 0 24 24" stroke="currentColor" @click.stop="$emit('showSleepTimer')">
|
||||
<span class="font-mono text-fg-muted cursor-pointer" style="font-size: 1.35rem" @click="$emit('selectPlaybackSpeed')">{{ currentPlaybackRate }}x</span>
|
||||
<svg v-if="!sleepTimerRunning" xmlns="http://www.w3.org/2000/svg" class="h-7 w-7 text-fg-muted cursor-pointer" fill="none" viewBox="0 0 24 24" stroke="currentColor" @click.stop="$emit('showSleepTimer')">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
||||
</svg>
|
||||
<div v-else class="h-7 w-7 flex items-center justify-around cursor-pointer" @click.stop="$emit('showSleepTimer')">
|
||||
<p class="text-xl font-mono text-success">{{ sleepTimeRemainingPretty }}</p>
|
||||
</div>
|
||||
|
||||
<span class="material-icons text-3xl text-white cursor-pointer" :class="chapters.length ? 'text-opacity-75' : 'text-opacity-10'" @click="showChapterModal = true">format_list_bulleted</span>
|
||||
<span class="material-icons text-3xl text-fg cursor-pointer" :class="chapters.length ? 'text-opacity-75' : 'text-opacity-10'" @click="clickChaptersBtn">format_list_bulleted</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="w-full h-full absolute top-0 left-0 pointer-events-none" style="background: linear-gradient(145deg, rgba(38, 38, 38, 0.5) 0%, rgba(38, 38, 38, 0.9) 20%, rgb(38, 38, 38) 60%)" />
|
||||
<div v-else class="w-full h-full absolute top-0 left-0 pointer-events-none" style="background: var(--gradient-minimized-audio-player)" />
|
||||
|
||||
<div id="playerControls" class="absolute right-0 bottom-0 mx-auto" style="max-width: 414px">
|
||||
<div class="flex items-center justify-between max-w-full">
|
||||
<span v-show="showFullscreen && !lockUi" class="material-icons next-icon text-white text-opacity-75 cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpChapterStart">first_page</span>
|
||||
<span v-show="!lockUi" class="material-icons jump-icon text-white cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpBackwards">{{ jumpBackwardsIcon }}</span>
|
||||
<div class="flex items-center max-w-full" :class="playerSettings.lockUi ? 'justify-center' : 'justify-between'">
|
||||
<span v-show="showFullscreen && !playerSettings.lockUi" class="material-icons next-icon text-fg cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpChapterStart">first_page</span>
|
||||
<span v-show="!playerSettings.lockUi" class="material-icons jump-icon text-fg cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpBackwards">{{ jumpBackwardsIcon }}</span>
|
||||
<div class="play-btn cursor-pointer shadow-sm flex items-center justify-center rounded-full text-primary mx-4 relative overflow-hidden" :style="{ backgroundColor: coverRgb }" :class="{ 'animate-spin': seekLoading }" @mousedown.prevent @mouseup.prevent @click.stop="playPauseClick">
|
||||
<div v-if="!coverBgIsLight" class="absolute top-0 left-0 w-full h-full bg-white bg-opacity-20 pointer-events-none" />
|
||||
|
||||
<span v-if="!isLoading" class="material-icons" :class="{ 'text-white': coverRgb && !coverBgIsLight }">{{ seekLoading ? 'autorenew' : !isPlaying ? 'play_arrow' : 'pause' }}</span>
|
||||
<widgets-spinner-icon v-else class="h-8 w-8" />
|
||||
</div>
|
||||
<span v-show="!lockUi" class="material-icons jump-icon text-white cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpForward">{{ jumpForwardIcon }}</span>
|
||||
<span v-show="showFullscreen && !lockUi" class="material-icons next-icon text-white cursor-pointer" :class="nextChapter && !isLoading ? 'text-opacity-75' : 'text-opacity-10'" @click.stop="jumpNextChapter">last_page</span>
|
||||
<span v-show="!playerSettings.lockUi" class="material-icons jump-icon text-fg cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpForward">{{ jumpForwardIcon }}</span>
|
||||
<span v-show="showFullscreen && !playerSettings.lockUi" class="material-icons next-icon text-fg cursor-pointer" :class="nextChapter && !isLoading ? 'text-opacity-75' : 'text-opacity-10'" @click.stop="jumpNextChapter">last_page</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="playerTrack" class="absolute left-0 w-full px-6">
|
||||
<div class="flex pointer-events-none">
|
||||
<p class="font-mono text-white text-opacity-90" style="font-size: 0.8rem" ref="currentTimestamp">0:00</p>
|
||||
<p class="font-mono text-fg" style="font-size: 0.8rem" ref="currentTimestamp">0:00</p>
|
||||
<div class="flex-grow" />
|
||||
<p class="font-mono text-white text-opacity-90" style="font-size: 0.8rem">{{ timeRemainingPretty }}</p>
|
||||
<p class="font-mono text-fg" style="font-size: 0.8rem">{{ timeRemainingPretty }}</p>
|
||||
</div>
|
||||
<div ref="track" class="h-1.5 w-full bg-gray-500 bg-opacity-50 relative rounded-full" :class="{ 'animate-pulse': isLoading }" @click.stop>
|
||||
<div ref="readyTrack" class="h-full bg-gray-600 absolute top-0 left-0 rounded-full pointer-events-none" />
|
||||
<div ref="bufferedTrack" class="h-full bg-gray-500 absolute top-0 left-0 rounded-full pointer-events-none" />
|
||||
<div ref="playedTrack" class="h-full bg-gray-200 absolute top-0 left-0 rounded-full pointer-events-none" />
|
||||
<div ref="trackCursor" class="h-3.5 w-3.5 -top-1 rounded-full bg-gray-200 absolute pointer-events-auto" :class="{ 'opacity-0': lockUi || !showFullscreen }" @touchstart="touchstartCursor" />
|
||||
<div ref="track" class="h-1.5 w-full bg-track/50 relative rounded-full" :class="{ 'animate-pulse': isLoading }" @click.stop>
|
||||
<div ref="readyTrack" class="h-full bg-track-buffered absolute top-0 left-0 rounded-full pointer-events-none" />
|
||||
<div ref="bufferedTrack" class="h-full bg-track absolute top-0 left-0 rounded-full pointer-events-none" />
|
||||
<div ref="playedTrack" class="h-full bg-track-cursor absolute top-0 left-0 rounded-full pointer-events-none" />
|
||||
<div ref="trackCursor" class="h-7 w-7 rounded-full absolute pointer-events-auto flex items-center justify-center" :style="{ top: '-11px' }" :class="{ 'opacity-0': playerSettings.lockUi || !showFullscreen }" @touchstart="touchstartCursor">
|
||||
<div class="bg-track-cursor rounded-full w-3.5 h-3.5 pointer-events-none" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<modals-chapters-modal v-model="showChapterModal" :current-chapter="currentChapter" :chapters="chapters" @select="selectChapter" />
|
||||
<modals-dialog v-model="showMoreMenuDialog" :items="menuItems" @action="clickMenuAction" />
|
||||
<modals-chapters-modal v-model="showChapterModal" :current-chapter="currentChapter" :chapters="chapters" :playback-rate="currentPlaybackRate" @select="selectChapter" />
|
||||
<modals-dialog v-model="showMoreMenuDialog" :items="menuItems" width="80vw" @action="clickMenuAction" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -104,6 +108,7 @@
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { AbsAudioPlayer } from '@/plugins/capacitor'
|
||||
import { FastAverageColor } from 'fast-average-color'
|
||||
import WrappingMarquee from '@/assets/WrappingMarquee.js'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
@@ -113,7 +118,7 @@ export default {
|
||||
},
|
||||
sleepTimerRunning: Boolean,
|
||||
sleepTimeRemaining: Number,
|
||||
isServerItem: Boolean
|
||||
serverLibraryItemId: String
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -143,10 +148,12 @@ export default {
|
||||
onPlaybackSpeedChangedListener: null,
|
||||
touchStartY: 0,
|
||||
touchStartTime: 0,
|
||||
touchEndY: 0,
|
||||
useChapterTrack: false,
|
||||
useTotalTrack: true,
|
||||
lockUi: false,
|
||||
playerSettings: {
|
||||
useChapterTrack: false,
|
||||
useTotalTrack: true,
|
||||
scaleElapsedTimeBySpeed: true,
|
||||
lockUi: false
|
||||
},
|
||||
isLoading: false,
|
||||
isDraggingCursor: false,
|
||||
draggingTouchStartX: 0,
|
||||
@@ -155,16 +162,21 @@ export default {
|
||||
syncStatus: 0,
|
||||
showMoreMenuDialog: false,
|
||||
coverRgb: 'rgb(55, 56, 56)',
|
||||
coverBgIsLight: false
|
||||
coverBgIsLight: false,
|
||||
titleMarquee: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
showFullscreen(val) {
|
||||
this.updateScreenSize()
|
||||
this.$store.commit('setPlayerFullscreen', !!val)
|
||||
document.querySelector('body').style.backgroundColor = this.showFullscreen ? this.coverRgb : ''
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
this.updateScreenSize()
|
||||
},
|
||||
title(val) {
|
||||
if (this.titleMarquee) this.titleMarquee.init(val)
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -173,7 +185,7 @@ export default {
|
||||
// TODO: Implement on iOS
|
||||
if (this.$platform !== 'ios' && !this.isPodcast && this.mediaId) {
|
||||
items.push({
|
||||
text: 'History',
|
||||
text: this.$strings.ButtonHistory,
|
||||
value: 'history',
|
||||
icon: 'history'
|
||||
})
|
||||
@@ -182,22 +194,27 @@ export default {
|
||||
items.push(
|
||||
...[
|
||||
{
|
||||
text: 'Total Track',
|
||||
text: this.$strings.LabelTotalTrack,
|
||||
value: 'total_track',
|
||||
icon: this.useTotalTrack ? 'check_box' : 'check_box_outline_blank'
|
||||
icon: this.playerSettings.useTotalTrack ? 'check_box' : 'check_box_outline_blank'
|
||||
},
|
||||
{
|
||||
text: 'Chapter Track',
|
||||
text: this.$strings.LabelChapterTrack,
|
||||
value: 'chapter_track',
|
||||
icon: this.useChapterTrack ? 'check_box' : 'check_box_outline_blank'
|
||||
icon: this.playerSettings.useChapterTrack ? 'check_box' : 'check_box_outline_blank'
|
||||
},
|
||||
{
|
||||
text: this.lockUi ? 'Unlock Player' : 'Lock Player',
|
||||
text: this.$strings.LabelScaleElapsedTimeBySpeed,
|
||||
value: 'scale_elapsed_time',
|
||||
icon: this.playerSettings.scaleElapsedTimeBySpeed ? 'check_box' : 'check_box_outline_blank'
|
||||
},
|
||||
{
|
||||
text: this.playerSettings.lockUi ? this.$strings.LabelUnlockPlayer : this.$strings.LabelLockPlayer,
|
||||
value: 'lock',
|
||||
icon: this.lockUi ? 'lock' : 'lock_open'
|
||||
icon: this.playerSettings.lockUi ? 'lock' : 'lock_open'
|
||||
},
|
||||
{
|
||||
text: 'Close Player',
|
||||
text: this.$strings.LabelClosePlayer,
|
||||
value: 'close',
|
||||
icon: 'close'
|
||||
}
|
||||
@@ -254,30 +271,30 @@ export default {
|
||||
return this.mediaPlayer === 'cast-player'
|
||||
},
|
||||
mediaPlayer() {
|
||||
return this.playbackSession ? this.playbackSession.mediaPlayer : null
|
||||
return this.playbackSession?.mediaPlayer || null
|
||||
},
|
||||
mediaType() {
|
||||
return this.playbackSession ? this.playbackSession.mediaType : null
|
||||
return this.playbackSession?.mediaType || null
|
||||
},
|
||||
isPodcast() {
|
||||
return this.mediaType === 'podcast'
|
||||
},
|
||||
mediaMetadata() {
|
||||
return this.playbackSession ? this.playbackSession.mediaMetadata : null
|
||||
return this.playbackSession?.mediaMetadata || null
|
||||
},
|
||||
libraryItem() {
|
||||
return this.playbackSession ? this.playbackSession.libraryItem || null : null
|
||||
return this.playbackSession?.libraryItem || null
|
||||
},
|
||||
localLibraryItem() {
|
||||
return this.playbackSession ? this.playbackSession.localLibraryItem || null : null
|
||||
return this.playbackSession?.localLibraryItem || null
|
||||
},
|
||||
localLibraryItemCoverSrc() {
|
||||
var localItemCover = this.localLibraryItem ? this.localLibraryItem.coverContentUrl : null
|
||||
var localItemCover = this.localLibraryItem?.coverContentUrl || null
|
||||
if (localItemCover) return Capacitor.convertFileSrc(localItemCover)
|
||||
return null
|
||||
},
|
||||
playMethod() {
|
||||
return this.playbackSession ? this.playbackSession.playMethod : null
|
||||
return this.playbackSession?.playMethod || 0
|
||||
},
|
||||
isLocalPlayMethod() {
|
||||
return this.playMethod == this.$constants.PlayMethod.LOCAL
|
||||
@@ -286,19 +303,19 @@ export default {
|
||||
return this.playMethod == this.$constants.PlayMethod.DIRECTPLAY
|
||||
},
|
||||
title() {
|
||||
if (this.currentChapterTitle && this.showFullscreen) return this.currentChapterTitle
|
||||
if (this.playbackSession) return this.playbackSession.displayTitle
|
||||
return this.mediaMetadata ? this.mediaMetadata.title : 'Title'
|
||||
const mediaItemTitle = this.playbackSession?.displayTitle || this.mediaMetadata?.title || 'Title'
|
||||
if (this.currentChapterTitle) {
|
||||
if (this.showFullscreen) return this.currentChapterTitle
|
||||
return `${mediaItemTitle} | ${this.currentChapterTitle}`
|
||||
}
|
||||
return mediaItemTitle
|
||||
},
|
||||
authorName() {
|
||||
if (this.playbackSession) return this.playbackSession.displayAuthor
|
||||
return this.mediaMetadata ? this.mediaMetadata.authorName : 'Author'
|
||||
return this.mediaMetadata?.authorName || 'Author'
|
||||
},
|
||||
chapters() {
|
||||
if (this.playbackSession && this.playbackSession.chapters) {
|
||||
return this.playbackSession.chapters
|
||||
}
|
||||
return []
|
||||
return this.playbackSession?.chapters || []
|
||||
},
|
||||
currentChapter() {
|
||||
if (!this.chapters.length) return null
|
||||
@@ -309,7 +326,7 @@ export default {
|
||||
return this.chapters.find((c) => Number(Number(c.start).toFixed(2)) > this.currentTime)
|
||||
},
|
||||
currentChapterTitle() {
|
||||
return this.currentChapter ? this.currentChapter.title : ''
|
||||
return this.currentChapter?.title || ''
|
||||
},
|
||||
currentChapterDuration() {
|
||||
return this.currentChapter ? this.currentChapter.end - this.currentChapter.start : this.totalDuration
|
||||
@@ -319,11 +336,14 @@ export default {
|
||||
},
|
||||
currentTimePretty() {
|
||||
let currentTimeToUse = this.isDraggingCursor ? this.draggingCurrentTime : this.currentTime
|
||||
return this.$secondsToTimestamp(currentTimeToUse / this.currentPlaybackRate)
|
||||
if (this.playerSettings.scaleElapsedTimeBySpeed) {
|
||||
currentTimeToUse = currentTimeToUse / this.currentPlaybackRate
|
||||
}
|
||||
return this.$secondsToTimestamp(currentTimeToUse)
|
||||
},
|
||||
timeRemaining() {
|
||||
let currentTimeToUse = this.isDraggingCursor ? this.draggingCurrentTime : this.currentTime
|
||||
if (this.useChapterTrack && this.currentChapter) {
|
||||
if (this.playerSettings.useChapterTrack && this.currentChapter) {
|
||||
var currChapTime = currentTimeToUse - this.currentChapter.start
|
||||
return (this.currentChapterDuration - currChapTime) / this.currentPlaybackRate
|
||||
}
|
||||
@@ -369,6 +389,10 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickChaptersBtn() {
|
||||
if (!this.chapters.length) return
|
||||
this.showChapterModal = true
|
||||
},
|
||||
async coverImageLoaded(fullCoverUrl) {
|
||||
if (!fullCoverUrl) return
|
||||
|
||||
@@ -385,7 +409,7 @@ export default {
|
||||
},
|
||||
clickTitleAndAuthor() {
|
||||
if (!this.showFullscreen) return
|
||||
const llid = this.libraryItem ? this.libraryItem.id : this.localLibraryItem ? this.localLibraryItem.id : null
|
||||
const llid = this.serverLibraryItemId || this.libraryItem?.id || this.localLibraryItem?.id
|
||||
if (llid) {
|
||||
this.$router.push(`/item/${llid}`)
|
||||
this.showFullscreen = false
|
||||
@@ -405,7 +429,11 @@ export default {
|
||||
AbsAudioPlayer.requestSession()
|
||||
},
|
||||
clickContainer() {
|
||||
this.expandToFullscreen()
|
||||
},
|
||||
expandToFullscreen() {
|
||||
this.showFullscreen = true
|
||||
if (this.titleMarquee) this.titleMarquee.reset()
|
||||
|
||||
// Update track for total time bar if useChapterTrack is set
|
||||
this.$nextTick(() => {
|
||||
@@ -414,6 +442,8 @@ export default {
|
||||
},
|
||||
collapseFullscreen() {
|
||||
this.showFullscreen = false
|
||||
if (this.titleMarquee) this.titleMarquee.reset()
|
||||
|
||||
this.forceCloseDropdownMenu()
|
||||
},
|
||||
async jumpNextChapter() {
|
||||
@@ -487,7 +517,7 @@ export default {
|
||||
this.updateReadyTrack()
|
||||
},
|
||||
updateReadyTrack() {
|
||||
if (this.useChapterTrack) {
|
||||
if (this.playerSettings.useChapterTrack) {
|
||||
if (this.$refs.totalReadyTrack) {
|
||||
this.$refs.totalReadyTrack.style.width = this.readyTrackWidth + 'px'
|
||||
}
|
||||
@@ -504,11 +534,14 @@ export default {
|
||||
}
|
||||
|
||||
let currentTime = this.isDraggingCursor ? this.draggingCurrentTime : this.currentTime
|
||||
if (this.useChapterTrack && this.currentChapter) {
|
||||
if (this.playerSettings.useChapterTrack && this.currentChapter) {
|
||||
currentTime = Math.max(0, currentTime - this.currentChapter.start)
|
||||
}
|
||||
if (this.playerSettings.scaleElapsedTimeBySpeed) {
|
||||
currentTime = currentTime / this.currentPlaybackRate
|
||||
}
|
||||
|
||||
ts.innerText = this.$secondsToTimestamp(currentTime / this.currentPlaybackRate)
|
||||
ts.innerText = this.$secondsToTimestamp(currentTime)
|
||||
},
|
||||
timeupdate() {
|
||||
if (!this.$refs.playedTrack) {
|
||||
@@ -536,7 +569,7 @@ export default {
|
||||
let bufferedPercent = this.bufferedTime / this.totalDuration
|
||||
const totalBufferedPercent = bufferedPercent
|
||||
|
||||
if (this.useChapterTrack && this.currentChapter) {
|
||||
if (this.playerSettings.useChapterTrack && this.currentChapter) {
|
||||
const currChapTime = currentTimeToUse - this.currentChapter.start
|
||||
percentDone = currChapTime / this.currentChapterDuration
|
||||
bufferedPercent = Math.max(0, Math.min(1, (this.bufferedTime - this.currentChapter.start) / this.currentChapterDuration))
|
||||
@@ -547,10 +580,10 @@ export default {
|
||||
this.$refs.bufferedTrack.style.width = Math.round(bufferedPercent * this.trackWidth) + 'px'
|
||||
|
||||
if (this.$refs.trackCursor) {
|
||||
this.$refs.trackCursor.style.left = ptWidth - 7 + 'px'
|
||||
this.$refs.trackCursor.style.left = ptWidth - 14 + 'px'
|
||||
}
|
||||
|
||||
if (this.useChapterTrack) {
|
||||
if (this.playerSettings.useChapterTrack) {
|
||||
if (this.$refs.totalPlayedTrack) this.$refs.totalPlayedTrack.style.width = Math.round(totalPercentDone * this.trackWidth) + 'px'
|
||||
if (this.$refs.totalBufferedTrack) this.$refs.totalBufferedTrack.style.width = Math.round(totalBufferedPercent * this.trackWidth) + 'px'
|
||||
}
|
||||
@@ -568,8 +601,8 @@ export default {
|
||||
AbsAudioPlayer.seek({ value: Math.floor(time) })
|
||||
|
||||
if (this.$refs.playedTrack) {
|
||||
var perc = time / this.totalDuration
|
||||
var ptWidth = Math.round(perc * this.trackWidth)
|
||||
const perc = time / this.totalDuration
|
||||
const ptWidth = Math.round(perc * this.trackWidth)
|
||||
this.$refs.playedTrack.style.width = ptWidth + 'px'
|
||||
|
||||
this.$refs.playedTrack.classList.remove('bg-gray-200')
|
||||
@@ -577,7 +610,7 @@ export default {
|
||||
}
|
||||
},
|
||||
async touchstartCursor(e) {
|
||||
if (!e || !e.touches || !this.$refs.track || !this.showFullscreen || this.lockUi) return
|
||||
if (!e || !e.touches || !this.$refs.track || !this.showFullscreen || this.playerSettings.lockUi) return
|
||||
|
||||
await this.$hapticsImpact()
|
||||
this.isDraggingCursor = true
|
||||
@@ -618,38 +651,49 @@ export default {
|
||||
resetStream(startTime) {
|
||||
this.closePlayback()
|
||||
},
|
||||
handleGesture() {
|
||||
var touchDistance = this.touchEndY - this.touchStartY
|
||||
if (touchDistance > 100) {
|
||||
this.collapseFullscreen()
|
||||
}
|
||||
},
|
||||
touchstart(e) {
|
||||
if (!this.showFullscreen || !e.changedTouches) return
|
||||
if (!e.changedTouches) return
|
||||
const touchPosY = e.changedTouches[0].pageY
|
||||
// when minimized only listen to touchstart on the player
|
||||
if (!this.showFullscreen && touchPosY < window.innerHeight - 120) return
|
||||
|
||||
this.touchStartY = e.changedTouches[0].screenY
|
||||
if (this.touchStartY > window.innerHeight / 3) {
|
||||
// console.log('touch too low')
|
||||
return
|
||||
// for ios
|
||||
if (!this.showFullscreen && e.pageX < 20) {
|
||||
e.preventDefault()
|
||||
e.stopImmediatePropagation()
|
||||
}
|
||||
|
||||
this.touchStartY = touchPosY
|
||||
this.touchStartTime = Date.now()
|
||||
},
|
||||
touchend(e) {
|
||||
if (!e.changedTouches) return
|
||||
const touchDuration = Date.now() - this.touchStartTime
|
||||
const touchEndY = e.changedTouches[0].pageY
|
||||
const touchDistanceY = touchEndY - this.touchStartY
|
||||
|
||||
// reset touch start data
|
||||
this.touchStartTime = 0
|
||||
this.touchStartY = 0
|
||||
|
||||
if (this.isDraggingCursor) {
|
||||
if (this.draggingCurrentTime !== this.currentTime) {
|
||||
this.seek(this.draggingCurrentTime)
|
||||
}
|
||||
this.isDraggingCursor = false
|
||||
} else if (this.showFullscreen) {
|
||||
this.touchEndY = e.changedTouches[0].screenY
|
||||
var touchDuration = Date.now() - this.touchStartTime
|
||||
} else {
|
||||
if (touchDuration > 1200) {
|
||||
// console.log('touch too long', touchDuration)
|
||||
return
|
||||
}
|
||||
this.handleGesture()
|
||||
if (this.showFullscreen) {
|
||||
// Touch start higher than touchend
|
||||
if (touchDistanceY > 100) {
|
||||
this.collapseFullscreen()
|
||||
}
|
||||
} else if (touchDistanceY < -100) {
|
||||
this.expandToFullscreen()
|
||||
}
|
||||
}
|
||||
},
|
||||
touchmove(e) {
|
||||
@@ -659,7 +703,7 @@ export default {
|
||||
let duration = this.totalDuration
|
||||
let minTime = 0
|
||||
let maxTime = duration
|
||||
if (this.useChapterTrack && this.currentChapter) {
|
||||
if (this.playerSettings.useChapterTrack && this.currentChapter) {
|
||||
duration = this.currentChapterDuration
|
||||
minTime = this.currentChapter.start
|
||||
maxTime = minTime + duration
|
||||
@@ -679,30 +723,42 @@ export default {
|
||||
if (action === 'history') {
|
||||
this.$router.push(`/media/${this.mediaId}/history?title=${this.title}`)
|
||||
this.showFullscreen = false
|
||||
} else if (action === 'scale_elapsed_time') {
|
||||
this.playerSettings.scaleElapsedTimeBySpeed = !this.playerSettings.scaleElapsedTimeBySpeed
|
||||
this.updateTimestamp()
|
||||
this.savePlayerSettings()
|
||||
} else if (action === 'lock') {
|
||||
this.lockUi = !this.lockUi
|
||||
this.$localStore.setPlayerLock(this.lockUi)
|
||||
this.playerSettings.lockUi = !this.playerSettings.lockUi
|
||||
this.savePlayerSettings()
|
||||
} else if (action === 'chapter_track') {
|
||||
this.useChapterTrack = !this.useChapterTrack
|
||||
this.useTotalTrack = !this.useChapterTrack || this.useTotalTrack
|
||||
this.playerSettings.useChapterTrack = !this.playerSettings.useChapterTrack
|
||||
this.playerSettings.useTotalTrack = !this.playerSettings.useChapterTrack || this.playerSettings.useTotalTrack
|
||||
|
||||
this.updateTimestamp()
|
||||
this.updateTrack()
|
||||
this.updateReadyTrack()
|
||||
this.$localStore.setUseChapterTrack(this.useChapterTrack)
|
||||
this.updateUseChapterTrack()
|
||||
this.savePlayerSettings()
|
||||
} else if (action === 'total_track') {
|
||||
this.useTotalTrack = !this.useTotalTrack
|
||||
this.useChapterTrack = !this.useTotalTrack || this.useChapterTrack
|
||||
this.playerSettings.useTotalTrack = !this.playerSettings.useTotalTrack
|
||||
this.playerSettings.useChapterTrack = !this.playerSettings.useTotalTrack || this.playerSettings.useChapterTrack
|
||||
|
||||
this.updateTimestamp()
|
||||
this.updateTrack()
|
||||
this.updateReadyTrack()
|
||||
this.$localStore.setUseTotalTrack(this.useTotalTrack)
|
||||
this.updateUseChapterTrack()
|
||||
this.savePlayerSettings()
|
||||
} else if (action === 'close') {
|
||||
this.closePlayback()
|
||||
}
|
||||
})
|
||||
},
|
||||
updateUseChapterTrack() {
|
||||
// Chapter track in NowPlaying only supported on iOS for now
|
||||
if (this.$platform === 'ios') {
|
||||
AbsAudioPlayer.setChapterTrack({ enabled: this.playerSettings.useChapterTrack })
|
||||
}
|
||||
},
|
||||
forceCloseDropdownMenu() {
|
||||
if (this.$refs.dropdownMenu && this.$refs.dropdownMenu.closeMenu) {
|
||||
this.$refs.dropdownMenu.closeMenu()
|
||||
@@ -713,12 +769,36 @@ export default {
|
||||
AbsAudioPlayer.closePlayback()
|
||||
},
|
||||
endPlayback() {
|
||||
this.$store.commit('setPlayerItem', null)
|
||||
this.$store.commit('setPlaybackSession', null)
|
||||
this.showFullscreen = false
|
||||
this.isEnded = false
|
||||
this.isLoading = false
|
||||
this.playbackSession = null
|
||||
},
|
||||
async loadPlayerSettings() {
|
||||
const savedPlayerSettings = await this.$localStore.getPlayerSettings()
|
||||
if (!savedPlayerSettings) {
|
||||
// In 0.9.72-beta 'useChapterTrack', 'useTotalTrack' and 'playerLock' was replaced with 'playerSettings' JSON object
|
||||
// Check if this old key was set and if so migrate them over to 'playerSettings'
|
||||
const chapterTrackPref = await this.$localStore.getPreferenceByKey('useChapterTrack')
|
||||
if (chapterTrackPref) {
|
||||
this.playerSettings.useChapterTrack = chapterTrackPref === '1'
|
||||
const totalTrackPref = await this.$localStore.getPreferenceByKey('useTotalTrack')
|
||||
this.playerSettings.useTotalTrack = totalTrackPref === '1'
|
||||
const playerLockPref = await this.$localStore.getPreferenceByKey('playerLock')
|
||||
this.playerSettings.lockUi = playerLockPref === '1'
|
||||
}
|
||||
this.savePlayerSettings()
|
||||
} else {
|
||||
this.playerSettings.useChapterTrack = !!savedPlayerSettings.useChapterTrack
|
||||
this.playerSettings.useTotalTrack = !!savedPlayerSettings.useTotalTrack
|
||||
this.playerSettings.lockUi = !!savedPlayerSettings.lockUi
|
||||
this.playerSettings.scaleElapsedTimeBySpeed = !!savedPlayerSettings.scaleElapsedTimeBySpeed
|
||||
}
|
||||
},
|
||||
savePlayerSettings() {
|
||||
return this.$localStore.setPlayerSettings({ ...this.playerSettings })
|
||||
},
|
||||
//
|
||||
// Listeners from audio AbsAudioPlayer
|
||||
//
|
||||
@@ -759,10 +839,14 @@ export default {
|
||||
this.isEnded = false
|
||||
this.isLoading = true
|
||||
this.syncStatus = 0
|
||||
this.$store.commit('setPlayerItem', this.playbackSession)
|
||||
this.$store.commit('setPlaybackSession', this.playbackSession)
|
||||
|
||||
// Set track width
|
||||
this.$nextTick(() => {
|
||||
if (this.titleMarquee) this.titleMarquee.reset()
|
||||
this.titleMarquee = new WrappingMarquee(this.$refs.titlewrapper)
|
||||
this.titleMarquee.init(this.title)
|
||||
|
||||
if (this.$refs.track) {
|
||||
this.trackWidth = this.$refs.track.clientWidth
|
||||
} else {
|
||||
@@ -785,8 +869,7 @@ export default {
|
||||
this.updateTimestamp()
|
||||
},
|
||||
async init() {
|
||||
this.useChapterTrack = await this.$localStore.getUseChapterTrack()
|
||||
this.lockUi = await this.$localStore.getPlayerLock()
|
||||
await this.loadPlayerSettings()
|
||||
|
||||
this.onPlaybackSessionListener = AbsAudioPlayer.addListener('onPlaybackSession', this.onPlaybackSession)
|
||||
this.onPlaybackClosedListener = AbsAudioPlayer.addListener('onPlaybackClosed', this.onPlaybackClosed)
|
||||
@@ -808,15 +891,23 @@ export default {
|
||||
}, 50)
|
||||
},
|
||||
updateScreenSize() {
|
||||
setTimeout(() => {
|
||||
if (this.titleMarquee) this.titleMarquee.init(this.title)
|
||||
}, 500)
|
||||
|
||||
this.windowHeight = window.innerHeight
|
||||
this.windowWidth = window.innerWidth
|
||||
const coverHeight = this.fullscreenBookCoverWidth * this.bookCoverAspectRatio
|
||||
const coverImageWidthCollapsed = 46 / this.bookCoverAspectRatio
|
||||
const titleAuthorLeftOffsetCollapsed = 30 + coverImageWidthCollapsed
|
||||
const titleAuthorWidthCollapsed = this.windowWidth - 128 - titleAuthorLeftOffsetCollapsed - 10
|
||||
|
||||
document.documentElement.style.setProperty('--cover-image-width', this.fullscreenBookCoverWidth + 'px')
|
||||
document.documentElement.style.setProperty('--cover-image-height', coverHeight + 'px')
|
||||
document.documentElement.style.setProperty('--cover-image-width-collapsed', coverImageWidthCollapsed + 'px')
|
||||
document.documentElement.style.setProperty('--cover-image-height-collapsed', 46 + 'px')
|
||||
document.documentElement.style.setProperty('--title-author-left-offset-collapsed', 30 + coverImageWidthCollapsed + 'px')
|
||||
document.documentElement.style.setProperty('--title-author-left-offset-collapsed', titleAuthorLeftOffsetCollapsed + 'px')
|
||||
document.documentElement.style.setProperty('--title-author-width-collapsed', titleAuthorWidthCollapsed + 'px')
|
||||
},
|
||||
minimizePlayerEvt() {
|
||||
this.collapseFullscreen()
|
||||
@@ -839,7 +930,7 @@ export default {
|
||||
window.addEventListener('resize', this.screenOrientationChange)
|
||||
|
||||
this.$eventBus.$on('minimize-player', this.minimizePlayerEvt)
|
||||
document.body.addEventListener('touchstart', this.touchstart)
|
||||
document.body.addEventListener('touchstart', this.touchstart, { passive: false })
|
||||
document.body.addEventListener('touchend', this.touchend)
|
||||
document.body.addEventListener('touchmove', this.touchmove)
|
||||
this.$nextTick(this.init)
|
||||
@@ -884,6 +975,7 @@ export default {
|
||||
--cover-image-width-collapsed: 46px;
|
||||
--cover-image-height-collapsed: 46px;
|
||||
--title-author-left-offset-collapsed: 80px;
|
||||
--title-author-width-collapsed: 40%;
|
||||
}
|
||||
|
||||
.playerContainer {
|
||||
@@ -931,7 +1023,7 @@ export default {
|
||||
transition-property: left, bottom, width, height;
|
||||
transform-origin: left bottom;
|
||||
|
||||
width: 40%;
|
||||
width: var(--title-author-width-collapsed);
|
||||
bottom: 76px;
|
||||
left: var(--title-author-left-offset-collapsed);
|
||||
text-align: left;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div>
|
||||
<app-audio-player ref="audioPlayer" :bookmarks="bookmarks" :sleep-timer-running="isSleepTimerRunning" :sleep-time-remaining="sleepTimeRemaining" :is-server-item="!!serverLibraryItemId" @selectPlaybackSpeed="showPlaybackSpeedModal = true" @updateTime="(t) => (currentTime = t)" @showSleepTimer="showSleepTimer" @showBookmarks="showBookmarks" />
|
||||
<app-audio-player ref="audioPlayer" :bookmarks="bookmarks" :sleep-timer-running="isSleepTimerRunning" :sleep-time-remaining="sleepTimeRemaining" :serverLibraryItemId="serverLibraryItemId" @selectPlaybackSpeed="showPlaybackSpeedModal = true" @updateTime="(t) => (currentTime = t)" @showSleepTimer="showSleepTimer" @showBookmarks="showBookmarks" />
|
||||
|
||||
<modals-playback-speed-modal v-model="showPlaybackSpeedModal" :playback-rate.sync="playbackSpeed" @update:playbackRate="updatePlaybackSpeed" @change="changePlaybackSpeed" />
|
||||
<modals-sleep-timer-modal v-model="showSleepTimerModal" :current-time="sleepTimeRemaining" :sleep-timer-running="isSleepTimerRunning" :current-end-of-chapter-time="currentEndOfChapterTime" :is-auto="isAutoSleepTimer" @change="selectSleepTimeout" @cancel="cancelSleepTimer" @increase="increaseSleepTimer" @decrease="decreaseSleepTimer" />
|
||||
@@ -110,7 +110,7 @@ export default {
|
||||
},
|
||||
streamProgress(data) {
|
||||
if (!data.numSegments) return
|
||||
var chunks = data.chunks
|
||||
const chunks = data.chunks
|
||||
if (this.$refs.audioPlayer) {
|
||||
this.$refs.audioPlayer.setChunksReady(chunks, data.numSegments)
|
||||
}
|
||||
@@ -204,6 +204,7 @@ export default {
|
||||
message: `Cannot cast downloaded media items. Confirm to close cast and play on your device.`
|
||||
})
|
||||
if (!value) {
|
||||
this.$store.commit('setPlayerDoneStartingPlayback')
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -214,7 +215,10 @@ export default {
|
||||
if (startTime !== undefined && startTime !== null) {
|
||||
// seek to start time
|
||||
AbsAudioPlayer.seek({ value: Math.floor(startTime) })
|
||||
} else if (this.$refs.audioPlayer) {
|
||||
this.$refs.audioPlayer.play()
|
||||
}
|
||||
this.$store.commit('setPlayerDoneStartingPlayback')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -252,9 +256,12 @@ export default {
|
||||
console.error('Failed', error)
|
||||
this.$toast.error('Failed to play')
|
||||
})
|
||||
.finally(() => {
|
||||
this.$store.commit('setPlayerDoneStartingPlayback')
|
||||
})
|
||||
},
|
||||
pauseItem() {
|
||||
if (this.$refs.audioPlayer && !this.$refs.audioPlayer.isPaused) {
|
||||
if (this.$refs.audioPlayer && this.$refs.audioPlayer.isPlaying) {
|
||||
this.$refs.audioPlayer.pause()
|
||||
}
|
||||
},
|
||||
@@ -263,8 +270,7 @@ export default {
|
||||
this.$store.commit('globals/updateLocalMediaProgress', localMediaProgress)
|
||||
},
|
||||
onMediaPlayerChanged(data) {
|
||||
var mediaPlayer = data.value
|
||||
this.$store.commit('setMediaPlayer', mediaPlayer)
|
||||
this.$store.commit('setMediaPlayer', data.value)
|
||||
},
|
||||
onReady() {
|
||||
// The UI is reporting elsewhere we are ready
|
||||
@@ -272,7 +278,7 @@ export default {
|
||||
this.notifyOnReady()
|
||||
},
|
||||
notifyOnReady() {
|
||||
// TODO: iOS opens last active playback session on app launch. Should be consistent with Android
|
||||
// TODO: was used on iOS to open last played media. May be removed
|
||||
if (!this.isIos) return
|
||||
|
||||
// If settings aren't loaded yet, native player will receive incorrect settings
|
||||
@@ -281,6 +287,56 @@ export default {
|
||||
this.$store.commit('setIsFirstAudioLoad', false) // Only run this once on app launch
|
||||
AbsAudioPlayer.onReady()
|
||||
}
|
||||
},
|
||||
playbackTimeUpdate(currentTime) {
|
||||
this.$refs.audioPlayer?.seek(currentTime)
|
||||
},
|
||||
/**
|
||||
* When device gains focus then refresh the timestamps in the audio player
|
||||
*/
|
||||
deviceFocused(hasFocus) {
|
||||
if (!this.$store.state.currentPlaybackSession) return
|
||||
|
||||
if (hasFocus) {
|
||||
if (!this.$refs.audioPlayer?.isPlaying) {
|
||||
const playbackSession = this.$store.state.currentPlaybackSession
|
||||
if (this.$refs.audioPlayer.isLocalPlayMethod) {
|
||||
const localLibraryItemId = playbackSession.localLibraryItem?.id
|
||||
const localEpisodeId = playbackSession.localEpisodeId
|
||||
if (!localLibraryItemId) {
|
||||
console.error('[AudioPlayerContainer] device visibility: no local library item for session', JSON.stringify(playbackSession))
|
||||
return
|
||||
}
|
||||
const localMediaProgress = this.$store.state.globals.localMediaProgress.find((mp) => {
|
||||
if (localEpisodeId) return mp.localEpisodeId === localEpisodeId
|
||||
return mp.localLibraryItemId === localLibraryItemId
|
||||
})
|
||||
if (localMediaProgress) {
|
||||
console.log('[AudioPlayerContainer] device visibility: found local media progress', localMediaProgress.currentTime, 'last time in player is', this.currentTime)
|
||||
this.$refs.audioPlayer.currentTime = localMediaProgress.currentTime
|
||||
this.$refs.audioPlayer.timeupdate()
|
||||
} else {
|
||||
console.error('[AudioPlayerContainer] device visibility: Local media progress not found')
|
||||
}
|
||||
} else {
|
||||
const libraryItemId = playbackSession.libraryItemId
|
||||
const episodeId = playbackSession.episodeId
|
||||
const url = episodeId ? `/api/me/progress/${libraryItemId}/${episodeId}` : `/api/me/progress/${libraryItemId}`
|
||||
this.$nativeHttp
|
||||
.get(url)
|
||||
.then((data) => {
|
||||
if (!this.$refs.audioPlayer?.isPlaying && data.libraryItemId === libraryItemId) {
|
||||
console.log('[AudioPlayerContainer] device visibility: got server media progress', data.currentTime, 'last time in player is', this.currentTime)
|
||||
this.$refs.audioPlayer.currentTime = data.currentTime
|
||||
this.$refs.audioPlayer.timeupdate()
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('[AudioPlayerContainer] device visibility: Failed to get progress', error)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
@@ -298,6 +354,8 @@ export default {
|
||||
this.$eventBus.$on('close-stream', this.closeStreamOnly)
|
||||
this.$eventBus.$on('cast-local-item', this.castLocalItem)
|
||||
this.$eventBus.$on('user-settings', this.settingsUpdated)
|
||||
this.$eventBus.$on('playback-time-update', this.playbackTimeUpdate)
|
||||
this.$eventBus.$on('device-focus-update', this.deviceFocused)
|
||||
},
|
||||
beforeDestroy() {
|
||||
if (this.onLocalMediaProgressUpdateListener) this.onLocalMediaProgressUpdateListener.remove()
|
||||
@@ -311,6 +369,8 @@ export default {
|
||||
this.$eventBus.$off('close-stream', this.closeStreamOnly)
|
||||
this.$eventBus.$off('cast-local-item', this.castLocalItem)
|
||||
this.$eventBus.$off('user-settings', this.settingsUpdated)
|
||||
this.$eventBus.$off('playback-time-update', this.playbackTimeUpdate)
|
||||
this.$eventBus.$off('device-focus-update', this.deviceFocused)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="fixed top-0 left-0 right-0 layout-wrapper w-full z-50 overflow-hidden pointer-events-none">
|
||||
<div class="absolute top-0 left-0 w-full h-full bg-black transition-opacity duration-200" :class="show ? 'bg-opacity-60 pointer-events-auto' : 'bg-opacity-0'" @click="clickBackground" />
|
||||
<div class="absolute top-0 right-0 w-64 h-full bg-primary transform transition-transform py-6 pointer-events-auto" :class="show ? '' : 'translate-x-64'" @click.stop>
|
||||
<div class="absolute top-0 right-0 w-64 h-full bg-bg transform transition-transform py-6 pointer-events-auto" :class="show ? '' : 'translate-x-64'" @click.stop>
|
||||
<div class="px-6 mb-4">
|
||||
<p v-if="user" class="text-base">
|
||||
Welcome,
|
||||
@@ -11,22 +11,26 @@
|
||||
|
||||
<div class="w-full overflow-y-auto">
|
||||
<template v-for="item in navItems">
|
||||
<nuxt-link :to="item.to" :key="item.text" class="w-full hover:bg-bg hover:bg-opacity-60 flex items-center py-3 px-6 text-gray-300" :class="currentRoutePath.startsWith(item.to) ? 'bg-bg bg-opacity-60' : ''">
|
||||
<button v-if="item.action" :key="item.text" class="w-full hover:bg-bg/60 flex items-center py-3 px-6 text-fg-muted" @click="clickAction(item.action)">
|
||||
<span class="text-lg" :class="item.iconOutlined ? 'material-icons-outlined' : 'material-icons'">{{ item.icon }}</span>
|
||||
<p class="pl-4">{{ item.text }}</p>
|
||||
</button>
|
||||
<nuxt-link v-else :to="item.to" :key="item.text" class="w-full hover:bg-bg/60 flex items-center py-3 px-6 text-fg" :class="currentRoutePath.startsWith(item.to) ? 'bg-bg-hover/50' : 'text-fg-muted'">
|
||||
<span class="text-lg" :class="item.iconOutlined ? 'material-icons-outlined' : 'material-icons'">{{ item.icon }}</span>
|
||||
<p class="pl-4">{{ item.text }}</p>
|
||||
</nuxt-link>
|
||||
</template>
|
||||
</div>
|
||||
<div class="absolute bottom-0 left-0 w-full py-6 px-6 text-gray-300">
|
||||
<div class="absolute bottom-0 left-0 w-full py-6 px-6 text-fg">
|
||||
<div v-if="serverConnectionConfig" class="mb-4 flex justify-center">
|
||||
<p class="text-xs text-gray-400" style="word-break: break-word">{{ serverConnectionConfig.address }} (v{{ serverSettings.version }})</p>
|
||||
<p class="text-xs text-fg-muted" style="word-break: break-word">{{ serverConnectionConfig.address }} (v{{ serverSettings.version }})</p>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<p class="text-xs">{{ $config.version }}</p>
|
||||
<div class="flex-grow" />
|
||||
<div v-if="user" class="flex items-center" @click="logout">
|
||||
<p class="text-xs pr-2">Logout</p>
|
||||
<span class="material-icons text-sm">logout</span>
|
||||
<div v-if="user" class="flex items-center" @click="disconnect">
|
||||
<p class="text-xs pr-2">{{ $strings.ButtonDisconnect }}</p>
|
||||
<i class="material-icons text-sm -mb-0.5">cloud_off</i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -81,7 +85,7 @@ export default {
|
||||
var items = [
|
||||
{
|
||||
icon: 'home',
|
||||
text: 'Home',
|
||||
text: this.$strings.ButtonHome,
|
||||
to: '/bookshelf'
|
||||
}
|
||||
]
|
||||
@@ -89,19 +93,19 @@ export default {
|
||||
items = [
|
||||
{
|
||||
icon: 'cloud_off',
|
||||
text: 'Connect to Server',
|
||||
text: this.$strings.ButtonConnectToServer,
|
||||
to: '/connect'
|
||||
}
|
||||
].concat(items)
|
||||
} else {
|
||||
items.push({
|
||||
icon: 'person',
|
||||
text: 'Account',
|
||||
text: this.$strings.HeaderAccount,
|
||||
to: '/account'
|
||||
})
|
||||
items.push({
|
||||
icon: 'equalizer',
|
||||
text: 'User Stats',
|
||||
text: this.$strings.ButtonUserStats,
|
||||
to: '/stats'
|
||||
})
|
||||
}
|
||||
@@ -110,22 +114,31 @@ export default {
|
||||
items.push({
|
||||
icon: 'folder',
|
||||
iconOutlined: true,
|
||||
text: 'Local Media',
|
||||
text: this.$strings.ButtonLocalMedia,
|
||||
to: '/localMedia/folders'
|
||||
})
|
||||
} else {
|
||||
items.push({
|
||||
icon: 'download',
|
||||
iconOutlined: false,
|
||||
text: 'Downloads',
|
||||
text: this.$strings.HeaderDownloads,
|
||||
to: '/downloads'
|
||||
})
|
||||
}
|
||||
items.push({
|
||||
icon: 'settings',
|
||||
text: 'Settings',
|
||||
text: this.$strings.HeaderSettings,
|
||||
to: '/settings'
|
||||
})
|
||||
|
||||
if (this.serverConnectionConfig) {
|
||||
items.push({
|
||||
icon: 'login',
|
||||
text: this.$strings.ButtonSwitchServerUser,
|
||||
action: 'logout'
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
},
|
||||
currentRoutePath() {
|
||||
@@ -133,14 +146,20 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async clickAction(action) {
|
||||
await this.$hapticsImpact()
|
||||
if (action === 'logout') {
|
||||
await this.logout()
|
||||
this.$router.push('/connect')
|
||||
}
|
||||
},
|
||||
clickBackground() {
|
||||
this.show = false
|
||||
},
|
||||
async logout() {
|
||||
await this.$hapticsImpact()
|
||||
if (this.user) {
|
||||
await this.$axios.$post('/logout').catch((error) => {
|
||||
console.error(error)
|
||||
await this.$nativeHttp.post('/logout').catch((error) => {
|
||||
console.error('Failed to logout', error)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -148,7 +167,17 @@ export default {
|
||||
await this.$db.logout()
|
||||
this.$localStore.removeLastLibraryId()
|
||||
this.$store.commit('user/logout')
|
||||
this.$router.push('/connect')
|
||||
this.$store.commit('libraries/setCurrentLibrary', null)
|
||||
},
|
||||
async disconnect() {
|
||||
await this.$hapticsImpact()
|
||||
await this.logout()
|
||||
|
||||
if (this.$route.name !== 'bookshelf') {
|
||||
this.$router.replace('/bookshelf')
|
||||
} else {
|
||||
location.reload()
|
||||
}
|
||||
},
|
||||
touchstart(e) {
|
||||
this.touchEvent = new TouchEvent(e)
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
<div v-show="!entities.length && initialized" class="w-full py-16 text-center text-xl">
|
||||
<div class="py-4 capitalize">No {{ entityName }}</div>
|
||||
<ui-btn v-if="hasFilter" @click="clearFilter">Clear Filter</ui-btn>
|
||||
<ui-btn v-if="hasFilter" @click="clearFilter">{{ $strings.ButtonClearFilter }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -72,6 +72,9 @@ export default {
|
||||
showBookshelfListView() {
|
||||
return this.isBookEntity && this.bookshelfListView
|
||||
},
|
||||
sortingIgnorePrefix() {
|
||||
return this.$store.getters['getServerSetting']('sortingIgnorePrefix')
|
||||
},
|
||||
entityName() {
|
||||
return this.page
|
||||
},
|
||||
@@ -164,9 +167,9 @@ export default {
|
||||
|
||||
const entityPath = this.entityName === 'books' || this.entityName === 'series-books' ? `items` : this.entityName
|
||||
const sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : ''
|
||||
const fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1`
|
||||
const fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1&include=rssfeed,numEpisodesIncomplete`
|
||||
|
||||
const payload = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/${entityPath}${fullQueryString}`).catch((error) => {
|
||||
const payload = await this.$nativeHttp.get(`/api/libraries/${this.currentLibraryId}/${entityPath}${fullQueryString}`).catch((error) => {
|
||||
console.error('failed to fetch books', error)
|
||||
return null
|
||||
})
|
||||
@@ -332,6 +335,12 @@ export default {
|
||||
},
|
||||
async init() {
|
||||
if (this.isFirstInit) return
|
||||
if (!this.user) {
|
||||
// Offline support not available
|
||||
await this.resetEntities()
|
||||
this.$eventBus.$emit('bookshelf-total-entities', 0)
|
||||
return
|
||||
}
|
||||
|
||||
this.localLibraryItems = await this.$db.getLocalLibraryItems(this.currentLibraryMediaType)
|
||||
console.log('Local library items loaded for lazy bookshelf', this.localLibraryItems.length)
|
||||
@@ -358,8 +367,14 @@ export default {
|
||||
this.handleScroll(scrollTop)
|
||||
},
|
||||
buildSearchParams() {
|
||||
if (this.page === 'search' || this.page === 'series' || this.page === 'collections') {
|
||||
if (this.page === 'search' || this.page === 'collections') {
|
||||
return ''
|
||||
} else if (this.page === 'series') {
|
||||
// Sort by name ascending
|
||||
let searchParams = new URLSearchParams()
|
||||
searchParams.set('sort', 'name')
|
||||
searchParams.set('desc', 0)
|
||||
return searchParams.toString()
|
||||
}
|
||||
|
||||
let searchParams = new URLSearchParams()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="w-full relative">
|
||||
<div v-if="altViewEnabled" class="px-5 pb-3 pt-4">
|
||||
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ label }}</p>
|
||||
<p class="font-semibold" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ label }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-end px-3 max-w-full overflow-x-auto" :class="altViewEnabled ? '' : 'bookshelfRow'" :style="{ height: shelfHeight + 'px', paddingBottom: entityPaddingBottom + 'px' }">
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
|
||||
<!-- Author name & num books overlay -->
|
||||
<div v-show="!searching && !nameBelow" class="absolute bottom-0 left-0 w-full py-1 bg-black bg-opacity-60 px-2">
|
||||
<p class="text-center font-semibold truncate" :style="{ fontSize: sizeMultiplier * 0.75 + 'rem' }">{{ name }}</p>
|
||||
<p class="text-center text-gray-200" :style="{ fontSize: sizeMultiplier * 0.65 + 'rem' }">{{ numBooks }} Book{{ numBooks === 1 ? '' : 's' }}</p>
|
||||
<p class="text-center font-semibold truncate text-white" :style="{ fontSize: sizeMultiplier * 0.75 + 'rem' }">{{ name }}</p>
|
||||
<p class="text-center text-gray-200" :style="{ fontSize: sizeMultiplier * 0.65 + 'rem' }">{{ numBooks }} {{ $strings.LabelBooks }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading spinner -->
|
||||
@@ -58,24 +58,7 @@ export default {
|
||||
return this._author.numBooks || 0
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async searchAuthor() {
|
||||
this.searching = true
|
||||
var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, { q: this.name }).catch((error) => {
|
||||
console.error('Failed', error)
|
||||
return null
|
||||
})
|
||||
if (!response) {
|
||||
this.$toast.error('Author not found')
|
||||
} else if (response.updated) {
|
||||
if (response.author.imagePath) this.$toast.success('Author was updated')
|
||||
else this.$toast.success('Author was updated (no image found)')
|
||||
} else {
|
||||
this.$toast.info('No updates were made for Author')
|
||||
}
|
||||
this.searching = false
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -7,26 +7,6 @@
|
||||
<p class="truncate text-sm">{{ name }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- <div class="flex h-full px-1 overflow-hidden shadow-sm">
|
||||
<div style="max-height: 48px; max-width: 48px" class="w-12 h-12 bg-primary overflow-hidden rounded">
|
||||
<svg width="140%" height="140%" style="margin-left: -20%; margin-top: -20%; opacity: 0.6" viewBox="0 0 177 266" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="white" d="M40.7156 165.47C10.2694 150.865 -31.5407 148.629 -38.0532 155.529L63.3191 204.159L76.9443 190.899C66.828 181.394 54.006 171.846 40.7156 165.47Z" stroke="white" stroke-width="4" transform="translate(-2 -1)" />
|
||||
<path d="M-38.0532 155.529C-31.5407 148.629 10.2694 150.865 40.7156 165.47C54.006 171.846 66.828 181.394 76.9443 190.899L95.0391 173.37C80.6681 159.403 64.7526 149.155 51.5747 142.834C21.3549 128.337 -46.2471 114.563 -60.6897 144.67L-71.5489 167.307L44.5864 223.019L63.3191 204.159L-38.0532 155.529Z" fill="white" />
|
||||
<path
|
||||
d="M105.87 29.6508C80.857 17.6515 50.8784 28.1923 38.879 53.2056C26.8797 78.219 37.4205 108.198 62.4338 120.197C87.4472 132.196 117.426 121.656 129.425 96.6422C141.425 71.6288 130.884 41.6502 105.87 29.6508ZM106.789 85.783C112.761 73.3329 107.461 58.2599 95.0112 52.2874C82.5611 46.3148 67.4881 51.6147 61.5156 64.0648C55.543 76.5149 60.8429 91.5879 73.293 97.5604C85.7431 103.533 100.816 98.2331 106.789 85.783Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
d="M151.336 159.01L159.048 166.762L82.7048 242.703L74.973 242.683L74.9934 234.951L151.336 159.01ZM181.725 108.497C179.624 108.491 177.436 109.326 175.835 110.918L160.415 126.257L191.848 157.856L207.268 142.517C210.554 139.248 210.568 133.954 207.299 130.667L187.685 110.95C186.009 109.264 183.91 108.502 181.725 108.497ZM151.399 135.226L58.2034 227.931L58.1203 259.447L89.6359 259.53L182.831 166.825L151.399 135.226Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path d="M151.336 159.01L159.048 166.762L82.7048 242.703L74.973 242.683L74.9934 234.951L151.336 159.01Z" fill="white" stroke="white" stroke-width="10px" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-grow px-2 authorSearchCardContent h-full">
|
||||
<p class="truncate text-sm">{{ author }}</p>
|
||||
</div>
|
||||
</div> -->
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
<p v-if="matchKey !== 'title'" class="truncate text-sm">{{ title }}</p>
|
||||
<p v-else class="truncate text-sm" v-html="matchHtml" />
|
||||
|
||||
<p v-if="matchKey === 'subtitle'" class="truncate text-xs text-gray-300">{{ matchHtml }}</p>
|
||||
<p v-if="matchKey === 'subtitle'" class="truncate text-xs text-fg-muted">{{ matchHtml }}</p>
|
||||
|
||||
<p v-if="matchKey !== 'authors'" class="text-xs text-gray-200 truncate">by {{ authorName }}</p>
|
||||
<p v-else class="truncate text-xs text-gray-200" v-html="matchHtml" />
|
||||
<p v-if="matchKey !== 'authors'" class="text-xs text-fg truncate">by {{ authorName }}</p>
|
||||
<p v-else class="truncate text-xs text-fg" v-html="matchHtml" />
|
||||
|
||||
<div v-if="matchKey === 'series' || matchKey === 'tags' || matchKey === 'isbn' || matchKey === 'asin'" class="m-0 p-0 truncate text-xs" v-html="matchHtml" />
|
||||
<div v-if="matchKey === 'series' || matchKey === 'tags' || matchKey === 'isbn' || matchKey === 'asin' || matchKey === 'episode' || matchKey === 'narrators'" class="m-0 p-0 truncate text-xs" v-html="matchHtml" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -78,11 +78,14 @@ export default {
|
||||
var lastPart = this.matchText.substr(totalLenSoFar)
|
||||
html += lastPart
|
||||
|
||||
if (this.matchKey === 'episode') return `<p class="truncate">Episode: ${html}</p>`
|
||||
if (this.matchKey === 'tags') return `<p class="truncate">Tags: ${html}</p>`
|
||||
if (this.matchKey === 'subtitle') return `<p class="truncate">${html}</p>`
|
||||
if (this.matchKey === 'authors') return `by ${html}`
|
||||
if (this.matchKey === 'isbn') return `<p class="truncate">ISBN: ${html}</p>`
|
||||
if (this.matchKey === 'asin') return `<p class="truncate">ASIN: ${html}</p>`
|
||||
if (this.matchKey === 'series') return `<p class="truncate">Series: ${html}</p>`
|
||||
if (this.matchKey === 'narrators') return `<p class="truncate">Narrator: ${html}</p>`
|
||||
return `${html}`
|
||||
}
|
||||
},
|
||||
|
||||
@@ -10,20 +10,20 @@
|
||||
<p class="truncate" :style="{ fontSize: 0.9 * sizeMultiplier + 'rem' }">
|
||||
{{ displayTitle }}
|
||||
</p>
|
||||
<p class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displayLineTwo || ' ' }}</p>
|
||||
<p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
|
||||
<p class="truncate text-fg-muted" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displayLineTwo || ' ' }}</p>
|
||||
<p v-if="displaySortLine" class="truncate text-fg-muted" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="seriesSequenceList" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-20 text-right" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }" style="background-color: #78350f">
|
||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ seriesSequenceList }}</p>
|
||||
<p class="text-white" :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ seriesSequenceList }}</p>
|
||||
</div>
|
||||
<div v-else-if="booksInSeries" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-20" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }" style="background-color: #cd9d49dd">
|
||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">{{ booksInSeries }}</p>
|
||||
<p class="text-white" :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">{{ booksInSeries }}</p>
|
||||
</div>
|
||||
|
||||
<div class="w-full h-full absolute top-0 left-0 rounded overflow-hidden z-10">
|
||||
<div v-show="libraryItem && !imageReady" class="absolute top-0 left-0 w-full h-full flex items-center justify-center" :style="{ padding: sizeMultiplier * 0.5 + 'rem' }">
|
||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }" class="text-gray-300 text-center">{{ title }}</p>
|
||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }" class="text-fg-muted text-center">{{ title }}</p>
|
||||
</div>
|
||||
|
||||
<img v-show="libraryItem" ref="cover" :src="bookCoverSrc" class="w-full h-full transition-opacity duration-300" :class="showCoverBg ? 'object-contain' : 'object-fill'" @load="imageLoaded" :style="{ opacity: imageReady ? 1 : 0 }" />
|
||||
@@ -37,17 +37,29 @@
|
||||
<div v-if="!hasCover" class="absolute left-0 right-0 w-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'rem', bottom: authorBottom + 'rem' }">
|
||||
<p class="text-center" style="color: rgb(247 223 187); opacity: 0.75" :style="{ fontSize: authorFontSize + 'rem' }">{{ authorCleaned }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="showPlayButton" class="absolute -bottom-16 -right-16 rotate-45 w-32 h-32 p-2 bg-gradient-to-r from-transparent to-black to-40% inline-flex justify-start items-center">
|
||||
<div class="hover:text-white text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto -rotate-45" @click.stop.prevent="play">
|
||||
<span class="material-icons" :style="{ fontSize: playIconFontSize + 'rem' }">{{ streamIsPlaying ? 'pause_circle' : 'play_circle_filled' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Play/pause button for podcast episode -->
|
||||
<div v-if="recentEpisode" class="absolute z-10 top-0 left-0 bottom-0 right-0 m-auto flex items-center justify-center w-12 h-12 rounded-full bg-white bg-opacity-70" @click.stop="playEpisode">
|
||||
<span class="material-icons text-6xl text-black text-opacity-80">{{ streamIsPlaying ? 'pause_circle' : 'play_circle_filled' }}</span>
|
||||
<div v-if="recentEpisode" class="absolute z-10 top-0 left-0 bottom-0 right-0 m-auto flex items-center justify-center w-12 h-12 rounded-full" :class="{ 'bg-white/70': !playerIsStartingForThisMedia }" @click.stop="playEpisode">
|
||||
<span v-if="!playerIsStartingForThisMedia" class="material-icons text-6xl text-black/80">{{ streamIsPlaying ? 'pause_circle' : 'play_circle_filled' }}</span>
|
||||
<div v-else class="text-fg absolute top-0 left-0 w-full h-full flex items-center justify-center bg-black/80 rounded-full overflow-hidden">
|
||||
<svg class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No progress shown for collapsed series in library -->
|
||||
<div v-if="!collapsedSeries && (!isPodcast || recentEpisode)" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div>
|
||||
|
||||
<div v-if="showHasLocalDownload" class="absolute right-0 top-0 z-20" :style="{ top: (isPodcast ? 1.75 : 0.375) * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
||||
<!-- Downloaded icon -->
|
||||
<div v-if="showHasLocalDownload" class="absolute right-0 top-0 z-20" :style="{ top: (isPodcast || (seriesSequence && showSequence) ? 1.75 : 0.375) * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
||||
<span class="material-icons text-2xl text-success">{{ isLocalOnly ? 'task' : 'download_done' }}</span>
|
||||
</div>
|
||||
|
||||
@@ -56,19 +68,31 @@
|
||||
<span class="material-icons text-red-100 pr-1" :style="{ fontSize: 0.875 * sizeMultiplier + 'rem' }">priority_high</span>
|
||||
</div>
|
||||
|
||||
<!-- rss feed icon -->
|
||||
<div v-if="rssFeed" class="absolute text-success top-0 left-0 z-10" :style="{ padding: 0.375 * sizeMultiplier + 'rem' }">
|
||||
<span class="material-icons" :style="{ fontSize: sizeMultiplier * 1.5 + 'rem' }">rss_feed</span>
|
||||
</div>
|
||||
|
||||
<!-- Series sequence -->
|
||||
<div v-if="seriesSequence && showSequence && !isSelectionMode" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ seriesSequence }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Podcast Episode # -->
|
||||
<div v-if="recentEpisodeNumber && !isSelectionMode" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">Episode #{{ recentEpisodeNumber }}</p>
|
||||
<div v-if="recentEpisodeNumber !== null && !isSelectionMode" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
||||
<p class="text-white" :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">
|
||||
Episode<span v-if="recentEpisodeNumber"> #{{ recentEpisodeNumber }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Podcast Num Episodes -->
|
||||
<div v-else-if="numEpisodes && !isSelectionMode" class="absolute rounded-full bg-black bg-opacity-90 box-shadow-md z-10 flex items-center justify-center" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', width: 1.25 * sizeMultiplier + 'rem', height: 1.25 * sizeMultiplier + 'rem' }">
|
||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">{{ numEpisodes }}</p>
|
||||
<div v-else-if="numEpisodes && !numEpisodesIncomplete && !isSelectionMode" class="absolute rounded-full bg-black bg-opacity-90 box-shadow-md z-10 flex items-center justify-center" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', width: 1.25 * sizeMultiplier + 'rem', height: 1.25 * sizeMultiplier + 'rem' }">
|
||||
<p class="text-white" :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">{{ numEpisodes }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Podcast Num Episodes Incomplete -->
|
||||
<div v-else-if="numEpisodesIncomplete && !isSelectionMode" class="absolute rounded-full bg-black bg-opacity-90 box-shadow-md z-10 flex items-center justify-center" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', width: 1.25 * sizeMultiplier + 'rem', height: 1.25 * sizeMultiplier + 'rem' }">
|
||||
<p class="text-white" :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">{{ numEpisodesIncomplete }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -104,7 +128,6 @@ export default {
|
||||
isProcessingReadUpdate: false,
|
||||
libraryItem: null,
|
||||
imageReady: false,
|
||||
rescanning: false,
|
||||
selected: false,
|
||||
isSelectionMode: false,
|
||||
showCoverBg: false,
|
||||
@@ -121,9 +144,6 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
showExperimentalFeatures() {
|
||||
return this.store.state.showExperimentalFeatures
|
||||
},
|
||||
_libraryItem() {
|
||||
return this.libraryItem || {}
|
||||
},
|
||||
@@ -172,6 +192,10 @@ export default {
|
||||
if (this.isLocal && this.isPodcast && this.media.episodes) return this.media.episodes.length
|
||||
return this.media.numEpisodes
|
||||
},
|
||||
numEpisodesIncomplete() {
|
||||
if (this.isLocal) return 0
|
||||
return this._libraryItem.numEpisodesIncomplete || 0
|
||||
},
|
||||
processingBatch() {
|
||||
return this.store.state.processingBatch
|
||||
},
|
||||
@@ -210,7 +234,7 @@ export default {
|
||||
return this.mediaMetadata.series
|
||||
},
|
||||
seriesSequence() {
|
||||
return this.series ? this.series.sequence : null
|
||||
return this.series?.sequence || null
|
||||
},
|
||||
recentEpisode() {
|
||||
// Only added to item when getting currently listening podcasts
|
||||
@@ -221,7 +245,7 @@ export default {
|
||||
if (this.recentEpisode.episode) {
|
||||
return this.recentEpisode.episode.replace(/^#/, '')
|
||||
}
|
||||
return this.recentEpisode.index
|
||||
return ''
|
||||
},
|
||||
collapsedSeries() {
|
||||
// Only added to item object when collapseSeries is enabled
|
||||
@@ -229,14 +253,14 @@ export default {
|
||||
},
|
||||
booksInSeries() {
|
||||
// Only added to item object when collapseSeries is enabled
|
||||
return this.collapsedSeries ? this.collapsedSeries.numBooks : 0
|
||||
return this.collapsedSeries?.numBooks || 0
|
||||
},
|
||||
seriesSequenceList() {
|
||||
return this.collapsedSeries ? this.collapsedSeries.seriesSequenceList : null
|
||||
return this.collapsedSeries?.seriesSequenceList || null
|
||||
},
|
||||
libraryItemIdsInSeries() {
|
||||
// Only added to item object when collapseSeries is enabled
|
||||
return this.collapsedSeries ? this.collapsedSeries.libraryItemIds || [] : []
|
||||
return this.collapsedSeries?.libraryItemIds || []
|
||||
},
|
||||
displayTitle() {
|
||||
if (this.recentEpisode) return this.recentEpisode.title
|
||||
@@ -274,35 +298,42 @@ export default {
|
||||
if (this.isLocal) return this.store.getters['globals/getLocalMediaProgressById'](this.libraryItemId)
|
||||
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId)
|
||||
},
|
||||
useEBookProgress() {
|
||||
if (!this.userProgress || this.userProgress.progress) return false
|
||||
return this.userProgress.ebookProgress > 0
|
||||
},
|
||||
userProgressPercent() {
|
||||
return this.userProgress ? this.userProgress.progress || 0 : 0
|
||||
if (this.useEBookProgress) return Math.max(Math.min(1, this.userProgress.ebookProgress), 0)
|
||||
return Math.max(Math.min(1, this.userProgress?.progress || 0), 0) || 0
|
||||
},
|
||||
itemIsFinished() {
|
||||
return this.userProgress ? !!this.userProgress.isFinished : false
|
||||
return !!this.userProgress?.isFinished
|
||||
},
|
||||
showError() {
|
||||
return this.numMissingParts || this.isMissing || this.isInvalid
|
||||
},
|
||||
playerIsLocal() {
|
||||
return !!this.$store.state.playerIsLocal
|
||||
},
|
||||
localLibraryItemId() {
|
||||
if (this.isLocal) return this.libraryItemId
|
||||
return this.localLibraryItem ? this.localLibraryItem.id : null
|
||||
return this.localLibraryItem?.id || null
|
||||
},
|
||||
localEpisode() {
|
||||
if (!this.recentEpisode || !this.localLibraryItem) return null
|
||||
// Current recentEpisode is only implemented server side so this will always be the serverEpisodeId
|
||||
return this.localLibraryItem.media.episodes.find((ep) => ep.serverEpisodeId === this.recentEpisode.id)
|
||||
},
|
||||
isStreaming() {
|
||||
if (this.isPodcast) {
|
||||
if (this.playerIsLocal) {
|
||||
// Check is streaming local version of this episode
|
||||
return false // episode cards not implemented for local yet
|
||||
}
|
||||
return this.$store.getters['getIsEpisodeStreaming'](this.libraryItemId, this.recentEpisode.id)
|
||||
} else {
|
||||
return false // not yet necessary for books
|
||||
}
|
||||
return this.store.getters['getIsMediaStreaming'](this.libraryItemId, this.recentEpisode?.id)
|
||||
},
|
||||
streamIsPlaying() {
|
||||
return this.$store.state.playerIsPlaying && this.isStreaming
|
||||
return this.store.state.playerIsPlaying && this.isStreaming
|
||||
},
|
||||
playerIsStartingPlayback() {
|
||||
// Play has been pressed and waiting for native play response
|
||||
return this.store.state.playerIsStartingPlayback
|
||||
},
|
||||
playerIsStartingForThisMedia() {
|
||||
const mediaId = this.store.state.playerStartingPlaybackMediaId
|
||||
return mediaId === this.recentEpisode?.id
|
||||
},
|
||||
isMissing() {
|
||||
return this._libraryItem.isMissing
|
||||
@@ -372,13 +403,20 @@ export default {
|
||||
showHasLocalDownload() {
|
||||
if (this.localLibraryItem || this.isLocal) {
|
||||
if (this.recentEpisode && !this.isLocal) {
|
||||
const localEpisode = this.localLibraryItem.media.episodes.find((ep) => ep.serverEpisodeId === this.recentEpisode.id)
|
||||
return !!localEpisode
|
||||
return !!this.localEpisode
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
rssFeed() {
|
||||
if (this.booksInSeries) return null
|
||||
return this._libraryItem.rssFeed || null
|
||||
},
|
||||
showPlayButton() {
|
||||
return false
|
||||
// return !this.isMissing && !this.isInvalid && !this.isStreaming && (this.numTracks || this.recentEpisode)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -420,7 +458,10 @@ export default {
|
||||
// Server books may have a local library item
|
||||
this.localLibraryItem = localLibraryItem
|
||||
},
|
||||
async play() {},
|
||||
async playEpisode() {
|
||||
if (this.playerIsStartingPlayback) return
|
||||
|
||||
await this.$hapticsImpact()
|
||||
const eventBus = this.$eventBus || this.$nuxt.$eventBus
|
||||
if (this.streamIsPlaying) {
|
||||
@@ -428,18 +469,16 @@ export default {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.localLibraryItem) {
|
||||
const localEpisode = this.localLibraryItem.media.episodes.find((ep) => ep.serverEpisodeId === this.recentEpisode.id)
|
||||
if (localEpisode) {
|
||||
// Play episode locally
|
||||
eventBus.$emit('play-item', {
|
||||
libraryItemId: this.localLibraryItemId,
|
||||
episodeId: localEpisode.id,
|
||||
serverLibraryItemId: this.libraryItemId,
|
||||
serverEpisodeId: this.recentEpisode.id
|
||||
})
|
||||
return
|
||||
}
|
||||
this.store.commit('setPlayerIsStartingPlayback', this.recentEpisode.id)
|
||||
if (this.localEpisode) {
|
||||
// Play episode locally
|
||||
eventBus.$emit('play-item', {
|
||||
libraryItemId: this.localLibraryItemId,
|
||||
episodeId: this.localEpisode.id,
|
||||
serverLibraryItemId: this.libraryItemId,
|
||||
serverEpisodeId: this.recentEpisode.id
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
eventBus.$emit('play-item', { libraryItemId: this.libraryItemId, episodeId: this.recentEpisode.id })
|
||||
@@ -454,55 +493,18 @@ export default {
|
||||
if (router) {
|
||||
if (this.recentEpisode) router.push(`/item/${this.libraryItemId}/${this.recentEpisode.id}`)
|
||||
else if (this.collapsedSeries) router.push(`/bookshelf/series/${this.collapsedSeries.id}`)
|
||||
else router.push(`/item/${this.libraryItemId}`)
|
||||
else if (this.localLibraryItem) {
|
||||
// Pass local library item id to server page to allow falling back to offline page
|
||||
router.push(`/item/${this.libraryItemId}?localLibraryItemId=${this.localLibraryItemId}`)
|
||||
} else {
|
||||
router.push(`/item/${this.libraryItemId}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
editClick() {
|
||||
this.$emit('edit', this.libraryItem)
|
||||
},
|
||||
toggleFinished() {
|
||||
var updatePayload = {
|
||||
isFinished: !this.itemIsFinished
|
||||
}
|
||||
this.isProcessingReadUpdate = true
|
||||
var toast = this.$toast || this.$nuxt.$toast
|
||||
var axios = this.$axios || this.$nuxt.$axios
|
||||
axios
|
||||
.$patch(`/api/me/progress/${this.libraryItemId}`, updatePayload)
|
||||
.then(() => {
|
||||
this.isProcessingReadUpdate = false
|
||||
toast.success(`Item marked as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
this.isProcessingReadUpdate = false
|
||||
toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
||||
})
|
||||
},
|
||||
rescan() {
|
||||
this.rescanning = true
|
||||
this.$axios
|
||||
.$get(`/api/items/${this.libraryItemId}/scan`)
|
||||
.then((data) => {
|
||||
this.rescanning = false
|
||||
var result = data.result
|
||||
if (!result) {
|
||||
this.$toast.error(`Re-Scan Failed for "${this.title}"`)
|
||||
} else if (result === 'UPDATED') {
|
||||
this.$toast.success(`Re-Scan complete item was updated`)
|
||||
} else if (result === 'UPTODATE') {
|
||||
this.$toast.success(`Re-Scan complete item was up to date`)
|
||||
} else if (result === 'REMOVED') {
|
||||
this.$toast.error(`Re-Scan complete item was removed`)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to scan library item', error)
|
||||
this.$toast.error('Failed to scan library item')
|
||||
this.rescanning = false
|
||||
})
|
||||
},
|
||||
showEditModalTracks() {
|
||||
// More menu func
|
||||
this.store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'tracks' })
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<covers-collection-cover ref="cover" :book-items="books" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
</div>
|
||||
|
||||
<div class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-mdtext-center" :style="{ width: Math.min(240, width) + 'px' }">
|
||||
<div class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md text-center" :style="{ width: Math.min(240, width) + 'px' }">
|
||||
<div class="w-full h-full flex items-center justify-center rounded-sm border" :class="isAltViewEnabled ? 'altBookshelfLabel' : 'shinyBlack'" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
|
||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
|
||||
</div>
|
||||
|
||||
@@ -15,13 +15,13 @@
|
||||
<div v-if="!isPodcast && !collapsedSeries" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: coverWidth * userProgressPercent + 'px' }"></div>
|
||||
</div>
|
||||
<div class="flex-grow px-2">
|
||||
<p class="whitespace-normal" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">
|
||||
<p class="whitespace-normal line-clamp-2" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">
|
||||
<span v-if="seriesSequence">#{{ seriesSequence }} </span>{{ displayTitle }}
|
||||
</p>
|
||||
<p class="truncate text-gray-400" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">by {{ displayAuthor }}</p>
|
||||
<p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
|
||||
<p v-if="duration" class="truncate text-gray-400" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">{{ $elapsedPretty(duration) }}</p>
|
||||
<p v-if="episodes" class="truncate text-gray-400" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">{{ episodes }}</p>
|
||||
<p class="truncate text-fg-muted" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">{{ displayAuthor }}</p>
|
||||
<p v-if="displaySortLine" class="truncate text-fg-muted" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
|
||||
<p v-if="duration" class="truncate text-fg-muted" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">{{ $elapsedPretty(duration) }}</p>
|
||||
<p v-if="episodes" class="truncate text-fg-muted" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">{{ episodes }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="localLibraryItem || isLocal" class="absolute top-0 right-0 z-20" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
||||
@@ -62,7 +62,6 @@ export default {
|
||||
isProcessingReadUpdate: false,
|
||||
libraryItem: null,
|
||||
imageReady: false,
|
||||
rescanning: false,
|
||||
selected: false,
|
||||
isSelectionMode: false,
|
||||
showCoverBg: false,
|
||||
@@ -82,9 +81,6 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
showExperimentalFeatures() {
|
||||
return this.store.state.showExperimentalFeatures
|
||||
},
|
||||
_libraryItem() {
|
||||
return this.libraryItem || {}
|
||||
},
|
||||
@@ -180,7 +176,7 @@ export default {
|
||||
return this.mediaMetadata.series
|
||||
},
|
||||
seriesSequence() {
|
||||
return this.series ? this.series.sequence : null
|
||||
return this.series?.sequence || null
|
||||
},
|
||||
collapsedSeries() {
|
||||
// Only added to item object when collapseSeries is enabled
|
||||
@@ -188,20 +184,21 @@ export default {
|
||||
},
|
||||
booksInSeries() {
|
||||
// Only added to item object when collapseSeries is enabled
|
||||
return this.collapsedSeries ? this.collapsedSeries.numBooks : 0
|
||||
return this.collapsedSeries?.numBooks || 0
|
||||
},
|
||||
displayTitle() {
|
||||
if (this.orderBy === 'media.metadata.title' && this.sortingIgnorePrefix && this.title.toLowerCase().startsWith('the ')) {
|
||||
return this.title.substr(4) + ', The'
|
||||
}
|
||||
return this.title
|
||||
const ignorePrefix = this.orderBy === 'media.metadata.title' && this.sortingIgnorePrefix
|
||||
if (this.collapsedSeries) return ignorePrefix ? this.collapsedSeries.nameIgnorePrefix : this.collapsedSeries.name
|
||||
return ignorePrefix ? this.mediaMetadata.titleIgnorePrefix : this.title
|
||||
},
|
||||
displayAuthor() {
|
||||
if (this.isPodcast) return this.author
|
||||
if (this.collapsedSeries) return `${this.booksInSeries} books in series`
|
||||
if (this.orderBy === 'media.metadata.authorNameLF') return this.authorLF
|
||||
return this.author
|
||||
},
|
||||
displaySortLine() {
|
||||
if (this.collapsedSeries) return null
|
||||
if (this.orderBy === 'mtimeMs') return 'Modified ' + this.$formatDate(this._libraryItem.mtimeMs)
|
||||
if (this.orderBy === 'birthtimeMs') return 'Born ' + this.$formatDate(this._libraryItem.birthtimeMs)
|
||||
if (this.orderBy === 'addedAt') return 'Added ' + this.$formatDate(this._libraryItem.addedAt)
|
||||
@@ -212,25 +209,25 @@ export default {
|
||||
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId)
|
||||
},
|
||||
userProgressPercent() {
|
||||
return this.userProgress ? this.userProgress.progress || 0 : 0
|
||||
return this.userProgress?.progress || 0
|
||||
},
|
||||
itemIsFinished() {
|
||||
return this.userProgress ? !!this.userProgress.isFinished : false
|
||||
return !!this.userProgress?.isFinished
|
||||
},
|
||||
showError() {
|
||||
return this.hasMissingParts || this.hasInvalidParts || this.isMissing || this.isInvalid
|
||||
return this.numMissingParts || this.isMissing || this.isInvalid
|
||||
},
|
||||
isStreaming() {
|
||||
return this.store.getters['getlibraryItemIdStreaming'] === this.libraryItemId
|
||||
},
|
||||
showReadButton() {
|
||||
return !this.isSelectionMode && this.showExperimentalFeatures && !this.showPlayButton && this.hasEbook
|
||||
return !this.isSelectionMode && !this.showPlayButton && this.hasEbook
|
||||
},
|
||||
showPlayButton() {
|
||||
return !this.isSelectionMode && !this.isMissing && !this.isInvalid && this.numTracks && !this.isStreaming
|
||||
},
|
||||
showSmallEBookIcon() {
|
||||
return !this.isSelectionMode && this.showExperimentalFeatures && this.hasEbook
|
||||
return !this.isSelectionMode && this.hasEbook
|
||||
},
|
||||
isMissing() {
|
||||
return this._libraryItem.isMissing
|
||||
@@ -238,85 +235,13 @@ export default {
|
||||
isInvalid() {
|
||||
return this._libraryItem.isInvalid
|
||||
},
|
||||
hasMissingParts() {
|
||||
return this._libraryItem.hasMissingParts
|
||||
},
|
||||
hasInvalidParts() {
|
||||
return this._libraryItem.hasInvalidParts
|
||||
},
|
||||
errorText() {
|
||||
if (this.isMissing) return 'Item directory is missing!'
|
||||
else if (this.isInvalid) return 'Item has no media files'
|
||||
var txt = ''
|
||||
if (this.hasMissingParts) {
|
||||
txt = `${this.hasMissingParts} missing parts.`
|
||||
}
|
||||
if (this.hasInvalidParts) {
|
||||
if (this.hasMissingParts) txt += ' '
|
||||
txt += `${this.hasInvalidParts} invalid parts.`
|
||||
}
|
||||
return txt || 'Unknown Error'
|
||||
},
|
||||
overlayWrapperClasslist() {
|
||||
var classes = []
|
||||
if (this.isSelectionMode) classes.push('bg-opacity-60')
|
||||
else classes.push('bg-opacity-40')
|
||||
if (this.selected) {
|
||||
classes.push('border-2 border-yellow-400')
|
||||
}
|
||||
return classes
|
||||
numMissingParts() {
|
||||
if (this.isPodcast) return 0
|
||||
return this.media.numMissingParts
|
||||
},
|
||||
store() {
|
||||
return this.$store || this.$nuxt.$store
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.store.getters['user/getUserCanUpdate']
|
||||
},
|
||||
userCanDelete() {
|
||||
return this.store.getters['user/getUserCanDelete']
|
||||
},
|
||||
userCanDownload() {
|
||||
return this.store.getters['user/getUserCanDownload']
|
||||
},
|
||||
userIsRoot() {
|
||||
return this.store.getters['user/getIsRoot']
|
||||
},
|
||||
titleFontSize() {
|
||||
return 0.75 * this.sizeMultiplier
|
||||
},
|
||||
authorFontSize() {
|
||||
return 0.6 * this.sizeMultiplier
|
||||
},
|
||||
placeholderCoverPadding() {
|
||||
return 0.8 * this.sizeMultiplier
|
||||
},
|
||||
authorBottom() {
|
||||
return 0.75 * this.sizeMultiplier
|
||||
},
|
||||
titleCleaned() {
|
||||
if (!this.title) return ''
|
||||
if (this.title.length > 60) {
|
||||
return this.title.slice(0, 57) + '...'
|
||||
}
|
||||
return this.title
|
||||
},
|
||||
authorCleaned() {
|
||||
if (!this.author) return ''
|
||||
if (this.author.length > 30) {
|
||||
return this.author.slice(0, 27) + '...'
|
||||
}
|
||||
return this.author
|
||||
},
|
||||
isAlternativeBookshelfView() {
|
||||
return false
|
||||
// var constants = this.$constants || this.$nuxt.$constants
|
||||
// return this.bookshelfView === constants.BookshelfView.TITLES
|
||||
},
|
||||
titleDisplayBottomOffset() {
|
||||
if (!this.isAlternativeBookshelfView) return 0
|
||||
else if (!this.displaySortLine) return 3 * this.sizeMultiplier
|
||||
return 4.25 * this.sizeMultiplier
|
||||
},
|
||||
coverWidth() {
|
||||
return 80 / this.bookCoverAspectRatio
|
||||
}
|
||||
@@ -341,7 +266,7 @@ export default {
|
||||
} else {
|
||||
var router = this.$router || this.$nuxt.$router
|
||||
if (router) {
|
||||
if (this.collapsedSeries) router.push(`/library/${this.libraryId}/series/${this.collapsedSeries.id}`)
|
||||
if (this.collapsedSeries) router.push(`/bookshelf/series/${this.collapsedSeries.id}`)
|
||||
else router.push(`/item/${this.libraryItemId}`)
|
||||
}
|
||||
}
|
||||
@@ -349,48 +274,6 @@ export default {
|
||||
editClick() {
|
||||
this.$emit('edit', this.libraryItem)
|
||||
},
|
||||
toggleFinished() {
|
||||
var updatePayload = {
|
||||
isFinished: !this.itemIsFinished
|
||||
}
|
||||
this.isProcessingReadUpdate = true
|
||||
var toast = this.$toast || this.$nuxt.$toast
|
||||
var axios = this.$axios || this.$nuxt.$axios
|
||||
axios
|
||||
.$patch(`/api/me/progress/${this.libraryItemId}`, updatePayload)
|
||||
.then(() => {
|
||||
this.isProcessingReadUpdate = false
|
||||
toast.success(`Item marked as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
this.isProcessingReadUpdate = false
|
||||
toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
||||
})
|
||||
},
|
||||
rescan() {
|
||||
this.rescanning = true
|
||||
this.$axios
|
||||
.$get(`/api/items/${this.libraryItemId}/scan`)
|
||||
.then((data) => {
|
||||
this.rescanning = false
|
||||
var result = data.result
|
||||
if (!result) {
|
||||
this.$toast.error(`Re-Scan Failed for "${this.title}"`)
|
||||
} else if (result === 'UPDATED') {
|
||||
this.$toast.success(`Re-Scan complete item was updated`)
|
||||
} else if (result === 'UPTODATE') {
|
||||
this.$toast.success(`Re-Scan complete item was up to date`)
|
||||
} else if (result === 'REMOVED') {
|
||||
this.$toast.error(`Re-Scan complete item was removed`)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to scan library item', error)
|
||||
this.$toast.error('Failed to scan library item')
|
||||
this.rescanning = false
|
||||
})
|
||||
},
|
||||
showEditModalTracks() {
|
||||
// More menu func
|
||||
this.store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'tracks' })
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<div class="w-full h-full bg-primary relative rounded overflow-hidden">
|
||||
<covers-playlist-cover ref="cover" :items="items" :width="width" :height="height" />
|
||||
</div>
|
||||
<div class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-mdtext-center" :style="{ width: Math.min(160, width) + 'px' }">
|
||||
<div class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md text-center" :style="{ width: Math.min(160, width) + 'px' }">
|
||||
<div class="w-full h-full flex items-center justify-center rounded-sm border" :class="isAltViewEnabled ? 'altBookshelfLabel' : 'shinyBlack'" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
|
||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
|
||||
</div>
|
||||
|
||||
@@ -5,10 +5,12 @@
|
||||
<covers-group-cover v-if="series" ref="cover" :id="seriesId" :name="title" :book-items="books" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
</div>
|
||||
|
||||
<div v-if="seriesPercentInProgress > 0" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b w-full" :class="isSeriesFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: seriesPercentInProgress * 100 + '%' }" />
|
||||
|
||||
<div v-if="isAltViewEnabled && isCategorized" class="absolute z-30 left-0 right-0 mx-auto -bottom-8 h-8 py-1 rounded-md text-center">
|
||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
|
||||
</div>
|
||||
<div v-if="!isCategorized" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-mdtext-center" :style="{ width: Math.min(240, width) + 'px' }">
|
||||
<div v-if="!isCategorized" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md text-center" :style="{ width: Math.min(240, width) + 'px' }">
|
||||
<div class="w-full h-full flex items-center justify-center rounded-sm border" :class="isAltViewEnabled ? 'altBookshelfLabel' : 'shinyBlack'" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
|
||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
|
||||
</div>
|
||||
@@ -53,6 +55,27 @@ export default {
|
||||
books() {
|
||||
return this.series ? this.series.books || [] : []
|
||||
},
|
||||
seriesBookProgress() {
|
||||
return this.books
|
||||
.map((libraryItem) => {
|
||||
return this.store.getters['user/getUserMediaProgress'](libraryItem.id)
|
||||
})
|
||||
.filter((p) => !!p)
|
||||
},
|
||||
seriesBooksFinished() {
|
||||
return this.seriesBookProgress.filter((p) => p.isFinished)
|
||||
},
|
||||
hasSeriesBookInProgress() {
|
||||
return this.seriesBookProgress.some((p) => !p.isFinished && p.progress > 0)
|
||||
},
|
||||
seriesPercentInProgress() {
|
||||
let totalFinishedAndInProgress = this.seriesBooksFinished.length
|
||||
if (this.hasSeriesBookInProgress) totalFinishedAndInProgress += 1
|
||||
return Math.min(1, Math.max(0, totalFinishedAndInProgress / this.books.length))
|
||||
},
|
||||
isSeriesFinished() {
|
||||
return this.books.length === this.seriesBooksFinished.length
|
||||
},
|
||||
store() {
|
||||
return this.$store || this.$nuxt.$store
|
||||
},
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<div class="flex h-full px-1 overflow-hidden">
|
||||
<div class="w-10 h-10 flex items-center justify-center">
|
||||
<span class="material-icons text-2xl text-gray-200">record_voice_over</span>
|
||||
</div>
|
||||
<div class="flex-grow px-2 narratorSearchCardContent h-full">
|
||||
<p class="truncate text-sm">{{ narrator }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
narrator: String
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.narratorSearchCardContent {
|
||||
width: calc(100% - 40px);
|
||||
height: 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<div class="flex h-full px-1 overflow-hidden">
|
||||
<div class="w-10 h-10 flex items-center justify-center">
|
||||
<span class="material-icons text-2xl text-fg">local_offer</span>
|
||||
</div>
|
||||
<div class="flex-grow px-2 tagSearchCardContent h-full">
|
||||
<p class="truncate text-sm">{{ tag }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
tag: String
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.tagSearchCardContent {
|
||||
width: calc(100% - 40px);
|
||||
height: 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
@@ -1,66 +1,68 @@
|
||||
<template>
|
||||
<div class="w-full max-w-md mx-auto px-2 sm:px-4 lg:px-8 z-10">
|
||||
<div v-show="!loggedIn" class="mt-8 bg-primary overflow-hidden shadow rounded-lg px-4 py-6 w-full">
|
||||
<!-- list of server connection configs -->
|
||||
<template v-if="!showForm">
|
||||
<div v-for="config in serverConnectionConfigs" :key="config.id" class="flex items-center py-4 my-1 border-b border-white border-opacity-10 relative" @click="connectToServer(config)">
|
||||
<span class="material-icons-outlined text-xl text-gray-300">dns</span>
|
||||
<p class="pl-3 pr-6 text-base text-gray-200">{{ config.name }}</p>
|
||||
<div v-for="config in serverConnectionConfigs" :key="config.id" class="flex items-center py-4 my-1 border-b border-fg/10 relative" @click="connectToServer(config)">
|
||||
<span class="material-icons-outlined text-xl text-fg-muted">dns</span>
|
||||
<p class="pl-3 pr-6 text-base text-fg">{{ config.name }}</p>
|
||||
|
||||
<div class="absolute top-0 right-0 h-full px-4 flex items-center" @click.stop="editServerConfig(config)">
|
||||
<span class="material-icons text-lg text-gray-300">more_vert</span>
|
||||
<span class="material-icons text-lg text-fg-muted">more_vert</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="my-1 py-4 w-full">
|
||||
<ui-btn class="w-full" @click="newServerConfigClick">Add New Server</ui-btn>
|
||||
<ui-btn class="w-full" @click="newServerConfigClick">{{ $strings.ButtonAddNewServer }}</ui-btn>
|
||||
</div>
|
||||
</template>
|
||||
<!-- form to add a new server connection config -->
|
||||
<div v-else class="w-full">
|
||||
<form v-show="!showAuth" @submit.prevent="submit" novalidate class="w-full">
|
||||
<!-- server address input -->
|
||||
<form v-if="!showAuth" @submit.prevent="submit" novalidate class="w-full">
|
||||
<div v-if="serverConnectionConfigs.length" class="flex items-center mb-4" @click="showServerList">
|
||||
<span class="material-icons text-gray-300">arrow_back</span>
|
||||
<span class="material-icons text-fg-muted">arrow_back</span>
|
||||
</div>
|
||||
<h2 class="text-lg leading-7 mb-2">Server address</h2>
|
||||
<h2 class="text-lg leading-7 mb-2">{{ $strings.LabelServerAddress }}</h2>
|
||||
<ui-text-input v-model="serverConfig.address" :disabled="processing || !networkConnected || !!serverConfig.id" placeholder="http://55.55.55.55:13378" type="url" class="w-full h-10" />
|
||||
<div class="flex justify-end items-center mt-6">
|
||||
<!-- <div class="relative flex">
|
||||
<button class="outline-none uppercase tracking-wide font-semibold text-xs text-gray-300" type="button" @click="addCustomHeaders">Add Custom Headers</button>
|
||||
<div v-if="numCustomHeaders" class="rounded-full h-5 w-5 flex items-center justify-center text-xs bg-success bg-opacity-40 leading-3 font-semibold font-mono ml-1">{{ numCustomHeaders }}</div>
|
||||
</div> -->
|
||||
|
||||
<ui-btn :disabled="processing || !networkConnected" type="submit" :padding-x="3" class="h-10">{{ networkConnected ? 'Submit' : 'No Internet' }}</ui-btn>
|
||||
<ui-btn :disabled="processing || !networkConnected" type="submit" :padding-x="3" class="h-10">{{ networkConnected ? $strings.ButtonSubmit : $strings.MessageNoNetworkConnection }}</ui-btn>
|
||||
</div>
|
||||
</form>
|
||||
<template v-if="showAuth">
|
||||
<!-- username/password and auth methods -->
|
||||
<template v-else>
|
||||
<div v-if="serverConfig.id" class="flex items-center mb-4" @click="showServerList">
|
||||
<span class="material-icons text-gray-300">arrow_back</span>
|
||||
<span class="material-icons text-fg-muted">arrow_back</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<p class="text-gray-300">{{ serverConfig.address }}</p>
|
||||
<p class="text-fg-muted">{{ serverConfig.address }}</p>
|
||||
<div class="flex-grow" />
|
||||
<span v-if="!serverConfig.id" class="material-icons" style="font-size: 1.1rem" @click="editServerAddress">edit</span>
|
||||
</div>
|
||||
<div class="w-full h-px bg-white bg-opacity-10 my-2" />
|
||||
<form @submit.prevent="submitAuth" class="pt-3">
|
||||
<ui-text-input v-model="serverConfig.username" :disabled="processing" placeholder="username" class="w-full mb-2 text-lg" />
|
||||
<ui-text-input v-model="password" type="password" :disabled="processing" placeholder="password" class="w-full mb-2 text-lg" />
|
||||
<div class="w-full h-px bg-fg/10 my-2" />
|
||||
<form v-if="isLocalAuthEnabled" @submit.prevent="submitAuth" class="pt-3">
|
||||
<ui-text-input v-model="serverConfig.username" :disabled="processing" :placeholder="$strings.LabelUsername" class="w-full mb-2 text-lg" />
|
||||
<ui-text-input v-model="password" type="password" :disabled="processing" :placeholder="$strings.LabelPassword" class="w-full mb-2 text-lg" />
|
||||
|
||||
<div class="flex items-center pt-2">
|
||||
<ui-icon-btn v-if="serverConfig.id" small bg-color="error" icon="delete" @click="removeServerConfigClick" />
|
||||
<div class="flex-grow" />
|
||||
<ui-btn :disabled="processing || !networkConnected" type="submit" class="mt-1 h-10">{{ networkConnected ? 'Submit' : 'No Internet' }}</ui-btn>
|
||||
<ui-btn :disabled="processing || !networkConnected" type="submit" class="mt-1 h-10">{{ networkConnected ? $strings.ButtonSubmit : $strings.MessageNoNetworkConnection }}</ui-btn>
|
||||
</div>
|
||||
</form>
|
||||
<div v-if="isLocalAuthEnabled && isOpenIDAuthEnabled" class="w-full h-px bg-fg/10 my-4" />
|
||||
<ui-btn v-if="isOpenIDAuthEnabled" :disabled="processing" class="h-10 w-full" @click="clickLoginWithOpenId">{{ oauth.buttonText }}</ui-btn>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- auth error message -->
|
||||
<div v-show="error" class="w-full rounded-lg bg-red-600 bg-opacity-10 border border-error border-opacity-50 py-3 px-2 flex items-center mt-4">
|
||||
<span class="material-icons mr-2 text-error" style="font-size: 1.1rem">warning</span>
|
||||
<p class="text-error">{{ error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :class="processing ? 'opacity-100' : 'opacity-0 pointer-events-none'" class="fixed w-full h-full top-0 left-0 bg-black bg-opacity-75 flex items-center justify-center z-30 transition-opacity duration-500">
|
||||
<div :class="processing ? 'opacity-100' : 'opacity-0 pointer-events-none'" class="fixed w-full h-full top-0 left-0 bg-black/75 flex items-center justify-center z-30 transition-opacity duration-500">
|
||||
<div>
|
||||
<div class="absolute top-0 left-0 w-full p-6 flex items-center flex-col justify-center z-0 short:hidden">
|
||||
<img src="/Logo.png" class="h-20 w-20 mb-2" />
|
||||
@@ -71,13 +73,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="!serverConnectionConfigs.length" class="mt-2 text-center text-error" v-html="$strings.MessageAudiobookshelfServerRequired" />
|
||||
|
||||
<modals-custom-headers-modal v-model="showAddCustomHeaders" :custom-headers.sync="serverConfig.customHeaders" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Browser } from '@capacitor/browser'
|
||||
import { CapacitorHttp } from '@capacitor/core'
|
||||
import { Dialog } from '@capacitor/dialog'
|
||||
|
||||
// TODO: when backend ready. See validateLoginFormResponse()
|
||||
//const requiredServerVersion = '2.5.0'
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
@@ -86,38 +95,298 @@ export default {
|
||||
processing: false,
|
||||
serverConfig: {
|
||||
address: null,
|
||||
version: null,
|
||||
username: null,
|
||||
customHeaders: null
|
||||
},
|
||||
password: null,
|
||||
error: null,
|
||||
showForm: false,
|
||||
showAddCustomHeaders: false
|
||||
showAddCustomHeaders: false,
|
||||
authMethods: [],
|
||||
oauth: {
|
||||
state: null,
|
||||
verifier: null,
|
||||
challenge: null,
|
||||
buttonText: 'Login with OpenID',
|
||||
enforceHTTPs: true // RFC 6749, Section 10.9 requires https
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
deviceData() {
|
||||
return this.$store.state.deviceData || {}
|
||||
},
|
||||
deviceSettings() {
|
||||
return this.deviceData.deviceSettings || {}
|
||||
},
|
||||
networkConnected() {
|
||||
return this.$store.state.networkConnected
|
||||
},
|
||||
serverConnectionConfigs() {
|
||||
return this.deviceData ? this.deviceData.serverConnectionConfigs || [] : []
|
||||
return this.deviceData?.serverConnectionConfigs || []
|
||||
},
|
||||
lastServerConnectionConfigId() {
|
||||
return this.deviceData ? this.deviceData.lastServerConnectionConfigId : null
|
||||
return this.deviceData?.lastServerConnectionConfigId || null
|
||||
},
|
||||
lastServerConnectionConfig() {
|
||||
if (!this.lastServerConnectionConfigId || !this.serverConnectionConfigs.length) return null
|
||||
return this.serverConnectionConfigs.find((s) => s.id == this.lastServerConnectionConfigId)
|
||||
},
|
||||
numCustomHeaders() {
|
||||
if (!this.serverConfig.customHeaders) return 0
|
||||
return Object.keys(this.serverConfig.customHeaders).length
|
||||
isLocalAuthEnabled() {
|
||||
return this.authMethods.includes('local') || !this.authMethods.length
|
||||
},
|
||||
isOpenIDAuthEnabled() {
|
||||
return this.authMethods.includes('openid')
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
/**
|
||||
* Initiates the login process using OpenID via OAuth2.0.
|
||||
* 1. Verifying the server's address
|
||||
* 2. Calling oauthRequest() to obtain the special OpenID redirect URL
|
||||
* including a challenge and specying audiobookshelf://oauth as redirect URL
|
||||
* 3. Open this redirect URL in browser (which is a website of the SSO provider)
|
||||
*
|
||||
* When the browser is open, the following flow is expected:
|
||||
* a. The user authenticates and the provider redirects back to custom URL audiobookshelf://oauth
|
||||
* b. The app calls appUrlOpen() when `audiobookshelf://oauth` is called
|
||||
* b. appUrlOpen() handles the incoming URL and extracts the authorization code from GET parameter
|
||||
* c. oauthExchangeCodeForToken() exchanges the authorization code for an access token
|
||||
*
|
||||
*
|
||||
* @async
|
||||
* @throws Will log a console error if the browser fails to open the URL and display errors via this.error to the user.
|
||||
*/
|
||||
async clickLoginWithOpenId() {
|
||||
// oauth standard requires https explicitly
|
||||
if (!this.serverConfig.address.startsWith('https') && this.oauth.enforceHTTPs) {
|
||||
console.warn(`[SSO] Oauth2 requires HTTPS`)
|
||||
this.$toast.error(`SSO: The URL to the server must be https:// secured`)
|
||||
return
|
||||
}
|
||||
|
||||
// First request that we want to do oauth/openid and get the URL which a browser window should open
|
||||
const redirectUrl = await this.oauthRequest(this.serverConfig.address)
|
||||
if (!redirectUrl) {
|
||||
// error message handled by oauthRequest
|
||||
return
|
||||
}
|
||||
|
||||
// Actually we should be able to use the redirectUrl directly for Browser.open below
|
||||
// However it seems that when directly using it there is a malformation and leads to the error
|
||||
// Unhandled Promise Rejection: DataCloneError: The object can not be cloned.
|
||||
// (On calling Browser.open)
|
||||
// Which is hard to debug
|
||||
// So we simply extract the important elements and build the required URL ourselves
|
||||
// which also has the advantage that we can replace the callbackurl with the app url
|
||||
|
||||
const client_id = redirectUrl.searchParams.get('client_id')
|
||||
const scope = redirectUrl.searchParams.get('scope')
|
||||
const state = redirectUrl.searchParams.get('state')
|
||||
let redirect_uri_param = redirectUrl.searchParams.get('redirect_uri')
|
||||
// Backwards compatability with 2.6.0
|
||||
if (this.serverConfig.version === '2.6.0') {
|
||||
redirect_uri_param = 'audiobookshelf://oauth'
|
||||
}
|
||||
|
||||
if (!client_id || !scope || !state || !redirect_uri_param) {
|
||||
console.warn(`[SSO] Invalid OpenID URL - client_id scope state or redirect_uri missing: ${redirectUrl}`)
|
||||
this.$toast.error(`SSO: Invalid answer`)
|
||||
return
|
||||
}
|
||||
|
||||
if (redirectUrl.protocol !== 'https:' && this.oauth.enforceHTTPs) {
|
||||
console.warn(`[SSO] Insecure Redirection by SSO provider: ${redirectUrl.protocol} is not allowed. Use HTTPS`)
|
||||
this.$toast.error(`SSO: The SSO provider must return a HTTPS secured URL`)
|
||||
return
|
||||
}
|
||||
|
||||
// We need to verify if the state is the same later
|
||||
this.oauth.state = state
|
||||
|
||||
const host = `${redirectUrl.protocol}//${redirectUrl.host}`
|
||||
const buildUrl = `${host}${redirectUrl.pathname}?response_type=code` + `&client_id=${encodeURIComponent(client_id)}&scope=${encodeURIComponent(scope)}&state=${encodeURIComponent(state)}` + `&redirect_uri=${encodeURIComponent(redirect_uri_param)}` + `&code_challenge=${encodeURIComponent(this.oauth.challenge)}&code_challenge_method=S256`
|
||||
|
||||
// example url for authentik
|
||||
// const authURL = "https://authentik/application/o/authorize/?response_type=code&client_id=41cd96f...&redirect_uri=audiobookshelf%3A%2F%2Foauth&scope=openid%20openid%20email%20profile&state=asdds..."
|
||||
|
||||
// Open the browser. The browser/identity provider in turn will redirect to an in-app link supplementing a code
|
||||
try {
|
||||
await Browser.open({ url: buildUrl })
|
||||
} catch (error) {
|
||||
console.error('Error opening browser', error)
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Requests the OAuth/OpenID URL from the backend server to open in browser
|
||||
*
|
||||
* @async
|
||||
* @param {string} url - The base URL of the server to append the OAuth request parameters to.
|
||||
* @return {Promise<URL|null>} OAuth URL which should be opened in a browser
|
||||
* @throws Logs an error and displays a toast notification if the token exchange fails.
|
||||
*/
|
||||
async oauthRequest(url) {
|
||||
// Generate oauth2 PKCE challenge
|
||||
// In accordance to RFC 7636 Section 4
|
||||
function base64URLEncode(arrayBuffer) {
|
||||
let base64String = btoa(String.fromCharCode.apply(null, new Uint8Array(arrayBuffer)))
|
||||
return base64String.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
|
||||
}
|
||||
|
||||
async function sha256(plain) {
|
||||
const encoder = new TextEncoder()
|
||||
const data = encoder.encode(plain)
|
||||
return await window.crypto.subtle.digest('SHA-256', data)
|
||||
}
|
||||
|
||||
function generateRandomString() {
|
||||
var array = new Uint32Array(42)
|
||||
window.crypto.getRandomValues(array)
|
||||
return Array.from(array, (dec) => ('0' + dec.toString(16)).slice(-2)).join('') // hex
|
||||
}
|
||||
|
||||
const verifier = generateRandomString()
|
||||
|
||||
const challenge = base64URLEncode(await sha256(verifier))
|
||||
|
||||
this.oauth.verifier = verifier
|
||||
this.oauth.challenge = challenge
|
||||
|
||||
let backendEndpoint = `${url}/auth/openid?code_challenge=${challenge}&code_challenge_method=S256&redirect_uri=${encodeURIComponent('audiobookshelf://oauth')}&client_id=${encodeURIComponent('Audiobookshelf-App')}&response_type=code`
|
||||
// Backwards compatability with 2.6.0
|
||||
if (this.serverConfig.version === '2.6.0') {
|
||||
backendEndpoint += '&isRest=true'
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await CapacitorHttp.get({
|
||||
url: backendEndpoint,
|
||||
disableRedirects: true,
|
||||
webFetchExtra: {
|
||||
redirect: 'manual'
|
||||
}
|
||||
})
|
||||
|
||||
// Every kind of redirection is allowed [RFC6749 - 1.7]
|
||||
if (!(response.status >= 300 && response.status < 400)) {
|
||||
throw new Error(`Unexpected response from server: ${response.status}`)
|
||||
}
|
||||
|
||||
// Depending on iOS or Android, it can be location or Location...
|
||||
const locationHeader = response.headers[Object.keys(response.headers).find((key) => key.toLowerCase() === 'location')]
|
||||
if (!locationHeader) {
|
||||
throw new Error(`No location header in SSO answer`)
|
||||
}
|
||||
|
||||
const url = new URL(locationHeader)
|
||||
return url
|
||||
} catch (error) {
|
||||
console.error(`[SSO] ${error.message}`)
|
||||
this.$toast.error(`SSO Error: ${error.message}`)
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Handles the callback received from the OAuth/OpenID provider.
|
||||
*
|
||||
* @async
|
||||
* @function appUrlOpen
|
||||
* @param {string} url - The callback URL received from the OAuth/OpenID provider.
|
||||
* @throws Logs a warning and displays a toast notification if the URL is invalid or the state doesn't match.
|
||||
*/
|
||||
async appUrlOpen(url) {
|
||||
if (!url) return
|
||||
|
||||
// Handle the OAuth callback
|
||||
const urlObj = new URL(url)
|
||||
|
||||
// audiobookshelf://oauth?code...
|
||||
// urlObj.hostname for iOS and urlObj.pathname for android
|
||||
if (url.startsWith('audiobookshelf://oauth')) {
|
||||
// Extract possible errors thrown by the SSO provider
|
||||
const authError = urlObj.searchParams.get('error')
|
||||
if (authError) {
|
||||
console.warn(`[SSO] Received the following error: ${authError}`)
|
||||
this.$toast.error(`SSO: Received the following error: ${authError}`)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract oauth2 code to be exchanged for a token
|
||||
const authCode = urlObj.searchParams.get('code')
|
||||
// Extract the state variable
|
||||
const state = urlObj.searchParams.get('state')
|
||||
|
||||
if (this.oauth.state !== state) {
|
||||
console.warn(`[SSO] Wrong state returned by SSO Provider`)
|
||||
this.$toast.error(`SSO: The response from the SSO Provider was invalid (wrong state)`)
|
||||
return
|
||||
}
|
||||
|
||||
// Clear the state variable from the component config
|
||||
this.oauth.state = null
|
||||
|
||||
if (authCode) {
|
||||
await this.oauthExchangeCodeForToken(authCode, state)
|
||||
} else {
|
||||
console.warn(`[SSO] No code received`)
|
||||
this.$toast.error(`SSO: The response from the SSO Provider did not include a code (authentication error?)`)
|
||||
}
|
||||
} else {
|
||||
console.warn(`[ServerConnectForm] appUrlOpen: Unknown url: ${url} - host: ${urlObj.hostname} - path: ${urlObj.pathname}`)
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Exchanges an oauth2 authorization code for a JWT token.
|
||||
* And uses that token to finalise the log in process using authenticateToken()
|
||||
*
|
||||
* @async
|
||||
* @function oauthExchangeCodeForToken
|
||||
* @param {string} code - The authorization code provided by the OpenID provider.
|
||||
* @param {string} state - The state value used to associate a client session with an ID token.
|
||||
* @throws Logs an error and displays a toast notification if the token exchange fails.
|
||||
*/
|
||||
async oauthExchangeCodeForToken(code, state) {
|
||||
// We need to read the url directly from this.serverConfig.address as the callback which is called via the external browser does not pass us that info
|
||||
const backendEndpoint = `${this.serverConfig.address}/auth/openid/callback?state=${encodeURIComponent(state)}&code=${encodeURIComponent(code)}&code_verifier=${encodeURIComponent(this.oauth.verifier)}`
|
||||
|
||||
try {
|
||||
// We can close the browser at this point (does not work on Android)
|
||||
if (this.$platform === 'ios' || this.$platform === 'web') {
|
||||
await Browser.close()
|
||||
}
|
||||
} catch (error) {} // No Error handling needed
|
||||
|
||||
try {
|
||||
const response = await CapacitorHttp.get({
|
||||
url: backendEndpoint
|
||||
})
|
||||
|
||||
if (!response.data || !response.data.user || !response.data.user.token) {
|
||||
throw new Error('Token data is missing in the response.')
|
||||
}
|
||||
|
||||
this.serverConfig.token = response.data.user.token
|
||||
const payload = await this.authenticateToken()
|
||||
|
||||
if (!payload) {
|
||||
throw new Error('Authentication failed with the provided token.')
|
||||
}
|
||||
|
||||
const duplicateConfig = this.serverConnectionConfigs.find((scc) => scc.address === this.serverConfig.address && scc.username === payload.user.username)
|
||||
if (duplicateConfig) {
|
||||
throw new Error('Config already exists for this address and username.')
|
||||
}
|
||||
|
||||
this.setUserAndConnection(payload)
|
||||
} catch (error) {
|
||||
console.error('[SSO] Error in exchangeCodeForToken: ', error)
|
||||
this.$toast.error(`SSO error: ${error.message || error}`)
|
||||
} finally {
|
||||
// We don't need the oauth verifier any more
|
||||
this.oauth.verifier = null
|
||||
this.oauth.challenge = null
|
||||
}
|
||||
},
|
||||
addCustomHeaders() {
|
||||
this.showAddCustomHeaders = true
|
||||
},
|
||||
@@ -206,10 +475,18 @@ export default {
|
||||
this.error = null
|
||||
this.showAuth = false
|
||||
},
|
||||
validateServerUrl(url) {
|
||||
/**
|
||||
* Validates a URL and reconstructs it with an optional protocol override.
|
||||
* If the URL is invalid, null is returned.
|
||||
*
|
||||
* @param {string} url - The URL to validate.
|
||||
* @param {string|null} [protocolOverride=null] - (Optional) Protocol to override the URL's original protocol.
|
||||
* @returns {string|null} The validated URL with the original or overridden protocol, or null if invalid.
|
||||
*/
|
||||
validateServerUrl(url, protocolOverride = null) {
|
||||
try {
|
||||
var urlObject = new URL(url)
|
||||
var address = `${urlObject.protocol}//${urlObject.hostname}`
|
||||
var address = `${protocolOverride ? protocolOverride : urlObject.protocol}//${urlObject.hostname}`
|
||||
if (urlObject.port) address += ':' + urlObject.port
|
||||
return address
|
||||
} catch (error) {
|
||||
@@ -217,27 +494,94 @@ export default {
|
||||
return null
|
||||
}
|
||||
},
|
||||
pingServerAddress(address, customHeaders) {
|
||||
const options = { timeout: 3000 }
|
||||
if (customHeaders) {
|
||||
options.headers = customHeaders
|
||||
/**
|
||||
* Sends a GET request to the specified URL with the provided headers and timeout.
|
||||
* If the response is successful (HTTP 200), the response object is returned.
|
||||
* Otherwise, throws an error object containing code.
|
||||
* code can be either a number, which is then a HTTP status code or
|
||||
* a string, which is then a keyword like NSURLErrorBadURL when the TCP connection could not be established.
|
||||
* When code is a string, error.message contains the human readable error by the OS or
|
||||
* the http body of the non-200 answer.
|
||||
*
|
||||
* @async
|
||||
* @param {string} url - The URL to which the GET request will be sent.
|
||||
* @param {Object} headers - HTTP headers to be included in the request.
|
||||
* @param {number} [connectTimeout=6000] - Timeout for the request in milliseconds.
|
||||
* @returns {Promise<HttpResponse>} The HTTP response object if the request is successful.
|
||||
* @throws {Error} An error with 'code' property set to the HTTP status code if the response is not successful.
|
||||
* @throws {Error} An error with 'code' property set to the error code if the request fails.
|
||||
*/
|
||||
async getRequest(url, headers, connectTimeout = 6000) {
|
||||
const options = {
|
||||
url,
|
||||
headers,
|
||||
connectTimeout
|
||||
}
|
||||
return this.$axios
|
||||
.$get(`${address}/ping`, options)
|
||||
.then((data) => data.success)
|
||||
try {
|
||||
const response = await CapacitorHttp.get(options)
|
||||
console.log('[ServerConnectForm] GET request response', response)
|
||||
if (response.status == 200) {
|
||||
return response
|
||||
} else {
|
||||
// Put the HTTP error code inside the cause
|
||||
let errorObj = new Error(response.data)
|
||||
errorObj.code = response.status
|
||||
throw errorObj
|
||||
}
|
||||
} catch (error) {
|
||||
// Put the error name inside the cause (a string)
|
||||
let errorObj = new Error(error.message)
|
||||
errorObj.code = error.code
|
||||
throw errorObj
|
||||
}
|
||||
},
|
||||
async postRequest(url, data, headers, connectTimeout = 6000) {
|
||||
if (!headers) headers = {}
|
||||
if (!headers['Content-Type'] && data) {
|
||||
headers['Content-Type'] = 'application/json'
|
||||
}
|
||||
const options = {
|
||||
url,
|
||||
headers,
|
||||
data,
|
||||
connectTimeout
|
||||
}
|
||||
const response = await CapacitorHttp.post(options)
|
||||
console.log('[ServerConnectForm] POST request response', response)
|
||||
if (response.status >= 400) {
|
||||
throw new Error(response.data)
|
||||
} else {
|
||||
return response.data
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Get request to server /status api endpoint
|
||||
*
|
||||
* @param {string} address
|
||||
* @returns {Promise<HttpResponse>}
|
||||
* HttpResponse.data is {isInit:boolean, language:string, authMethods:string[]}>
|
||||
*/
|
||||
async getServerAddressStatus(address) {
|
||||
return this.getRequest(`${address}/status`)
|
||||
},
|
||||
pingServerAddress(address, customHeaders) {
|
||||
return this.getRequest(`${address}/ping`, customHeaders)
|
||||
.then((response) => {
|
||||
return response.data.success
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Server check failed', error)
|
||||
console.error('Server ping failed', error)
|
||||
const errorMsg = error.message || error
|
||||
this.error = 'Failed to ping server'
|
||||
if (typeof errorMsg === 'string') {
|
||||
this.error += ` (${errorMsg})`
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
},
|
||||
requestServerLogin() {
|
||||
const options = {}
|
||||
if (this.serverConfig.customHeaders) {
|
||||
options.headers = this.serverConfig.customHeaders
|
||||
}
|
||||
return this.$axios
|
||||
.$post(`${this.serverConfig.address}/login`, { username: this.serverConfig.username, password: this.password }, options)
|
||||
return this.postRequest(`${this.serverConfig.address}/login`, { username: this.serverConfig.username, password: this.password || '' }, this.serverConfig.customHeaders, 20000)
|
||||
.then((data) => {
|
||||
if (!data.user) {
|
||||
console.error(data.error)
|
||||
@@ -248,30 +592,187 @@ export default {
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Server auth failed', error)
|
||||
var errorMsg = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
|
||||
this.error = errorMsg
|
||||
const errorMsg = error.message || error
|
||||
this.error = 'Failed to login'
|
||||
if (typeof errorMsg === 'string') {
|
||||
this.error += ` (${errorMsg})`
|
||||
}
|
||||
return false
|
||||
})
|
||||
},
|
||||
async submit() {
|
||||
if (!this.networkConnected) return
|
||||
if (!this.serverConfig.address) return
|
||||
if (!this.serverConfig.address.startsWith('http')) {
|
||||
this.serverConfig.address = 'http://' + this.serverConfig.address
|
||||
}
|
||||
var validServerAddress = this.validateServerUrl(this.serverConfig.address)
|
||||
if (!validServerAddress) {
|
||||
this.error = 'Invalid server address'
|
||||
return
|
||||
}
|
||||
|
||||
this.serverConfig.address = validServerAddress
|
||||
const initialAddress = this.serverConfig.address
|
||||
// Did the user specify a protocol?
|
||||
const protocolProvided = initialAddress.startsWith('http://') || initialAddress.startsWith('https://')
|
||||
// Add https:// if not provided
|
||||
this.serverConfig.address = this.prependProtocolIfNeeded(initialAddress)
|
||||
|
||||
this.processing = true
|
||||
this.error = null
|
||||
this.authMethods = []
|
||||
|
||||
var success = await this.pingServerAddress(this.serverConfig.address, this.serverConfig.customHeaders)
|
||||
this.processing = false
|
||||
if (success) this.showAuth = true
|
||||
try {
|
||||
// Try the server URL. If it fails and the protocol was not provided, try with http instead of https
|
||||
const statusData = await this.tryServerUrl(this.serverConfig.address, !protocolProvided)
|
||||
if (this.validateLoginFormResponse(statusData, this.serverConfig.address, protocolProvided)) {
|
||||
this.showAuth = true
|
||||
this.authMethods = statusData.data.authMethods || []
|
||||
this.oauth.buttonText = statusData.data.authFormData?.authOpenIDButtonText || 'Login with OpenID'
|
||||
this.serverConfig.version = statusData.data.serverVersion
|
||||
|
||||
if (statusData.data.authFormData?.authOpenIDAutoLaunch) {
|
||||
this.clickLoginWithOpenId()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.handleLoginFormError(error)
|
||||
} finally {
|
||||
this.processing = false
|
||||
}
|
||||
},
|
||||
/** Validates the login form response from the server.
|
||||
*
|
||||
* Ensure the request has not been redirected to an unexpected hostname and check if it is Audiobookshelf
|
||||
*
|
||||
* @param {object} statusData - The data received from the server's response, including data and url.
|
||||
* @param {string} initialAddressWithProtocol - The initial server address including the protocol used for the request.
|
||||
* @param {boolean} protocolProvided - Indicates whether the protocol was explicitly provided in the initial address.
|
||||
*
|
||||
* @returns {boolean} - Returns `true` if the response is valid, otherwise `false` and sets this.error.
|
||||
*/
|
||||
validateLoginFormResponse(statusData, initialAddressWithProtocol, protocolProvided) {
|
||||
// We have a 200 status code at this point
|
||||
|
||||
// Check if we got redirected to a different hostname, we don't allow this
|
||||
const initialAddressUrl = new URL(initialAddressWithProtocol)
|
||||
const currentAddressUrl = new URL(statusData.url)
|
||||
if (initialAddressUrl.hostname !== currentAddressUrl.hostname) {
|
||||
this.error = `Server redirected somewhere else (to ${currentAddressUrl.hostname})`
|
||||
console.error(`[ServerConnectForm] Server redirected somewhere else (to ${currentAddressUrl.hostname})`)
|
||||
return false
|
||||
} // We don't allow a redirection back from https to http if the user used https:// explicitly
|
||||
else if (protocolProvided && initialAddressWithProtocol.startsWith('https://') && currentAddressUrl.protocol === 'http') {
|
||||
this.error = `You specified https:// but the Server redirected back to plain http`
|
||||
console.error(`[ServerConnectForm] User specified https:// but server redirected to http`)
|
||||
return false
|
||||
}
|
||||
|
||||
// Check content of response now
|
||||
if (!statusData || !statusData.data || Object.keys(statusData).length === 0) {
|
||||
this.error = 'Response from server was empty' // Usually some kind of config error on server side
|
||||
console.error('[ServerConnectForm] Received empty response')
|
||||
return false
|
||||
} else if (!('isInit' in statusData.data) || !('language' in statusData.data)) {
|
||||
this.error = 'This does not seem to be a Audiobookshelf server'
|
||||
console.error('[ServerConnectForm] Received as response from Server:\n', statusData)
|
||||
return false
|
||||
// TODO: delete the if above and comment the ones below out, as soon as the backend is ready to introduce a version check
|
||||
// } else if (!('app' in statusData.data) || statusData.data.app.toLowerCase() !== 'audiobookshelf') {
|
||||
// this.error = 'This does not seem to be a Audiobookshelf server'
|
||||
// console.error('[ServerConnectForm] Received as response from Server:\n', statusData)
|
||||
// return false
|
||||
// } else if (!this.isValidVersion(statusData.data.serverVersion, requiredServerVersion)) {
|
||||
// this.error = `Server version is below minimum required version of ${requiredServerVersion} (${statusData.data.serverVersion})`
|
||||
// console.error('[ServerConnectForm] Server version is too low: ', statusData.data.serverVersion)
|
||||
// return false
|
||||
} else if (!statusData.data.isInit) {
|
||||
this.error = 'Server is not initialized'
|
||||
return false
|
||||
}
|
||||
|
||||
// If we got redirected from http to https, we allow this
|
||||
// Also there is the possibility that https was tried (with protocolProvided false) but only http was successfull
|
||||
// So set the correct protocol for the config
|
||||
const configUrl = new URL(this.serverConfig.address)
|
||||
configUrl.protocol = currentAddressUrl.protocol
|
||||
// Remove trailing slash
|
||||
this.serverConfig.address = configUrl.toString().replace(/\/$/, '')
|
||||
|
||||
return true
|
||||
},
|
||||
/**
|
||||
* Handles errors received during the login form process, providing user-friendly error messages.
|
||||
*
|
||||
* @param {Object} error - The error object received from a failed login attempt.
|
||||
*/
|
||||
handleLoginFormError(error) {
|
||||
console.error('[ServerConnectForm] Received invalid status', error)
|
||||
|
||||
if (error.code === 404) {
|
||||
this.error = `This does not seem to be an Audiobookshelf server. (Error: 404 querying /status)`
|
||||
} else if (typeof error.code === 'number') {
|
||||
// Error with HTTP Code
|
||||
this.error = `Failed to retrieve status of server: ${error.code}`
|
||||
} else {
|
||||
// error is usually a meaningful error like "Server timed out"
|
||||
this.error = `Failed to contact server. (${error})`
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Attempts to retrieve the server address status for the given URL.
|
||||
* If the initial attempt fails, it retries with HTTP if allowed.
|
||||
*
|
||||
* @param {string} address - The URL address to validate and check.
|
||||
* @param {boolean} shouldRetryWithHttp - Flag to indicate if the function should retry with HTTP on failure.
|
||||
* @returns {Promise<HttpResponse>}
|
||||
* HttpResponse.data is {isInit:boolean, language:string, authMethods:string[]}>
|
||||
* @throws Will throw an error if the URL has a wrong format or if both HTTPS and HTTP (if retried) requests fail.
|
||||
*/
|
||||
async tryServerUrl(address, shouldRetryWithHttp) {
|
||||
const validatedUrl = this.validateServerUrl(address)
|
||||
if (!validatedUrl) {
|
||||
throw new Error('URL has wrong format')
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.getServerAddressStatus(validatedUrl)
|
||||
} catch (error) {
|
||||
// We only retry when the user did not specify a protocol
|
||||
// Also for security reasons, we only retry when the https request did not
|
||||
// return a http status code (so only retry when the TCP connection could not be established)
|
||||
if (shouldRetryWithHttp && typeof error.code !== 'number') {
|
||||
console.log('[ServerConnectForm] https failed, trying to connect with http...')
|
||||
const validatedHttpUrl = this.validateServerUrl(address, 'http:')
|
||||
if (validatedHttpUrl) {
|
||||
return await this.getServerAddressStatus(validatedHttpUrl)
|
||||
}
|
||||
// else if validatedHttpUrl is false return the original error below
|
||||
}
|
||||
// rethrow original error
|
||||
throw error
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Ensures that a protocol is prepended to the given address if it does not already start with http:// or https://.
|
||||
*
|
||||
* @param {string} address - The server address that may or may not have a protocol.
|
||||
* @returns {string} The address with a protocol prepended if it was missing.
|
||||
*/
|
||||
prependProtocolIfNeeded(address) {
|
||||
return address.startsWith('http://') || address.startsWith('https://') ? address : `https://${address}`
|
||||
},
|
||||
/**
|
||||
* Compares two semantic versioning strings to determine if the current version meets
|
||||
* or exceeds the minimum version requirement.
|
||||
*
|
||||
* @param {string} currentVersion - The current version string to compare, e.g., "1.2.3".
|
||||
* @param {string} minVersion - The minimum version string required, e.g., "1.0.0".
|
||||
* @returns {boolean} - Returns true if the current version is greater than or equal
|
||||
* to the minimum version, false otherwise.
|
||||
*/
|
||||
isValidVersion(currentVersion, minVersion) {
|
||||
const currentParts = currentVersion.split('.').map(Number)
|
||||
const minParts = minVersion.split('.').map(Number)
|
||||
|
||||
for (let i = 0; i < minParts.length; i++) {
|
||||
if (currentParts[i] > minParts[i]) return true
|
||||
if (currentParts[i] < minParts[i]) return false
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
async submitAuth() {
|
||||
if (!this.networkConnected) return
|
||||
@@ -295,12 +796,14 @@ export default {
|
||||
this.setUserAndConnection(payload)
|
||||
}
|
||||
},
|
||||
async setUserAndConnection({ user, userDefaultLibraryId, serverSettings }) {
|
||||
async setUserAndConnection({ user, userDefaultLibraryId, serverSettings, ereaderDevices }) {
|
||||
if (!user) return
|
||||
|
||||
console.log('Successfully logged in', JSON.stringify(user))
|
||||
|
||||
this.$store.commit('setServerSettings', serverSettings)
|
||||
this.$store.commit('libraries/setEReaderDevices', ereaderDevices)
|
||||
this.$setServerLanguageCode(serverSettings.language)
|
||||
|
||||
// Set library - Use last library if set and available fallback to default user library
|
||||
var lastLibraryId = await this.$localStore.getLastLibraryId()
|
||||
@@ -312,9 +815,24 @@ export default {
|
||||
|
||||
this.serverConfig.userId = user.id
|
||||
this.serverConfig.token = user.token
|
||||
this.serverConfig.username = user.username
|
||||
delete this.serverConfig.version
|
||||
|
||||
var serverConnectionConfig = await this.$db.setServerConnectionConfig(this.serverConfig)
|
||||
|
||||
// Set the device language to match the servers if this is the first server connection
|
||||
if (!this.serverConnectionConfigs.length && serverSettings.language !== 'en-us') {
|
||||
const deviceSettings = {
|
||||
...this.deviceSettings,
|
||||
languageCode: serverSettings.language
|
||||
}
|
||||
const updatedDeviceData = await this.$db.updateDeviceSettings(deviceSettings)
|
||||
if (updatedDeviceData) {
|
||||
this.$store.commit('setDeviceData', updatedDeviceData)
|
||||
this.$setLanguageCode(updatedDeviceData.deviceSettings?.languageCode || 'en-us')
|
||||
}
|
||||
}
|
||||
|
||||
this.$store.commit('user/setUser', user)
|
||||
this.$store.commit('user/setServerConnectionConfig', serverConnectionConfig)
|
||||
|
||||
@@ -330,16 +848,23 @@ export default {
|
||||
|
||||
this.error = null
|
||||
this.processing = true
|
||||
var authRes = await this.$axios.$post(`${this.serverConfig.address}/api/authorize`, null, { headers: { Authorization: `Bearer ${this.serverConfig.token}` } }).catch((error) => {
|
||||
console.error('[Server] Server auth failed', error)
|
||||
var errorMsg = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
|
||||
this.error = errorMsg
|
||||
|
||||
const authRes = await this.postRequest(`${this.serverConfig.address}/api/authorize`, null, { Authorization: `Bearer ${this.serverConfig.token}` }).catch((error) => {
|
||||
console.error('[ServerConnectForm] Server auth failed', error)
|
||||
const errorMsg = error.message || error
|
||||
this.error = 'Failed to authorize'
|
||||
if (typeof errorMsg === 'string') {
|
||||
this.error += ` (${errorMsg})`
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
console.log('[ServerConnectForm] authRes=', authRes)
|
||||
|
||||
this.processing = false
|
||||
return authRes
|
||||
},
|
||||
async init() {
|
||||
init() {
|
||||
if (this.lastServerConnectionConfig) {
|
||||
this.connectToServer(this.lastServerConnectionConfig)
|
||||
} else {
|
||||
@@ -348,7 +873,11 @@ export default {
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.$eventBus.$on('url-open', this.appUrlOpen)
|
||||
this.init()
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$eventBus.$off('url-open', this.appUrlOpen)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -70,7 +70,7 @@ export default {
|
||||
return this.libraryItem.isLocal
|
||||
},
|
||||
localCover() {
|
||||
return this.libraryItem ? this.libraryItem.coverContentUrl : null
|
||||
return this.libraryItem?.coverContentUrl || null
|
||||
},
|
||||
squareAspectRatio() {
|
||||
return this.bookCoverAspectRatio === 1
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<div v-else class="relative w-full h-full flex items-center justify-center p-2 bg-primary rounded-sm">
|
||||
<div class="absolute top-0 left-0 w-full h-full bg-gray-400 bg-opacity-5" />
|
||||
|
||||
<p class="text-white text-opacity-60 text-center" :style="{ fontSize: Math.min(1, sizeMultiplier) + 'rem' }">Empty Collection</p>
|
||||
<p class="text-fg-muted text-center" :style="{ fontSize: Math.min(1, sizeMultiplier) + 'rem' }">Empty Collection</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -51,9 +51,6 @@ export default {
|
||||
if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6 * 2)
|
||||
return this.width / 240
|
||||
},
|
||||
showExperimentalFeatures() {
|
||||
return this.store.state.showExperimentalFeatures
|
||||
},
|
||||
store() {
|
||||
return this.$store || this.$nuxt.$store
|
||||
},
|
||||
|
||||
@@ -2,24 +2,24 @@
|
||||
<div class="w-full px-2">
|
||||
<img v-if="podcast.imageUrl" :src="podcast.imageUrl" class="h-36 w-36 object-contain mx-auto mb-2" />
|
||||
|
||||
<ui-text-input-with-label v-model="podcast.title" :label="'Title'" class="mb-2 text-sm" @input="titleUpdated" />
|
||||
<ui-text-input-with-label v-model="podcast.title" :label="$strings.LabelTitle" class="mb-2 text-sm" @input="titleUpdated" />
|
||||
|
||||
<ui-text-input-with-label v-model="podcast.author" :label="'Author'" class="mb-2 text-sm" />
|
||||
<ui-text-input-with-label v-model="podcast.author" :label="$strings.LabelAuthor" class="mb-2 text-sm" />
|
||||
|
||||
<ui-text-input-with-label v-model="podcast.feedUrl" :label="'Feed URL'" readonly class="mb-2 text-sm" />
|
||||
<ui-text-input-with-label v-model="podcast.feedUrl" :label="$strings.LabelFeedURL" readonly class="mb-2 text-sm" />
|
||||
|
||||
<ui-multi-select v-model="podcast.genres" :items="podcast.genres" :label="'Genres'" class="mb-2 text-sm" />
|
||||
<ui-multi-select v-model="podcast.genres" :items="podcast.genres" :label="$strings.LabelGenres" class="mb-2 text-sm" />
|
||||
|
||||
<ui-textarea-with-label v-model="podcast.description" :label="'Description'" :rows="3" class="mb-2 text-sm" />
|
||||
<ui-textarea-with-label v-model="podcast.description" :label="$strings.LabelDescription" :rows="3" class="mb-2 text-sm" />
|
||||
|
||||
<ui-dropdown v-model="selectedFolderId" :items="folderItems" :disabled="processing" :label="'Folder'" class="mb-2 text-sm" @input="folderUpdated" />
|
||||
<ui-dropdown v-model="selectedFolderId" :items="folderItems" :disabled="processing" :label="$strings.LabelFolder" class="mb-2 text-sm" @input="folderUpdated" />
|
||||
|
||||
<ui-text-input-with-label v-model="fullPath" :label="'Podcast Path'" input-class="h-10" readonly class="mb-2 text-sm" />
|
||||
<ui-text-input-with-label v-model="fullPath" :label="$strings.LabelPath" input-class="h-10" readonly class="mb-2 text-sm" />
|
||||
|
||||
<div class="flex items-center py-4 px-2">
|
||||
<ui-checkbox v-model="podcast.autoDownloadEpisodes" :label="'Auto Download Episodes'" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-sm font-semibold" />
|
||||
<ui-checkbox v-model="podcast.autoDownloadEpisodes" :label="$strings.LabelAutoDownloadEpisodes" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-sm font-semibold" />
|
||||
<div class="flex-grow" />
|
||||
<ui-btn color="success" @click="submit">Submit</ui-btn>
|
||||
<ui-btn color="success" @click="submit">{{ $strings.ButtonSubmit }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -140,15 +140,15 @@ export default {
|
||||
console.log('Podcast payload', podcastPayload)
|
||||
|
||||
this._processing = true
|
||||
this.$axios
|
||||
.$post('/api/podcasts', podcastPayload)
|
||||
this.$nativeHttp
|
||||
.post('/api/podcasts', podcastPayload)
|
||||
.then((libraryItem) => {
|
||||
this._processing = false
|
||||
this.$toast.success('Podcast added')
|
||||
this.$toast.success(this.$strings.ToastPodcastCreateSuccess)
|
||||
this.$router.push(`/item/${libraryItem.id}`)
|
||||
})
|
||||
.catch((error) => {
|
||||
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed to add podcast'
|
||||
var errorMsg = error.response && error.response.data ? error.response.data : this.$strings.ToastPodcastCreateFailed
|
||||
console.error('Failed to create podcast', error)
|
||||
this._processing = false
|
||||
this.$toast.error(errorMsg)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="w-full h-9 bg-bg relative">
|
||||
<div id="bookshelf-navbar" class="absolute z-10 top-0 left-0 w-full h-full flex bg-secondary text-gray-200">
|
||||
<nuxt-link v-for="item in items" :key="item.to" :to="item.to" class="h-full flex-grow flex items-center justify-center" :class="routeName === item.routeName ? 'bg-primary' : 'text-gray-400'">
|
||||
<div id="bookshelf-navbar" class="absolute z-10 top-0 left-0 w-full h-full flex bg-secondary">
|
||||
<nuxt-link v-for="item in items" :key="item.to" :to="item.to" class="h-full flex-grow flex items-center justify-center" :class="routeName === item.routeName ? 'bg-primary' : 'text-fg-muted'">
|
||||
<p v-if="routeName === item.routeName" class="text-sm font-semibold">{{ item.text }}</p>
|
||||
<span v-else-if="item.iconPack === 'abs-icons'" class="abs-icons" :class="`icon-${item.icon} ${item.iconClass || ''}`"></span>
|
||||
<span v-else :class="`${item.iconPack} ${item.iconClass || ''}`">{{ item.icon }}</span>
|
||||
@@ -20,7 +20,7 @@ export default {
|
||||
return this.$store.getters['libraries/getCurrentLibrary']
|
||||
},
|
||||
currentLibraryIcon() {
|
||||
return this.currentLibrary ? this.currentLibrary.icon : 'database'
|
||||
return this.currentLibrary?.icon || 'database'
|
||||
},
|
||||
userHasPlaylists() {
|
||||
return this.$store.state.libraries.numUserPlaylists
|
||||
@@ -38,7 +38,7 @@ export default {
|
||||
iconPack: 'abs-icons',
|
||||
icon: 'home',
|
||||
iconClass: 'text-xl',
|
||||
text: 'Home'
|
||||
text: this.$strings.ButtonHome
|
||||
},
|
||||
{
|
||||
to: '/bookshelf/latest',
|
||||
@@ -46,7 +46,7 @@ export default {
|
||||
iconPack: 'abs-icons',
|
||||
icon: 'list',
|
||||
iconClass: 'text-xl',
|
||||
text: 'Latest'
|
||||
text: this.$strings.ButtonLatest
|
||||
},
|
||||
{
|
||||
to: '/bookshelf/library',
|
||||
@@ -54,18 +54,18 @@ export default {
|
||||
iconPack: 'abs-icons',
|
||||
icon: this.currentLibraryIcon,
|
||||
iconClass: 'text-lg',
|
||||
text: 'Library'
|
||||
text: this.$strings.ButtonLibrary
|
||||
}
|
||||
]
|
||||
|
||||
if (this.userIsAdminOrUp) {
|
||||
items.push({
|
||||
to: '/bookshelf/search',
|
||||
routeName: 'bookshelf-search',
|
||||
to: '/bookshelf/add-podcast',
|
||||
routeName: 'bookshelf-add-podcast',
|
||||
iconPack: 'material-icons',
|
||||
icon: 'podcasts',
|
||||
iconClass: 'text-xl',
|
||||
text: 'Search'
|
||||
text: this.$strings.ButtonAdd
|
||||
})
|
||||
}
|
||||
} else {
|
||||
@@ -76,7 +76,7 @@ export default {
|
||||
iconPack: 'abs-icons',
|
||||
icon: 'home',
|
||||
iconClass: 'text-xl',
|
||||
text: 'Home'
|
||||
text: this.$strings.ButtonHome
|
||||
},
|
||||
{
|
||||
to: '/bookshelf/library',
|
||||
@@ -84,7 +84,7 @@ export default {
|
||||
iconPack: 'abs-icons',
|
||||
icon: this.currentLibraryIcon,
|
||||
iconClass: 'text-lg',
|
||||
text: 'Library'
|
||||
text: this.$strings.ButtonLibrary
|
||||
},
|
||||
{
|
||||
to: '/bookshelf/series',
|
||||
@@ -92,7 +92,7 @@ export default {
|
||||
iconPack: 'abs-icons',
|
||||
icon: 'columns',
|
||||
iconClass: 'text-lg pt-px',
|
||||
text: 'Series'
|
||||
text: this.$strings.ButtonSeries
|
||||
},
|
||||
{
|
||||
to: '/bookshelf/collections',
|
||||
@@ -100,7 +100,7 @@ export default {
|
||||
iconPack: 'material-icons-outlined',
|
||||
icon: 'collections_bookmark',
|
||||
iconClass: 'text-xl',
|
||||
text: 'Collections'
|
||||
text: this.$strings.ButtonCollections
|
||||
},
|
||||
{
|
||||
to: '/bookshelf/authors',
|
||||
@@ -108,7 +108,7 @@ export default {
|
||||
iconPack: 'abs-icons',
|
||||
icon: 'authors',
|
||||
iconClass: 'text-2xl',
|
||||
text: 'Authors'
|
||||
text: this.$strings.ButtonAuthors
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -119,7 +119,7 @@ export default {
|
||||
routeName: 'bookshelf-playlists',
|
||||
iconPack: 'material-icons',
|
||||
icon: 'queue_music',
|
||||
text: 'Playlists'
|
||||
text: this.$strings.ButtonPlaylists
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -64,16 +64,16 @@ export default {
|
||||
return this.$route.query || {}
|
||||
},
|
||||
entityTitle() {
|
||||
if (this.isPodcast) return 'Podcasts'
|
||||
if (this.page === 'library') return 'Books'
|
||||
if (this.isPodcast) return this.$strings.LabelPodcasts
|
||||
if (this.page === 'library') return this.$strings.LabelBooks
|
||||
else if (this.page === 'series') {
|
||||
return 'Series'
|
||||
return this.$strings.LabelSeries
|
||||
} else if (this.page === 'collections') {
|
||||
return 'Collections'
|
||||
return this.$strings.ButtonCollections
|
||||
} else if (this.page === 'playlists') {
|
||||
return 'Playlists'
|
||||
return this.$strings.ButtonPlaylists
|
||||
} else if (this.page === 'authors') {
|
||||
return 'Authors'
|
||||
return this.$strings.LabelAuthors
|
||||
}
|
||||
return ''
|
||||
},
|
||||
@@ -92,7 +92,7 @@ export default {
|
||||
if (this.seriesBookPage) {
|
||||
return [
|
||||
{
|
||||
text: 'Collapse Sub-Series',
|
||||
text: this.$strings.LabelCollapseSeries,
|
||||
value: 'collapse_subseries',
|
||||
icon: this.settings.collapseBookSeries ? 'check_box' : 'check_box_outline_blank'
|
||||
}
|
||||
@@ -100,7 +100,7 @@ export default {
|
||||
} else {
|
||||
return [
|
||||
{
|
||||
text: 'Collapse Series',
|
||||
text: this.$strings.LabelCollapseSeries,
|
||||
value: 'collapse_series',
|
||||
icon: this.settings.collapseSeries ? 'check_box' : 'check_box_outline_blank'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" :width="200" height="100%">
|
||||
<template #outer>
|
||||
<div class="absolute top-11 left-4 z-40">
|
||||
<p class="text-white text-2xl truncate capitalize">{{ $strings.LabelAutoRewindTime }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="
|
||||
show = false
|
||||
manualTimerModal = false
|
||||
">
|
||||
<div class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
|
||||
<div v-if="manualTimerModal" class="p-4">
|
||||
<div class="flex mb-4" @click="manualTimerModal = false">
|
||||
<span class="material-icons text-3xl">arrow_back</span>
|
||||
</div>
|
||||
<div class="flex my-2 justify-between">
|
||||
<ui-btn @click="decreaseManualTimeout" class="w-9 h-9" :padding-x="0" small style="max-width: 36px">
|
||||
<span class="material-icons">remove</span>
|
||||
</ui-btn>
|
||||
<p class="text-2xl font-mono text-center">{{ manualTimeoutMin }} min</p>
|
||||
<ui-btn @click="increaseManualTimeout" class="w-9 h-9" :padding-x="0" small style="max-width: 36px">
|
||||
<span class="material-icons">add</span>
|
||||
</ui-btn>
|
||||
</div>
|
||||
<ui-btn @click="clickedOption(manualTimeoutMin)" class="w-full">{{ $strings.ButtonSetTimer }}</ui-btn>
|
||||
</div>
|
||||
<ul v-else class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||
<template v-for="timeout in timeouts">
|
||||
<li :key="timeout" class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption(timeout)">
|
||||
<div class="flex items-center justify-center">
|
||||
<span class="font-normal block truncate text-lg">{{ timeout }} min</span>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
<li class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="manualTimerModal = true">
|
||||
<div class="flex items-center justify-center">
|
||||
<span class="font-normal block truncate text-lg text-center">{{ $strings.LabelCustomTime }}</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
manualTimerModal: null,
|
||||
manualTimeoutMin: 1
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
timeouts() {
|
||||
return [5, 10, 15, 30, 45, 60, 90]
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async clickedChapterOption() {
|
||||
await this.$hapticsImpact()
|
||||
this.show = false
|
||||
this.$nextTick(() => this.$emit('change', 0))
|
||||
},
|
||||
async clickedOption(timeoutMin) {
|
||||
await this.$hapticsImpact()
|
||||
const timeout = timeoutMin * 1000 * 60
|
||||
this.show = false
|
||||
this.manualTimerModal = false
|
||||
this.$nextTick(() => this.$emit('change', timeout))
|
||||
},
|
||||
async increaseManualTimeout() {
|
||||
await this.$hapticsImpact()
|
||||
this.manualTimeoutMin++
|
||||
},
|
||||
async decreaseManualTimeout() {
|
||||
await this.$hapticsImpact()
|
||||
if (this.manualTimeoutMin > 1) this.manualTimeoutMin--
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" :width="300" height="100%">
|
||||
<modals-modal v-model="show" :width="400" height="100%">
|
||||
<template #outer>
|
||||
<div class="absolute top-8 left-4 z-40">
|
||||
<p class="text-white text-2xl truncate">Bookmarks</p>
|
||||
<div class="absolute top-11 left-4 z-40">
|
||||
<p class="text-white text-2xl truncate">{{ $strings.LabelYourBookmarks }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
|
||||
<div ref="container" class="w-full rounded-lg bg-primary border border-white border-opacity-20 overflow-y-auto overflow-x-hidden" style="max-height: 80vh" @click.stop.prevent>
|
||||
<div ref="container" class="w-full rounded-lg bg-primary border border-border overflow-y-auto overflow-x-hidden" style="max-height: 80vh" @click.stop.prevent>
|
||||
<div class="w-full h-full p-4" v-show="showBookmarkTitleInput">
|
||||
<div class="flex mb-4 items-center">
|
||||
<div class="w-9 h-9 flex items-center justify-center rounded-full hover:bg-white hover:bg-opacity-10 cursor-pointer" @click.stop="showBookmarkTitleInput = false">
|
||||
@@ -14,9 +14,7 @@
|
||||
</div>
|
||||
<p class="text-xl pl-2">{{ selectedBookmark ? 'Edit Bookmark' : 'New Bookmark' }}</p>
|
||||
<div class="flex-grow" />
|
||||
<p class="text-xl font-mono">
|
||||
{{ this.$secondsToTimestamp(currentTime) }}
|
||||
</p>
|
||||
<p class="text-xl font-mono">{{ this.$secondsToTimestamp(currentTime) }}</p>
|
||||
</div>
|
||||
|
||||
<ui-text-input-with-label v-model="newBookmarkTitle" label="Note" />
|
||||
@@ -29,14 +27,12 @@
|
||||
<modals-bookmarks-bookmark-item :key="bookmark.id" :highlight="currentTime === bookmark.time" :bookmark="bookmark" @click="clickBookmark" @edit="editBookmark" @delete="deleteBookmark" />
|
||||
</template>
|
||||
<div v-if="!bookmarks.length" class="flex h-32 items-center justify-center">
|
||||
<p class="text-xl">No Bookmarks</p>
|
||||
<p class="text-xl">{{ $strings.MessageNoBookmarks }}</p>
|
||||
</div>
|
||||
<div v-show="canCreateBookmark" class="flex px-4 py-2 items-center text-center justify-between border-b border-white border-opacity-10 bg-blue-500 bg-opacity-20 cursor-pointer text-white text-opacity-80 hover:bg-opacity-40 hover:text-opacity-100" @click.stop="createBookmark">
|
||||
<div v-show="canCreateBookmark" class="flex px-4 py-2 items-center text-center justify-between border-b border-fg/10 bg-success cursor-pointer text-white text-opacity-80" @click.stop="createBookmark">
|
||||
<span class="material-icons">add</span>
|
||||
<p class="text-base pl-2">Create Bookmark</p>
|
||||
<p class="text-sm font-mono">
|
||||
{{ this.$secondsToTimestamp(currentTime) }}
|
||||
</p>
|
||||
<p class="text-base pl-2">{{ $strings.ButtonCreateBookmark }}</p>
|
||||
<p class="text-sm font-mono">{{ this.$secondsToTimestamp(currentTime) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -98,53 +94,51 @@ export default {
|
||||
await this.$hapticsImpact()
|
||||
const { value } = await Dialog.confirm({
|
||||
title: 'Remove Bookmark',
|
||||
message: `Are you sure you want to remove bookmark?`
|
||||
message: this.$strings.MessageConfirmRemoveBookmark
|
||||
})
|
||||
if (!value) return
|
||||
|
||||
this.$axios
|
||||
.$delete(`/api/me/item/${this.libraryItemId}/bookmark/${bm.time}`)
|
||||
this.$nativeHttp
|
||||
.delete(`/api/me/item/${this.libraryItemId}/bookmark/${bm.time}`)
|
||||
.then(() => {
|
||||
this.$toast.success('Bookmark removed')
|
||||
this.$store.commit('user/deleteBookmark', { libraryItemId: this.libraryItemId, time: bm.time })
|
||||
})
|
||||
.catch((error) => {
|
||||
this.$toast.error(`Failed to remove bookmark`)
|
||||
this.$toast.error(this.$strings.ToastBookmarkRemoveFailed)
|
||||
console.error(error)
|
||||
})
|
||||
this.show = false
|
||||
},
|
||||
async clickBookmark(bm) {
|
||||
await this.$hapticsImpact()
|
||||
this.$emit('select', bm)
|
||||
},
|
||||
submitUpdateBookmark(updatedBookmark) {
|
||||
var bookmark = { ...updatedBookmark }
|
||||
this.$axios
|
||||
.$patch(`/api/me/item/${this.libraryItemId}/bookmark`, bookmark)
|
||||
.then(() => {
|
||||
this.$toast.success('Bookmark updated')
|
||||
this.$nativeHttp
|
||||
.patch(`/api/me/item/${this.libraryItemId}/bookmark`, updatedBookmark)
|
||||
.then((bookmark) => {
|
||||
this.$store.commit('user/updateBookmark', bookmark)
|
||||
this.showBookmarkTitleInput = false
|
||||
})
|
||||
.catch((error) => {
|
||||
this.$toast.error(`Failed to update bookmark`)
|
||||
this.$toast.error(this.$strings.ToastBookmarkUpdateFailed)
|
||||
console.error(error)
|
||||
})
|
||||
this.show = false
|
||||
},
|
||||
submitCreateBookmark() {
|
||||
if (!this.newBookmarkTitle) {
|
||||
this.newBookmarkTitle = this.$formatDate(Date.now(), 'MMM dd, yyyy HH:mm')
|
||||
}
|
||||
var bookmark = {
|
||||
const bookmark = {
|
||||
title: this.newBookmarkTitle,
|
||||
time: Math.floor(this.currentTime)
|
||||
}
|
||||
this.$axios
|
||||
.$post(`/api/me/item/${this.libraryItemId}/bookmark`, bookmark)
|
||||
this.$nativeHttp
|
||||
.post(`/api/me/item/${this.libraryItemId}/bookmark`, bookmark)
|
||||
.then(() => {
|
||||
this.$toast.success('Bookmark added')
|
||||
})
|
||||
.catch((error) => {
|
||||
this.$toast.error(`Failed to create bookmark`)
|
||||
this.$toast.error(this.$strings.ToastBookmarkCreateFailed)
|
||||
console.error(error)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" :width="350" height="100%">
|
||||
<modals-modal v-model="show" :width="400" height="100%">
|
||||
<template #outer>
|
||||
<div v-if="currentChapter" class="absolute top-8 left-4 z-40 pt-0.5" style="max-width: 80%">
|
||||
<p class="text-white text-lg truncate">Current: {{ currentChapterTitle }}</p>
|
||||
<div v-if="currentChapter" class="absolute top-10 left-4 z-40 pt-1" style="max-width: 80%">
|
||||
<p class="text-white text-lg truncate">{{ chapters.length }} {{ $strings.LabelChapters }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
|
||||
<div ref="container" class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
|
||||
<div ref="container" class="w-full overflow-x-hidden overflow-y-auto bg-secondary rounded-lg border border-fg/20" style="max-height: 75%" @click.stop>
|
||||
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||
<template v-for="(chapter, index) in chapters">
|
||||
<li :key="chapter.id" :id="`chapter-row-${chapter.id}`" class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" :class="currentChapterId === chapter.id ? 'bg-bg bg-opacity-80' : ''" role="option" @click="clickedOption(chapter)">
|
||||
<template v-for="chapter in chapters">
|
||||
<li :key="chapter.id" :id="`chapter-row-${chapter.id}`" class="text-fg select-none relative py-4 cursor-pointer" :class="currentChapterId === chapter.id ? 'bg-primary bg-opacity-80' : ''" role="option" @click="clickedOption(chapter)">
|
||||
<div class="relative flex items-center pl-3 pr-20">
|
||||
<p class="font-normal block truncate text-sm text-white text-opacity-80">{{ index + 1 }} - {{ chapter.title }}</p>
|
||||
<p class="font-normal block truncate text-sm text-fg/80">{{ chapter.title }}</p>
|
||||
<div class="absolute top-0 right-3 -mt-0.5">
|
||||
<span class="font-mono text-white text-opacity-90 leading-3 text-sm" style="letter-spacing: -0.5px">{{ $secondsToTimestamp(chapter.start) }}</span>
|
||||
<span class="font-mono text-fg-muted leading-3 text-sm" style="letter-spacing: -0.5px">{{ $secondsToTimestamp(chapter.start / _playbackRate) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -38,14 +38,17 @@ export default {
|
||||
currentChapter: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
}
|
||||
},
|
||||
playbackRate: Number
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
watch: {
|
||||
value(newVal) {
|
||||
this.$nextTick(this.scrollToChapter)
|
||||
if (newVal) {
|
||||
this.$nextTick(this.scrollToChapter)
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -57,11 +60,15 @@ export default {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
_playbackRate() {
|
||||
if (!this.playbackRate || isNaN(this.playbackRate)) return 1
|
||||
return this.playbackRate
|
||||
},
|
||||
currentChapterId() {
|
||||
return this.currentChapter ? this.currentChapter.id : null
|
||||
return this.currentChapter?.id
|
||||
},
|
||||
currentChapterTitle() {
|
||||
return this.currentChapter ? this.currentChapter.title : null
|
||||
return this.currentChapter?.title || null
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -71,12 +78,12 @@ export default {
|
||||
scrollToChapter() {
|
||||
if (!this.currentChapterId) return
|
||||
|
||||
var container = this.$refs.container
|
||||
const container = this.$refs.container
|
||||
if (container) {
|
||||
var currChapterEl = document.getElementById(`chapter-row-${this.currentChapterId}`)
|
||||
const currChapterEl = document.getElementById(`chapter-row-${this.currentChapterId}`)
|
||||
if (currChapterEl) {
|
||||
var offsetTop = currChapterEl.offsetTop
|
||||
var containerHeight = container.clientHeight
|
||||
const offsetTop = currChapterEl.offsetTop
|
||||
const containerHeight = container.clientHeight
|
||||
container.scrollTo({ top: offsetTop - containerHeight / 2 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" :width="300" height="100%">
|
||||
<modals-modal v-model="show" :width="width" height="100%">
|
||||
<template #outer>
|
||||
<div v-if="title" class="absolute top-8 left-4 z-40" style="max-width: 80%">
|
||||
<div v-if="title" class="absolute top-10 left-4 z-40 pt-1 pb-1.5" style="max-width: 80%">
|
||||
<p class="text-white text-xl truncate">{{ title }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
|
||||
<div ref="container" class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20 p-2" style="max-height: 75%" @click.stop>
|
||||
<div ref="container" class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-fg/20 p-2" style="max-height: 75%" @click.stop>
|
||||
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||
<template v-for="item in items">
|
||||
<template v-for="item in itemsToShow">
|
||||
<slot :name="item.value" :item="item" :selected="item.value === selected">
|
||||
<li :key="item.value" class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" :class="selected === item.value ? 'bg-success bg-opacity-10' : ''" role="option" @click="clickedOption(item.value)">
|
||||
<li :key="item.value" :ref="`item-${item.value}`" class="text-fg select-none relative cursor-pointer hover:bg-black-400" :class="selected === item.value ? 'bg-success bg-opacity-10' : ''" :style="{ paddingTop: itemPaddingY, paddingBottom: itemPaddingY }" role="option" @click="clickedOption(item.value)">
|
||||
<div class="relative flex items-center px-3">
|
||||
<span v-if="item.icon" class="material-icons-outlined text-xl mr-2 text-white text-opacity-80">{{ item.icon }}</span>
|
||||
<p class="font-normal block truncate text-base text-white text-opacity-80">{{ item.text }}</p>
|
||||
<span v-if="item.icon" class="material-icons-outlined text-xl mr-2 text-fg text-opacity-80">{{ item.icon }}</span>
|
||||
<p class="font-normal block truncate text-base text-fg text-opacity-80">{{ item.text }}</p>
|
||||
</div>
|
||||
</li>
|
||||
</slot>
|
||||
@@ -34,11 +34,27 @@ export default {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
selected: String // optional
|
||||
selected: [String, Number], // optional
|
||||
itemPaddingY: {
|
||||
type: String,
|
||||
default: '16px'
|
||||
},
|
||||
width: {
|
||||
type: [String, Number],
|
||||
default: 300
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
watch: {
|
||||
show: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
if (newVal) this.$nextTick(this.init)
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
@@ -47,11 +63,32 @@ export default {
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
itemsToShow() {
|
||||
return this.items.map((i) => {
|
||||
if (typeof i === 'string') {
|
||||
return {
|
||||
text: i,
|
||||
value: i
|
||||
}
|
||||
}
|
||||
return i
|
||||
})
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickedOption(action) {
|
||||
this.$emit('action', action)
|
||||
},
|
||||
init() {
|
||||
if (this.selected && this.$refs[`item-${this.selected}`]?.[0]) {
|
||||
// Set scroll position so that selected item is in the center
|
||||
const containerOffset = this.$refs.container.offsetTop + this.$refs.container.clientHeight / 2
|
||||
const scrollAmount = this.$refs[`item-${this.selected}`][0].offsetTop - containerOffset
|
||||
this.$refs.container.scrollTo({
|
||||
top: scrollAmount
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" width="90%" height="100%">
|
||||
<template #outer>
|
||||
<div v-show="selected !== 'all'" class="absolute top-6 left-4 z-40">
|
||||
<ui-btn class="text-xl border-yellow-400 border-opacity-40" @click="clearSelected">Clear</ui-btn>
|
||||
<div v-show="selected !== 'all'" class="absolute top-12 left-4 z-40">
|
||||
<ui-btn class="text-lg border-yellow-400 border-opacity-40 h-10" :padding-y="0" @click="clearSelected">{{ $strings.ButtonClearFilter }}</ui-btn>
|
||||
</div>
|
||||
</template>
|
||||
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
|
||||
<div class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
|
||||
<div class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-fg/20 mt-8" style="max-height: 75%" @click.stop>
|
||||
<ul v-show="!sublist" class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||
<template v-for="item in items">
|
||||
<li :key="item.value" class="text-gray-50 select-none relative py-4 pr-9 cursor-pointer hover:bg-black-400" :class="item.value === selected ? 'bg-bg bg-opacity-50' : ''" role="option" @click="clickedOption(item)">
|
||||
<li :key="item.value" class="text-fg select-none relative py-4 pr-9 cursor-pointer" :class="item.value === selected ? 'bg-bg bg-opacity-50' : ''" role="option" @click="clickedOption(item)">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-normal ml-3 block truncate text-lg">{{ item.text }}</span>
|
||||
</div>
|
||||
@@ -20,12 +20,12 @@
|
||||
</template>
|
||||
</ul>
|
||||
<ul v-show="sublist" class="h-full w-full rounded-lg" role="listbox" aria-labelledby="listbox-label">
|
||||
<li class="text-gray-50 select-none relative py-3 pl-9 cursor-pointer hover:bg-black-400" role="option" @click="sublist = null">
|
||||
<li class="text-fg select-none relative py-3 pl-9 cursor-pointer" role="option" @click="sublist = null">
|
||||
<div class="absolute left-1 top-0 bottom-0 h-full flex items-center">
|
||||
<span class="material-icons text-2xl">arrow_left</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-normal ml-3 block truncate text-lg">Back</span>
|
||||
<span class="font-normal ml-3 block truncate text-lg">{{ $strings.ButtonBack }}</span>
|
||||
</div>
|
||||
</li>
|
||||
<li v-if="!sublistItems.length" class="text-gray-400 select-none relative px-2" role="option">
|
||||
@@ -34,7 +34,7 @@
|
||||
</div>
|
||||
</li>
|
||||
<template v-for="item in sublistItems">
|
||||
<li :key="item.value" class="text-gray-50 select-none relative px-4 cursor-pointer hover:bg-black-400" :class="`${sublist}.${item.value}` === selected ? 'bg-bg bg-opacity-50' : ''" role="option" @click="clickedSublistOption(item.value)">
|
||||
<li :key="item.value" class="text-fg select-none relative px-4 cursor-pointer" :class="`${sublist}.${item.value}` === selected ? 'bg-bg bg-opacity-50' : ''" role="option" @click="clickedSublistOption(item.value)">
|
||||
<div class="flex items-center">
|
||||
<span class="font-normal truncate py-3 text-base">{{ item.text }}</span>
|
||||
</div>
|
||||
@@ -57,62 +57,67 @@ export default {
|
||||
sublist: null,
|
||||
bookItems: [
|
||||
{
|
||||
text: 'All',
|
||||
text: this.$strings.LabelAll,
|
||||
value: 'all'
|
||||
},
|
||||
{
|
||||
text: 'Genre',
|
||||
text: this.$strings.LabelGenre,
|
||||
value: 'genres',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: 'Tag',
|
||||
text: this.$strings.LabelTag,
|
||||
value: 'tags',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: 'Series',
|
||||
text: this.$strings.LabelSeries,
|
||||
value: 'series',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: 'Authors',
|
||||
text: this.$strings.LabelAuthor,
|
||||
value: 'authors',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: 'Narrator',
|
||||
text: this.$strings.LabelNarrator,
|
||||
value: 'narrators',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: 'Language',
|
||||
text: this.$strings.LabelLanguage,
|
||||
value: 'languages',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: 'Progress',
|
||||
text: this.$strings.LabelProgress,
|
||||
value: 'progress',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: 'Issues',
|
||||
text: this.$strings.LabelEbooks,
|
||||
value: 'ebooks',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: this.$strings.ButtonIssues,
|
||||
value: 'issues',
|
||||
sublist: false
|
||||
}
|
||||
],
|
||||
podcastItems: [
|
||||
{
|
||||
text: 'All',
|
||||
text: this.$strings.LabelAll,
|
||||
value: 'all'
|
||||
},
|
||||
{
|
||||
text: 'Genre',
|
||||
text: this.$strings.LabelGenre,
|
||||
value: 'genres',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: 'Tag',
|
||||
text: this.$strings.LabelTag,
|
||||
value: 'tags',
|
||||
sublist: true
|
||||
}
|
||||
@@ -176,19 +181,31 @@ export default {
|
||||
return [
|
||||
{
|
||||
id: 'finished',
|
||||
name: 'Finished'
|
||||
name: this.$strings.LabelFinished
|
||||
},
|
||||
{
|
||||
id: 'in-progress',
|
||||
name: 'In Progress'
|
||||
name: this.$strings.LabelInProgress
|
||||
},
|
||||
{
|
||||
id: 'not-started',
|
||||
name: 'Not Started'
|
||||
name: this.$strings.LabelNotStarted
|
||||
},
|
||||
{
|
||||
id: 'not-finished',
|
||||
name: 'Not Finished'
|
||||
name: this.$strings.LabelNotFinished
|
||||
}
|
||||
]
|
||||
},
|
||||
ebooks() {
|
||||
return [
|
||||
{
|
||||
id: 'ebook',
|
||||
name: this.$strings.LabelHasEbook
|
||||
},
|
||||
{
|
||||
id: 'supplementary',
|
||||
name: this.$strings.LabelHasSupplementaryEbook
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<div ref="wrapper" class="modal modal-bg w-screen fixed bottom-0 left-0 flex items-center justify-center z-50" :class="halfScreen ? 'h-[50vh] min-h-[400px]' : 'h-screen'" @click.stop @touchstart.stop @touchend.stop>
|
||||
<div ref="content" class="relative text-fg h-full w-full bg-bg">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
processing: Boolean,
|
||||
halfScreen: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
el: null,
|
||||
content: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
show(newVal) {
|
||||
if (newVal) {
|
||||
this.setShow()
|
||||
} else {
|
||||
this.setHide()
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
setShow() {
|
||||
this.$store.commit('globals/setIsModalOpen', true)
|
||||
|
||||
document.body.appendChild(this.el)
|
||||
setTimeout(() => {
|
||||
this.content.style.transform = 'translateY(0)'
|
||||
}, 10)
|
||||
document.documentElement.classList.add('modal-open')
|
||||
},
|
||||
setHide() {
|
||||
this.$store.commit('globals/setIsModalOpen', false)
|
||||
|
||||
this.content.style.transform = 'translateY(100vh)'
|
||||
setTimeout(() => {
|
||||
this.el.remove()
|
||||
document.documentElement.classList.remove('modal-open')
|
||||
}, 250)
|
||||
},
|
||||
closeModalEvt() {
|
||||
console.log('Close modal event')
|
||||
this.show = false
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.$eventBus.$on('close-modal', this.closeModalEvt)
|
||||
this.el = this.$refs.wrapper
|
||||
this.content = this.$refs.content
|
||||
this.content.style.transform = 'translateY(100vh)'
|
||||
this.content.style.transition = 'transform 0.25s cubic-bezier(0.16, 1, 0.3, 1)'
|
||||
this.el.remove()
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$eventBus.$off('close-modal', this.closeModalEvt)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,18 +1,18 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" :width="400" height="100%">
|
||||
<template #outer>
|
||||
<div class="absolute top-8 left-4 z-40">
|
||||
<p class="text-white text-2xl truncate">Details</p>
|
||||
<div class="absolute top-11 left-4 z-40">
|
||||
<p class="text-white text-2xl truncate">{{ $strings.HeaderDetails }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
|
||||
<div class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20 p-2" style="max-height: 75%" @click.stop>
|
||||
<div class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-border p-2" style="max-height: 75%" @click.stop>
|
||||
<p class="mb-2">{{ mediaMetadata.title }}</p>
|
||||
|
||||
<div v-if="size" class="text-sm mb-2">Size: {{ $bytesPretty(size) }}</div>
|
||||
<div v-if="size" class="text-sm mb-2">{{ $strings.LabelSize }}: {{ $bytesPretty(size) }}</div>
|
||||
|
||||
<p class="mb-1 text-xs text-gray-200">ID: {{ _libraryItem.id }}</p>
|
||||
<p class="mb-1 text-xs text-fg">ID: {{ _libraryItem.id }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</modals-modal>
|
||||
|
||||
@@ -0,0 +1,463 @@
|
||||
<template>
|
||||
<div>
|
||||
<modals-dialog v-model="show" :items="moreMenuItems" @action="moreMenuAction" />
|
||||
<modals-item-details-modal v-model="showDetailsModal" :library-item="libraryItem" />
|
||||
<modals-dialog v-model="showSendEbookDevicesModal" :title="$strings.LabelSelectADevice" :items="ereaderDeviceItems" @action="sendEbookToDeviceAction" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Dialog } from '@capacitor/dialog'
|
||||
import { AbsFileSystem } from '@/plugins/capacitor'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
processing: Boolean,
|
||||
libraryItem: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
episode: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
rssFeed: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
},
|
||||
hideRssFeedOption: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showDetailsModal: false,
|
||||
showSendEbookDevicesModal: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
userIsAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
moreMenuItems() {
|
||||
const items = []
|
||||
|
||||
// TODO: Implement on iOS
|
||||
if (this.$platform !== 'ios' && !this.isPodcast) {
|
||||
items.push({
|
||||
text: this.$strings.ButtonHistory,
|
||||
value: 'history',
|
||||
icon: 'history'
|
||||
})
|
||||
}
|
||||
|
||||
if (!this.isPodcast || this.episode) {
|
||||
if (!this.userIsFinished) {
|
||||
items.push({
|
||||
text: this.$strings.MessageMarkAsFinished,
|
||||
value: 'markFinished',
|
||||
icon: 'beenhere'
|
||||
})
|
||||
}
|
||||
|
||||
if (this.progressPercent > 0) {
|
||||
items.push({
|
||||
text: this.$strings.MessageDiscardProgress,
|
||||
value: 'discardProgress',
|
||||
icon: 'backspace'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if ((!this.isPodcast && this.serverLibraryItemId) || (this.episode && this.serverEpisodeId)) {
|
||||
items.push({
|
||||
text: this.$strings.LabelAddToPlaylist,
|
||||
value: 'playlist',
|
||||
icon: 'playlist_add'
|
||||
})
|
||||
|
||||
if (this.ereaderDeviceItems.length) {
|
||||
items.push({
|
||||
text: this.$strings.ButtonSendEbookToDevice,
|
||||
value: 'sendEbook',
|
||||
icon: 'send'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (this.showRSSFeedOption) {
|
||||
items.push({
|
||||
text: this.rssFeed ? this.$strings.HeaderRSSFeed : this.$strings.HeaderOpenRSSFeed,
|
||||
value: 'rssFeed',
|
||||
icon: 'rss_feed'
|
||||
})
|
||||
}
|
||||
|
||||
if (this.localLibraryItemId) {
|
||||
items.push({
|
||||
text: this.$strings.ButtonManageLocalFiles,
|
||||
value: 'manageLocal',
|
||||
icon: 'folder'
|
||||
})
|
||||
|
||||
if (!this.isPodcast) {
|
||||
items.push({
|
||||
text: this.$strings.ButtonDeleteLocalItem,
|
||||
value: 'deleteLocal',
|
||||
icon: 'delete'
|
||||
})
|
||||
} else if (this.localEpisodeId) {
|
||||
items.push({
|
||||
text: this.$strings.ButtonDeleteLocalEpisode,
|
||||
value: 'deleteLocalEpisode',
|
||||
icon: 'delete'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.episode) {
|
||||
items.push({
|
||||
text: this.$strings.LabelMoreInfo,
|
||||
value: 'details',
|
||||
icon: 'info'
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
},
|
||||
ereaderDeviceItems() {
|
||||
if (!this.ebookFile || !this.$store.state.libraries.ereaderDevices?.length) return []
|
||||
return this.$store.state.libraries.ereaderDevices.map((d) => {
|
||||
return {
|
||||
text: d.name,
|
||||
value: d.name
|
||||
}
|
||||
})
|
||||
},
|
||||
isConnectedToServer() {
|
||||
if (!this.isLocal) return true
|
||||
if (!this.libraryItem?.serverAddress) return false
|
||||
return this.$store.getters['user/getServerAddress'] === this.libraryItem.serverAddress
|
||||
},
|
||||
isLocal() {
|
||||
return !!this.libraryItem?.isLocal
|
||||
},
|
||||
localLibraryItem() {
|
||||
if (this.isLocal) return this.libraryItem
|
||||
return this.libraryItem?.localLibraryItem || null
|
||||
},
|
||||
localLibraryItemId() {
|
||||
return this.localLibraryItem?.id || null
|
||||
},
|
||||
serverLibraryItemId() {
|
||||
if (!this.isLocal) return this.libraryItem?.id
|
||||
if (this.isConnectedToServer) {
|
||||
return this.libraryItem.libraryItemId
|
||||
}
|
||||
return null
|
||||
},
|
||||
localEpisode() {
|
||||
if (this.isLocal) return this.episode
|
||||
return this.episode?.localEpisode
|
||||
},
|
||||
localEpisodeId() {
|
||||
return this.localEpisode?.id || null
|
||||
},
|
||||
serverEpisodeId() {
|
||||
if (!this.isLocal) return this.episode?.id
|
||||
if (this.isConnectedToServer) {
|
||||
return this.episode.serverEpisodeId
|
||||
}
|
||||
return null
|
||||
},
|
||||
mediaType() {
|
||||
return this.libraryItem?.mediaType
|
||||
},
|
||||
isPodcast() {
|
||||
return this.mediaType == 'podcast'
|
||||
},
|
||||
media() {
|
||||
return this.libraryItem?.media || {}
|
||||
},
|
||||
mediaMetadata() {
|
||||
return this.media.metadata || {}
|
||||
},
|
||||
title() {
|
||||
return this.mediaMetadata.title
|
||||
},
|
||||
tracks() {
|
||||
return this.media.tracks || []
|
||||
},
|
||||
episodes() {
|
||||
return this.media.episodes || []
|
||||
},
|
||||
ebookFile() {
|
||||
return this.media.ebookFile
|
||||
},
|
||||
localItemProgress() {
|
||||
if (this.isPodcast) {
|
||||
if (!this.localEpisodeId) return null
|
||||
return this.$store.getters['globals/getLocalMediaProgressById'](this.localLibraryItemId, this.localEpisodeId)
|
||||
}
|
||||
return this.$store.getters['globals/getLocalMediaProgressById'](this.localLibraryItemId)
|
||||
},
|
||||
serverItemProgress() {
|
||||
if (this.isPodcast) {
|
||||
if (!this.serverEpisodeId) return null
|
||||
return this.$store.getters['user/getUserMediaProgress'](this.serverLibraryItemId, this.serverEpisodeId)
|
||||
}
|
||||
return this.$store.getters['user/getUserMediaProgress'](this.serverLibraryItemId)
|
||||
},
|
||||
userItemProgress() {
|
||||
if (this.isLocal) return this.localItemProgress
|
||||
return this.serverItemProgress
|
||||
},
|
||||
userIsFinished() {
|
||||
return !!this.userItemProgress?.isFinished
|
||||
},
|
||||
useEBookProgress() {
|
||||
if (!this.userItemProgress || this.userItemProgress.progress) return false
|
||||
return this.userItemProgress.ebookProgress > 0
|
||||
},
|
||||
progressPercent() {
|
||||
if (this.useEBookProgress) return Math.max(Math.min(1, this.userItemProgress.ebookProgress), 0)
|
||||
return Math.max(Math.min(1, this.userItemProgress?.progress || 0), 0)
|
||||
},
|
||||
showRSSFeedOption() {
|
||||
if (this.hideRssFeedOption) return false
|
||||
if (!this.serverLibraryItemId) return false
|
||||
if (!this.rssFeed && !this.episodes.length && !this.tracks.length) return false // Cannot open RSS feed with no episodes/tracks
|
||||
|
||||
// If rss feed is open then show feed url to users otherwise just show to admins
|
||||
return this.userIsAdminOrUp || this.rssFeed
|
||||
},
|
||||
mediaId() {
|
||||
if (this.isPodcast) return null
|
||||
return this.serverLibraryItemId || this.localLibraryItemId
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
moreMenuAction(action) {
|
||||
this.show = false
|
||||
if (action === 'manageLocal') {
|
||||
this.$nextTick(() => {
|
||||
this.$router.push(`/localMedia/item/${this.localLibraryItemId}`)
|
||||
})
|
||||
} else if (action === 'details') {
|
||||
this.showDetailsModal = true
|
||||
} else if (action === 'playlist') {
|
||||
this.$store.commit('globals/setSelectedPlaylistItems', [{ libraryItem: this.libraryItem, episode: this.episode }])
|
||||
this.$store.commit('globals/setShowPlaylistsAddCreateModal', true)
|
||||
} else if (action === 'markFinished') {
|
||||
if (this.episode) this.toggleEpisodeFinished()
|
||||
else this.toggleFinished()
|
||||
} else if (action === 'history') {
|
||||
this.$router.push(`/media/${this.mediaId}/history?title=${this.title}`)
|
||||
} else if (action === 'discardProgress') {
|
||||
this.clearProgressClick()
|
||||
} else if (action === 'deleteLocal') {
|
||||
this.deleteLocalItem()
|
||||
} else if (action === 'deleteLocalEpisode') {
|
||||
this.deleteLocalEpisode()
|
||||
} else if (action === 'rssFeed') {
|
||||
this.clickRSSFeed()
|
||||
} else if (action === 'sendEbook') {
|
||||
this.showSendEbookDevicesModal = true
|
||||
}
|
||||
},
|
||||
async toggleFinished() {
|
||||
await this.$hapticsImpact()
|
||||
|
||||
// Show confirm if item has progress since it will reset
|
||||
if (this.userItemProgress && this.userItemProgress.progress > 0 && !this.userIsFinished) {
|
||||
const { value } = await Dialog.confirm({
|
||||
title: 'Confirm',
|
||||
message: this.$strings.MessageConfirmMarkAsFinished
|
||||
})
|
||||
if (!value) return
|
||||
}
|
||||
|
||||
this.$emit('update:processing', true)
|
||||
if (this.isLocal) {
|
||||
const isFinished = !this.userIsFinished
|
||||
const payload = await this.$db.updateLocalMediaProgressFinished({ localLibraryItemId: this.localLibraryItemId, isFinished })
|
||||
console.log('toggleFinished payload', JSON.stringify(payload))
|
||||
if (payload?.error) {
|
||||
this.$toast.error(payload?.error || 'Unknown error')
|
||||
} else {
|
||||
const localMediaProgress = payload.localMediaProgress
|
||||
console.log('toggleFinished localMediaProgress', JSON.stringify(localMediaProgress))
|
||||
if (localMediaProgress) {
|
||||
this.$store.commit('globals/updateLocalMediaProgress', localMediaProgress)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const updatePayload = {
|
||||
isFinished: !this.userIsFinished
|
||||
}
|
||||
await this.$nativeHttp.patch(`/api/me/progress/${this.serverLibraryItemId}`, updatePayload).catch((error) => {
|
||||
console.error('Failed', error)
|
||||
this.$toast.error(updatePayload.isFinished ? this.$strings.ToastItemMarkedAsFinishedFailed : this.$strings.ToastItemMarkedAsNotFinishedFailed)
|
||||
})
|
||||
}
|
||||
this.$emit('update:processing', false)
|
||||
},
|
||||
async toggleEpisodeFinished() {
|
||||
await this.$hapticsImpact()
|
||||
|
||||
this.$emit('update:processing', true)
|
||||
if (this.isLocal || this.localEpisode) {
|
||||
const isFinished = !this.userIsFinished
|
||||
const localLibraryItemId = this.localLibraryItemId
|
||||
const localEpisodeId = this.localEpisodeId
|
||||
const payload = await this.$db.updateLocalMediaProgressFinished({ localLibraryItemId, localEpisodeId, isFinished })
|
||||
console.log('toggleFinished payload', JSON.stringify(payload))
|
||||
|
||||
if (payload?.error) {
|
||||
this.$toast.error(payload?.error || 'Unknown error')
|
||||
} else {
|
||||
const localMediaProgress = payload.localMediaProgress
|
||||
console.log('toggleFinished localMediaProgress', JSON.stringify(localMediaProgress))
|
||||
if (localMediaProgress) {
|
||||
this.$store.commit('globals/updateLocalMediaProgress', localMediaProgress)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const updatePayload = {
|
||||
isFinished: !this.userIsFinished
|
||||
}
|
||||
await this.$nativeHttp.patch(`/api/me/progress/${this.serverLibraryItemId}/${this.serverEpisodeId}`, updatePayload).catch((error) => {
|
||||
console.error('Failed', error)
|
||||
this.$toast.error(updatePayload.isFinished ? this.$strings.ToastItemMarkedAsFinishedFailed : this.$strings.ToastItemMarkedAsNotFinishedFailed)
|
||||
})
|
||||
}
|
||||
this.$emit('update:processing', false)
|
||||
},
|
||||
async clearProgressClick() {
|
||||
await this.$hapticsImpact()
|
||||
|
||||
const { value } = await Dialog.confirm({
|
||||
title: 'Confirm',
|
||||
message: this.$strings.MessageConfirmDiscardProgress
|
||||
})
|
||||
if (value) {
|
||||
this.$emit('update:processing', true)
|
||||
const serverMediaProgressId = this.serverItemProgress?.id
|
||||
if (this.localItemProgress) {
|
||||
await this.$db.removeLocalMediaProgress(this.localItemProgress.id)
|
||||
this.$store.commit('globals/removeLocalMediaProgressForItem', this.localItemProgress.id)
|
||||
}
|
||||
|
||||
if (serverMediaProgressId) {
|
||||
await this.$nativeHttp
|
||||
.delete(`/api/me/progress/${serverMediaProgressId}`)
|
||||
.then(() => {
|
||||
console.log('Progress reset complete')
|
||||
this.$toast.success(`Your progress was reset`)
|
||||
this.$store.commit('user/removeMediaProgress', serverMediaProgressId)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Progress reset failed', error)
|
||||
})
|
||||
}
|
||||
|
||||
this.$emit('update:processing', false)
|
||||
}
|
||||
},
|
||||
async deleteLocalEpisode() {
|
||||
await this.$hapticsImpact()
|
||||
|
||||
const localEpisodeAudioTrack = this.localEpisode.audioTrack
|
||||
const localFile = this.localLibraryItem.localFiles.find((lf) => lf.id === localEpisodeAudioTrack.localFileId)
|
||||
if (!localFile) {
|
||||
this.$toast.error('Audio track does not have matching local file..')
|
||||
return
|
||||
}
|
||||
|
||||
const { value } = await Dialog.confirm({
|
||||
title: 'Confirm',
|
||||
message: this.$getString('MessageConfirmDeleteLocalEpisode', [localFile.basePath])
|
||||
})
|
||||
if (value) {
|
||||
const res = await AbsFileSystem.deleteTrackFromItem({ id: this.localLibraryItemId, trackLocalFileId: localFile.id, trackContentUrl: localEpisodeAudioTrack.contentUrl })
|
||||
if (res?.id) {
|
||||
if (this.isLocal) {
|
||||
// If this is local episode then redirect to server episode when available
|
||||
if (this.serverEpisodeId) {
|
||||
this.$router.replace(`/item/${this.serverLibraryItemId}/${this.serverEpisodeId}`)
|
||||
} else {
|
||||
this.$router.replace(`/item/${this.localLibraryItemId}`)
|
||||
}
|
||||
} else {
|
||||
// Update local library item and local episode
|
||||
this.libraryItem.localLibraryItem = res
|
||||
this.$delete(this.episode, 'localEpisode')
|
||||
}
|
||||
} else this.$toast.error('Failed to delete')
|
||||
}
|
||||
},
|
||||
async deleteLocalItem() {
|
||||
await this.$hapticsImpact()
|
||||
|
||||
const { value } = await Dialog.confirm({
|
||||
title: 'Confirm',
|
||||
message: this.$strings.MessageConfirmDeleteLocalFiles
|
||||
})
|
||||
if (value) {
|
||||
const res = await AbsFileSystem.deleteItem(this.localLibraryItem)
|
||||
if (res?.success) {
|
||||
if (this.isLocal) {
|
||||
// If local then redirect to server version when available
|
||||
if (this.serverLibraryItemId) {
|
||||
this.$router.replace(`/item/${this.serverLibraryItemId}`)
|
||||
} else {
|
||||
this.$router.replace('/bookshelf')
|
||||
}
|
||||
} else {
|
||||
// Remove localLibraryItem
|
||||
this.$delete(this.libraryItem, 'localLibraryItem')
|
||||
}
|
||||
} else this.$toast.error('Failed to delete')
|
||||
}
|
||||
},
|
||||
clickRSSFeed() {
|
||||
this.$store.commit('globals/setRSSFeedOpenCloseModal', {
|
||||
id: this.serverLibraryItemId,
|
||||
name: this.title,
|
||||
type: 'item',
|
||||
feed: this.rssFeed,
|
||||
hasEpisodesWithoutPubDate: this.episodes.some((ep) => !ep.pubDate)
|
||||
})
|
||||
},
|
||||
sendEbookToDeviceAction(deviceName) {
|
||||
this.showSendEbookDevicesModal = false
|
||||
|
||||
const payload = {
|
||||
libraryItemId: this.serverLibraryItemId,
|
||||
deviceName
|
||||
}
|
||||
this.$emit('update:processing', true)
|
||||
this.$nativeHttp
|
||||
.post(`/api/emails/send-ebook-to-device`, payload)
|
||||
.then(() => {
|
||||
this.$toast.success('Ebook sent successfully')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to send ebook to device', error)
|
||||
this.$toast.error('Failed to send ebook to device')
|
||||
})
|
||||
.finally(() => {
|
||||
this.$emit('update:processing', false)
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -1,16 +1,16 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" :width="300" :processing="processing" height="100%">
|
||||
<template #outer>
|
||||
<div class="absolute top-8 left-4 z-40" style="max-width: 80%">
|
||||
<p class="text-white text-2xl truncate">Libraries</p>
|
||||
<div class="absolute top-11 left-4 z-40" style="max-width: 80%">
|
||||
<p class="text-white text-2xl truncate">{{ $strings.HeaderLibraries }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
|
||||
<div class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
|
||||
<div class="w-full overflow-x-hidden overflow-y-auto bg-secondary rounded-lg border border-border" style="max-height: 75%" @click.stop>
|
||||
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||
<template v-for="library in libraries">
|
||||
<li :key="library.id" class="text-gray-50 select-none relative py-3 cursor-pointer hover:bg-black-400" :class="currentLibraryId === library.id ? 'bg-bg bg-opacity-80' : ''" role="option" @click="clickedOption(library)">
|
||||
<li :key="library.id" class="text-fg select-none relative py-3 cursor-pointer" :class="currentLibraryId === library.id ? 'bg-primary bg-opacity-80' : ''" role="option" @click="clickedOption(library)">
|
||||
<div v-show="currentLibraryId === library.id" class="absolute top-0 left-0 w-0.5 bg-warning h-full" />
|
||||
<div class="flex items-center px-3">
|
||||
<ui-library-icon :icon="library.icon" />
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
<div ref="wrapper" class="modal modal-bg w-full h-full max-h-screen fixed top-0 left-0 bg-primary bg-opacity-75 flex items-center justify-center z-50 opacity-0">
|
||||
<div class="absolute top-0 left-0 w-full h-40 bg-gradient-to-b from-black to-transparent opacity-90 pointer-events-none" />
|
||||
|
||||
<div class="absolute z-40 top-6 right-4 h-12 w-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300" @click="show = false">
|
||||
<div class="absolute z-40 top-11 right-4 h-10 w-10 flex items-center justify-center cursor-pointer text-white hover:text-gray-300" @click="show = false">
|
||||
<span class="material-icons text-4xl">close</span>
|
||||
</div>
|
||||
<slot name="outer" />
|
||||
<div ref="content" style="min-height: 200px" class="relative text-white max-h-screen" :style="{ height: modalHeight, width: modalWidth, maxWidth: maxWidth }" v-click-outside="clickBg">
|
||||
<div ref="content" style="min-height: 200px" class="relative text-fg max-h-screen" :style="{ height: modalHeight, width: modalWidth, maxWidth: maxWidth }" v-click-outside="clickBg">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" width="90%">
|
||||
<div class="w-full h-full bg-primary rounded-lg border border-white border-opacity-20">
|
||||
<div class="w-full h-full bg-primary rounded-lg border border-fg/20">
|
||||
<ul class="w-full rounded-lg text-base" role="listbox" aria-labelledby="listbox-label">
|
||||
<template v-for="item in items">
|
||||
<li :key="item.value" class="text-gray-50 select-none relative py-4 pr-9 cursor-pointer hover:bg-black-400" :class="item.value === selected ? 'bg-bg bg-opacity-50' : ''" role="option" @click="clickedOption(item.value)">
|
||||
<li :key="item.value" class="text-fg select-none relative py-4 pr-9 cursor-pointer" :class="item.value === selected ? 'bg-bg bg-opacity-50' : ''" role="option" @click="clickedOption(item.value)">
|
||||
<div class="flex items-center">
|
||||
<span class="font-normal ml-3 block truncate text-lg">{{ item.text }}</span>
|
||||
</div>
|
||||
@@ -22,73 +22,92 @@ export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
orderBy: String,
|
||||
descending: Boolean
|
||||
descending: Boolean,
|
||||
episodes: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
bookItems: [
|
||||
{
|
||||
text: 'Title',
|
||||
text: this.$strings.LabelTitle,
|
||||
value: 'media.metadata.title'
|
||||
},
|
||||
{
|
||||
text: 'Author (First Last)',
|
||||
text: this.$strings.LabelAuthorFirstLast,
|
||||
value: 'media.metadata.authorName'
|
||||
},
|
||||
{
|
||||
text: 'Author (Last, First)',
|
||||
text: this.$strings.LabelAuthorLastFirst,
|
||||
value: 'media.metadata.authorNameLF'
|
||||
},
|
||||
{
|
||||
text: 'Published Year',
|
||||
text: this.$strings.LabelPublishYear,
|
||||
value: 'media.metadata.publishedYear'
|
||||
},
|
||||
{
|
||||
text: 'Added At',
|
||||
text: this.$strings.LabelAddedAt,
|
||||
value: 'addedAt'
|
||||
},
|
||||
{
|
||||
text: 'Size',
|
||||
text: this.$strings.LabelSize,
|
||||
value: 'size'
|
||||
},
|
||||
{
|
||||
text: 'Duration',
|
||||
text: this.$strings.LabelDuration,
|
||||
value: 'media.duration'
|
||||
},
|
||||
{
|
||||
text: 'File Birthtime',
|
||||
text: this.$strings.LabelFileBirthtime,
|
||||
value: 'birthtimeMs'
|
||||
},
|
||||
{
|
||||
text: 'File Modified',
|
||||
text: this.$strings.LabelFileModified,
|
||||
value: 'mtimeMs'
|
||||
}
|
||||
],
|
||||
podcastItems: [
|
||||
{
|
||||
text: 'Title',
|
||||
text: this.$strings.LabelTitle,
|
||||
value: 'media.metadata.title'
|
||||
},
|
||||
{
|
||||
text: 'Author',
|
||||
text: this.$strings.LabelAuthor,
|
||||
value: 'media.metadata.author'
|
||||
},
|
||||
{
|
||||
text: 'Added At',
|
||||
text: this.$strings.LabelAddedAt,
|
||||
value: 'addedAt'
|
||||
},
|
||||
{
|
||||
text: 'Size',
|
||||
text: this.$strings.LabelSize,
|
||||
value: 'size'
|
||||
},
|
||||
{
|
||||
text: 'File Birthtime',
|
||||
text: this.$strings.LabelFileBirthtime,
|
||||
value: 'birthtimeMs'
|
||||
},
|
||||
{
|
||||
text: 'File Modified',
|
||||
text: this.$strings.LabelFileModified,
|
||||
value: 'mtimeMs'
|
||||
}
|
||||
],
|
||||
episodeItems: [
|
||||
{
|
||||
text: this.$strings.LabelPubDate,
|
||||
value: 'publishedAt'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelTitle,
|
||||
value: 'title'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelSeason,
|
||||
value: 'season'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelEpisode,
|
||||
value: 'episode'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -121,6 +140,7 @@ export default {
|
||||
return this.$store.getters['libraries/getCurrentLibraryMediaType'] === 'podcast'
|
||||
},
|
||||
items() {
|
||||
if (this.episodes) return this.episodeItems
|
||||
if (this.isPodcast) return this.podcastItems
|
||||
return this.bookItems
|
||||
}
|
||||
|
||||
@@ -2,29 +2,29 @@
|
||||
<modals-modal v-model="show" @input="modalInput" :width="200" height="100%">
|
||||
<template #outer>
|
||||
<div class="absolute top-8 left-4 z-40">
|
||||
<p class="text-white text-2xl truncate">Playback Speed</p>
|
||||
<p class="text-white text-2xl truncate">{{ $strings.LabelPlaybackSpeed }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center">
|
||||
<div class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
|
||||
<div class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-border" style="max-height: 75%" @click.stop>
|
||||
<ul class="w-full" role="listbox" aria-labelledby="listbox-label">
|
||||
<template v-for="rate in rates">
|
||||
<li :key="rate" class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" :class="rate === selected ? 'bg-bg bg-opacity-80' : ''" role="option" @click="clickedOption(rate)">
|
||||
<li :key="rate" class="text-fg select-none relative py-4" :class="rate === selected ? 'bg-bg-hover/50' : ''" role="option" @click="clickedOption(rate)">
|
||||
<div class="flex items-center justify-center">
|
||||
<span class="font-normal block truncate text-lg">{{ rate }}x</span>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
<div class="flex items-center justify-center py-3 border-t border-white border-opacity-10">
|
||||
<button :disabled="!canDecrement" @click="decrement" class="icon-num-btn w-8 h-8 text-white text-opacity-75 rounded border border-white border-opacity-20 flex items-center justify-center">
|
||||
<div class="flex items-center justify-center py-3 border-t border-fg/10">
|
||||
<button :disabled="!canDecrement" @click="decrement" class="icon-num-btn w-8 h-8 text-fg-muted rounded border border-border flex items-center justify-center">
|
||||
<span class="material-icons">remove</span>
|
||||
</button>
|
||||
<div class="w-24 text-center">
|
||||
<p class="text-xl">{{ playbackRate }}<span class="text-lg">⨯</span></p>
|
||||
</div>
|
||||
<button :disabled="!canIncrement" @click="increment" class="icon-num-btn w-8 h-8 text-white text-opacity-75 rounded border border-white border-opacity-20 flex items-center justify-center">
|
||||
<button :disabled="!canIncrement" @click="increment" class="icon-num-btn w-8 h-8 text-fg-muted rounded border border-border flex items-center justify-center">
|
||||
<span class="material-icons">add</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -96,8 +96,8 @@ export default {
|
||||
}
|
||||
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$post(`/api/podcasts/${this.libraryItem.id}/download-episodes`, episodesToDownload)
|
||||
this.$nativeHttp
|
||||
.post(`/api/podcasts/${this.libraryItem.id}/download-episodes`, episodesToDownload)
|
||||
.then(() => {
|
||||
this.processing = false
|
||||
this.$toast.success('Started downloading episodes on server')
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" :width="300" height="100%">
|
||||
<template #outer>
|
||||
<div class="absolute top-8 left-4 z-40" style="max-width: 80%">
|
||||
<p class="text-white text-lg truncate">Select Local Folder</p>
|
||||
<div class="absolute top-10 left-4 z-40" style="max-width: 80%">
|
||||
<p class="text-white text-lg truncate">{{ $strings.HeaderSelectDownloadLocation }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -10,8 +10,9 @@
|
||||
<div ref="container" class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
|
||||
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||
<template v-for="folder in localFolders">
|
||||
<li :key="folder.id" :id="`folder-${folder.id}`" class="text-gray-50 select-none relative py-4" role="option" @click="clickedOption(folder)">
|
||||
<li :key="folder.id" :id="`folder-${folder.id}`" class="text-gray-50 select-none relative py-5" role="option" @click="clickedOption(folder)">
|
||||
<div class="relative flex items-center pl-3" style="padding-right: 4.5rem">
|
||||
<span class="material-icons-outlined text-xl mr-2 text-white text-opacity-80">folder</span>
|
||||
<p class="font-normal block truncate text-sm text-white text-opacity-80">{{ folder.name }}</p>
|
||||
</div>
|
||||
</li>
|
||||
@@ -24,36 +25,56 @@
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
mediaType: String
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
localFolders: []
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
value(newVal) {
|
||||
this.$nextTick(this.init)
|
||||
show(newVal) {
|
||||
if (newVal) {
|
||||
this.$nextTick(this.init)
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
return this.$store.state.globals.showSelectLocalFolderModal
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
this.$store.commit('globals/setShowSelectLocalFolderModal', val)
|
||||
}
|
||||
},
|
||||
modalData() {
|
||||
return this.$store.state.globals.localFolderSelectData || {}
|
||||
},
|
||||
callback() {
|
||||
return this.modalData.callback
|
||||
},
|
||||
mediaType() {
|
||||
return this.modalData.mediaType
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickedOption(folder) {
|
||||
this.$emit('select', folder)
|
||||
this.show = false
|
||||
if (!this.callback) {
|
||||
console.error('Callback not set')
|
||||
return
|
||||
}
|
||||
this.callback(folder)
|
||||
},
|
||||
async init() {
|
||||
var localFolders = (await this.$db.getLocalFolders()) || []
|
||||
const localFolders = (await this.$db.getLocalFolders()) || []
|
||||
|
||||
if (!localFolders.some((lf) => lf.id === `internal-${this.mediaType}`)) {
|
||||
localFolders.push({
|
||||
id: `internal-${this.mediaType}`,
|
||||
name: this.$strings.LabelInternalAppStorage,
|
||||
mediaType: this.mediaType
|
||||
})
|
||||
}
|
||||
this.localFolders = localFolders.filter((lf) => lf.mediaType == this.mediaType)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<modals-modal v-model="show" :width="200" height="100%">
|
||||
<template #outer>
|
||||
<div class="absolute top-8 left-4 z-40">
|
||||
<p class="text-white text-2xl truncate">Sleep Timer</p>
|
||||
<p class="text-white text-2xl truncate">{{ $strings.HeaderSleepTimer }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -23,24 +23,24 @@
|
||||
<p class="text-2xl font-mono text-center">{{ manualTimeoutMin }} min</p>
|
||||
<ui-btn @click="increaseManualTimeout" class="w-9 h-9" :padding-x="0" small style="max-width: 36px"><span class="material-icons">add</span></ui-btn>
|
||||
</div>
|
||||
<ui-btn @click="clickedOption(manualTimeoutMin)" class="w-full">Set Timer</ui-btn>
|
||||
<ui-btn @click="clickedOption(manualTimeoutMin)" class="w-full">{{ $strings.ButtonSetTimer }}</ui-btn>
|
||||
</div>
|
||||
<ul v-else class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||
<template v-for="timeout in timeouts">
|
||||
<li :key="timeout" class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption(timeout)">
|
||||
<li :key="timeout" class="text-fg select-none relative py-4" role="option" @click="clickedOption(timeout)">
|
||||
<div class="flex items-center justify-center">
|
||||
<span class="font-normal block truncate text-lg">{{ timeout }} min</span>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
<li class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="clickedChapterOption">
|
||||
<li class="text-fg select-none relative py-4" role="option" @click="clickedChapterOption">
|
||||
<div class="flex items-center justify-center">
|
||||
<span class="font-normal block truncate text-lg text-center">End of Chapter</span>
|
||||
<span class="font-normal block truncate text-lg text-center">{{ $strings.LabelEndOfChapter }}</span>
|
||||
</div>
|
||||
</li>
|
||||
<li class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="manualTimerModal = true">
|
||||
<li class="text-fg select-none relative py-4" role="option" @click="manualTimerModal = true">
|
||||
<div class="flex items-center justify-center">
|
||||
<span class="font-normal block truncate text-lg text-center">Custom time</span>
|
||||
<span class="font-normal block truncate text-lg text-center">{{ $strings.LabelCustomTime }}</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<modals-modal v-model="show" :width="200" height="100%">
|
||||
<template #outer>
|
||||
<div class="absolute top-8 left-4 z-40">
|
||||
<p class="text-white text-2xl truncate">Sleep Timer</p>
|
||||
<p class="text-white text-2xl truncate">{{ $strings.HeaderSleepTimer }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
manualTimerModal = false
|
||||
"
|
||||
>
|
||||
<div class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
|
||||
<div class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-border" style="max-height: 75%" @click.stop>
|
||||
<div v-if="manualTimerModal" class="p-4">
|
||||
<div class="flex mb-4" @click="manualTimerModal = false">
|
||||
<span class="material-icons text-3xl">arrow_back</span>
|
||||
@@ -23,24 +23,24 @@
|
||||
<p class="text-2xl font-mono text-center">{{ manualTimeoutMin }} min</p>
|
||||
<ui-btn @click="increaseManualTimeout" class="w-9 h-9" :padding-x="0" small style="max-width: 36px"><span class="material-icons">add</span></ui-btn>
|
||||
</div>
|
||||
<ui-btn @click="clickedOption(manualTimeoutMin)" class="w-full">Set Timer</ui-btn>
|
||||
<ui-btn @click="clickedOption(manualTimeoutMin)" class="w-full">{{ $strings.ButtonSetTimer }}</ui-btn>
|
||||
</div>
|
||||
<ul v-else-if="!sleepTimerRunning" class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||
<template v-for="timeout in timeouts">
|
||||
<li :key="timeout" class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption(timeout)">
|
||||
<li :key="timeout" class="text-fg select-none relative py-4" role="option" @click="clickedOption(timeout)">
|
||||
<div class="flex items-center justify-center">
|
||||
<span class="font-normal block truncate text-lg">{{ timeout }} min</span>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
<li v-if="currentEndOfChapterTime" class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="clickedChapterOption(timeout)">
|
||||
<li v-if="currentEndOfChapterTime" class="text-fg select-none relative py-4" role="option" @click="clickedChapterOption(timeout)">
|
||||
<div class="flex items-center justify-center">
|
||||
<span class="font-normal block truncate text-lg text-center">End of Chapter</span>
|
||||
<span class="font-normal block truncate text-lg text-center">{{ $strings.LabelEndOfChapter }}</span>
|
||||
</div>
|
||||
</li>
|
||||
<li class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="manualTimerModal = true">
|
||||
<li class="text-fg select-none relative py-4" role="option" @click="manualTimerModal = true">
|
||||
<div class="flex items-center justify-center">
|
||||
<span class="font-normal block truncate text-lg text-center">Custom time</span>
|
||||
<span class="font-normal block truncate text-lg text-center">{{ $strings.LabelCustomTime }}</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -51,7 +51,7 @@
|
||||
<ui-btn @click="increaseSleepTime" class="w-9 h-9" :padding-x="0" small style="max-width: 36px"><span class="material-icons">add</span></ui-btn>
|
||||
</div>
|
||||
|
||||
<ui-btn @click="cancelSleepTimer" class="w-full">{{ isAuto ? 'Disable Auto Timer' : 'Cancel Timer' }}</ui-btn>
|
||||
<ui-btn @click="cancelSleepTimer" class="w-full">{{ isAuto ? $strings.ButtonDisableAutoTimer : $strings.ButtonCancelTimer }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
<template>
|
||||
<div :key="bookmark.id" :id="`bookmark-row-${bookmark.id}`" class="flex items-center px-3 py-4 justify-start cursor-pointer hover:bg-bg relative" :class="highlight ? 'bg-bg bg-opacity-60' : ' bg-opacity-20'" @click="click">
|
||||
<span class="material-icons text-xl" :class="highlight ? 'text-success' : 'text-white text-opacity-60'">{{ highlight ? 'bookmark' : 'bookmark_border' }}</span>
|
||||
<div class="flex-grow overflow-hidden">
|
||||
<p class="pl-2 pr-2 truncate text-sm">{{ bookmark.title }}</p>
|
||||
<div :key="bookmark.id" :id="`bookmark-row-${bookmark.id}`" class="flex items-center px-1 py-4 justify-start relative" :class="highlight ? 'bg-bg bg-opacity-60' : ' bg-opacity-20'" @click="click">
|
||||
<div class="flex-grow overflow-hidden px-2">
|
||||
<div class="flex items-center mb-0.5">
|
||||
<i class="material-icons text-lg pr-1 -mb-1" :class="highlight ? 'text-success' : 'text-fg-muted'">{{ highlight ? 'bookmark' : 'bookmark_border' }}</i>
|
||||
<p class="truncate text-sm">
|
||||
{{ bookmark.title }}
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-sm font-mono text-fg-muted flex items-center"><span class="material-icons text-base pl-px pr-1">schedule</span>{{ $secondsToTimestamp(bookmark.time) }}</p>
|
||||
</div>
|
||||
<div class="h-full flex items-center w-14 justify-end">
|
||||
<span class="font-mono text-xs text-gray-300">{{ $secondsToTimestamp(bookmark.time) }}</span>
|
||||
</div>
|
||||
<div class="h-full flex items-center justify-end transform w-16">
|
||||
<span class="material-icons text-lg mr-2 text-gray-200 hover:text-yellow-400" @click.stop="editClick">edit</span>
|
||||
<span class="material-icons text-lg text-gray-200 hover:text-error cursor-pointer" @click.stop="deleteClick">delete</span>
|
||||
<div class="h-full flex items-center justify-end transform w-16 pr-2" @click.stop>
|
||||
<span class="material-icons text-2xl mr-2 text-fg hover:text-yellow-400" @click.stop="editClick">edit</span>
|
||||
<span class="material-icons text-2xl text-fg hover:text-error" @click.stop="deleteClick">delete</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user