mirror of
https://github.com/advplyr/audiobookshelf-app.git
synced 2026-08-06 20:08:48 +02:00
Compare commits
165
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87614bc78a | ||
|
|
224d75fac5 | ||
|
|
f4e0a6121f | ||
|
|
80ee88b488 | ||
|
|
79d8ccbf52 | ||
|
|
b29401909f | ||
|
|
beb5e1a56c | ||
|
|
be08efeca3 | ||
|
|
4534ffaead | ||
|
|
d35dd2df1a | ||
|
|
2f3a9a5d96 | ||
|
|
fab94cd363 | ||
|
|
b06274866d | ||
|
|
5766c49f61 | ||
|
|
bc927d4c35 | ||
|
|
5804c54656 | ||
|
|
52f86cbce9 | ||
|
|
f6e2e4010f | ||
|
|
44613e12f1 | ||
|
|
b99e0b112b | ||
|
|
80b565c23f | ||
|
|
bee88c43e5 | ||
|
|
467fedbfe7 | ||
|
|
ba4e9ab7e3 | ||
|
|
1b4fa5e44a | ||
|
|
e92c9b2990 | ||
|
|
f23f8e9ade | ||
|
|
dc00aeece4 | ||
|
|
d8cdb7073e | ||
|
|
4fdea9b0ec | ||
|
|
3ded7687f7 | ||
|
|
42119425af | ||
|
|
93c7e1c44a | ||
|
|
381fd84839 | ||
|
|
67bab72783 | ||
|
|
b9c084a442 | ||
|
|
a77f345596 | ||
|
|
6a2f487ed5 | ||
|
|
616cf9029d | ||
|
|
3f21b4172b | ||
|
|
62f6a11522 | ||
|
|
ae1dce455d | ||
|
|
7b5dc52620 | ||
|
|
0bf50de54d | ||
|
|
1eec470902 | ||
|
|
867dc69479 | ||
|
|
9792f4ebc1 | ||
|
|
a44fc41caa | ||
|
|
1927dbf0ad | ||
|
|
7052674f32 | ||
|
|
9a487f3bb6 | ||
|
|
d6242c350d | ||
|
|
4cc4aceb41 | ||
|
|
dd8f4d6201 | ||
|
|
f44dcdf124 | ||
|
|
65e8f2317e | ||
|
|
1c4f43db7c | ||
|
|
24b9df6fbd | ||
|
|
c46891ecb0 | ||
|
|
e723601f6b | ||
|
|
77545df174 | ||
|
|
7e136b17a5 | ||
|
|
a970a06f57 | ||
|
|
95dd78d4aa | ||
|
|
eeaf33be20 | ||
|
|
7ed0cd26fe | ||
|
|
d26403c800 | ||
|
|
5ed6e3a6a3 | ||
|
|
7e83405370 | ||
|
|
a5de31da2b | ||
|
|
39c06005ed | ||
|
|
9479160044 | ||
|
|
46ec7369e5 | ||
|
|
424e7d742b | ||
|
|
44036399b9 | ||
|
|
e4394da467 | ||
|
|
b837ada3f8 | ||
|
|
a623de89aa | ||
|
|
2768407907 | ||
|
|
64ed35d2b1 | ||
|
|
b92f4dfa02 | ||
|
|
e4f02b7a51 | ||
|
|
24c3f45018 | ||
|
|
fcf55eb581 | ||
|
|
9ade7add32 | ||
|
|
d16dfcb4b4 | ||
|
|
37c58a5d32 | ||
|
|
e6cdde4f70 | ||
|
|
93c0adecbd | ||
|
|
33d44a5181 | ||
|
|
80e6fa7e7a | ||
|
|
b8eed48112 | ||
|
|
8d563dcfed | ||
|
|
1cf36e5549 | ||
|
|
1357a0628f | ||
|
|
c4fe0680f3 | ||
|
|
16694aa932 | ||
|
|
ea59ad2953 | ||
|
|
cf2f684e80 | ||
|
|
01522eda43 | ||
|
|
a200ee1310 | ||
|
|
579a07baa2 | ||
|
|
a82dd88b1f | ||
|
|
36ba98920a | ||
|
|
47cc0c34eb | ||
|
|
53eaca8724 | ||
|
|
8a2814b391 | ||
|
|
345c8ab217 | ||
|
|
7c010895e0 | ||
|
|
e716cfd19b | ||
|
|
461a6c8be5 | ||
|
|
3360f1a3f6 | ||
|
|
8b939e8a02 | ||
|
|
3a8998081c | ||
|
|
9b986a7b15 | ||
|
|
69ab395798 | ||
|
|
7aa3c33348 | ||
|
|
734328dd1f | ||
|
|
7d6c30e733 | ||
|
|
3e682dfe50 | ||
|
|
50efda3858 | ||
|
|
ca1b9e2bdb | ||
|
|
aa3a91b9d9 | ||
|
|
7117608445 | ||
|
|
6de640f53f | ||
|
|
75627ea40f | ||
|
|
0fa04d7d9a | ||
|
|
1e76ebe075 | ||
|
|
965da2fee3 | ||
|
|
b03f59ace3 | ||
|
|
79c7244b24 | ||
|
|
0843be3f4e | ||
|
|
6911f368e5 | ||
|
|
42167ea738 | ||
|
|
1c57d3506a | ||
|
|
8adc6fae4c | ||
|
|
e9746577ba | ||
|
|
9f68730622 | ||
|
|
f019b67b0c | ||
|
|
0f650c0572 | ||
|
|
dfc77ea0d0 | ||
|
|
67f514524f | ||
|
|
d97c6a0872 | ||
|
|
e7ad62760f | ||
|
|
fd34ea8124 | ||
|
|
5db94bf5b8 | ||
|
|
796d6d79d4 | ||
|
|
03aafafe1c | ||
|
|
3f303abc19 | ||
|
|
85d6958025 | ||
|
|
669bd7827b | ||
|
|
2b48f0c6a9 | ||
|
|
2c44d38906 | ||
|
|
750726ff6a | ||
|
|
dafab492fe | ||
|
|
6419c8dc3a | ||
|
|
3bb5ce5924 | ||
|
|
882c2749ab | ||
|
|
26b0fae0fb | ||
|
|
fe921fd5b1 | ||
|
|
88e1877742 | ||
|
|
74758c7762 | ||
|
|
2000534e37 | ||
|
|
390388fe83 | ||
|
|
b9e3ccd0c1 |
@@ -72,10 +72,12 @@ body:
|
|||||||
description: Please ensure your app is up to date. *If you are using a 3rd-party app, please reach out to them directly.*
|
description: Please ensure your app is up to date. *If you are using a 3rd-party app, please reach out to them directly.*
|
||||||
multiple: true
|
multiple: true
|
||||||
options:
|
options:
|
||||||
|
- 'Android App - 0.9.81'
|
||||||
|
- 'iOS App - 0.9.81'
|
||||||
|
- 'Android App - 0.9.80'
|
||||||
|
- 'iOS App - 0.9.80'
|
||||||
- 'Android App - 0.9.79'
|
- 'Android App - 0.9.79'
|
||||||
- 'iOS App - 0.9.79'
|
- 'iOS App - 0.9.79'
|
||||||
- 'Android App - 0.9.78'
|
|
||||||
- 'iOS App - 0.9.78'
|
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
|
|||||||
@@ -43,10 +43,12 @@ body:
|
|||||||
description: Please ensure your app is up to date. *If you are using a 3rd-party app, please reach out to them directly.*
|
description: Please ensure your app is up to date. *If you are using a 3rd-party app, please reach out to them directly.*
|
||||||
multiple: true
|
multiple: true
|
||||||
options:
|
options:
|
||||||
- Android App - 0.9.79
|
- 'Android App - 0.9.81'
|
||||||
- iOS App - 0.9.79
|
- 'iOS App - 0.9.81'
|
||||||
- Android App - 0.9.78
|
- 'Android App - 0.9.80'
|
||||||
- iOS App - 0.9.78
|
- 'iOS App - 0.9.80'
|
||||||
|
- 'Android App - 0.9.79'
|
||||||
|
- 'iOS App - 0.9.79'
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: textarea
|
- type: textarea
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<!--
|
||||||
|
For Work In Progress Pull Requests, please use the Draft PR feature,
|
||||||
|
see https://github.blog/2019-02-14-introducing-draft-pull-requests/ for further details.
|
||||||
|
|
||||||
|
If you do not follow this template, the PR may be closed without review.
|
||||||
|
|
||||||
|
Please ensure all checks pass.
|
||||||
|
If you are a new contributor, the workflows will need to be manually approved before they run.
|
||||||
|
-->
|
||||||
|
|
||||||
|
## Brief summary
|
||||||
|
|
||||||
|
<!-- Please provide a brief summary of what your PR attempts to achieve. -->
|
||||||
|
|
||||||
|
## Which issue is fixed?
|
||||||
|
|
||||||
|
<!-- Which issue number does this PR fix? Ex: "Fixes #1234" -->
|
||||||
|
|
||||||
|
## Pull Request Type
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Does this affect only Android, only iOS, or both?
|
||||||
|
Does this change the frontend or the backend of the apps?
|
||||||
|
-->
|
||||||
|
|
||||||
|
## In-depth Description
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Describe your solution in more depth.
|
||||||
|
How does it work? Why is this the best solution?
|
||||||
|
Does it solve a problem that affects multiple users or is this an edge case for your setup?
|
||||||
|
-->
|
||||||
|
|
||||||
|
## How have you tested this?
|
||||||
|
|
||||||
|
<!-- Please describe in detail with reproducible steps how you tested your changes. -->
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
<!-- If your PR includes any changes to the front-end, please include screenshots or a
|
||||||
|
short video from before and after your changes. -->
|
||||||
@@ -24,7 +24,7 @@ jobs:
|
|||||||
uses: actions/setup-java@v2
|
uses: actions/setup-java@v2
|
||||||
with:
|
with:
|
||||||
distribution: 'temurin'
|
distribution: 'temurin'
|
||||||
java-version: 17
|
java-version: 21
|
||||||
|
|
||||||
- name: install dependencies
|
- name: install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|||||||
Vendored
+6
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"java.project.root": "android",
|
||||||
|
"java.import.gradle.enabled": true,
|
||||||
|
"java.import.gradle.wrapper.enabled": true,
|
||||||
|
"java.import.gradle.user.home": "${workspaceFolder}/.gradle"
|
||||||
|
}
|
||||||
@@ -36,8 +36,8 @@ android {
|
|||||||
applicationId "com.audiobookshelf.app"
|
applicationId "com.audiobookshelf.app"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 110
|
versionCode 112
|
||||||
versionName "0.9.79-beta"
|
versionName "0.9.81-beta"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
manifestPlaceholders = [
|
manifestPlaceholders = [
|
||||||
"appAuthRedirectScheme": "com.audiobookshelf.app"
|
"appAuthRedirectScheme": "com.audiobookshelf.app"
|
||||||
|
|||||||
@@ -5,6 +5,10 @@
|
|||||||
"plugins": {
|
"plugins": {
|
||||||
"CapacitorHttp": {
|
"CapacitorHttp": {
|
||||||
"enabled": false
|
"enabled": false
|
||||||
|
},
|
||||||
|
"StatusBar": {
|
||||||
|
"backgroundColor": "#232323",
|
||||||
|
"style": "DARK"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"server": {
|
"server": {
|
||||||
|
|||||||
@@ -6,10 +6,15 @@ import android.content.Context
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.ServiceConnection
|
import android.content.ServiceConnection
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.view.WindowInsets
|
||||||
|
import android.webkit.WebView
|
||||||
import androidx.core.app.ActivityCompat
|
import androidx.core.app.ActivityCompat
|
||||||
|
import androidx.core.view.updateLayoutParams
|
||||||
import com.anggrayudi.storage.SimpleStorage
|
import com.anggrayudi.storage.SimpleStorage
|
||||||
import com.anggrayudi.storage.SimpleStorageHelper
|
import com.anggrayudi.storage.SimpleStorageHelper
|
||||||
import com.audiobookshelf.app.managers.DbManager
|
import com.audiobookshelf.app.managers.DbManager
|
||||||
@@ -18,6 +23,7 @@ import com.audiobookshelf.app.plugins.AbsAudioPlayer
|
|||||||
import com.audiobookshelf.app.plugins.AbsDatabase
|
import com.audiobookshelf.app.plugins.AbsDatabase
|
||||||
import com.audiobookshelf.app.plugins.AbsDownloader
|
import com.audiobookshelf.app.plugins.AbsDownloader
|
||||||
import com.audiobookshelf.app.plugins.AbsFileSystem
|
import com.audiobookshelf.app.plugins.AbsFileSystem
|
||||||
|
import com.audiobookshelf.app.plugins.AbsLogger
|
||||||
import com.getcapacitor.BridgeActivity
|
import com.getcapacitor.BridgeActivity
|
||||||
|
|
||||||
|
|
||||||
@@ -39,29 +45,58 @@ class MainActivity : BridgeActivity() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
public override fun onCreate(savedInstanceState: Bundle?) {
|
public override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
// TODO: Optimize using strict mode logs
|
|
||||||
// StrictMode.setThreadPolicy(StrictMode.ThreadPolicy.Builder()
|
|
||||||
// .detectDiskReads()
|
|
||||||
// .detectDiskWrites().detectAll()
|
|
||||||
// .detectNetwork() // or .detectAll() for all detectable problems
|
|
||||||
// .penaltyLog()
|
|
||||||
// .build())
|
|
||||||
// StrictMode.setVmPolicy(VmPolicy.Builder()
|
|
||||||
// .detectLeakedSqlLiteObjects()
|
|
||||||
// .detectLeakedClosableObjects()
|
|
||||||
// .penaltyLog()
|
|
||||||
// .build())
|
|
||||||
DbManager.initialize(applicationContext)
|
DbManager.initialize(applicationContext)
|
||||||
|
|
||||||
registerPlugin(AbsAudioPlayer::class.java)
|
registerPlugin(AbsAudioPlayer::class.java)
|
||||||
registerPlugin(AbsDownloader::class.java)
|
registerPlugin(AbsDownloader::class.java)
|
||||||
registerPlugin(AbsFileSystem::class.java)
|
registerPlugin(AbsFileSystem::class.java)
|
||||||
registerPlugin(AbsDatabase::class.java)
|
registerPlugin(AbsDatabase::class.java)
|
||||||
|
registerPlugin(AbsLogger::class.java)
|
||||||
|
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
Log.d(tag, "onCreate")
|
Log.d(tag, "onCreate")
|
||||||
|
|
||||||
|
// Update the margins to handle edge-to-edge enforced in SDK 35
|
||||||
|
// See: https://developer.android.com/develop/ui/views/layout/edge-to-edge
|
||||||
|
val webView: WebView = findViewById(R.id.webview)
|
||||||
|
webView.setOnApplyWindowInsetsListener { v, insets ->
|
||||||
|
val (left, top, right, bottom) = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
val sysInsets = insets.getInsets(WindowInsets.Type.systemBars())
|
||||||
|
Log.d(tag, "safe sysInsets: $sysInsets")
|
||||||
|
arrayOf(sysInsets.left, sysInsets.top, sysInsets.right, sysInsets.bottom)
|
||||||
|
} else {
|
||||||
|
arrayOf(
|
||||||
|
insets.systemWindowInsetLeft,
|
||||||
|
insets.systemWindowInsetTop,
|
||||||
|
insets.systemWindowInsetRight,
|
||||||
|
insets.systemWindowInsetBottom
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject as CSS variables
|
||||||
|
// NOTE: Possibly able to use in the future to support edge-to-edge better.
|
||||||
|
val js = """
|
||||||
|
document.documentElement.style.setProperty('--safe-area-inset-top', '${top}px');
|
||||||
|
document.documentElement.style.setProperty('--safe-area-inset-bottom', '${bottom}px');
|
||||||
|
document.documentElement.style.setProperty('--safe-area-inset-left', '${left}px');
|
||||||
|
document.documentElement.style.setProperty('--safe-area-inset-right', '${right}px');
|
||||||
|
""".trimIndent()
|
||||||
|
webView.evaluateJavascript(js, null)
|
||||||
|
|
||||||
|
// Set margins
|
||||||
|
v.updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
||||||
|
leftMargin = left
|
||||||
|
bottomMargin = bottom
|
||||||
|
rightMargin = right
|
||||||
|
topMargin = top
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
WindowInsets.CONSUMED
|
||||||
|
} else {
|
||||||
|
insets
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val permission = ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE)
|
val permission = ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE)
|
||||||
if (permission != PackageManager.PERMISSION_GRANTED) {
|
if (permission != PackageManager.PERMISSION_GRANTED) {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ data class AudioTrack(
|
|||||||
var metadata: FileMetadata?,
|
var metadata: FileMetadata?,
|
||||||
var isLocal: Boolean,
|
var isLocal: Boolean,
|
||||||
var localFileId: String?,
|
var localFileId: String?,
|
||||||
|
// TODO: This should no longer be necessary
|
||||||
var serverIndex: Int? // Need to know if server track index is different
|
var serverIndex: Int? // Need to know if server track index is different
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
|||||||
@@ -32,11 +32,14 @@ enum class AndroidAutoBrowseSeriesSequenceOrderSetting {
|
|||||||
ASC, DESC
|
ASC, DESC
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
data class ServerConnectionConfig(
|
data class ServerConnectionConfig(
|
||||||
var id:String,
|
var id:String,
|
||||||
var index:Int,
|
var index:Int,
|
||||||
var name:String,
|
var name:String,
|
||||||
var address:String,
|
var address:String,
|
||||||
|
// version added after 0.9.81-beta
|
||||||
|
var version:String?,
|
||||||
var userId:String,
|
var userId:String,
|
||||||
var username:String,
|
var username:String,
|
||||||
var token:String,
|
var token:String,
|
||||||
@@ -135,6 +138,7 @@ data class DeviceSettings(
|
|||||||
var sleepTimerLength: Long, // Time in milliseconds
|
var sleepTimerLength: Long, // Time in milliseconds
|
||||||
var disableSleepTimerFadeOut: Boolean,
|
var disableSleepTimerFadeOut: Boolean,
|
||||||
var disableSleepTimerResetFeedback: Boolean,
|
var disableSleepTimerResetFeedback: Boolean,
|
||||||
|
var enableSleepTimerAlmostDoneChime: Boolean,
|
||||||
var languageCode: String,
|
var languageCode: String,
|
||||||
var downloadUsingCellular: DownloadUsingCellularSetting,
|
var downloadUsingCellular: DownloadUsingCellularSetting,
|
||||||
var streamingUsingCellular: StreamingUsingCellularSetting,
|
var streamingUsingCellular: StreamingUsingCellularSetting,
|
||||||
@@ -163,6 +167,7 @@ data class DeviceSettings(
|
|||||||
autoSleepTimerAutoRewindTime = 300000L, // 5 minutes
|
autoSleepTimerAutoRewindTime = 300000L, // 5 minutes
|
||||||
disableSleepTimerFadeOut = false,
|
disableSleepTimerFadeOut = false,
|
||||||
disableSleepTimerResetFeedback = false,
|
disableSleepTimerResetFeedback = false,
|
||||||
|
enableSleepTimerAlmostDoneChime = false,
|
||||||
languageCode = "en-us",
|
languageCode = "en-us",
|
||||||
downloadUsingCellular = DownloadUsingCellularSetting.ALWAYS,
|
downloadUsingCellular = DownloadUsingCellularSetting.ALWAYS,
|
||||||
streamingUsingCellular = StreamingUsingCellularSetting.ALWAYS,
|
streamingUsingCellular = StreamingUsingCellularSetting.ALWAYS,
|
||||||
@@ -188,9 +193,9 @@ data class DeviceSettings(
|
|||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun getShakeThresholdGravity() : Float { // Used in ShakeDetector
|
fun getShakeThresholdGravity() : Float { // Used in ShakeDetector
|
||||||
return if (shakeSensitivity == ShakeSensitivitySetting.VERY_HIGH) 1.2f
|
return if (shakeSensitivity == ShakeSensitivitySetting.VERY_HIGH) 1.1f
|
||||||
else if (shakeSensitivity == ShakeSensitivitySetting.HIGH) 1.4f
|
else if (shakeSensitivity == ShakeSensitivitySetting.HIGH) 1.3f
|
||||||
else if (shakeSensitivity == ShakeSensitivitySetting.MEDIUM) 1.6f
|
else if (shakeSensitivity == ShakeSensitivitySetting.MEDIUM) 1.5f
|
||||||
else if (shakeSensitivity == ShakeSensitivitySetting.LOW) 2f
|
else if (shakeSensitivity == ShakeSensitivitySetting.LOW) 2f
|
||||||
else if (shakeSensitivity == ShakeSensitivitySetting.VERY_LOW) 2.7f
|
else if (shakeSensitivity == ShakeSensitivitySetting.VERY_LOW) 2.7f
|
||||||
else {
|
else {
|
||||||
|
|||||||
@@ -5,8 +5,7 @@
|
|||||||
package com.audiobookshelf.app.data
|
package com.audiobookshelf.app.data
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
|
||||||
import com.fasterxml.jackson.module.kotlin.readValue
|
import com.fasterxml.jackson.module.kotlin.readValue
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
|
||||||
@@ -18,8 +17,7 @@ data class ItemInProgress(
|
|||||||
val isLocal: Boolean
|
val isLocal: Boolean
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
fun makeFromServerObject(serverItem: JSONObject):ItemInProgress {
|
fun makeFromServerObject(serverItem: JSONObject, jacksonMapper: ObjectMapper):ItemInProgress {
|
||||||
val jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
|
||||||
val libraryItem = jacksonMapper.readValue<LibraryItem>(serverItem.toString())
|
val libraryItem = jacksonMapper.readValue<LibraryItem>(serverItem.toString())
|
||||||
|
|
||||||
var episode:PodcastEpisode? = null
|
var episode:PodcastEpisode? = null
|
||||||
|
|||||||
@@ -53,6 +53,11 @@ class LibraryItem(
|
|||||||
return Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
|
return Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// As of v2.17.0 token is not needed with cover image requests
|
||||||
|
if (DeviceManager.isServerVersionGreaterThanOrEqualTo("2.17.0")) {
|
||||||
|
return Uri.parse("${DeviceManager.serverAddress}/api/items/$id/cover")
|
||||||
|
}
|
||||||
|
|
||||||
return Uri.parse("${DeviceManager.serverAddress}/api/items/$id/cover?token=${DeviceManager.token}")
|
return Uri.parse("${DeviceManager.serverAddress}/api/items/$id/cover?token=${DeviceManager.token}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -149,6 +149,16 @@ class PlaybackSession(
|
|||||||
return total
|
return total
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun checkIsServerVersionGte(compareVersion: String): Boolean {
|
||||||
|
// Safety check this playback session is the same one currently connected (should always be)
|
||||||
|
if (DeviceManager.serverConnectionConfigId != serverConnectionConfigId) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return DeviceManager.isServerVersionGreaterThanOrEqualTo(compareVersion)
|
||||||
|
}
|
||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun getCoverUri(ctx: Context): Uri {
|
fun getCoverUri(ctx: Context): Uri {
|
||||||
if (localLibraryItem?.coverContentUrl != null) {
|
if (localLibraryItem?.coverContentUrl != null) {
|
||||||
@@ -168,12 +178,22 @@ class PlaybackSession(
|
|||||||
|
|
||||||
if (coverPath == null)
|
if (coverPath == null)
|
||||||
return Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
|
return Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
|
||||||
|
|
||||||
|
// As of v2.17.0 token is not needed with cover image requests
|
||||||
|
if (checkIsServerVersionGte("2.17.0")) {
|
||||||
|
return Uri.parse("$serverAddress/api/items/$libraryItemId/cover")
|
||||||
|
}
|
||||||
return Uri.parse("$serverAddress/api/items/$libraryItemId/cover?token=${DeviceManager.token}")
|
return Uri.parse("$serverAddress/api/items/$libraryItemId/cover?token=${DeviceManager.token}")
|
||||||
}
|
}
|
||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun getContentUri(audioTrack: AudioTrack): Uri {
|
fun getContentUri(audioTrack: AudioTrack): Uri {
|
||||||
if (isLocal) return Uri.parse(audioTrack.contentUrl) // Local content url
|
if (isLocal) return Uri.parse(audioTrack.contentUrl) // Local content url
|
||||||
|
// As of v2.22.0 tracks use a different endpoint
|
||||||
|
// See: https://github.com/advplyr/audiobookshelf/pull/4263
|
||||||
|
if (checkIsServerVersionGte("2.22.0")) {
|
||||||
|
return Uri.parse("$serverAddress/public/session/$id/track/${audioTrack.index}")
|
||||||
|
}
|
||||||
return Uri.parse("$serverAddress${audioTrack.contentUrl}?token=${DeviceManager.token}")
|
return Uri.parse("$serverAddress${audioTrack.contentUrl}?token=${DeviceManager.token}")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,14 +284,16 @@ class PlaybackSession(
|
|||||||
com.google.android.gms.cast.MediaMetadata.MEDIA_TYPE_AUDIOBOOK_CHAPTER
|
com.google.android.gms.cast.MediaMetadata.MEDIA_TYPE_AUDIOBOOK_CHAPTER
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// As of v2.17.0 token is not needed with cover image requests
|
||||||
|
val coverUri = if (checkIsServerVersionGte("2.17.0")) {
|
||||||
|
Uri.parse("$serverAddress/api/items/$libraryItemId/cover")
|
||||||
|
} else {
|
||||||
|
Uri.parse("$serverAddress/api/items/$libraryItemId/cover?token=${DeviceManager.token}")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cast always uses server cover uri
|
||||||
coverPath?.let {
|
coverPath?.let {
|
||||||
castMetadata.addImage(
|
castMetadata.addImage(WebImage(coverUri))
|
||||||
WebImage(
|
|
||||||
Uri.parse(
|
|
||||||
"$serverAddress/api/items/$libraryItemId/cover?token=${DeviceManager.token}"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
castMetadata.putString(com.google.android.gms.cast.MediaMetadata.KEY_TITLE, displayTitle ?: "")
|
castMetadata.putString(com.google.android.gms.cast.MediaMetadata.KEY_TITLE, displayTitle ?: "")
|
||||||
|
|||||||
@@ -32,14 +32,16 @@ object DeviceManager {
|
|||||||
var deviceData: DeviceData = dbManager.getDeviceData()
|
var deviceData: DeviceData = dbManager.getDeviceData()
|
||||||
var serverConnectionConfig: ServerConnectionConfig? = null
|
var serverConnectionConfig: ServerConnectionConfig? = null
|
||||||
|
|
||||||
val serverConnectionConfigId
|
val serverConnectionConfigId get() = serverConnectionConfig?.id ?: ""
|
||||||
get() = serverConnectionConfig?.id ?: ""
|
val serverConnectionConfigName get() = serverConnectionConfig?.name ?: ""
|
||||||
|
val serverConnectionConfigString get() = serverConnectionConfig?.name ?: "No server connection"
|
||||||
val serverAddress
|
val serverAddress
|
||||||
get() = serverConnectionConfig?.address ?: ""
|
get() = serverConnectionConfig?.address ?: ""
|
||||||
val serverUserId
|
val serverUserId
|
||||||
get() = serverConnectionConfig?.userId ?: ""
|
get() = serverConnectionConfig?.userId ?: ""
|
||||||
val token
|
val token
|
||||||
get() = serverConnectionConfig?.token ?: ""
|
get() = serverConnectionConfig?.token ?: ""
|
||||||
|
val serverVersion get() = serverConnectionConfig?.version ?: ""
|
||||||
val isConnectedToServer
|
val isConnectedToServer
|
||||||
get() = serverConnectionConfig != null
|
get() = serverConnectionConfig != null
|
||||||
|
|
||||||
@@ -63,6 +65,10 @@ object DeviceManager {
|
|||||||
if (deviceData.deviceSettings?.autoSleepTimerAutoRewindTime == null) {
|
if (deviceData.deviceSettings?.autoSleepTimerAutoRewindTime == null) {
|
||||||
deviceData.deviceSettings?.autoSleepTimerAutoRewindTime = 300000L // 5 minutes
|
deviceData.deviceSettings?.autoSleepTimerAutoRewindTime = 300000L // 5 minutes
|
||||||
}
|
}
|
||||||
|
// Initialize sleep timer almost done chime added in v0.9.81
|
||||||
|
if (deviceData.deviceSettings?.enableSleepTimerAlmostDoneChime == null) {
|
||||||
|
deviceData.deviceSettings?.enableSleepTimerAlmostDoneChime = false
|
||||||
|
}
|
||||||
|
|
||||||
// Language added in v0.9.69
|
// Language added in v0.9.69
|
||||||
if (deviceData.deviceSettings?.languageCode == null) {
|
if (deviceData.deviceSettings?.languageCode == null) {
|
||||||
@@ -106,6 +112,41 @@ object DeviceManager {
|
|||||||
return id?.let { deviceData.serverConnectionConfigs.find { it.id == id } }
|
return id?.let { deviceData.serverConnectionConfigs.find { it.id == id } }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the currently connected server version is >= compareVersion
|
||||||
|
* Abs server only uses major.minor.patch
|
||||||
|
* Note: Version is returned in Abs auth payloads starting v2.6.0
|
||||||
|
* Note: Version is saved with the server connection config starting after v0.9.81
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* serverVersion=2.25.1
|
||||||
|
* isServerVersionGreaterThanOrEqualTo("2.26.0") = false
|
||||||
|
*
|
||||||
|
* serverVersion=2.26.1
|
||||||
|
* isServerVersionGreaterThanOrEqualTo("2.26.0") = true
|
||||||
|
*/
|
||||||
|
fun isServerVersionGreaterThanOrEqualTo(compareVersion:String):Boolean {
|
||||||
|
if (serverVersion == "") return false
|
||||||
|
if (compareVersion == "") return true
|
||||||
|
|
||||||
|
val serverVersionParts = serverVersion.split(".").map { it.toIntOrNull() ?: 0 }
|
||||||
|
val compareVersionParts = compareVersion.split(".").map { it.toIntOrNull() ?: 0 }
|
||||||
|
|
||||||
|
// Compare major, minor, and patch components
|
||||||
|
for (i in 0 until maxOf(serverVersionParts.size, compareVersionParts.size)) {
|
||||||
|
val serverVersionComponent = serverVersionParts.getOrElse(i) { 0 }
|
||||||
|
val compareVersionComponent = compareVersionParts.getOrElse(i) { 0 }
|
||||||
|
|
||||||
|
if (serverVersionComponent < compareVersionComponent) {
|
||||||
|
return false // Server version is less than compareVersion
|
||||||
|
} else if (serverVersionComponent > compareVersionComponent) {
|
||||||
|
return true // Server version is greater than compareVersion
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true // versions are equal in major, minor, and patch
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks the network connectivity status.
|
* Checks the network connectivity status.
|
||||||
* @param ctx The context to use for checking connectivity.
|
* @param ctx The context to use for checking connectivity.
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import android.content.Context
|
|||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.audiobookshelf.app.data.*
|
import com.audiobookshelf.app.data.*
|
||||||
import com.audiobookshelf.app.models.DownloadItem
|
import com.audiobookshelf.app.models.DownloadItem
|
||||||
|
import com.audiobookshelf.app.plugins.AbsLog
|
||||||
|
import com.audiobookshelf.app.plugins.AbsLogger
|
||||||
import io.paperdb.Paper
|
import io.paperdb.Paper
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
@@ -287,4 +289,35 @@ class DbManager {
|
|||||||
}
|
}
|
||||||
return sessions
|
return sessions
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun saveLog(log:AbsLog) {
|
||||||
|
Paper.book("log").write(log.id, log)
|
||||||
|
}
|
||||||
|
fun getAllLogs() : List<AbsLog> {
|
||||||
|
val logs:MutableList<AbsLog> = mutableListOf()
|
||||||
|
Paper.book("log").allKeys.forEach { logId ->
|
||||||
|
Paper.book("log").read<AbsLog>(logId)?.let {
|
||||||
|
logs.add(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return logs.sortedBy { it.timestamp }
|
||||||
|
}
|
||||||
|
fun removeAllLogs() {
|
||||||
|
Paper.book("log").destroy()
|
||||||
|
}
|
||||||
|
fun cleanLogs() {
|
||||||
|
val numberOfHoursToKeep = 48
|
||||||
|
val keepLogCutoff = System.currentTimeMillis() - (3600000 * numberOfHoursToKeep)
|
||||||
|
val allLogs = getAllLogs()
|
||||||
|
var logsRemoved = 0
|
||||||
|
allLogs.forEach {
|
||||||
|
if (it.timestamp < keepLogCutoff) {
|
||||||
|
Paper.book("log").delete(it.id)
|
||||||
|
logsRemoved++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (logsRemoved > 0) {
|
||||||
|
AbsLogger.info("DbManager", "cleanLogs: Removed $logsRemoved logs older than $numberOfHoursToKeep hours")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -29,7 +29,7 @@ class InternalDownloadManager(
|
|||||||
*/
|
*/
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
fun download(url: String) {
|
fun download(url: String) {
|
||||||
val request: Request = Request.Builder().url(url).build()
|
val request: Request = Request.Builder().url(url).addHeader("Accept-Encoding", "identity").build()
|
||||||
client.newCall(request)
|
client.newCall(request)
|
||||||
.enqueue(
|
.enqueue(
|
||||||
object : Callback {
|
object : Callback {
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package com.audiobookshelf.app.managers
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.security.keystore.KeyGenParameterSpec
|
||||||
|
import android.security.keystore.KeyProperties
|
||||||
|
import android.util.Base64
|
||||||
|
import android.util.Log
|
||||||
|
import java.security.KeyStore
|
||||||
|
import javax.crypto.Cipher
|
||||||
|
import javax.crypto.KeyGenerator
|
||||||
|
import javax.crypto.SecretKey
|
||||||
|
import javax.crypto.spec.GCMParameterSpec
|
||||||
|
|
||||||
|
class SecureStorage(private val context: Context) {
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "SecureStorage"
|
||||||
|
private const val KEYSTORE_PROVIDER = "AndroidKeyStore"
|
||||||
|
private const val KEY_ALIAS = "AudiobookshelfRefreshTokens"
|
||||||
|
private const val TRANSFORMATION = "AES/GCM/NoPadding"
|
||||||
|
private const val IV_LENGTH = 12
|
||||||
|
private const val TAG_LENGTH = 128
|
||||||
|
}
|
||||||
|
|
||||||
|
private val keyStore = KeyStore.getInstance(KEYSTORE_PROVIDER).apply {
|
||||||
|
load(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypts and stores a refresh token for a specific server connection
|
||||||
|
*/
|
||||||
|
fun storeRefreshToken(serverConnectionId: String, refreshToken: String): Boolean {
|
||||||
|
return try {
|
||||||
|
val key = getOrCreateKey()
|
||||||
|
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||||
|
cipher.init(Cipher.ENCRYPT_MODE, key)
|
||||||
|
|
||||||
|
val encryptedBytes = cipher.doFinal(refreshToken.toByteArray(Charsets.UTF_8))
|
||||||
|
val combined = cipher.iv + encryptedBytes
|
||||||
|
|
||||||
|
val encoded = Base64.encodeToString(combined, Base64.DEFAULT)
|
||||||
|
|
||||||
|
val sharedPrefs = context.getSharedPreferences("SecureStorage", Context.MODE_PRIVATE)
|
||||||
|
sharedPrefs.edit().putString("refresh_token_$serverConnectionId", encoded).apply()
|
||||||
|
|
||||||
|
Log.d(TAG, "Successfully stored encrypted refresh token for server: $serverConnectionId")
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to store refresh token for server: $serverConnectionId", e)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves and decrypts a refresh token for a specific server connection
|
||||||
|
*/
|
||||||
|
fun getRefreshToken(serverConnectionId: String): String? {
|
||||||
|
return try {
|
||||||
|
val sharedPrefs = context.getSharedPreferences("SecureStorage", Context.MODE_PRIVATE)
|
||||||
|
val encoded = sharedPrefs.getString("refresh_token_$serverConnectionId", null) ?: return null
|
||||||
|
|
||||||
|
val combined = Base64.decode(encoded, Base64.DEFAULT)
|
||||||
|
val iv = combined.copyOfRange(0, IV_LENGTH)
|
||||||
|
val encryptedBytes = combined.copyOfRange(IV_LENGTH, combined.size)
|
||||||
|
|
||||||
|
val key = getOrCreateKey()
|
||||||
|
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||||
|
val spec = GCMParameterSpec(TAG_LENGTH, iv)
|
||||||
|
cipher.init(Cipher.DECRYPT_MODE, key, spec)
|
||||||
|
|
||||||
|
val decryptedBytes = cipher.doFinal(encryptedBytes)
|
||||||
|
String(decryptedBytes, Charsets.UTF_8)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to retrieve refresh token for server: $serverConnectionId", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a refresh token for a specific server connection
|
||||||
|
*/
|
||||||
|
fun removeRefreshToken(serverConnectionId: String): Boolean {
|
||||||
|
return try {
|
||||||
|
val sharedPrefs = context.getSharedPreferences("SecureStorage", Context.MODE_PRIVATE)
|
||||||
|
sharedPrefs.edit().remove("refresh_token_$serverConnectionId").apply()
|
||||||
|
Log.d(TAG, "Successfully removed refresh token for server: $serverConnectionId")
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to remove refresh token for server: $serverConnectionId", e)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a refresh token exists for a specific server connection
|
||||||
|
*/
|
||||||
|
fun hasRefreshToken(serverConnectionId: String): Boolean {
|
||||||
|
val sharedPrefs = context.getSharedPreferences("SecureStorage", Context.MODE_PRIVATE)
|
||||||
|
return sharedPrefs.contains("refresh_token_$serverConnectionId")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getOrCreateKey(): SecretKey {
|
||||||
|
return if (keyStore.containsAlias(KEY_ALIAS)) {
|
||||||
|
keyStore.getKey(KEY_ALIAS, null) as SecretKey
|
||||||
|
} else {
|
||||||
|
createKey()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createKey(): SecretKey {
|
||||||
|
val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE_PROVIDER)
|
||||||
|
val keyGenSpec = KeyGenParameterSpec.Builder(
|
||||||
|
KEY_ALIAS,
|
||||||
|
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
|
||||||
|
)
|
||||||
|
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||||
|
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||||
|
.setUserAuthenticationRequired(false)
|
||||||
|
.setRandomizedEncryptionRequired(true)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
keyGenerator.init(keyGenSpec)
|
||||||
|
return keyGenerator.generateKey()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,20 @@
|
|||||||
package com.audiobookshelf.app.managers
|
package com.audiobookshelf.app.managers
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.media.MediaPlayer
|
||||||
import android.os.*
|
import android.os.*
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import com.audiobookshelf.app.R
|
||||||
import com.audiobookshelf.app.device.DeviceManager
|
import com.audiobookshelf.app.device.DeviceManager
|
||||||
import com.audiobookshelf.app.player.PlayerNotificationService
|
import com.audiobookshelf.app.player.PlayerNotificationService
|
||||||
import com.audiobookshelf.app.player.SLEEP_TIMER_WAKE_UP_EXPIRATION
|
import com.audiobookshelf.app.player.SLEEP_TIMER_WAKE_UP_EXPIRATION
|
||||||
|
import com.audiobookshelf.app.plugins.AbsLogger
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import kotlin.concurrent.schedule
|
import kotlin.concurrent.schedule
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
const val SLEEP_TIMER_CHIME_SOUND_VOLUME = 0.7f
|
||||||
|
|
||||||
class SleepTimerManager
|
class SleepTimerManager
|
||||||
constructor(private val playerNotificationService: PlayerNotificationService) {
|
constructor(private val playerNotificationService: PlayerNotificationService) {
|
||||||
private val tag = "SleepTimerManager"
|
private val tag = "SleepTimerManager"
|
||||||
@@ -156,6 +161,10 @@ constructor(private val playerNotificationService: PlayerNotificationService) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (sleepTimeSecondsRemaining == 30 && sleepTimerElapsed > 1 && DeviceManager.deviceData.deviceSettings?.enableSleepTimerAlmostDoneChime == true) {
|
||||||
|
playChimeSound()
|
||||||
|
}
|
||||||
|
|
||||||
if (sleepTimeSecondsRemaining <= 0) {
|
if (sleepTimeSecondsRemaining <= 0) {
|
||||||
Log.d(tag, "Sleep Timer Pausing Player on Chapter")
|
Log.d(tag, "Sleep Timer Pausing Player on Chapter")
|
||||||
pause()
|
pause()
|
||||||
@@ -263,6 +272,18 @@ constructor(private val playerNotificationService: PlayerNotificationService) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Plays chime sound */
|
||||||
|
private fun playChimeSound() {
|
||||||
|
AbsLogger.info(tag, "playChimeSound: Playing sleep timer chime sound")
|
||||||
|
val ctx = playerNotificationService.getContext()
|
||||||
|
val mediaPlayer = MediaPlayer.create(ctx, R.raw.bell)
|
||||||
|
mediaPlayer.setVolume(SLEEP_TIMER_CHIME_SOUND_VOLUME, SLEEP_TIMER_CHIME_SOUND_VOLUME)
|
||||||
|
mediaPlayer.start()
|
||||||
|
mediaPlayer.setOnCompletionListener {
|
||||||
|
mediaPlayer.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the chapter end time for use in End of Chapter timers. If less than 10 seconds remain in
|
* Gets the chapter end time for use in End of Chapter timers. If less than 10 seconds remain in
|
||||||
* the chapter, then use the next chapter.
|
* the chapter, then use the next chapter.
|
||||||
|
|||||||
@@ -136,7 +136,6 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
|
|||||||
val serverConnConfig = if (DeviceManager.isConnectedToServer) DeviceManager.serverConnectionConfig else DeviceManager.deviceData.getLastServerConnectionConfig()
|
val serverConnConfig = if (DeviceManager.isConnectedToServer) DeviceManager.serverConnectionConfig else DeviceManager.deviceData.getLastServerConnectionConfig()
|
||||||
|
|
||||||
if (!DeviceManager.isConnectedToServer || !DeviceManager.checkConnectivity(ctx) || serverConnConfig == null || serverConnConfig.id !== serverConfigIdUsed) {
|
if (!DeviceManager.isConnectedToServer || !DeviceManager.checkConnectivity(ctx) || serverConnConfig == null || serverConnConfig.id !== serverConfigIdUsed) {
|
||||||
Log.d(tag, "Reset caches")
|
|
||||||
podcastEpisodeLibraryItemMap = mutableMapOf()
|
podcastEpisodeLibraryItemMap = mutableMapOf()
|
||||||
serverLibraries = listOf()
|
serverLibraries = listOf()
|
||||||
serverLibraryItems = mutableListOf()
|
serverLibraryItems = mutableListOf()
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import com.audiobookshelf.app.data.MediaProgress
|
|||||||
import com.audiobookshelf.app.data.PlaybackSession
|
import com.audiobookshelf.app.data.PlaybackSession
|
||||||
import com.audiobookshelf.app.device.DeviceManager
|
import com.audiobookshelf.app.device.DeviceManager
|
||||||
import com.audiobookshelf.app.player.PlayerNotificationService
|
import com.audiobookshelf.app.player.PlayerNotificationService
|
||||||
|
import com.audiobookshelf.app.plugins.AbsLogger
|
||||||
import com.audiobookshelf.app.server.ApiHandler
|
import com.audiobookshelf.app.server.ApiHandler
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import kotlin.concurrent.schedule
|
import kotlin.concurrent.schedule
|
||||||
@@ -208,6 +209,7 @@ class MediaProgressSyncer(
|
|||||||
MediaEventManager.seekEvent(currentPlaybackSession!!, null)
|
MediaEventManager.seekEvent(currentPlaybackSession!!, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Currently unused
|
||||||
fun syncFromServerProgress(mediaProgress: MediaProgress) {
|
fun syncFromServerProgress(mediaProgress: MediaProgress) {
|
||||||
currentPlaybackSession?.let {
|
currentPlaybackSession?.let {
|
||||||
it.updatedAt = mediaProgress.lastUpdate
|
it.updatedAt = mediaProgress.lastUpdate
|
||||||
@@ -260,44 +262,46 @@ class MediaProgressSyncer(
|
|||||||
tag,
|
tag,
|
||||||
"Sync local device current serverConnectionConfigId=${DeviceManager.serverConnectionConfig?.id}"
|
"Sync local device current serverConnectionConfigId=${DeviceManager.serverConnectionConfig?.id}"
|
||||||
)
|
)
|
||||||
|
AbsLogger.info("MediaProgressSyncer", "sync: Saved local progress (title: \"$currentDisplayTitle\") (currentTime: $currentTime) (session id: ${it.id})")
|
||||||
|
|
||||||
// Local library item is linked to a server library item
|
// 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
|
// Send sync to server also if connected to this server and local item belongs to this
|
||||||
// server
|
// server
|
||||||
|
val isConnectedToSameServer = it.serverConnectionConfigId != null && DeviceManager.serverConnectionConfig?.id == it.serverConnectionConfigId
|
||||||
if (hasNetworkConnection &&
|
if (hasNetworkConnection &&
|
||||||
shouldSyncServer &&
|
shouldSyncServer &&
|
||||||
!it.libraryItemId.isNullOrEmpty() &&
|
!it.libraryItemId.isNullOrEmpty() &&
|
||||||
it.serverConnectionConfigId != null &&
|
isConnectedToSameServer
|
||||||
DeviceManager.serverConnectionConfig?.id == it.serverConnectionConfigId
|
|
||||||
) {
|
) {
|
||||||
apiHandler.sendLocalProgressSync(it) { syncSuccess, errorMsg ->
|
apiHandler.sendLocalProgressSync(it) { syncSuccess, errorMsg ->
|
||||||
if (syncSuccess) {
|
if (syncSuccess) {
|
||||||
failedSyncs = 0
|
failedSyncs = 0
|
||||||
playerNotificationService.alertSyncSuccess()
|
playerNotificationService.alertSyncSuccess()
|
||||||
DeviceManager.dbManager.removePlaybackSession(it.id) // Remove session from db
|
DeviceManager.dbManager.removePlaybackSession(it.id) // Remove session from db
|
||||||
|
AbsLogger.info("MediaProgressSyncer", "sync: Successfully synced local progress (title: \"$currentDisplayTitle\") (currentTime: $currentTime) (session id: ${it.id})")
|
||||||
} else {
|
} else {
|
||||||
failedSyncs++
|
failedSyncs++
|
||||||
if (failedSyncs == 2) {
|
if (failedSyncs == 2) {
|
||||||
playerNotificationService.alertSyncFailing() // Show alert in client
|
playerNotificationService.alertSyncFailing() // Show alert in client
|
||||||
failedSyncs = 0
|
failedSyncs = 0
|
||||||
}
|
}
|
||||||
Log.e(
|
AbsLogger.error("MediaProgressSyncer", "sync: Local progress sync failed (count: $failedSyncs) (title: \"$currentDisplayTitle\") (currentTime: $currentTime) (session id: ${it.id}) (${DeviceManager.serverConnectionConfigName})")
|
||||||
tag,
|
|
||||||
"Local Progress sync failed ($failedSyncs) to send to server $currentDisplayTitle for time $currentTime with session id=${it.id}"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cb(SyncResult(true, syncSuccess, errorMsg))
|
cb(SyncResult(true, syncSuccess, errorMsg))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
AbsLogger.info("MediaProgressSyncer", "sync: Not sending local progress to server (title: \"$currentDisplayTitle\") (currentTime: $currentTime) (session id: ${it.id}) (hasNetworkConnection: $hasNetworkConnection) (isConnectedToSameServer: $isConnectedToSameServer)")
|
||||||
cb(SyncResult(false, null, null))
|
cb(SyncResult(false, null, null))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (hasNetworkConnection && shouldSyncServer) {
|
} else if (hasNetworkConnection && shouldSyncServer) {
|
||||||
Log.d(tag, "sync: currentSessionId=$currentSessionId")
|
AbsLogger.info("MediaProgressSyncer", "sync: Sending progress sync to server (title: \"$currentDisplayTitle\") (currentTime: $currentTime) (session id: ${currentSessionId}) (${DeviceManager.serverConnectionConfigName})")
|
||||||
|
|
||||||
apiHandler.sendProgressSync(currentSessionId, syncData) { syncSuccess, errorMsg ->
|
apiHandler.sendProgressSync(currentSessionId, syncData) { syncSuccess, errorMsg ->
|
||||||
if (syncSuccess) {
|
if (syncSuccess) {
|
||||||
Log.d(tag, "Progress sync data sent to server $currentDisplayTitle for time $currentTime")
|
AbsLogger.info("MediaProgressSyncer", "sync: Successfully synced progress (title: \"$currentDisplayTitle\") (currentTime: $currentTime) (session id: ${currentSessionId}) (${DeviceManager.serverConnectionConfigName})")
|
||||||
|
|
||||||
failedSyncs = 0
|
failedSyncs = 0
|
||||||
playerNotificationService.alertSyncSuccess()
|
playerNotificationService.alertSyncSuccess()
|
||||||
lastSyncTime = System.currentTimeMillis()
|
lastSyncTime = System.currentTimeMillis()
|
||||||
@@ -308,14 +312,12 @@ class MediaProgressSyncer(
|
|||||||
playerNotificationService.alertSyncFailing() // Show alert in client
|
playerNotificationService.alertSyncFailing() // Show alert in client
|
||||||
failedSyncs = 0
|
failedSyncs = 0
|
||||||
}
|
}
|
||||||
Log.e(
|
AbsLogger.error("MediaProgressSyncer", "sync: Progress sync failed (count: $failedSyncs) (title: \"$currentDisplayTitle\") (currentTime: $currentTime) (session id: $currentSessionId) (${DeviceManager.serverConnectionConfigName})")
|
||||||
tag,
|
|
||||||
"Progress sync failed ($failedSyncs) to send to server $currentDisplayTitle for time $currentTime with session id=${currentSessionId}"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
cb(SyncResult(true, syncSuccess, errorMsg))
|
cb(SyncResult(true, syncSuccess, errorMsg))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
AbsLogger.info("MediaProgressSyncer", "sync: Not sending progress to server (title: \"$currentDisplayTitle\") (currentTime: $currentTime) (session id: $currentSessionId) (${DeviceManager.serverConnectionConfigName}) (hasNetworkConnection: $hasNetworkConnection)")
|
||||||
cb(SyncResult(false, null, null))
|
cb(SyncResult(false, null, null))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-4
@@ -7,7 +7,6 @@ import android.net.Uri
|
|||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import android.support.v4.media.session.MediaControllerCompat
|
import android.support.v4.media.session.MediaControllerCompat
|
||||||
import android.util.Log
|
|
||||||
import com.audiobookshelf.app.BuildConfig
|
import com.audiobookshelf.app.BuildConfig
|
||||||
import com.audiobookshelf.app.R
|
import com.audiobookshelf.app.R
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
@@ -15,7 +14,7 @@ import com.google.android.exoplayer2.Player
|
|||||||
import com.google.android.exoplayer2.ui.PlayerNotificationManager
|
import com.google.android.exoplayer2.ui.PlayerNotificationManager
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
|
|
||||||
class AbMediaDescriptionAdapter constructor(private val controller: MediaControllerCompat, private val playerNotificationService: PlayerNotificationService) : PlayerNotificationManager.MediaDescriptionAdapter {
|
class AbMediaDescriptionAdapter (private val controller: MediaControllerCompat, private val playerNotificationService: PlayerNotificationService) : PlayerNotificationManager.MediaDescriptionAdapter {
|
||||||
private val tag = "MediaDescriptionAdapter"
|
private val tag = "MediaDescriptionAdapter"
|
||||||
|
|
||||||
private var currentIconUri: Uri? = null
|
private var currentIconUri: Uri? = null
|
||||||
@@ -36,12 +35,17 @@ class AbMediaDescriptionAdapter constructor(private val controller: MediaControl
|
|||||||
callback: PlayerNotificationManager.BitmapCallback
|
callback: PlayerNotificationManager.BitmapCallback
|
||||||
): Bitmap? {
|
): Bitmap? {
|
||||||
val albumArtUri = controller.metadata.description.iconUri
|
val albumArtUri = controller.metadata.description.iconUri
|
||||||
|
val albumBitmap = controller.metadata.description.iconBitmap
|
||||||
|
|
||||||
|
// For local cover images, bitmap is set in PlayerNotificationService TimelineQueueNavigator.getMediaDescription
|
||||||
|
if (albumBitmap != null) {
|
||||||
|
return albumBitmap
|
||||||
|
}
|
||||||
|
|
||||||
return if (currentIconUri != albumArtUri || currentBitmap == null) {
|
return if (currentIconUri != albumArtUri || currentBitmap == null) {
|
||||||
// Cache the bitmap for the current audiobook so that successive calls to
|
// Cache the bitmap for the current audiobook so that successive calls to
|
||||||
// `getCurrentLargeIcon` don't cause the bitmap to be recreated.
|
// `getCurrentLargeIcon` don't cause the bitmap to be recreated.
|
||||||
currentIconUri = albumArtUri
|
currentIconUri = albumArtUri
|
||||||
Log.d(tag, "ART $currentIconUri")
|
|
||||||
|
|
||||||
if (currentIconUri.toString().startsWith("content://")) {
|
if (currentIconUri.toString().startsWith("content://")) {
|
||||||
currentBitmap = if (Build.VERSION.SDK_INT < 28) {
|
currentBitmap = if (Build.VERSION.SDK_INT < 28) {
|
||||||
@@ -61,7 +65,6 @@ class AbMediaDescriptionAdapter constructor(private val controller: MediaControl
|
|||||||
}
|
}
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
currentBitmap
|
currentBitmap
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import android.util.Log
|
|||||||
import android.view.KeyEvent
|
import android.view.KeyEvent
|
||||||
import com.audiobookshelf.app.data.LibraryItemWrapper
|
import com.audiobookshelf.app.data.LibraryItemWrapper
|
||||||
import com.audiobookshelf.app.data.PodcastEpisode
|
import com.audiobookshelf.app.data.PodcastEpisode
|
||||||
import com.audiobookshelf.app.device.DeviceManager
|
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import kotlin.concurrent.schedule
|
import kotlin.concurrent.schedule
|
||||||
|
|
||||||
|
|||||||
+36
-23
@@ -36,6 +36,7 @@ import com.audiobookshelf.app.media.MediaManager
|
|||||||
import com.audiobookshelf.app.media.MediaProgressSyncer
|
import com.audiobookshelf.app.media.MediaProgressSyncer
|
||||||
import com.audiobookshelf.app.media.getUriToAbsIconDrawable
|
import com.audiobookshelf.app.media.getUriToAbsIconDrawable
|
||||||
import com.audiobookshelf.app.media.getUriToDrawable
|
import com.audiobookshelf.app.media.getUriToDrawable
|
||||||
|
import com.audiobookshelf.app.plugins.AbsLogger
|
||||||
import com.audiobookshelf.app.server.ApiHandler
|
import com.audiobookshelf.app.server.ApiHandler
|
||||||
import com.google.android.exoplayer2.*
|
import com.google.android.exoplayer2.*
|
||||||
import com.google.android.exoplayer2.audio.AudioAttributes
|
import com.google.android.exoplayer2.audio.AudioAttributes
|
||||||
@@ -310,17 +311,20 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
|
|
||||||
val coverUri = currentPlaybackSession!!.getCoverUri(ctx)
|
val coverUri = currentPlaybackSession!!.getCoverUri(ctx)
|
||||||
|
|
||||||
|
|
||||||
var bitmap: Bitmap? = null
|
var bitmap: Bitmap? = null
|
||||||
// Local covers get bitmap
|
// Local covers get bitmap
|
||||||
|
// Note: In Android Auto for local cover images, setting the icon uri to a local path does not work (cover is blank)
|
||||||
|
// so we create and set the bitmap here instead of AbMediaDescriptionAdapter
|
||||||
if (currentPlaybackSession!!.localLibraryItem?.coverContentUrl != null) {
|
if (currentPlaybackSession!!.localLibraryItem?.coverContentUrl != null) {
|
||||||
bitmap =
|
bitmap =
|
||||||
if (Build.VERSION.SDK_INT < 28) {
|
if (Build.VERSION.SDK_INT < 28) {
|
||||||
MediaStore.Images.Media.getBitmap(ctx.contentResolver, coverUri)
|
MediaStore.Images.Media.getBitmap(ctx.contentResolver, coverUri)
|
||||||
} else {
|
} else {
|
||||||
val source: ImageDecoder.Source =
|
val source: ImageDecoder.Source =
|
||||||
ImageDecoder.createSource(ctx.contentResolver, coverUri)
|
ImageDecoder.createSource(ctx.contentResolver, coverUri)
|
||||||
ImageDecoder.decodeBitmap(source)
|
ImageDecoder.decodeBitmap(source)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fix for local images crashing on Android 11 for specific devices
|
// Fix for local images crashing on Android 11 for specific devices
|
||||||
@@ -347,7 +351,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
.setTitle(currentPlaybackSession!!.displayTitle)
|
.setTitle(currentPlaybackSession!!.displayTitle)
|
||||||
|
|
||||||
bitmap?.let { mediaDescriptionBuilder.setIconBitmap(it) }
|
bitmap?.let { mediaDescriptionBuilder.setIconBitmap(it) }
|
||||||
?: mediaDescriptionBuilder.setIconUri(coverUri)
|
?: mediaDescriptionBuilder.setIconUri(coverUri)
|
||||||
|
|
||||||
return mediaDescriptionBuilder.build()
|
return mediaDescriptionBuilder.build()
|
||||||
}
|
}
|
||||||
@@ -452,7 +456,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
playbackSession
|
playbackSession
|
||||||
) // Save playback session to use when app is closed
|
) // Save playback session to use when app is closed
|
||||||
|
|
||||||
Log.d(tag, "Set CurrentPlaybackSession MediaPlayer ${currentPlaybackSession?.mediaPlayer}")
|
AbsLogger.info("PlayerNotificationService", "preparePlayer: Started playback session for item ${currentPlaybackSession?.mediaItemId}. MediaPlayer ${currentPlaybackSession?.mediaPlayer}")
|
||||||
// Notify client
|
// Notify client
|
||||||
clientEventEmitter?.onPlaybackSession(playbackSession)
|
clientEventEmitter?.onPlaybackSession(playbackSession)
|
||||||
|
|
||||||
@@ -469,7 +473,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
val mediaSource: MediaSource
|
val mediaSource: MediaSource
|
||||||
|
|
||||||
if (playbackSession.isLocal) {
|
if (playbackSession.isLocal) {
|
||||||
Log.d(tag, "Playing Local Item")
|
AbsLogger.info("PlayerNotificationService", "preparePlayer: Playing local item ${currentPlaybackSession?.mediaItemId}.")
|
||||||
val dataSourceFactory = DefaultDataSource.Factory(ctx)
|
val dataSourceFactory = DefaultDataSource.Factory(ctx)
|
||||||
|
|
||||||
val extractorsFactory = DefaultExtractorsFactory()
|
val extractorsFactory = DefaultExtractorsFactory()
|
||||||
@@ -483,7 +487,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
ProgressiveMediaSource.Factory(dataSourceFactory, extractorsFactory)
|
ProgressiveMediaSource.Factory(dataSourceFactory, extractorsFactory)
|
||||||
.createMediaSource(mediaItems[0])
|
.createMediaSource(mediaItems[0])
|
||||||
} else if (!playbackSession.isHLS) {
|
} else if (!playbackSession.isHLS) {
|
||||||
Log.d(tag, "Direct Playing Item")
|
AbsLogger.info("PlayerNotificationService", "preparePlayer: Direct playing item ${currentPlaybackSession?.mediaItemId}.")
|
||||||
val dataSourceFactory = DefaultHttpDataSource.Factory()
|
val dataSourceFactory = DefaultHttpDataSource.Factory()
|
||||||
|
|
||||||
val extractorsFactory = DefaultExtractorsFactory()
|
val extractorsFactory = DefaultExtractorsFactory()
|
||||||
@@ -498,7 +502,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
ProgressiveMediaSource.Factory(dataSourceFactory, extractorsFactory)
|
ProgressiveMediaSource.Factory(dataSourceFactory, extractorsFactory)
|
||||||
.createMediaSource(mediaItems[0])
|
.createMediaSource(mediaItems[0])
|
||||||
} else {
|
} else {
|
||||||
Log.d(tag, "Playing HLS Item")
|
AbsLogger.info("PlayerNotificationService", "preparePlayer: Playing HLS stream of item ${currentPlaybackSession?.mediaItemId}.")
|
||||||
val dataSourceFactory = DefaultHttpDataSource.Factory()
|
val dataSourceFactory = DefaultHttpDataSource.Factory()
|
||||||
dataSourceFactory.setUserAgent(channelId)
|
dataSourceFactory.setUserAgent(channelId)
|
||||||
dataSourceFactory.setDefaultRequestProperties(
|
dataSourceFactory.setDefaultRequestProperties(
|
||||||
@@ -1105,11 +1109,12 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
// No further calls will be made to other media browsing methods.
|
// No further calls will be made to other media browsing methods.
|
||||||
null
|
null
|
||||||
} else {
|
} else {
|
||||||
Log.d(tag, "Android Auto starting $clientPackageName $clientUid")
|
AbsLogger.info(tag, "onGetRoot: clientPackageName: $clientPackageName, clientUid: $clientUid")
|
||||||
isStarted = true
|
isStarted = true
|
||||||
|
|
||||||
// Reset cache if no longer connected to server or server changed
|
// Reset cache if no longer connected to server or server changed
|
||||||
if (mediaManager.checkResetServerItems()) {
|
if (mediaManager.checkResetServerItems()) {
|
||||||
|
AbsLogger.info(tag, "onGetRoot: Reset Android Auto server items cache (${DeviceManager.serverConnectionConfigString})")
|
||||||
forceReloadingAndroidAuto = true
|
forceReloadingAndroidAuto = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1134,7 +1139,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
parentMediaId: String,
|
parentMediaId: String,
|
||||||
result: Result<MutableList<MediaBrowserCompat.MediaItem>>
|
result: Result<MutableList<MediaBrowserCompat.MediaItem>>
|
||||||
) {
|
) {
|
||||||
Log.d(tag, "ON LOAD CHILDREN $parentMediaId")
|
AbsLogger.info(tag, "onLoadChildren: parentMediaId: $parentMediaId (${DeviceManager.serverConnectionConfigString})")
|
||||||
|
|
||||||
result.detach()
|
result.detach()
|
||||||
|
|
||||||
@@ -1145,7 +1150,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (parentMediaId == DOWNLOADS_ROOT) { // Load downloads
|
if (parentMediaId == DOWNLOADS_ROOT) { // Load downloads
|
||||||
|
|
||||||
val localBooks = DeviceManager.dbManager.getLocalLibraryItems("book")
|
val localBooks = DeviceManager.dbManager.getLocalLibraryItems("book")
|
||||||
val localPodcasts = DeviceManager.dbManager.getLocalLibraryItems("podcast")
|
val localPodcasts = DeviceManager.dbManager.getLocalLibraryItems("podcast")
|
||||||
val localBrowseItems: MutableList<MediaBrowserCompat.MediaItem> = mutableListOf()
|
val localBrowseItems: MutableList<MediaBrowserCompat.MediaItem> = mutableListOf()
|
||||||
@@ -1242,8 +1246,10 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
Log.d(tag, "Trying to initialize browseTree.")
|
Log.d(tag, "Trying to initialize browseTree.")
|
||||||
if (!this::browseTree.isInitialized || forceReloadingAndroidAuto) {
|
if (!this::browseTree.isInitialized || forceReloadingAndroidAuto) {
|
||||||
forceReloadingAndroidAuto = false
|
forceReloadingAndroidAuto = false
|
||||||
|
AbsLogger.info(tag, "onLoadChildren: Loading Android Auto items")
|
||||||
mediaManager.loadAndroidAutoItems {
|
mediaManager.loadAndroidAutoItems {
|
||||||
Log.d(tag, "android auto loaded. Starting browseTree initialize")
|
AbsLogger.info(tag, "onLoadChildren: Loaded Android Auto data, initializing browseTree")
|
||||||
|
|
||||||
browseTree =
|
browseTree =
|
||||||
BrowseTree(
|
BrowseTree(
|
||||||
this,
|
this,
|
||||||
@@ -1259,15 +1265,21 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
MediaBrowserCompat.MediaItem.FLAG_BROWSABLE
|
MediaBrowserCompat.MediaItem.FLAG_BROWSABLE
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Log.d(tag, "browseTree initialize and android auto loaded")
|
|
||||||
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
|
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
|
||||||
firstLoadDone = true
|
firstLoadDone = true
|
||||||
if (mediaManager.serverLibraries.isNotEmpty()) {
|
if (mediaManager.serverLibraries.isNotEmpty()) {
|
||||||
Log.d(tag, "Starting personalization fetch")
|
AbsLogger.info(tag, "onLoadChildren: Android Auto fetching personalized data for all libraries")
|
||||||
mediaManager.populatePersonalizedDataForAllLibraries { notifyChildrenChanged("/") }
|
mediaManager.populatePersonalizedDataForAllLibraries {
|
||||||
|
AbsLogger.info(tag, "onLoadChildren: Android Auto loaded personalized data for all libraries")
|
||||||
|
notifyChildrenChanged("/")
|
||||||
|
}
|
||||||
|
|
||||||
Log.d(tag, "Initialize inprogress items")
|
AbsLogger.info(tag, "onLoadChildren: Android Auto fetching in progress items")
|
||||||
mediaManager.initializeInProgressItems { notifyChildrenChanged("/") }
|
mediaManager.initializeInProgressItems {
|
||||||
|
AbsLogger.info(tag, "onLoadChildren: Android Auto loaded in progress items")
|
||||||
|
notifyChildrenChanged("/")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -1287,7 +1299,8 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
MediaBrowserCompat.MediaItem.FLAG_BROWSABLE
|
MediaBrowserCompat.MediaItem.FLAG_BROWSABLE
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Log.d(tag, "browseTree initialize and android auto loaded")
|
|
||||||
|
AbsLogger.info(tag, "onLoadChildren: Android auto data loaded")
|
||||||
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
|
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
|
||||||
}
|
}
|
||||||
} else if (parentMediaId == LIBRARIES_ROOT || parentMediaId == RECENTLY_ROOT) {
|
} else if (parentMediaId == LIBRARIES_ROOT || parentMediaId == RECENTLY_ROOT) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import android.hardware.SensorEvent
|
|||||||
import android.hardware.SensorEventListener
|
import android.hardware.SensorEventListener
|
||||||
import android.hardware.SensorManager
|
import android.hardware.SensorManager
|
||||||
import com.audiobookshelf.app.device.DeviceManager
|
import com.audiobookshelf.app.device.DeviceManager
|
||||||
|
import com.audiobookshelf.app.plugins.AbsLogger
|
||||||
import kotlin.math.sqrt
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
class ShakeDetector : SensorEventListener {
|
class ShakeDetector : SensorEventListener {
|
||||||
@@ -46,6 +47,7 @@ class ShakeDetector : SensorEventListener {
|
|||||||
if (mShakeTimestamp + SHAKE_COUNT_RESET_TIME_MS < now) {
|
if (mShakeTimestamp + SHAKE_COUNT_RESET_TIME_MS < now) {
|
||||||
mShakeCount = 0
|
mShakeCount = 0
|
||||||
}
|
}
|
||||||
|
AbsLogger.info("ShakeDetector", "Device shake above threshold ($gForce > $shakeThreshold)")
|
||||||
mShakeTimestamp = now
|
mShakeTimestamp = now
|
||||||
mShakeCount++
|
mShakeCount++
|
||||||
mListener!!.onShake(mShakeCount)
|
mListener!!.onShake(mShakeCount)
|
||||||
|
|||||||
@@ -180,7 +180,8 @@ class AbsAudioPlayer : Plugin() {
|
|||||||
val playWhenReady = call.getBoolean("playWhenReady") == true
|
val playWhenReady = call.getBoolean("playWhenReady") == true
|
||||||
val playbackRate = call.getFloat("playbackRate",1f) ?: 1f
|
val playbackRate = call.getFloat("playbackRate",1f) ?: 1f
|
||||||
val startTimeOverride = call.getDouble("startTime")
|
val startTimeOverride = call.getDouble("startTime")
|
||||||
Log.d(tag, "prepareLibraryItem lid=$libraryItemId, startTimeOverride=$startTimeOverride, playbackRate=$playbackRate")
|
|
||||||
|
AbsLogger.info("AbsAudioPlayer", "prepareLibraryItem: lid=$libraryItemId, startTimeOverride=$startTimeOverride, playbackRate=$playbackRate")
|
||||||
|
|
||||||
if (libraryItemId.isEmpty()) {
|
if (libraryItemId.isEmpty()) {
|
||||||
Log.e(tag, "Invalid call to play library item no library item id")
|
Log.e(tag, "Invalid call to play library item no library item id")
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import com.audiobookshelf.app.data.*
|
|||||||
import com.audiobookshelf.app.device.DeviceManager
|
import com.audiobookshelf.app.device.DeviceManager
|
||||||
import com.audiobookshelf.app.media.MediaEventManager
|
import com.audiobookshelf.app.media.MediaEventManager
|
||||||
import com.audiobookshelf.app.server.ApiHandler
|
import com.audiobookshelf.app.server.ApiHandler
|
||||||
|
import com.audiobookshelf.app.managers.SecureStorage
|
||||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||||
import com.fasterxml.jackson.module.kotlin.readValue
|
import com.fasterxml.jackson.module.kotlin.readValue
|
||||||
@@ -22,20 +23,25 @@ class AbsDatabase : Plugin() {
|
|||||||
val tag = "AbsDatabase"
|
val tag = "AbsDatabase"
|
||||||
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||||
|
|
||||||
lateinit var mainActivity: MainActivity
|
private lateinit var mainActivity: MainActivity
|
||||||
lateinit var apiHandler: ApiHandler
|
private lateinit var apiHandler: ApiHandler
|
||||||
|
private lateinit var secureStorage: SecureStorage
|
||||||
|
|
||||||
data class LocalMediaProgressPayload(val value:List<LocalMediaProgress>)
|
data class LocalMediaProgressPayload(val value:List<LocalMediaProgress>)
|
||||||
data class LocalLibraryItemsPayload(val value:List<LocalLibraryItem>)
|
data class LocalLibraryItemsPayload(val value:List<LocalLibraryItem>)
|
||||||
data class LocalFoldersPayload(val value:List<LocalFolder>)
|
data class LocalFoldersPayload(val value:List<LocalFolder>)
|
||||||
data class ServerConnConfigPayload(val id:String?, val index:Int, val name:String?, val userId:String, val username:String, val token:String, val address:String?, val customHeaders:Map<String,String>?)
|
data class ServerConnConfigPayload(val id:String?, val index:Int, val name:String?, val userId:String, val username:String, var version:String, val token:String, val refreshToken:String?, val address:String?, val customHeaders:Map<String,String>?)
|
||||||
|
|
||||||
override fun load() {
|
override fun load() {
|
||||||
mainActivity = (activity as MainActivity)
|
mainActivity = (activity as MainActivity)
|
||||||
apiHandler = ApiHandler(mainActivity)
|
apiHandler = ApiHandler(mainActivity)
|
||||||
|
ApiHandler.absDatabaseNotifyListeners = ::notifyListeners
|
||||||
|
|
||||||
|
secureStorage = SecureStorage(mainActivity)
|
||||||
|
|
||||||
DeviceManager.dbManager.cleanLocalMediaProgress()
|
DeviceManager.dbManager.cleanLocalMediaProgress()
|
||||||
DeviceManager.dbManager.cleanLocalLibraryItems()
|
DeviceManager.dbManager.cleanLocalLibraryItems()
|
||||||
|
DeviceManager.dbManager.cleanLogs()
|
||||||
}
|
}
|
||||||
|
|
||||||
@PluginMethod
|
@PluginMethod
|
||||||
@@ -119,7 +125,9 @@ class AbsDatabase : Plugin() {
|
|||||||
|
|
||||||
val userId = serverConfigPayload.userId
|
val userId = serverConfigPayload.userId
|
||||||
val username = serverConfigPayload.username
|
val username = serverConfigPayload.username
|
||||||
val token = serverConfigPayload.token
|
val serverVersion = serverConfigPayload.version
|
||||||
|
val accessToken = serverConfigPayload.token
|
||||||
|
val refreshToken = serverConfigPayload.refreshToken // Refresh only sent after login or refresh
|
||||||
|
|
||||||
GlobalScope.launch(Dispatchers.IO) {
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
if (serverConnectionConfig == null) { // New Server Connection
|
if (serverConnectionConfig == null) { // New Server Connection
|
||||||
@@ -128,7 +136,16 @@ class AbsDatabase : Plugin() {
|
|||||||
// Create new server connection config
|
// Create new server connection config
|
||||||
val sscId = DeviceManager.getBase64Id("$serverAddress@$username")
|
val sscId = DeviceManager.getBase64Id("$serverAddress@$username")
|
||||||
val sscIndex = DeviceManager.deviceData.serverConnectionConfigs.size
|
val sscIndex = DeviceManager.deviceData.serverConnectionConfigs.size
|
||||||
serverConnectionConfig = ServerConnectionConfig(sscId, sscIndex, "$serverAddress ($username)", serverAddress, userId, username, token, serverConfigPayload.customHeaders)
|
|
||||||
|
// Store refresh token securely if provided
|
||||||
|
val hasRefreshToken = if (!refreshToken.isNullOrEmpty()) {
|
||||||
|
secureStorage.storeRefreshToken(sscId, refreshToken)
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
Log.d(tag, "Refresh token secured = $hasRefreshToken")
|
||||||
|
|
||||||
|
serverConnectionConfig = ServerConnectionConfig(sscId, sscIndex, "$serverAddress ($username)", serverAddress, serverVersion, userId, username, accessToken, serverConfigPayload.customHeaders)
|
||||||
|
|
||||||
// Add and save
|
// Add and save
|
||||||
DeviceManager.deviceData.serverConnectionConfigs.add(serverConnectionConfig!!)
|
DeviceManager.deviceData.serverConnectionConfigs.add(serverConnectionConfig!!)
|
||||||
@@ -136,14 +153,21 @@ class AbsDatabase : Plugin() {
|
|||||||
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
|
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
|
||||||
} else {
|
} else {
|
||||||
var shouldSave = false
|
var shouldSave = false
|
||||||
if (serverConnectionConfig?.username != username || serverConnectionConfig?.token != token) {
|
if (serverConnectionConfig?.username != username || serverConnectionConfig?.token != accessToken || serverConnectionConfig?.version != serverVersion) {
|
||||||
serverConnectionConfig?.userId = userId
|
serverConnectionConfig?.userId = userId
|
||||||
serverConnectionConfig?.username = username
|
serverConnectionConfig?.username = username
|
||||||
serverConnectionConfig?.name = "${serverConnectionConfig?.address} (${serverConnectionConfig?.username})"
|
serverConnectionConfig?.name = "${serverConnectionConfig?.address} (${serverConnectionConfig?.username})"
|
||||||
serverConnectionConfig?.token = token
|
serverConnectionConfig?.version = serverVersion
|
||||||
|
serverConnectionConfig?.token = accessToken
|
||||||
shouldSave = true
|
shouldSave = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update refresh token if provided
|
||||||
|
if (!refreshToken.isNullOrEmpty()) {
|
||||||
|
val stored = secureStorage.storeRefreshToken(serverConnectionConfig!!.id, refreshToken)
|
||||||
|
Log.d(tag, "Refresh token secured = $stored")
|
||||||
|
}
|
||||||
|
|
||||||
// Set last connection config
|
// Set last connection config
|
||||||
if (DeviceManager.deviceData.lastServerConnectionConfigId != serverConfigPayload.id) {
|
if (DeviceManager.deviceData.lastServerConnectionConfigId != serverConfigPayload.id) {
|
||||||
DeviceManager.deviceData.lastServerConnectionConfigId = serverConfigPayload.id
|
DeviceManager.deviceData.lastServerConnectionConfigId = serverConfigPayload.id
|
||||||
@@ -162,6 +186,10 @@ class AbsDatabase : Plugin() {
|
|||||||
fun removeServerConnectionConfig(call:PluginCall) {
|
fun removeServerConnectionConfig(call:PluginCall) {
|
||||||
GlobalScope.launch(Dispatchers.IO) {
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
val serverConnectionConfigId = call.getString("serverConnectionConfigId", "").toString()
|
val serverConnectionConfigId = call.getString("serverConnectionConfigId", "").toString()
|
||||||
|
|
||||||
|
// Remove refresh token if it exists
|
||||||
|
secureStorage.removeRefreshToken(serverConnectionConfigId)
|
||||||
|
|
||||||
DeviceManager.deviceData.serverConnectionConfigs = DeviceManager.deviceData.serverConnectionConfigs.filter { it.id != serverConnectionConfigId } as MutableList<ServerConnectionConfig>
|
DeviceManager.deviceData.serverConnectionConfigs = DeviceManager.deviceData.serverConnectionConfigs.filter { it.id != serverConnectionConfigId } as MutableList<ServerConnectionConfig>
|
||||||
if (DeviceManager.deviceData.lastServerConnectionConfigId == serverConnectionConfigId) {
|
if (DeviceManager.deviceData.lastServerConnectionConfigId == serverConnectionConfigId) {
|
||||||
DeviceManager.deviceData.lastServerConnectionConfigId = null
|
DeviceManager.deviceData.lastServerConnectionConfigId = null
|
||||||
@@ -174,6 +202,42 @@ class AbsDatabase : Plugin() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PluginMethod
|
||||||
|
fun getRefreshToken(call:PluginCall) {
|
||||||
|
val serverConnectionConfigId = call.getString("serverConnectionConfigId", "").toString()
|
||||||
|
|
||||||
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
|
val refreshToken = secureStorage.getRefreshToken(serverConnectionConfigId)
|
||||||
|
if (refreshToken != null) {
|
||||||
|
val result = JSObject()
|
||||||
|
result.put("refreshToken", refreshToken)
|
||||||
|
call.resolve(result)
|
||||||
|
} else {
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PluginMethod
|
||||||
|
fun clearRefreshToken(call:PluginCall) {
|
||||||
|
val serverConnectionConfigId = call.getString("serverConnectionConfigId", "").toString()
|
||||||
|
|
||||||
|
val refreshToken = secureStorage.removeRefreshToken(serverConnectionConfigId)
|
||||||
|
val result = JSObject()
|
||||||
|
result.put("success", refreshToken)
|
||||||
|
call.resolve(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@PluginMethod
|
||||||
|
fun getAccessToken(call:PluginCall) {
|
||||||
|
val serverConnectionConfigId = call.getString("serverConnectionConfigId", "").toString()
|
||||||
|
val serverConnectionConfig = DeviceManager.deviceData.serverConnectionConfigs.find { it.id == serverConnectionConfigId }
|
||||||
|
val token = serverConnectionConfig?.token ?: ""
|
||||||
|
val ret = JSObject()
|
||||||
|
ret.put("token", token)
|
||||||
|
call.resolve(ret)
|
||||||
|
}
|
||||||
|
|
||||||
@PluginMethod
|
@PluginMethod
|
||||||
fun logout(call:PluginCall) {
|
fun logout(call:PluginCall) {
|
||||||
GlobalScope.launch(Dispatchers.IO) {
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
@@ -220,12 +284,11 @@ class AbsDatabase : Plugin() {
|
|||||||
@PluginMethod
|
@PluginMethod
|
||||||
fun syncLocalSessionsWithServer(call:PluginCall) {
|
fun syncLocalSessionsWithServer(call:PluginCall) {
|
||||||
if (DeviceManager.serverConnectionConfig == null) {
|
if (DeviceManager.serverConnectionConfig == null) {
|
||||||
Log.e(tag, "syncLocalSessionsWithServer not connected to server")
|
AbsLogger.error("AbsDatabase", "syncLocalSessionsWithServer: not connected to server")
|
||||||
return call.resolve()
|
return call.resolve()
|
||||||
}
|
}
|
||||||
|
|
||||||
apiHandler.syncLocalMediaProgressForUser {
|
apiHandler.syncLocalMediaProgressForUser {
|
||||||
Log.d(tag, "Finished syncing local media progress for user")
|
|
||||||
val savedSessions = DeviceManager.dbManager.getPlaybackSessions().filter { it.serverConnectionConfigId == DeviceManager.serverConnectionConfigId }
|
val savedSessions = DeviceManager.dbManager.getPlaybackSessions().filter { it.serverConnectionConfigId == DeviceManager.serverConnectionConfigId }
|
||||||
|
|
||||||
if (savedSessions.isNotEmpty()) {
|
if (savedSessions.isNotEmpty()) {
|
||||||
@@ -233,6 +296,7 @@ class AbsDatabase : Plugin() {
|
|||||||
if (!success) {
|
if (!success) {
|
||||||
call.resolve(JSObject("{\"error\":\"$errorMsg\"}"))
|
call.resolve(JSObject("{\"error\":\"$errorMsg\"}"))
|
||||||
} else {
|
} else {
|
||||||
|
AbsLogger.info("AbsDatabase", "syncLocalSessionsWithServer: Finished sending local playback sessions to server. Removing ${savedSessions.size} saved sessions.")
|
||||||
// Remove all local sessions
|
// Remove all local sessions
|
||||||
savedSessions.forEach {
|
savedSessions.forEach {
|
||||||
DeviceManager.dbManager.removePlaybackSession(it.id)
|
DeviceManager.dbManager.removePlaybackSession(it.id)
|
||||||
@@ -241,6 +305,7 @@ class AbsDatabase : Plugin() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
AbsLogger.info("AbsDatabase", "syncLocalSessionsWithServer: No saved local playback sessions to send to server.")
|
||||||
call.resolve()
|
call.resolve()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package com.audiobookshelf.app.plugins
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import com.audiobookshelf.app.device.DeviceManager
|
||||||
|
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||||
|
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||||
|
import com.getcapacitor.JSObject
|
||||||
|
import com.getcapacitor.Plugin
|
||||||
|
import com.getcapacitor.PluginCall
|
||||||
|
import com.getcapacitor.PluginMethod
|
||||||
|
import com.getcapacitor.annotation.CapacitorPlugin
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
data class AbsLog(
|
||||||
|
var id:String,
|
||||||
|
var tag:String,
|
||||||
|
var level:String,
|
||||||
|
var message:String,
|
||||||
|
var timestamp:Long
|
||||||
|
)
|
||||||
|
|
||||||
|
data class AbsLogList(val value:List<AbsLog>)
|
||||||
|
|
||||||
|
@CapacitorPlugin(name = "AbsLogger")
|
||||||
|
class AbsLogger : Plugin() {
|
||||||
|
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||||
|
|
||||||
|
override fun load() {
|
||||||
|
onLogEmitter = { log:AbsLog ->
|
||||||
|
notifyListeners("onLog", JSObject(jacksonMapper.writeValueAsString(log)))
|
||||||
|
}
|
||||||
|
info("AbsLogger", "load: AbsLogger plugin initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
var onLogEmitter:((log:AbsLog) -> Unit)? = null
|
||||||
|
|
||||||
|
fun log(level:String, tag:String, message:String) {
|
||||||
|
val absLog = AbsLog(id = UUID.randomUUID().toString(), tag, level, message, timestamp = System.currentTimeMillis())
|
||||||
|
DeviceManager.dbManager.saveLog(absLog)
|
||||||
|
onLogEmitter?.let { it(absLog) }
|
||||||
|
}
|
||||||
|
fun info(tag:String, message:String) {
|
||||||
|
Log.i("AbsLogger", message)
|
||||||
|
log("info", tag, message)
|
||||||
|
}
|
||||||
|
fun error(tag:String, message:String) {
|
||||||
|
Log.e("AbsLogger", message)
|
||||||
|
log("error", tag, message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PluginMethod
|
||||||
|
fun info(call: PluginCall) {
|
||||||
|
val msg = call.getString("message") ?: return call.reject("No message")
|
||||||
|
val tag = call.getString("tag") ?: ""
|
||||||
|
info(tag, msg)
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
@PluginMethod
|
||||||
|
fun error(call: PluginCall) {
|
||||||
|
val msg = call.getString("message") ?: return call.reject("No message")
|
||||||
|
val tag = call.getString("tag") ?: ""
|
||||||
|
error(tag, msg)
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
@PluginMethod
|
||||||
|
fun getAllLogs(call: PluginCall) {
|
||||||
|
val absLogs = DeviceManager.dbManager.getAllLogs()
|
||||||
|
call.resolve(JSObject(jacksonMapper.writeValueAsString(AbsLogList(absLogs))))
|
||||||
|
}
|
||||||
|
|
||||||
|
@PluginMethod
|
||||||
|
fun clearLogs(call: PluginCall) {
|
||||||
|
DeviceManager.dbManager.removeAllLogs()
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,8 @@ import com.audiobookshelf.app.media.MediaProgressSyncData
|
|||||||
import com.audiobookshelf.app.media.SyncResult
|
import com.audiobookshelf.app.media.SyncResult
|
||||||
import com.audiobookshelf.app.models.User
|
import com.audiobookshelf.app.models.User
|
||||||
import com.audiobookshelf.app.BuildConfig
|
import com.audiobookshelf.app.BuildConfig
|
||||||
|
import com.audiobookshelf.app.plugins.AbsLogger
|
||||||
|
import com.audiobookshelf.app.managers.SecureStorage
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||||
@@ -32,9 +34,19 @@ import java.util.concurrent.TimeUnit
|
|||||||
class ApiHandler(var ctx:Context) {
|
class ApiHandler(var ctx:Context) {
|
||||||
val tag = "ApiHandler"
|
val tag = "ApiHandler"
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
// For sending data back to the Webview frontend
|
||||||
|
lateinit var absDatabaseNotifyListeners:(String, JSObject) -> Unit
|
||||||
|
|
||||||
|
fun checkAbsDatabaseNotifyListenersInitted():Boolean {
|
||||||
|
return ::absDatabaseNotifyListeners.isInitialized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var defaultClient = OkHttpClient()
|
private var defaultClient = OkHttpClient()
|
||||||
private var pingClient = OkHttpClient.Builder().callTimeout(3, TimeUnit.SECONDS).build()
|
private var pingClient = OkHttpClient.Builder().callTimeout(3, TimeUnit.SECONDS).build()
|
||||||
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||||
|
private var secureStorage = SecureStorage(ctx)
|
||||||
|
|
||||||
data class LocalSessionsSyncRequestPayload(val sessions:List<PlaybackSession>, val deviceInfo:DeviceInfo)
|
data class LocalSessionsSyncRequestPayload(val sessions:List<PlaybackSession>, val deviceInfo:DeviceInfo)
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
@@ -109,6 +121,13 @@ class ApiHandler(var ctx:Context) {
|
|||||||
|
|
||||||
override fun onResponse(call: Call, response: Response) {
|
override fun onResponse(call: Call, response: Response) {
|
||||||
response.use {
|
response.use {
|
||||||
|
if (it.code == 401) {
|
||||||
|
// Handle 401 Unauthorized by attempting token refresh
|
||||||
|
AbsLogger.info(tag, "makeRequest: 401 Unauthorized for request to \"${request.url}\" - attempt token refresh")
|
||||||
|
handleTokenRefresh(request, httpClient, cb)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (!it.isSuccessful) {
|
if (!it.isSuccessful) {
|
||||||
val jsobj = JSObject()
|
val jsobj = JSObject()
|
||||||
jsobj.put("error", "Unexpected code $response")
|
jsobj.put("error", "Unexpected code $response")
|
||||||
@@ -141,6 +160,266 @@ class ApiHandler(var ctx:Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles token refresh when a 401 Unauthorized response is received
|
||||||
|
* This function will:
|
||||||
|
* 1. Get the refresh token from secure storage for the current server connection
|
||||||
|
* 2. Make a request to /auth/refresh endpoint with the refresh token
|
||||||
|
* 3. Update the stored tokens with the new access token
|
||||||
|
* 4. Retry the original request with the new access token
|
||||||
|
* 5. If refresh fails, handle logout
|
||||||
|
*
|
||||||
|
* @param originalRequest The original request that failed with 401
|
||||||
|
* @param httpClient The HTTP client to use for the request
|
||||||
|
* @param callback The callback to return the response
|
||||||
|
*/
|
||||||
|
private fun handleTokenRefresh(originalRequest: Request, httpClient: OkHttpClient?, callback: (JSObject) -> Unit) {
|
||||||
|
try {
|
||||||
|
AbsLogger.info(tag, "handleTokenRefresh: Attempting to refresh auth tokens for server ${DeviceManager.serverConnectionConfigString}")
|
||||||
|
|
||||||
|
// Get current server connection config ID
|
||||||
|
val serverConnectionConfigId = DeviceManager.serverConnectionConfigId
|
||||||
|
if (serverConnectionConfigId.isEmpty()) {
|
||||||
|
AbsLogger.error(tag, "handleTokenRefresh: Unable to refresh auth tokens. No server connection config ID")
|
||||||
|
val errorObj = JSObject()
|
||||||
|
errorObj.put("error", "No server connection available")
|
||||||
|
callback(errorObj)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get refresh token from secure storage
|
||||||
|
val refreshToken = secureStorage.getRefreshToken(serverConnectionConfigId)
|
||||||
|
if (refreshToken.isNullOrEmpty()) {
|
||||||
|
AbsLogger.error(tag, "handleTokenRefresh: Unable to refresh auth tokens. No refresh token available for server ${DeviceManager.serverConnectionConfigString}")
|
||||||
|
val errorObj = JSObject()
|
||||||
|
errorObj.put("error", "No refresh token available")
|
||||||
|
callback(errorObj)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(tag, "handleTokenRefresh: Retrieved refresh token, attempting to refresh access token")
|
||||||
|
|
||||||
|
// Create refresh token request
|
||||||
|
val refreshEndpoint = "${DeviceManager.serverAddress}/auth/refresh"
|
||||||
|
val refreshRequest = Request.Builder()
|
||||||
|
.url(refreshEndpoint)
|
||||||
|
.addHeader("x-refresh-token", refreshToken)
|
||||||
|
.addHeader("Content-Type", "application/json")
|
||||||
|
.post(EMPTY_REQUEST)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
// Make the refresh request
|
||||||
|
val client = httpClient ?: defaultClient
|
||||||
|
client.newCall(refreshRequest).enqueue(object : Callback {
|
||||||
|
override fun onFailure(call: Call, e: IOException) {
|
||||||
|
Log.e(tag, "handleTokenRefresh: Failed to connect to refresh endpoint", e)
|
||||||
|
AbsLogger.error(tag, "handleTokenRefresh: Failed to connect to refresh endpoint for server ${DeviceManager.serverConnectionConfigString} (error: ${e.message})")
|
||||||
|
handleRefreshFailure(callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResponse(call: Call, response: Response) {
|
||||||
|
response.use {
|
||||||
|
if (!it.isSuccessful) {
|
||||||
|
AbsLogger.error(tag, "handleTokenRefresh: Refresh request failed with status ${it.code} for server ${DeviceManager.serverConnectionConfigString}")
|
||||||
|
handleRefreshFailure(callback)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val bodyString = it.body!!.string()
|
||||||
|
try {
|
||||||
|
val responseJson = JSONObject(bodyString)
|
||||||
|
val userObj = responseJson.optJSONObject("user")
|
||||||
|
|
||||||
|
if (userObj == null) {
|
||||||
|
AbsLogger.error(tag, "handleTokenRefresh: No user object in refresh response for server ${DeviceManager.serverConnectionConfigString}")
|
||||||
|
handleRefreshFailure(callback)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val newAccessToken = userObj.optString("accessToken")
|
||||||
|
val newRefreshToken = userObj.optString("refreshToken")
|
||||||
|
|
||||||
|
if (newAccessToken.isEmpty()) {
|
||||||
|
AbsLogger.error(tag, "handleTokenRefresh: No access token in refresh response for server ${DeviceManager.serverConnectionConfigString}")
|
||||||
|
handleRefreshFailure(callback)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(tag, "handleTokenRefresh: Successfully obtained new access token")
|
||||||
|
|
||||||
|
// Update tokens in secure storage and device manager
|
||||||
|
updateTokens(newAccessToken, newRefreshToken.ifEmpty { refreshToken }, serverConnectionConfigId)
|
||||||
|
|
||||||
|
// Retry the original request with the new access token
|
||||||
|
Log.d(tag, "handleTokenRefresh: Retrying original request with new token")
|
||||||
|
retryOriginalRequest(originalRequest, newAccessToken, httpClient, callback)
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(tag, "handleTokenRefresh: Failed to parse refresh response", e)
|
||||||
|
AbsLogger.error(tag, "handleTokenRefresh: Failed to parse refresh response for server ${DeviceManager.serverConnectionConfigString} (error: ${e.message})")
|
||||||
|
handleRefreshFailure(callback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(tag, "handleTokenRefresh: Unexpected error during token refresh", e)
|
||||||
|
handleRefreshFailure(callback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the stored tokens with new access and refresh tokens
|
||||||
|
*
|
||||||
|
* @param newAccessToken The new access token
|
||||||
|
* @param newRefreshToken The new refresh token (or existing one if not provided)
|
||||||
|
*/
|
||||||
|
private fun updateTokens(newAccessToken: String, newRefreshToken: String, serverConnectionConfigId: String) {
|
||||||
|
try {
|
||||||
|
// Update the refresh token in secure storage if it's new
|
||||||
|
if (newRefreshToken != secureStorage.getRefreshToken(serverConnectionConfigId)) {
|
||||||
|
secureStorage.storeRefreshToken(serverConnectionConfigId, newRefreshToken)
|
||||||
|
Log.d(tag, "updateTokens: Updated refresh token in secure storage")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the access token in the current server connection config
|
||||||
|
DeviceManager.serverConnectionConfig?.let { config ->
|
||||||
|
config.token = newAccessToken
|
||||||
|
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
|
||||||
|
Log.d(tag, "updateTokens: Updated access token in server connection config")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send access token to Webview frontend
|
||||||
|
if (checkAbsDatabaseNotifyListenersInitted()) {
|
||||||
|
val tokenJsObject = JSObject()
|
||||||
|
tokenJsObject.put("accessToken", newAccessToken)
|
||||||
|
absDatabaseNotifyListeners("onTokenRefresh", tokenJsObject)
|
||||||
|
} else {
|
||||||
|
// Can happen if Webview is never run
|
||||||
|
Log.i(tag, "AbsDatabaseNotifyListeners is not initialized so cannot send new access token")
|
||||||
|
}
|
||||||
|
AbsLogger.info(tag, "updateTokens: Successfully refreshed auth tokens for server ${DeviceManager.serverConnectionConfigString}")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(tag, "updateTokens: Failed to update tokens", e)
|
||||||
|
AbsLogger.error(tag, "updateTokens: Failed to refresh auth tokens for server ${DeviceManager.serverConnectionConfigString} (error: ${e.message})")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retries the original request with the new access token
|
||||||
|
*
|
||||||
|
* @param originalRequest The original request to retry
|
||||||
|
* @param newAccessToken The new access token to use
|
||||||
|
* @param httpClient The HTTP client to use
|
||||||
|
* @param callback The callback to return the response
|
||||||
|
*/
|
||||||
|
private fun retryOriginalRequest(originalRequest: Request, newAccessToken: String, httpClient: OkHttpClient?, callback: (JSObject) -> Unit) {
|
||||||
|
try {
|
||||||
|
// Create a new request with the updated authorization header
|
||||||
|
val newRequest = originalRequest.newBuilder()
|
||||||
|
.removeHeader("Authorization")
|
||||||
|
.addHeader("Authorization", "Bearer $newAccessToken")
|
||||||
|
.build()
|
||||||
|
|
||||||
|
Log.d(tag, "retryOriginalRequest: Retrying request to ${newRequest.url}")
|
||||||
|
|
||||||
|
// Make the retry request
|
||||||
|
val client = httpClient ?: defaultClient
|
||||||
|
client.newCall(newRequest).enqueue(object : Callback {
|
||||||
|
override fun onFailure(call: Call, e: IOException) {
|
||||||
|
Log.e(tag, "retryOriginalRequest: Failed to retry request", e)
|
||||||
|
AbsLogger.error(tag, "retryOriginalRequest: Failed to retry request after token refresh for server ${DeviceManager.serverConnectionConfigString} (error: ${e.message})")
|
||||||
|
val errorObj = JSObject()
|
||||||
|
errorObj.put("error", "Failed to retry request after token refresh")
|
||||||
|
callback(errorObj)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResponse(call: Call, response: Response) {
|
||||||
|
response.use {
|
||||||
|
if (!it.isSuccessful) {
|
||||||
|
Log.e(tag, "retryOriginalRequest: Retry request failed with status ${it.code}")
|
||||||
|
AbsLogger.error(tag, "retryOriginalRequest: Retry request failed with status ${it.code} for server ${DeviceManager.serverConnectionConfigString}")
|
||||||
|
val errorObj = JSObject()
|
||||||
|
errorObj.put("error", "Retry request failed with status ${it.code}")
|
||||||
|
callback(errorObj)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val bodyString = it.body!!.string()
|
||||||
|
if (bodyString == "OK") {
|
||||||
|
callback(JSObject())
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
var jsonObj = JSObject()
|
||||||
|
if (bodyString.startsWith("[")) {
|
||||||
|
val array = JSArray(bodyString)
|
||||||
|
jsonObj.put("value", array)
|
||||||
|
} else {
|
||||||
|
jsonObj = JSObject(bodyString)
|
||||||
|
}
|
||||||
|
callback(jsonObj)
|
||||||
|
} catch(je:JSONException) {
|
||||||
|
Log.e(tag, "retryOriginalRequest: Invalid JSON response ${je.localizedMessage} from body $bodyString")
|
||||||
|
val errorObj = JSObject()
|
||||||
|
errorObj.put("error", "Invalid response body")
|
||||||
|
callback(errorObj)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(tag, "retryOriginalRequest: Unexpected error during retry", e)
|
||||||
|
AbsLogger.error(tag, "retryOriginalRequest: Unexpected error during retry for server ${DeviceManager.serverConnectionConfigString}")
|
||||||
|
val errorObj = JSObject()
|
||||||
|
errorObj.put("error", "Failed to retry request")
|
||||||
|
callback(errorObj)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the case when token refresh fails
|
||||||
|
* This will clear the current session and notify the callback
|
||||||
|
*
|
||||||
|
* @param callback The callback to return the error
|
||||||
|
*/
|
||||||
|
private fun handleRefreshFailure(callback: (JSObject) -> Unit) {
|
||||||
|
try {
|
||||||
|
Log.d(tag, "handleRefreshFailure: Token refresh failed, clearing session")
|
||||||
|
|
||||||
|
// Clear the current server connection
|
||||||
|
DeviceManager.serverConnectionConfig = null
|
||||||
|
DeviceManager.deviceData.lastServerConnectionConfigId = null
|
||||||
|
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
|
||||||
|
|
||||||
|
// Remove refresh token from secure storage
|
||||||
|
val serverConnectionConfigId = DeviceManager.serverConnectionConfigId
|
||||||
|
if (serverConnectionConfigId.isNotEmpty()) {
|
||||||
|
secureStorage.removeRefreshToken(serverConnectionConfigId)
|
||||||
|
}
|
||||||
|
|
||||||
|
val errorObj = JSObject()
|
||||||
|
errorObj.put("error", "Authentication failed - please login again")
|
||||||
|
callback(errorObj)
|
||||||
|
|
||||||
|
if (checkAbsDatabaseNotifyListenersInitted()) {
|
||||||
|
val tokenJsObject = JSObject()
|
||||||
|
tokenJsObject.put("error", "Token refresh failed")
|
||||||
|
absDatabaseNotifyListeners("onTokenRefreshFailure", tokenJsObject)
|
||||||
|
} else {
|
||||||
|
// Can happen if Webview is never run
|
||||||
|
Log.i(tag, "AbsDatabaseNotifyListeners is not initialized so cannot send token refresh failure notification")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(tag, "handleRefreshFailure: Error during failure handling", e)
|
||||||
|
val errorObj = JSObject()
|
||||||
|
errorObj.put("error", "Authentication failed")
|
||||||
|
callback(errorObj)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun getCurrentUser(cb: (User?) -> Unit) {
|
fun getCurrentUser(cb: (User?) -> Unit) {
|
||||||
getRequest("/api/me", null, null) {
|
getRequest("/api/me", null, null) {
|
||||||
if (it.has("error")) {
|
if (it.has("error")) {
|
||||||
@@ -333,8 +612,7 @@ class ApiHandler(var ctx:Context) {
|
|||||||
val array = it.getJSONArray("libraryItems")
|
val array = it.getJSONArray("libraryItems")
|
||||||
for (i in 0 until array.length()) {
|
for (i in 0 until array.length()) {
|
||||||
val jsobj = array.get(i) as JSONObject
|
val jsobj = array.get(i) as JSONObject
|
||||||
|
val itemInProgress = ItemInProgress.makeFromServerObject(jsobj, jacksonMapper)
|
||||||
val itemInProgress = ItemInProgress.makeFromServerObject(jsobj)
|
|
||||||
items.add(itemInProgress)
|
items.add(itemInProgress)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -468,22 +746,27 @@ class ApiHandler(var ctx:Context) {
|
|||||||
val deviceInfo = DeviceInfo(deviceId, Build.MANUFACTURER, Build.MODEL, Build.VERSION.SDK_INT, BuildConfig.VERSION_NAME)
|
val deviceInfo = DeviceInfo(deviceId, Build.MANUFACTURER, Build.MODEL, Build.VERSION.SDK_INT, BuildConfig.VERSION_NAME)
|
||||||
|
|
||||||
val payload = JSObject(jacksonMapper.writeValueAsString(LocalSessionsSyncRequestPayload(playbackSessions, deviceInfo)))
|
val payload = JSObject(jacksonMapper.writeValueAsString(LocalSessionsSyncRequestPayload(playbackSessions, deviceInfo)))
|
||||||
Log.d(tag, "Sending ${playbackSessions.size} saved local playback sessions to server")
|
AbsLogger.info("ApiHandler", "sendSyncLocalSessions: Sending ${playbackSessions.size} saved local playback sessions to server (${DeviceManager.serverConnectionConfigName})")
|
||||||
|
|
||||||
postRequest("/api/session/local-all", payload, null) {
|
postRequest("/api/session/local-all", payload, null) {
|
||||||
if (!it.getString("error").isNullOrEmpty()) {
|
if (!it.getString("error").isNullOrEmpty()) {
|
||||||
Log.e(tag, "Failed to sync local sessions")
|
AbsLogger.error("ApiHandler", "sendSyncLocalSessions: Failed to sync local sessions. (${it.getString("error")})")
|
||||||
cb(false, it.getString("error"))
|
cb(false, it.getString("error"))
|
||||||
} else {
|
} else {
|
||||||
val response = jacksonMapper.readValue<LocalSessionsSyncResponsePayload>(it.toString())
|
val response = jacksonMapper.readValue<LocalSessionsSyncResponsePayload>(it.toString())
|
||||||
response.results.forEach { localSessionSyncResult ->
|
response.results.forEach { localSessionSyncResult ->
|
||||||
Log.d(tag, "Synced session result ${localSessionSyncResult.id}|${localSessionSyncResult.progressSynced}|${localSessionSyncResult.success}")
|
Log.d(tag, "Synced session result ${localSessionSyncResult.id}|${localSessionSyncResult.progressSynced}|${localSessionSyncResult.success}")
|
||||||
|
|
||||||
playbackSessions.find { ps -> ps.id == localSessionSyncResult.id }?.let { session ->
|
playbackSessions.find { ps -> ps.id == localSessionSyncResult.id }?.let { session ->
|
||||||
if (localSessionSyncResult.progressSynced == true) {
|
if (localSessionSyncResult.progressSynced == true) {
|
||||||
val syncResult = SyncResult(true, true, "Progress synced on server")
|
val syncResult = SyncResult(true, true, "Progress synced on server")
|
||||||
MediaEventManager.saveEvent(session, syncResult)
|
MediaEventManager.saveEvent(session, syncResult)
|
||||||
Log.i(tag, "Successfully synced session ${session.displayTitle} with server")
|
|
||||||
|
AbsLogger.info("ApiHandler", "sendSyncLocalSessions: Synced session \"${session.displayTitle}\" with server, server progress was updated for item ${session.mediaItemId}")
|
||||||
} else if (!localSessionSyncResult.success) {
|
} else if (!localSessionSyncResult.success) {
|
||||||
Log.e(tag, "Failed to sync session ${session.displayTitle} with server. Error: ${localSessionSyncResult.error}")
|
AbsLogger.error("ApiHandler", "sendSyncLocalSessions: Failed to sync session \"${session.displayTitle}\" with server. Error: ${localSessionSyncResult.error}")
|
||||||
|
} else {
|
||||||
|
AbsLogger.info("ApiHandler", "sendSyncLocalSessions: Synced session \"${session.displayTitle}\" with server. Server progress was up-to-date for item ${session.mediaItemId}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -493,37 +776,72 @@ class ApiHandler(var ctx:Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun syncLocalMediaProgressForUser(cb: () -> Unit) {
|
fun syncLocalMediaProgressForUser(cb: () -> Unit) {
|
||||||
|
AbsLogger.info("ApiHandler", "[ApiHandler] syncLocalMediaProgressForUser: Server connection ${DeviceManager.serverConnectionConfigName}")
|
||||||
|
|
||||||
// Get all local media progress for this server
|
// Get all local media progress for this server
|
||||||
val allLocalMediaProgress = DeviceManager.dbManager.getAllLocalMediaProgress().filter { it.serverConnectionConfigId == DeviceManager.serverConnectionConfigId }
|
val allLocalMediaProgress = DeviceManager.dbManager.getAllLocalMediaProgress().filter { it.serverConnectionConfigId == DeviceManager.serverConnectionConfigId }
|
||||||
if (allLocalMediaProgress.isEmpty()) {
|
if (allLocalMediaProgress.isEmpty()) {
|
||||||
Log.d(tag, "No local media progress to sync")
|
AbsLogger.info("ApiHandler", "[ApiHandler] syncLocalMediaProgressForUser: No local media progress to sync")
|
||||||
return cb()
|
return cb()
|
||||||
}
|
}
|
||||||
|
|
||||||
getCurrentUser { _user ->
|
AbsLogger.info("ApiHandler", "syncLocalMediaProgressForUser: Found ${allLocalMediaProgress.size} local media progress")
|
||||||
_user?.let { user->
|
|
||||||
|
getCurrentUser { user ->
|
||||||
|
if (user == null) {
|
||||||
|
AbsLogger.error("ApiHandler", "syncLocalMediaProgressForUser: Failed to load user from server (${DeviceManager.serverConnectionConfigName})")
|
||||||
|
} else {
|
||||||
|
var numLocalMediaProgressUptToDate = 0
|
||||||
|
var numLocalMediaProgressUpdated = 0
|
||||||
|
|
||||||
// Compare server user progress with local progress
|
// Compare server user progress with local progress
|
||||||
user.mediaProgress.forEach { mediaProgress ->
|
user.mediaProgress.forEach { mediaProgress ->
|
||||||
// Get matching local media progress
|
// Get matching local media progress
|
||||||
allLocalMediaProgress.find { it.isMatch(mediaProgress) }?.let { localMediaProgress ->
|
allLocalMediaProgress.find { it.isMatch(mediaProgress) }?.let { localMediaProgress ->
|
||||||
if (mediaProgress.lastUpdate > localMediaProgress.lastUpdate) {
|
if (mediaProgress.lastUpdate > localMediaProgress.lastUpdate) {
|
||||||
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}")
|
val updateLogs = mutableListOf<String>()
|
||||||
|
if (mediaProgress.progress != localMediaProgress.progress) {
|
||||||
|
updateLogs.add("Updated progress from ${localMediaProgress.progress} to ${mediaProgress.progress}")
|
||||||
|
}
|
||||||
|
if (mediaProgress.currentTime != localMediaProgress.currentTime) {
|
||||||
|
updateLogs.add("Updated currentTime from ${localMediaProgress.currentTime} to ${mediaProgress.currentTime}")
|
||||||
|
}
|
||||||
|
if (mediaProgress.isFinished != localMediaProgress.isFinished) {
|
||||||
|
updateLogs.add("Updated isFinished from ${localMediaProgress.isFinished} to ${mediaProgress.isFinished}")
|
||||||
|
}
|
||||||
|
if (mediaProgress.ebookProgress != localMediaProgress.ebookProgress) {
|
||||||
|
updateLogs.add("Updated ebookProgress from ${localMediaProgress.isFinished} to ${mediaProgress.isFinished}")
|
||||||
|
}
|
||||||
|
if (updateLogs.isNotEmpty()) {
|
||||||
|
AbsLogger.info("ApiHandler", "syncLocalMediaProgressForUser: Server progress for item \"${mediaProgress.mediaItemId}\" is more recent than local (server lastUpdate=${mediaProgress.lastUpdate}, local lastUpdate=${localMediaProgress.lastUpdate}). ${updateLogs.joinToString()}")
|
||||||
|
}
|
||||||
|
|
||||||
localMediaProgress.updateFromServerMediaProgress(mediaProgress)
|
localMediaProgress.updateFromServerMediaProgress(mediaProgress)
|
||||||
MediaEventManager.syncEvent(mediaProgress, "Sync on server connection")
|
|
||||||
|
// Only report sync if progress changed
|
||||||
|
if (updateLogs.isNotEmpty()) {
|
||||||
|
MediaEventManager.syncEvent(mediaProgress, "Sync on server connection")
|
||||||
|
}
|
||||||
DeviceManager.dbManager.saveLocalMediaProgress(localMediaProgress)
|
DeviceManager.dbManager.saveLocalMediaProgress(localMediaProgress)
|
||||||
|
numLocalMediaProgressUpdated++
|
||||||
} else if (localMediaProgress.lastUpdate > mediaProgress.lastUpdate && localMediaProgress.ebookLocation != null && localMediaProgress.ebookLocation != mediaProgress.ebookLocation) {
|
} else if (localMediaProgress.lastUpdate > mediaProgress.lastUpdate && localMediaProgress.ebookLocation != null && localMediaProgress.ebookLocation != mediaProgress.ebookLocation) {
|
||||||
// Patch ebook progress to server
|
// Patch ebook progress to server
|
||||||
|
AbsLogger.info("ApiHandler", "syncLocalMediaProgressForUser: Local progress for ebook item \"${mediaProgress.mediaItemId}\" is more recent than server progress. Local progress last updated ${localMediaProgress.lastUpdate}, server progress last updated ${mediaProgress.lastUpdate}. Sending server request to update ebook progress from ${mediaProgress.ebookProgress} to ${localMediaProgress.ebookProgress}")
|
||||||
val endpoint = "/api/me/progress/${localMediaProgress.libraryItemId}"
|
val endpoint = "/api/me/progress/${localMediaProgress.libraryItemId}"
|
||||||
val updatePayload = JSObject()
|
val updatePayload = JSObject()
|
||||||
updatePayload.put("ebookLocation", localMediaProgress.ebookLocation)
|
updatePayload.put("ebookLocation", localMediaProgress.ebookLocation)
|
||||||
updatePayload.put("ebookProgress", localMediaProgress.ebookProgress)
|
updatePayload.put("ebookProgress", localMediaProgress.ebookProgress)
|
||||||
updatePayload.put("lastUpdate", localMediaProgress.lastUpdate)
|
updatePayload.put("lastUpdate", localMediaProgress.lastUpdate)
|
||||||
patchRequest(endpoint,updatePayload) {
|
patchRequest(endpoint,updatePayload) {
|
||||||
Log.d(tag, "syncLocalMediaProgressForUser patched ebook progress")
|
AbsLogger.info("ApiHandler", "syncLocalMediaProgressForUser: Successfully updated server ebook progress for item item \"${mediaProgress.mediaItemId}\"")
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
numLocalMediaProgressUptToDate++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AbsLogger.info("ApiHandler", "syncLocalMediaProgressForUser: Finishing syncing local media progress with server. $numLocalMediaProgressUptToDate up-to-date, $numLocalMediaProgressUpdated updated")
|
||||||
}
|
}
|
||||||
cb()
|
cb()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
tools:context=".MainActivity">
|
tools:context=".MainActivity">
|
||||||
|
|
||||||
<WebView
|
<WebView
|
||||||
|
android:id="@+id/webview"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent" />
|
android:layout_height="match_parent" />
|
||||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||||
|
|||||||
Binary file not shown.
@@ -10,7 +10,7 @@
|
|||||||
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.NoActionBar">
|
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.NoActionBar">
|
||||||
<item name="windowActionBar">false</item>
|
<item name="windowActionBar">false</item>
|
||||||
<item name="windowNoTitle">true</item>
|
<item name="windowNoTitle">true</item>
|
||||||
<item name="android:background">@null</item>
|
<item name="android:background">@color/background_dark</item>
|
||||||
<item name="android:statusBarColor">@color/background_dark</item>
|
<item name="android:statusBarColor">@color/background_dark</item>
|
||||||
<item name="android:navigationBarColor">@color/background_dark</item>
|
<item name="android:navigationBarColor">@color/background_dark</item>
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -3,5 +3,5 @@
|
|||||||
<color name="light_blue_200">#FF81D4FA</color>
|
<color name="light_blue_200">#FF81D4FA</color>
|
||||||
<color name="light_blue_600">#FF039BE5</color>
|
<color name="light_blue_600">#FF039BE5</color>
|
||||||
<color name="light_blue_900">#FF01579B</color>
|
<color name="light_blue_900">#FF01579B</color>
|
||||||
<color name="background_dark">#262626</color>
|
<color name="background_dark">#232323</color>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
|
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||||
<item name="windowActionBar">false</item>
|
<item name="windowActionBar">false</item>
|
||||||
<item name="windowNoTitle">true</item>
|
<item name="windowNoTitle">true</item>
|
||||||
<item name="android:background">@null</item>
|
<item name="android:background">@color/background_dark</item>
|
||||||
<item name="android:statusBarColor">@color/background_dark</item>
|
<item name="android:statusBarColor">@color/background_dark</item>
|
||||||
<item name="android:navigationBarColor">@color/background_dark</item>
|
<item name="android:navigationBarColor">@color/background_dark</item>
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ ext {
|
|||||||
androidx_core_ktx_version = '1.16.0'
|
androidx_core_ktx_version = '1.16.0'
|
||||||
androidx_media_version = '1.7.0'
|
androidx_media_version = '1.7.0'
|
||||||
exoplayer_version = '2.18.7'
|
exoplayer_version = '2.18.7'
|
||||||
glide_version = '4.11.0'
|
glide_version = '4.16.0'
|
||||||
junit_version = '4.13.2'
|
junit_version = '4.13.2'
|
||||||
kotlin_version = '2.1.0'
|
kotlin_version = '2.1.0'
|
||||||
kotlin_coroutines_version = '1.10.1'
|
kotlin_coroutines_version = '1.10.1'
|
||||||
|
|||||||
+7
-3
@@ -1,5 +1,5 @@
|
|||||||
@import "./tailwind.css";
|
@import './tailwind.css';
|
||||||
@import "./fonts.css";
|
@import './fonts.css';
|
||||||
@import './defaultStyles.css';
|
@import './defaultStyles.css';
|
||||||
@import './absicons.css';
|
@import './absicons.css';
|
||||||
@import './transitions.css';
|
@import './transitions.css';
|
||||||
@@ -61,6 +61,10 @@ textarea {
|
|||||||
box-shadow: 4px 1px 8px #11111166, -4px 1px 8px #11111166, 1px -4px 8px #11111166;
|
box-shadow: 4px 1px 8px #11111166, -4px 1px 8px #11111166, 1px -4px 8px #11111166;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.box-shadow-progressbar {
|
||||||
|
box-shadow: 0px -1px 4px rgb(62, 50, 2, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
.shadow-height {
|
.shadow-height {
|
||||||
height: calc(100% - 4px);
|
height: calc(100% - 4px);
|
||||||
}
|
}
|
||||||
@@ -164,4 +168,4 @@ Bookshelf Label
|
|||||||
.tracksTable th {
|
.tracksTable th {
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,25 @@
|
|||||||
--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%);
|
--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='black'] {
|
||||||
|
color: white;
|
||||||
|
--color-bg: 0 0 0;
|
||||||
|
--color-bg-hover: 0 0 0;
|
||||||
|
--color-fg: 230 237 243;
|
||||||
|
--color-fg-muted: 120 126 132;
|
||||||
|
--color-primary: 0 0 0;
|
||||||
|
--color-secondary: 0 0 0;
|
||||||
|
--color-border: 55 62 65;
|
||||||
|
--color-bg-toggle: 0 0 0;
|
||||||
|
--color-bg-toggle-selected: 35 35 35;
|
||||||
|
--color-track-cursor: 229 231 235;
|
||||||
|
--color-track: 107 114 128;
|
||||||
|
--color-track-buffered: 75 85 99;
|
||||||
|
--gradient-item-page: rgb(0, 0, 0);
|
||||||
|
--gradient-audio-player: rgb(0, 0, 0);
|
||||||
|
--gradient-minimized-audio-player: rgb(0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
html[data-theme='light'] {
|
html[data-theme='light'] {
|
||||||
color: black;
|
color: black;
|
||||||
--color-bg: 255 255 255;
|
--color-bg: 255 255 255;
|
||||||
|
|||||||
@@ -5,6 +5,10 @@
|
|||||||
"plugins": {
|
"plugins": {
|
||||||
"CapacitorHttp": {
|
"CapacitorHttp": {
|
||||||
"enabled": false
|
"enabled": false
|
||||||
|
},
|
||||||
|
"StatusBar": {
|
||||||
|
"backgroundColor": "#232323",
|
||||||
|
"style": "DARK"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"server": {
|
"server": {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<a v-if="showBack" @click="back" class="rounded-full h-10 w-10 flex items-center justify-center mr-2 cursor-pointer">
|
<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-symbols text-3xl text-fg">arrow_back</span>
|
<span class="material-symbols text-3xl text-fg">arrow_back</span>
|
||||||
</a>
|
</a>
|
||||||
<div v-if="user && currentLibrary && socketConnected">
|
<div v-if="user && currentLibrary">
|
||||||
<div class="pl-1.5 pr-2.5 py-2 bg-bg bg-opacity-30 rounded-md flex items-center" @click="clickShowLibraryModal">
|
<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" />
|
<ui-library-icon :icon="currentLibraryIcon" :size="4" font-size="base" />
|
||||||
<p class="text-sm leading-4 ml-2 mt-0.5 max-w-24 truncate">{{ currentLibraryName }}</p>
|
<p class="text-sm leading-4 ml-2 mt-0.5 max-w-24 truncate">{{ currentLibraryName }}</p>
|
||||||
@@ -56,9 +56,6 @@ export default {
|
|||||||
this.$store.commit('setCastAvailable', val)
|
this.$store.commit('setCastAvailable', val)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
socketConnected() {
|
|
||||||
return this.$store.state.socketConnected
|
|
||||||
},
|
|
||||||
currentLibrary() {
|
currentLibrary() {
|
||||||
return this.$store.getters['libraries/getCurrentLibrary']
|
return this.$store.getters['libraries/getCurrentLibrary']
|
||||||
},
|
},
|
||||||
@@ -103,14 +100,14 @@ export default {
|
|||||||
this.isCastAvailable = data && data.value
|
this.isCastAvailable = data && data.value
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
async mounted() {
|
||||||
AbsAudioPlayer.getIsCastAvailable().then((data) => {
|
AbsAudioPlayer.getIsCastAvailable().then((data) => {
|
||||||
this.isCastAvailable = data && data.value
|
this.isCastAvailable = data && data.value
|
||||||
})
|
})
|
||||||
this.onCastAvailableUpdateListener = AbsAudioPlayer.addListener('onCastAvailableUpdate', this.onCastAvailableUpdate)
|
this.onCastAvailableUpdateListener = await AbsAudioPlayer.addListener('onCastAvailableUpdate', this.onCastAvailableUpdate)
|
||||||
},
|
},
|
||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
if (this.onCastAvailableUpdateListener) this.onCastAvailableUpdateListener.remove()
|
this.onCastAvailableUpdateListener?.remove()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -4,15 +4,15 @@
|
|||||||
<div class="w-full h-full absolute top-0 left-0 pointer-events-none" style="background: var(--gradient-audio-player)" />
|
<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">
|
<div class="top-4 left-4 absolute cursor-pointer">
|
||||||
<span class="material-symbols text-5xl" :class="{ 'text-black text-opacity-75': coverBgIsLight }" @click="collapseFullscreen">keyboard_arrow_down</span>
|
<span class="material-symbols text-5xl" :class="{ 'text-black text-opacity-75': coverBgIsLight && theme !== 'black' }" @click="collapseFullscreen">keyboard_arrow_down</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-show="showCastBtn" class="top-6 right-16 absolute cursor-pointer">
|
<div v-show="showCastBtn" class="top-6 right-16 absolute cursor-pointer">
|
||||||
<span class="material-symbols text-3xl" :class="coverBgIsLight ? 'text-black' : ''" @click="castClick">{{ isCasting ? 'cast_connected' : 'cast' }}</span>
|
<span class="material-symbols text-3xl" :class="coverBgIsLight && theme !== 'black' ? 'text-black' : ''" @click="castClick">{{ isCasting ? 'cast_connected' : 'cast' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="top-6 right-4 absolute cursor-pointer">
|
<div class="top-6 right-4 absolute cursor-pointer">
|
||||||
<span class="material-symbols text-3xl" :class="{ 'text-black text-opacity-75': coverBgIsLight }" @click="showMoreMenuDialog = true">more_vert</span>
|
<span class="material-symbols text-3xl" :class="{ 'text-black text-opacity-75': coverBgIsLight && theme !== 'black' }" @click="showMoreMenuDialog = true">more_vert</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="top-4 absolute left-0 right-0 mx-auto text-center uppercase tracking-widest text-opacity-75" :class="{ 'text-black text-opacity-75': coverBgIsLight }" style="font-size: 10px">{{ isDirectPlayMethod ? $strings.LabelPlaybackDirect : isLocalPlayMethod ? $strings.LabelPlaybackLocal : $strings.LabelPlaybackTranscode }}</p>
|
<p class="top-4 absolute left-0 right-0 mx-auto text-center uppercase tracking-widest text-opacity-75" :class="{ 'text-black text-opacity-75': coverBgIsLight && theme !== 'black' }" style="font-size: 10px">{{ isDirectPlayMethod ? $strings.LabelPlaybackDirect : isLocalPlayMethod ? $strings.LabelPlaybackLocal : $strings.LabelPlaybackTranscode }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="playerSettings.useChapterTrack && playerSettings.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">
|
||||||
@@ -140,13 +140,6 @@ export default {
|
|||||||
readyTrackWidth: 0,
|
readyTrackWidth: 0,
|
||||||
seekedTime: 0,
|
seekedTime: 0,
|
||||||
seekLoading: false,
|
seekLoading: false,
|
||||||
onPlaybackSessionListener: null,
|
|
||||||
onPlaybackClosedListener: null,
|
|
||||||
onPlayingUpdateListener: null,
|
|
||||||
onMetadataListener: null,
|
|
||||||
onProgressSyncFailing: null,
|
|
||||||
onProgressSyncSuccess: null,
|
|
||||||
onPlaybackSpeedChangedListener: null,
|
|
||||||
touchStartY: 0,
|
touchStartY: 0,
|
||||||
touchStartTime: 0,
|
touchStartTime: 0,
|
||||||
playerSettings: {
|
playerSettings: {
|
||||||
@@ -182,6 +175,9 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
theme() {
|
||||||
|
return document.documentElement.dataset.theme || 'dark'
|
||||||
|
},
|
||||||
menuItems() {
|
menuItems() {
|
||||||
const items = []
|
const items = []
|
||||||
// TODO: Implement on iOS
|
// TODO: Implement on iOS
|
||||||
@@ -880,14 +876,14 @@ export default {
|
|||||||
async init() {
|
async init() {
|
||||||
await this.loadPlayerSettings()
|
await this.loadPlayerSettings()
|
||||||
|
|
||||||
this.onPlaybackSessionListener = AbsAudioPlayer.addListener('onPlaybackSession', this.onPlaybackSession)
|
AbsAudioPlayer.addListener('onPlaybackSession', this.onPlaybackSession)
|
||||||
this.onPlaybackClosedListener = AbsAudioPlayer.addListener('onPlaybackClosed', this.onPlaybackClosed)
|
AbsAudioPlayer.addListener('onPlaybackClosed', this.onPlaybackClosed)
|
||||||
this.onPlaybackFailedListener = AbsAudioPlayer.addListener('onPlaybackFailed', this.onPlaybackFailed)
|
AbsAudioPlayer.addListener('onPlaybackFailed', this.onPlaybackFailed)
|
||||||
this.onPlayingUpdateListener = AbsAudioPlayer.addListener('onPlayingUpdate', this.onPlayingUpdate)
|
AbsAudioPlayer.addListener('onPlayingUpdate', this.onPlayingUpdate)
|
||||||
this.onMetadataListener = AbsAudioPlayer.addListener('onMetadata', this.onMetadata)
|
AbsAudioPlayer.addListener('onMetadata', this.onMetadata)
|
||||||
this.onProgressSyncFailing = AbsAudioPlayer.addListener('onProgressSyncFailing', this.showProgressSyncIsFailing)
|
AbsAudioPlayer.addListener('onProgressSyncFailing', this.showProgressSyncIsFailing)
|
||||||
this.onProgressSyncSuccess = AbsAudioPlayer.addListener('onProgressSyncSuccess', this.showProgressSyncSuccess)
|
AbsAudioPlayer.addListener('onProgressSyncSuccess', this.showProgressSyncSuccess)
|
||||||
this.onPlaybackSpeedChangedListener = AbsAudioPlayer.addListener('onPlaybackSpeedChanged', this.onPlaybackSpeedChanged)
|
AbsAudioPlayer.addListener('onPlaybackSpeedChanged', this.onPlaybackSpeedChanged)
|
||||||
},
|
},
|
||||||
async screenOrientationChange() {
|
async screenOrientationChange() {
|
||||||
if (this.isRefreshingUI) return
|
if (this.isRefreshingUI) return
|
||||||
@@ -981,14 +977,9 @@ export default {
|
|||||||
document.body.removeEventListener('touchend', this.touchend)
|
document.body.removeEventListener('touchend', this.touchend)
|
||||||
document.body.removeEventListener('touchmove', this.touchmove)
|
document.body.removeEventListener('touchmove', this.touchmove)
|
||||||
|
|
||||||
if (this.onPlayingUpdateListener) this.onPlayingUpdateListener.remove()
|
if (AbsAudioPlayer.removeAllListeners) {
|
||||||
if (this.onMetadataListener) this.onMetadataListener.remove()
|
AbsAudioPlayer.removeAllListeners()
|
||||||
if (this.onPlaybackSessionListener) this.onPlaybackSessionListener.remove()
|
}
|
||||||
if (this.onPlaybackClosedListener) this.onPlaybackClosedListener.remove()
|
|
||||||
if (this.onPlaybackFailedListener) this.onPlaybackFailedListener.remove()
|
|
||||||
if (this.onProgressSyncFailing) this.onProgressSyncFailing.remove()
|
|
||||||
if (this.onProgressSyncSuccess) this.onProgressSyncSuccess.remove()
|
|
||||||
if (this.onPlaybackSpeedChangedListener) this.onPlaybackSpeedChangedListener.remove()
|
|
||||||
clearInterval(this.playInterval)
|
clearInterval(this.playInterval)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { AbsAudioPlayer } from '@/plugins/capacitor'
|
import { AbsAudioPlayer, AbsLogger } from '@/plugins/capacitor'
|
||||||
import { Dialog } from '@capacitor/dialog'
|
import { Dialog } from '@capacitor/dialog'
|
||||||
import CellularPermissionHelpers from '@/mixins/cellularPermissionHelpers'
|
import CellularPermissionHelpers from '@/mixins/cellularPermissionHelpers'
|
||||||
|
|
||||||
@@ -190,6 +190,7 @@ export default {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
async playLibraryItem(payload) {
|
async playLibraryItem(payload) {
|
||||||
|
await AbsLogger.info({ tag: 'AudioPlayerContainer', message: `playLibraryItem: Received play request for library item ${payload.libraryItemId} ${payload.episodeId ? `episode ${payload.episodeId}` : ''}` })
|
||||||
const libraryItemId = payload.libraryItemId
|
const libraryItemId = payload.libraryItemId
|
||||||
const episodeId = payload.episodeId
|
const episodeId = payload.episodeId
|
||||||
const startTime = payload.startTime
|
const startTime = payload.startTime
|
||||||
@@ -350,11 +351,11 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
async mounted() {
|
||||||
this.onLocalMediaProgressUpdateListener = AbsAudioPlayer.addListener('onLocalMediaProgressUpdate', this.onLocalMediaProgressUpdate)
|
this.onLocalMediaProgressUpdateListener = await AbsAudioPlayer.addListener('onLocalMediaProgressUpdate', this.onLocalMediaProgressUpdate)
|
||||||
this.onSleepTimerEndedListener = AbsAudioPlayer.addListener('onSleepTimerEnded', this.onSleepTimerEnded)
|
this.onSleepTimerEndedListener = await AbsAudioPlayer.addListener('onSleepTimerEnded', this.onSleepTimerEnded)
|
||||||
this.onSleepTimerSetListener = AbsAudioPlayer.addListener('onSleepTimerSet', this.onSleepTimerSet)
|
this.onSleepTimerSetListener = await AbsAudioPlayer.addListener('onSleepTimerSet', this.onSleepTimerSet)
|
||||||
this.onMediaPlayerChangedListener = AbsAudioPlayer.addListener('onMediaPlayerChanged', this.onMediaPlayerChanged)
|
this.onMediaPlayerChangedListener = await AbsAudioPlayer.addListener('onMediaPlayerChanged', this.onMediaPlayerChanged)
|
||||||
|
|
||||||
this.playbackSpeed = this.$store.getters['user/getUserSetting']('playbackRate')
|
this.playbackSpeed = this.$store.getters['user/getUserSetting']('playbackRate')
|
||||||
console.log(`[AudioPlayerContainer] Init Playback Speed: ${this.playbackSpeed}`)
|
console.log(`[AudioPlayerContainer] Init Playback Speed: ${this.playbackSpeed}`)
|
||||||
@@ -369,10 +370,10 @@ export default {
|
|||||||
this.$eventBus.$on('device-focus-update', this.deviceFocused)
|
this.$eventBus.$on('device-focus-update', this.deviceFocused)
|
||||||
},
|
},
|
||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
if (this.onLocalMediaProgressUpdateListener) this.onLocalMediaProgressUpdateListener.remove()
|
this.onLocalMediaProgressUpdateListener?.remove()
|
||||||
if (this.onSleepTimerEndedListener) this.onSleepTimerEndedListener.remove()
|
this.onSleepTimerEndedListener?.remove()
|
||||||
if (this.onSleepTimerSetListener) this.onSleepTimerSetListener.remove()
|
this.onSleepTimerSetListener?.remove()
|
||||||
if (this.onMediaPlayerChangedListener) this.onMediaPlayerChangedListener.remove()
|
this.onMediaPlayerChangedListener?.remove()
|
||||||
|
|
||||||
this.$eventBus.$off('abs-ui-ready', this.onReady)
|
this.$eventBus.$off('abs-ui-ready', this.onReady)
|
||||||
this.$eventBus.$off('play-item', this.playLibraryItem)
|
this.$eventBus.$off('play-item', this.playLibraryItem)
|
||||||
|
|||||||
@@ -134,6 +134,15 @@ export default {
|
|||||||
to: '/settings'
|
to: '/settings'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (this.$platform !== 'ios') {
|
||||||
|
items.push({
|
||||||
|
icon: 'bug_report',
|
||||||
|
iconOutlined: true,
|
||||||
|
text: this.$strings.ButtonLogs,
|
||||||
|
to: '/logs'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (this.serverConnectionConfig) {
|
if (this.serverConnectionConfig) {
|
||||||
items.push({
|
items.push({
|
||||||
icon: 'language',
|
icon: 'language',
|
||||||
@@ -170,21 +179,7 @@ export default {
|
|||||||
this.show = false
|
this.show = false
|
||||||
},
|
},
|
||||||
async logout() {
|
async logout() {
|
||||||
if (this.user) {
|
await this.$store.dispatch('user/logout')
|
||||||
if (this.$store.getters['getIsPlayerOpen']) {
|
|
||||||
this.$eventBus.$emit('close-stream')
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.$nativeHttp.post('/logout').catch((error) => {
|
|
||||||
console.error('Failed to logout', error)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
this.$socket.logout()
|
|
||||||
await this.$db.logout()
|
|
||||||
this.$localStore.removeLastLibraryId()
|
|
||||||
this.$store.commit('user/logout')
|
|
||||||
this.$store.commit('libraries/setCurrentLibrary', null)
|
|
||||||
},
|
},
|
||||||
async disconnect() {
|
async disconnect() {
|
||||||
await this.$hapticsImpact()
|
await this.$hapticsImpact()
|
||||||
|
|||||||
@@ -59,7 +59,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- No progress shown for collapsed series in library -->
|
<!-- 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="!collapsedSeries && (!isPodcast || recentEpisode)" class="absolute bottom-0 left-0 h-1 max-w-full z-10 rounded-b box-shadow-progressbar" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div>
|
||||||
|
|
||||||
<!-- Downloaded icon -->
|
<!-- 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` }">
|
<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` }">
|
||||||
@@ -77,12 +77,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Series sequence -->
|
<!-- 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` }">
|
<div v-if="seriesSequence && showSequence && !isSelectionMode" class="absolute rounded-lg bg-black/90 text-white 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>
|
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ seriesSequence }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Podcast Episode # -->
|
<!-- Podcast Episode # -->
|
||||||
<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` }">
|
<div v-if="recentEpisodeNumber !== null && !isSelectionMode" class="absolute rounded-lg bg-black/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' }">
|
<p class="text-white" :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">
|
||||||
Episode<span v-if="recentEpisodeNumber"> #{{ recentEpisodeNumber }}</span>
|
Episode<span v-if="recentEpisodeNumber"> #{{ recentEpisodeNumber }}</span>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
<!-- No progress shown for collapsed series or podcasts in library -->
|
<!-- No progress shown for collapsed series or podcasts in library -->
|
||||||
<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 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>
|
||||||
<div class="flex-grow px-2">
|
<div class="flex-grow pl-2" :class="showPlayButton ? 'pr-12' : 'pr-2'">
|
||||||
<p class="whitespace-normal line-clamp-2" :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 }}
|
<span v-if="seriesSequence">#{{ seriesSequence }} </span>{{ displayTitle }}
|
||||||
</p>
|
</p>
|
||||||
@@ -29,6 +29,16 @@
|
|||||||
{{ $getString('LabelNumEpisodes', [numEpisodes]) }}
|
{{ $getString('LabelNumEpisodes', [numEpisodes]) }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="showPlayButton" class="absolute top-0 bottom-0 right-0 h-full flex items-center justify-center z-20 pr-1">
|
||||||
|
<button type="button" class="relative rounded-full bg-fg-muted/50" :class="{ 'p-2': !playerIsStartingForThisMedia }" @click.stop.prevent="play">
|
||||||
|
<span v-if="!playerIsStartingForThisMedia" class="material-symbols text-2xl fill text-white">{{ playerIsPlaying ? 'pause' : 'play_arrow' }}</span>
|
||||||
|
<div v-else class="p-2 text-fg w-10 h-10 flex items-center justify-center bg-fg-muted/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>
|
||||||
|
</button>
|
||||||
|
</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` }">
|
<div v-if="localLibraryItem || isLocal" class="absolute top-0 right-0 z-20" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
||||||
<span class="material-symbols text-2xl text-success">download_done</span>
|
<span class="material-symbols text-2xl text-success">download_done</span>
|
||||||
@@ -129,6 +139,9 @@ export default {
|
|||||||
libraryItemId() {
|
libraryItemId() {
|
||||||
return this._libraryItem.id
|
return this._libraryItem.id
|
||||||
},
|
},
|
||||||
|
localLibraryItemId() {
|
||||||
|
return this.localLibraryItem?.id
|
||||||
|
},
|
||||||
series() {
|
series() {
|
||||||
return this.mediaMetadata.series
|
return this.mediaMetadata.series
|
||||||
},
|
},
|
||||||
@@ -217,13 +230,31 @@ export default {
|
|||||||
return this.isMissing || this.isInvalid
|
return this.isMissing || this.isInvalid
|
||||||
},
|
},
|
||||||
isStreaming() {
|
isStreaming() {
|
||||||
return this.store.getters['getlibraryItemIdStreaming'] === this.libraryItemId
|
return this.isPlaying && !this.store.getters['getIsCurrentSessionLocal']
|
||||||
|
},
|
||||||
|
isPlaying() {
|
||||||
|
if (this.localLibraryItemId && this.store.getters['getIsMediaStreaming'](this.localLibraryItemId)) return true
|
||||||
|
return this.store.getters['getIsMediaStreaming'](this.libraryItemId)
|
||||||
|
},
|
||||||
|
playerIsPlaying() {
|
||||||
|
return this.store.state.playerIsPlaying && (this.isStreaming || this.isPlaying)
|
||||||
|
},
|
||||||
|
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.libraryItemId
|
||||||
|
},
|
||||||
|
isCasting() {
|
||||||
|
return this.store.state.isCasting
|
||||||
},
|
},
|
||||||
showReadButton() {
|
showReadButton() {
|
||||||
return !this.isSelectionMode && !this.showPlayButton && this.hasEbook
|
return !this.isSelectionMode && !this.showPlayButton && this.hasEbook
|
||||||
},
|
},
|
||||||
showPlayButton() {
|
showPlayButton() {
|
||||||
return !this.isSelectionMode && !this.isMissing && !this.isInvalid && this.numTracks && !this.isStreaming
|
return !this.isSelectionMode && !this.isMissing && !this.isInvalid && this.numTracks && !this.isPodcast
|
||||||
},
|
},
|
||||||
showSmallEBookIcon() {
|
showSmallEBookIcon() {
|
||||||
return !this.isSelectionMode && this.hasEbook
|
return !this.isSelectionMode && this.hasEbook
|
||||||
@@ -293,9 +324,32 @@ export default {
|
|||||||
this.selected = !this.selected
|
this.selected = !this.selected
|
||||||
this.$emit('select', this.libraryItem)
|
this.$emit('select', this.libraryItem)
|
||||||
},
|
},
|
||||||
play() {
|
async play() {
|
||||||
var eventBus = this.$eventBus || this.$nuxt.$eventBus
|
if (this.playerIsStartingPlayback) return
|
||||||
eventBus.$emit('play-item', { libraryItemId: this.libraryItemId })
|
|
||||||
|
const hapticsImpact = this.$hapticsImpact || this.$nuxt.$hapticsImpact
|
||||||
|
if (hapticsImpact) {
|
||||||
|
await hapticsImpact()
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventBus = this.$eventBus || this.$nuxt.$eventBus
|
||||||
|
|
||||||
|
if (this.playerIsPlaying) {
|
||||||
|
eventBus.$emit('pause-item')
|
||||||
|
} else {
|
||||||
|
// Audiobook
|
||||||
|
let libraryItemId = this.libraryItemId
|
||||||
|
|
||||||
|
// When casting use server library item
|
||||||
|
if (this.localLibraryItem && !this.isCasting) {
|
||||||
|
libraryItemId = this.localLibraryItem.id
|
||||||
|
} else if (this.hasLocal) {
|
||||||
|
libraryItemId = this.localLibraryItem.id
|
||||||
|
}
|
||||||
|
|
||||||
|
this.store.commit('setPlayerIsStartingPlayback', this.libraryItemId)
|
||||||
|
eventBus.$emit('play-item', { libraryItemId, serverLibraryItemId: this.libraryItemId })
|
||||||
|
}
|
||||||
},
|
},
|
||||||
destroy() {
|
destroy() {
|
||||||
// destroy the vue listeners, etc
|
// destroy the vue listeners, etc
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<covers-group-cover v-if="series" ref="cover" :id="seriesId" :name="title" :book-items="books" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<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>
|
||||||
|
|
||||||
<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="seriesPercentInProgress > 0" class="absolute bottom-0 left-0 h-1 max-w-full z-10 rounded-b w-full box-shadow-progressbar" :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">
|
<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>
|
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
|
||||||
|
|||||||
@@ -17,6 +17,11 @@
|
|||||||
<p class="text-xs text-warning">{{ $strings.MessageOldServerConnectionWarning }}</p>
|
<p class="text-xs text-warning">{{ $strings.MessageOldServerConnectionWarning }}</p>
|
||||||
<ui-btn class="text-xs whitespace-nowrap" :padding-x="2" :padding-y="1" @click="showOldUserIdWarningDialog">{{ $strings.LabelMoreInfo }}</ui-btn>
|
<ui-btn class="text-xs whitespace-nowrap" :padding-x="2" :padding-y="1" @click="showOldUserIdWarningDialog">{{ $strings.LabelMoreInfo }}</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- warning message if server connection config is using an old auth method -->
|
||||||
|
<div v-if="config.version && checkIsUsingOldAuth(config)" class="flex flex-nowrap justify-between items-center space-x-4 pt-4">
|
||||||
|
<p class="text-xs text-warning">{{ $strings.MessageOldServerAuthWarning }}</p>
|
||||||
|
<ui-btn class="text-xs whitespace-nowrap" :padding-x="2" :padding-y="1" @click="showOldAuthWarningDialog">{{ $strings.LabelMoreInfo }}</ui-btn>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="my-1 py-4 w-full">
|
<div class="my-1 py-4 w-full">
|
||||||
<ui-btn class="w-full" @click="newServerConfigClick">{{ $strings.ButtonAddNewServer }}</ui-btn>
|
<ui-btn class="w-full" @click="newServerConfigClick">{{ $strings.ButtonAddNewServer }}</ui-btn>
|
||||||
@@ -155,9 +160,23 @@ export default {
|
|||||||
cancelText: this.$strings.ButtonOk
|
cancelText: this.$strings.ButtonOk
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
async showOldAuthWarningDialog() {
|
||||||
|
const confirmResult = await Dialog.confirm({
|
||||||
|
title: 'Old Server Auth Warning',
|
||||||
|
message: this.$strings.MessageOldServerAuthWarningHelp,
|
||||||
|
cancelButtonTitle: this.$strings.ButtonReadMore
|
||||||
|
})
|
||||||
|
if (!confirmResult.value) {
|
||||||
|
window.open('https://github.com/advplyr/audiobookshelf/discussions/4460', '_blank')
|
||||||
|
}
|
||||||
|
},
|
||||||
checkIdUuid(userId) {
|
checkIdUuid(userId) {
|
||||||
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(userId)
|
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(userId)
|
||||||
},
|
},
|
||||||
|
checkIsUsingOldAuth(config) {
|
||||||
|
if (!config.version) return true
|
||||||
|
return !this.$isValidVersion(config.version, '2.26.0')
|
||||||
|
},
|
||||||
/**
|
/**
|
||||||
* Initiates the login process using OpenID via OAuth2.0.
|
* Initiates the login process using OpenID via OAuth2.0.
|
||||||
* 1. Verifying the server's address
|
* 1. Verifying the server's address
|
||||||
@@ -374,15 +393,19 @@ export default {
|
|||||||
} catch (error) {} // No Error handling needed
|
} catch (error) {} // No Error handling needed
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Returns the same user response payload as /login
|
||||||
const response = await CapacitorHttp.get({
|
const response = await CapacitorHttp.get({
|
||||||
url: backendEndpoint
|
url: backendEndpoint
|
||||||
})
|
})
|
||||||
|
// v2.26.0+ returns accessToken and refreshToken on user object
|
||||||
if (!response.data || !response.data.user || !response.data.user.token) {
|
if (!response.data?.user?.token && !response.data?.user?.accessToken) {
|
||||||
throw new Error('Token data is missing in the response.')
|
throw new Error('Token is missing in response.')
|
||||||
}
|
}
|
||||||
|
|
||||||
this.serverConfig.token = response.data.user.token
|
const user = response.data.user
|
||||||
|
this.serverConfig.token = user.accessToken || user.token
|
||||||
|
|
||||||
|
// TODO: Is it necessary to authenticate again?
|
||||||
const payload = await this.authenticateToken()
|
const payload = await this.authenticateToken()
|
||||||
|
|
||||||
if (!payload) {
|
if (!payload) {
|
||||||
@@ -394,6 +417,12 @@ export default {
|
|||||||
throw new Error('Config already exists for this address and username.')
|
throw new Error('Config already exists for this address and username.')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For v2.26.0+ re-attach accessToken and refreshToken to user object because /authorize does not return them
|
||||||
|
if (user.accessToken) {
|
||||||
|
payload.user.accessToken = user.accessToken
|
||||||
|
payload.user.refreshToken = user.refreshToken
|
||||||
|
}
|
||||||
|
|
||||||
this.setUserAndConnection(payload)
|
this.setUserAndConnection(payload)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[SSO] Error in exchangeCodeForToken: ', error)
|
console.error('[SSO] Error in exchangeCodeForToken: ', error)
|
||||||
@@ -436,9 +465,10 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.error = null
|
this.error = null
|
||||||
var payload = await this.authenticateToken()
|
const payload = await this.authenticateToken()
|
||||||
|
|
||||||
if (payload) {
|
if (payload) {
|
||||||
|
// Will NOT include access token and refresh token
|
||||||
this.setUserAndConnection(payload)
|
this.setUserAndConnection(payload)
|
||||||
} else {
|
} else {
|
||||||
this.showAuth = true
|
this.showAuth = true
|
||||||
@@ -504,7 +534,7 @@ export default {
|
|||||||
try {
|
try {
|
||||||
var urlObject = new URL(url)
|
var urlObject = new URL(url)
|
||||||
if (protocolOverride) urlObject.protocol = protocolOverride
|
if (protocolOverride) urlObject.protocol = protocolOverride
|
||||||
return urlObject.href
|
return urlObject.href.replace(/\/$/, '') // Remove trailing slash
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Invalid URL', error)
|
console.error('Invalid URL', error)
|
||||||
return null
|
return null
|
||||||
@@ -597,7 +627,12 @@ export default {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
requestServerLogin() {
|
requestServerLogin() {
|
||||||
return this.postRequest(`${this.serverConfig.address}/login`, { username: this.serverConfig.username, password: this.password || '' }, this.serverConfig.customHeaders, 20000)
|
const headers = {
|
||||||
|
// Tells the Abs server to return the refresh token
|
||||||
|
'x-return-tokens': 'true',
|
||||||
|
...(this.serverConfig.customHeaders || {})
|
||||||
|
}
|
||||||
|
return this.postRequest(`${this.serverConfig.address}/login`, { username: this.serverConfig.username, password: this.password || '' }, headers, 20000)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (!data.user) {
|
if (!data.user) {
|
||||||
console.error(data.error)
|
console.error(data.error)
|
||||||
@@ -642,9 +677,13 @@ export default {
|
|||||||
if (statusData.data.authFormData?.authOpenIDAutoLaunch) {
|
if (statusData.data.authFormData?.authOpenIDAutoLaunch) {
|
||||||
this.clickLoginWithOpenId()
|
this.clickLoginWithOpenId()
|
||||||
}
|
}
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.handleLoginFormError(error)
|
this.handleLoginFormError(error)
|
||||||
|
return false
|
||||||
} finally {
|
} finally {
|
||||||
this.processing = false
|
this.processing = false
|
||||||
}
|
}
|
||||||
@@ -770,26 +809,6 @@ export default {
|
|||||||
prependProtocolIfNeeded(address) {
|
prependProtocolIfNeeded(address) {
|
||||||
return address.startsWith('http://') || address.startsWith('https://') ? address : `https://${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() {
|
async submitAuth() {
|
||||||
if (!this.networkConnected) return
|
if (!this.networkConnected) return
|
||||||
if (!this.serverConfig.username) {
|
if (!this.serverConfig.username) {
|
||||||
@@ -806,9 +825,10 @@ export default {
|
|||||||
this.error = null
|
this.error = null
|
||||||
this.processing = true
|
this.processing = true
|
||||||
|
|
||||||
var payload = await this.requestServerLogin()
|
const payload = await this.requestServerLogin()
|
||||||
this.processing = false
|
this.processing = false
|
||||||
if (payload) {
|
if (payload) {
|
||||||
|
// Will include access token and refresh token
|
||||||
this.setUserAndConnection(payload)
|
this.setUserAndConnection(payload)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -821,18 +841,30 @@ export default {
|
|||||||
this.$store.commit('libraries/setEReaderDevices', ereaderDevices)
|
this.$store.commit('libraries/setEReaderDevices', ereaderDevices)
|
||||||
this.$setServerLanguageCode(serverSettings.language)
|
this.$setServerLanguageCode(serverSettings.language)
|
||||||
|
|
||||||
// Set library - Use last library if set and available fallback to default user library
|
this.serverConfig.userId = user.id
|
||||||
var lastLibraryId = await this.$localStore.getLastLibraryId()
|
this.serverConfig.username = user.username
|
||||||
if (lastLibraryId && (!user.librariesAccessible.length || user.librariesAccessible.includes(lastLibraryId))) {
|
|
||||||
this.$store.commit('libraries/setCurrentLibrary', lastLibraryId)
|
if (this.$isValidVersion(serverSettings.version, '2.26.0')) {
|
||||||
} else if (userDefaultLibraryId) {
|
// Tokens only returned from /login endpoint
|
||||||
this.$store.commit('libraries/setCurrentLibrary', userDefaultLibraryId)
|
if (user.accessToken) {
|
||||||
|
this.serverConfig.token = user.accessToken
|
||||||
|
this.serverConfig.refreshToken = user.refreshToken
|
||||||
|
} else {
|
||||||
|
// Detect if the connection config is using the old token. If so, force re-login
|
||||||
|
if (this.serverConfig.token === user.token || user.isOldToken) {
|
||||||
|
this.setForceReloginForNewAuth()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the token was updated during a refresh (in nativeHttp.js) it gets updated in the store, so refetch
|
||||||
|
this.serverConfig.token = this.$store.getters['user/getToken'] || this.serverConfig.token
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Server version before new JWT auth, use old user.token
|
||||||
|
this.serverConfig.token = user.token
|
||||||
}
|
}
|
||||||
|
|
||||||
this.serverConfig.userId = user.id
|
this.serverConfig.version = serverSettings.version
|
||||||
this.serverConfig.token = user.token
|
|
||||||
this.serverConfig.username = user.username
|
|
||||||
delete this.serverConfig.version
|
|
||||||
|
|
||||||
var serverConnectionConfig = await this.$db.setServerConnectionConfig(this.serverConfig)
|
var serverConnectionConfig = await this.$db.setServerConnectionConfig(this.serverConfig)
|
||||||
|
|
||||||
@@ -849,7 +881,16 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set library - Use last library if set and available fallback to default user library
|
||||||
|
const lastLibraryId = await this.$localStore.getLastLibraryId()
|
||||||
|
if (lastLibraryId && (!user.librariesAccessible.length || user.librariesAccessible.includes(lastLibraryId))) {
|
||||||
|
this.$store.commit('libraries/setCurrentLibrary', lastLibraryId)
|
||||||
|
} else if (userDefaultLibraryId) {
|
||||||
|
this.$store.commit('libraries/setCurrentLibrary', userDefaultLibraryId)
|
||||||
|
}
|
||||||
|
|
||||||
this.$store.commit('user/setUser', user)
|
this.$store.commit('user/setUser', user)
|
||||||
|
this.$store.commit('user/setAccessToken', serverConnectionConfig.token)
|
||||||
this.$store.commit('user/setServerConnectionConfig', serverConnectionConfig)
|
this.$store.commit('user/setServerConnectionConfig', serverConnectionConfig)
|
||||||
|
|
||||||
this.$socket.connect(this.serverConfig.address, this.serverConfig.token)
|
this.$socket.connect(this.serverConfig.address, this.serverConfig.token)
|
||||||
@@ -865,7 +906,13 @@ export default {
|
|||||||
this.error = null
|
this.error = null
|
||||||
this.processing = true
|
this.processing = true
|
||||||
|
|
||||||
const authRes = await this.postRequest(`${this.serverConfig.address}/api/authorize`, null, { Authorization: `Bearer ${this.serverConfig.token}` }).catch((error) => {
|
const nativeHttpOptions = {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${this.serverConfig.token}`
|
||||||
|
},
|
||||||
|
serverConnectionConfig: this.serverConfig
|
||||||
|
}
|
||||||
|
const authRes = await this.$nativeHttp.post(`${this.serverConfig.address}/api/authorize`, null, nativeHttpOptions).catch((error) => {
|
||||||
console.error('[ServerConnectForm] Server auth failed', error)
|
console.error('[ServerConnectForm] Server auth failed', error)
|
||||||
const errorMsg = error.message || error
|
const errorMsg = error.message || error
|
||||||
this.error = 'Failed to authorize'
|
this.error = 'Failed to authorize'
|
||||||
@@ -874,14 +921,30 @@ export default {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log('[ServerConnectForm] authRes=', authRes)
|
console.log('[ServerConnectForm] authRes=', authRes)
|
||||||
|
|
||||||
this.processing = false
|
this.processing = false
|
||||||
return authRes
|
return authRes
|
||||||
},
|
},
|
||||||
|
async setForceReloginForNewAuth() {
|
||||||
|
// This calls /status on the server and sets the auth methods
|
||||||
|
const result = await this.submit()
|
||||||
|
if (result) {
|
||||||
|
this.error = this.$strings.MessageOldServerAuthReLoginRequired
|
||||||
|
}
|
||||||
|
},
|
||||||
init() {
|
init() {
|
||||||
|
// Handle force re-login for servers using new JWT auth but still using an old token in the server config
|
||||||
|
if (this.$route.query.error === 'oldAuthToken' && this.$route.query.serverConnectionConfigId) {
|
||||||
|
this.serverConfig = this.serverConnectionConfigs.find((scc) => scc.id === this.$route.query.serverConnectionConfigId)
|
||||||
|
if (this.serverConfig) {
|
||||||
|
this.setForceReloginForNewAuth()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (this.lastServerConnectionConfig) {
|
if (this.lastServerConnectionConfig) {
|
||||||
|
console.log('[ServerConnectForm] init with lastServerConnectionConfig', this.lastServerConnectionConfig)
|
||||||
this.connectToServer(this.lastServerConnectionConfig)
|
this.connectToServer(this.lastServerConnectionConfig)
|
||||||
} else {
|
} else {
|
||||||
this.showForm = !this.serverConnectionConfigs.length
|
this.showForm = !this.serverConnectionConfigs.length
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
<p class="text-xl font-mono">{{ this.$secondsToTimestamp(currentTime / _playbackRate) }}</p>
|
<p class="text-xl font-mono">{{ this.$secondsToTimestamp(currentTime / _playbackRate) }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ui-text-input-with-label v-model="newBookmarkTitle" ref="noteInput" label="Note" />
|
<ui-text-input-with-label v-model="newBookmarkTitle" :placeholder="bookmarkPlaceholder()" :autofocus="false" ref="noteInput" label="Note" />
|
||||||
<div class="flex justify-end mt-6">
|
<div class="flex justify-end mt-6">
|
||||||
<ui-btn color="success" class="w-full" @click.stop="submitBookmark">{{ selectedBookmark ? 'Update' : 'Create' }}</ui-btn>
|
<ui-btn color="success" class="w-full" @click.stop="submitBookmark">{{ selectedBookmark ? 'Update' : 'Create' }}</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
@@ -93,6 +93,10 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
bookmarkPlaceholder() {
|
||||||
|
// using a method prevents caching the date
|
||||||
|
return this.$formatDate(Date.now(), 'MMM dd, yyyy HH:mm')
|
||||||
|
},
|
||||||
editBookmark(bm) {
|
editBookmark(bm) {
|
||||||
this.selectedBookmark = bm
|
this.selectedBookmark = bm
|
||||||
this.newBookmarkTitle = bm.title
|
this.newBookmarkTitle = bm.title
|
||||||
@@ -157,18 +161,8 @@ export default {
|
|||||||
},
|
},
|
||||||
createBookmark() {
|
createBookmark() {
|
||||||
this.selectedBookmark = null
|
this.selectedBookmark = null
|
||||||
this.newBookmarkTitle = this.$formatDate(Date.now(), 'MMM dd, yyyy HH:mm')
|
this.newBookmarkTitle = ''
|
||||||
this.showBookmarkTitleInput = true
|
this.showBookmarkTitleInput = true
|
||||||
|
|
||||||
// Auto focus the input and select the text
|
|
||||||
this.$nextTick(() => {
|
|
||||||
if (this.$refs.noteInput?.$refs.input?.$refs.input) {
|
|
||||||
this.$refs.noteInput.$refs.input.$refs.input.focus()
|
|
||||||
setTimeout(() => {
|
|
||||||
this.$refs.noteInput?.$refs.input?.$refs.input?.select()
|
|
||||||
}, 10)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
async submitBookmark() {
|
async submitBookmark() {
|
||||||
await this.$hapticsImpact()
|
await this.$hapticsImpact()
|
||||||
|
|||||||
@@ -54,8 +54,39 @@ export default {
|
|||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
sublist: null,
|
sublist: null
|
||||||
bookItems: [
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
show(newVal) {
|
||||||
|
if (!newVal) {
|
||||||
|
if (this.sublist && !this.selectedItemSublist) this.sublist = null
|
||||||
|
if (!this.sublist && this.selectedItemSublist) this.sublist = this.selectedItemSublist
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
show: {
|
||||||
|
get() {
|
||||||
|
return this.value
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('input', val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
selected: {
|
||||||
|
get() {
|
||||||
|
return this.filterBy
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('update:filterBy', val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
userCanAccessExplicitContent() {
|
||||||
|
return this.$store.getters['user/getUserCanAccessExplicitContent']
|
||||||
|
},
|
||||||
|
bookItems() {
|
||||||
|
const items = [
|
||||||
{
|
{
|
||||||
text: this.$strings.LabelAll,
|
text: this.$strings.LabelAll,
|
||||||
value: 'all'
|
value: 'all'
|
||||||
@@ -104,9 +135,26 @@ export default {
|
|||||||
text: this.$strings.ButtonIssues,
|
text: this.$strings.ButtonIssues,
|
||||||
value: 'issues',
|
value: 'issues',
|
||||||
sublist: false
|
sublist: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelRSSFeedOpen,
|
||||||
|
value: 'feed-open',
|
||||||
|
sublist: false
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
podcastItems: [
|
|
||||||
|
if (this.userCanAccessExplicitContent) {
|
||||||
|
items.push({
|
||||||
|
text: this.$strings.LabelExplicit,
|
||||||
|
value: 'explicit',
|
||||||
|
sublist: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return items
|
||||||
|
},
|
||||||
|
podcastItems() {
|
||||||
|
const items = [
|
||||||
{
|
{
|
||||||
text: this.$strings.LabelAll,
|
text: this.$strings.LabelAll,
|
||||||
value: 'all'
|
value: 'all'
|
||||||
@@ -120,34 +168,23 @@ export default {
|
|||||||
text: this.$strings.LabelTag,
|
text: this.$strings.LabelTag,
|
||||||
value: 'tags',
|
value: 'tags',
|
||||||
sublist: true
|
sublist: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelRSSFeedOpen,
|
||||||
|
value: 'feed-open',
|
||||||
|
sublist: false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
|
||||||
},
|
if (this.userCanAccessExplicitContent) {
|
||||||
watch: {
|
items.push({
|
||||||
show(newVal) {
|
text: this.$strings.LabelExplicit,
|
||||||
if (!newVal) {
|
value: 'explicit',
|
||||||
if (this.sublist && !this.selectedItemSublist) this.sublist = null
|
sublist: false
|
||||||
if (!this.sublist && this.selectedItemSublist) this.sublist = this.selectedItemSublist
|
})
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
show: {
|
|
||||||
get() {
|
|
||||||
return this.value
|
|
||||||
},
|
|
||||||
set(val) {
|
|
||||||
this.$emit('input', val)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
selected: {
|
|
||||||
get() {
|
|
||||||
return this.filterBy
|
|
||||||
},
|
|
||||||
set(val) {
|
|
||||||
this.$emit('update:filterBy', val)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return items
|
||||||
},
|
},
|
||||||
isPodcast() {
|
isPodcast() {
|
||||||
return this.$store.getters['libraries/getCurrentLibraryMediaType'] === 'podcast'
|
return this.$store.getters['libraries/getCurrentLibraryMediaType'] === 'podcast'
|
||||||
|
|||||||
@@ -77,9 +77,6 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
userToken() {
|
|
||||||
return this.$store.getters['user/getToken']
|
|
||||||
},
|
|
||||||
libraryItemId() {
|
libraryItemId() {
|
||||||
return this.libraryItem?.id
|
return this.libraryItem?.id
|
||||||
},
|
},
|
||||||
@@ -245,11 +242,9 @@ export default {
|
|||||||
async extract() {
|
async extract() {
|
||||||
this.loading = true
|
this.loading = true
|
||||||
|
|
||||||
|
// TODO: Handle JWT auth refresh
|
||||||
const buff = await this.$axios.$get(this.url, {
|
const buff = await this.$axios.$get(this.url, {
|
||||||
responseType: 'blob',
|
responseType: 'blob'
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${this.userToken}`
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const archive = await Archive.open(buff)
|
const archive = await Archive.open(buff)
|
||||||
|
|||||||
@@ -49,9 +49,6 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
userToken() {
|
|
||||||
return this.$store.getters['user/getToken']
|
|
||||||
},
|
|
||||||
/** @returns {string} */
|
/** @returns {string} */
|
||||||
libraryItemId() {
|
libraryItemId() {
|
||||||
return this.libraryItem?.id
|
return this.libraryItem?.id
|
||||||
@@ -298,15 +295,26 @@ export default {
|
|||||||
|
|
||||||
/** @type {EpubReader} */
|
/** @type {EpubReader} */
|
||||||
const reader = this
|
const reader = this
|
||||||
|
|
||||||
|
// Use axios to make request because we have token refresh logic in interceptor
|
||||||
|
const customRequest = async (url) => {
|
||||||
|
try {
|
||||||
|
return this.$axios.$get(url, {
|
||||||
|
responseType: 'arraybuffer'
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('EpubReader.initEpub customRequest failed:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
console.log('[EpubReader] initEpub', reader.url)
|
console.log('[EpubReader] initEpub', reader.url)
|
||||||
/** @type {ePub.Book} */
|
/** @type {ePub.Book} */
|
||||||
reader.book = new ePub(reader.url, {
|
reader.book = new ePub(reader.url, {
|
||||||
width: window.innerWidth,
|
width: window.innerWidth,
|
||||||
height: window.innerHeight - this.readerHeightOffset,
|
height: window.innerHeight - this.readerHeightOffset,
|
||||||
openAs: 'epub',
|
openAs: 'epub',
|
||||||
requestHeaders: {
|
requestMethod: this.isLocal ? null : customRequest
|
||||||
Authorization: `Bearer ${this.userToken}`
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
/** @type {ePub.Rendition} */
|
/** @type {ePub.Rendition} */
|
||||||
|
|||||||
@@ -22,11 +22,7 @@ export default {
|
|||||||
data() {
|
data() {
|
||||||
return {}
|
return {}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {},
|
||||||
userToken() {
|
|
||||||
return this.$store.getters['user/getToken']
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
methods: {
|
||||||
addHtmlCss() {
|
addHtmlCss() {
|
||||||
let iframe = document.getElementsByTagName('iframe')[0]
|
let iframe = document.getElementsByTagName('iframe')[0]
|
||||||
@@ -84,11 +80,9 @@ export default {
|
|||||||
},
|
},
|
||||||
async initMobi() {
|
async initMobi() {
|
||||||
// Fetch mobi file as blob
|
// Fetch mobi file as blob
|
||||||
|
// TODO: Handle JWT auth refresh
|
||||||
var buff = await this.$axios.$get(this.url, {
|
var buff = await this.$axios.$get(this.url, {
|
||||||
responseType: 'blob',
|
responseType: 'blob'
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${this.userToken}`
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
var reader = new FileReader()
|
var reader = new FileReader()
|
||||||
reader.onload = async (event) => {
|
reader.onload = async (event) => {
|
||||||
@@ -133,4 +127,4 @@ export default {
|
|||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -49,7 +49,8 @@ export default {
|
|||||||
numPages: 0,
|
numPages: 0,
|
||||||
windowWidth: 0,
|
windowWidth: 0,
|
||||||
windowHeight: 0,
|
windowHeight: 0,
|
||||||
pdfDocInitParams: null
|
pdfDocInitParams: null,
|
||||||
|
isRefreshing: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -109,6 +110,10 @@ export default {
|
|||||||
},
|
},
|
||||||
isPlayerOpen() {
|
isPlayerOpen() {
|
||||||
return this.$store.getters['getIsPlayerOpen']
|
return this.$store.getters['getIsPlayerOpen']
|
||||||
|
},
|
||||||
|
ebookUrl() {
|
||||||
|
const serverAddress = this.$store.getters['user/getServerAddress']
|
||||||
|
return this.isLocal ? this.url : `${serverAddress}${this.url}`
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -164,7 +169,54 @@ export default {
|
|||||||
this.page++
|
this.page++
|
||||||
this.updateProgress()
|
this.updateProgress()
|
||||||
},
|
},
|
||||||
error(err) {
|
async handleRefreshFailure() {
|
||||||
|
try {
|
||||||
|
console.log('[PdfReader] Handling refresh failure - logging out user')
|
||||||
|
|
||||||
|
// Clear store
|
||||||
|
await this.$store.dispatch('user/logout')
|
||||||
|
|
||||||
|
if (this.$store.getters['user/getServerConnectionConfigId']) {
|
||||||
|
// Clear refresh token for server connection config
|
||||||
|
await this.$db.clearRefreshToken(this.$store.getters['user/getServerConnectionConfigId'])
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.location.pathname !== '/connect') {
|
||||||
|
window.location.href = '/connect'
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[PdfReader] Failed to handle refresh failure:', error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async refreshToken() {
|
||||||
|
if (this.isRefreshing) return
|
||||||
|
this.isRefreshing = true
|
||||||
|
// Cannot use axios with this pdf reader so we need to handle the refresh separately
|
||||||
|
// Should work on migrating to a different pdf reader in the future
|
||||||
|
const newAccessToken = await this.$store.dispatch('user/refreshToken').catch((error) => {
|
||||||
|
console.error('Failed to refresh token', error)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
if (!newAccessToken) {
|
||||||
|
this.handleRefreshFailure()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force Vue to re-render the PDF component by creating a new object
|
||||||
|
this.pdfDocInitParams = {
|
||||||
|
url: this.ebookUrl,
|
||||||
|
httpHeaders: {
|
||||||
|
Authorization: `Bearer ${newAccessToken}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.isRefreshing = false
|
||||||
|
},
|
||||||
|
async error(err) {
|
||||||
|
if (err && err.status === 401) {
|
||||||
|
console.log('Received 401 error, refreshing token')
|
||||||
|
await this.refreshToken()
|
||||||
|
return
|
||||||
|
}
|
||||||
console.error(err)
|
console.error(err)
|
||||||
},
|
},
|
||||||
screenOrientationChange() {
|
screenOrientationChange() {
|
||||||
@@ -173,7 +225,7 @@ export default {
|
|||||||
},
|
},
|
||||||
init() {
|
init() {
|
||||||
this.pdfDocInitParams = {
|
this.pdfDocInitParams = {
|
||||||
url: this.url,
|
url: this.ebookUrl,
|
||||||
httpHeaders: {
|
httpHeaders: {
|
||||||
Authorization: `Bearer ${this.userToken}`
|
Authorization: `Bearer ${this.userToken}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
<span class="material-symbols text-2xl text-fg">settings</span>
|
<span class="material-symbols text-2xl text-fg">settings</span>
|
||||||
</button>
|
</button>
|
||||||
<button v-if="comicHasMetadata" type="button" class="inline-flex mx-2" @click.stop="clickMetadataBtn">
|
<button v-if="comicHasMetadata" type="button" class="inline-flex mx-2" @click.stop="clickMetadataBtn">
|
||||||
<span class="material-symbols text-2xl text-fg">more-vert</span>
|
<span class="material-symbols text-2xl text-fg">more</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -260,6 +260,7 @@ export default {
|
|||||||
return null
|
return null
|
||||||
},
|
},
|
||||||
ebookFile() {
|
ebookFile() {
|
||||||
|
if (!this.media) return null
|
||||||
// ebook file id is passed when reading a supplementary ebook
|
// ebook file id is passed when reading a supplementary ebook
|
||||||
if (this.ebookFileId) {
|
if (this.ebookFileId) {
|
||||||
return this.selectedLibraryItem.libraryFiles.find((lf) => lf.ino === this.ebookFileId)
|
return this.selectedLibraryItem.libraryFiles.find((lf) => lf.ino === this.ebookFileId)
|
||||||
@@ -304,11 +305,11 @@ export default {
|
|||||||
if (this.localContentUrl) {
|
if (this.localContentUrl) {
|
||||||
return Capacitor.convertFileSrc(this.localContentUrl)
|
return Capacitor.convertFileSrc(this.localContentUrl)
|
||||||
}
|
}
|
||||||
const serverAddress = this.$store.getters['user/getServerAddress']
|
|
||||||
if (this.ebookFileId) {
|
if (this.ebookFileId) {
|
||||||
return `${serverAddress}/api/items/${this.selectedLibraryItem.id}/ebook/${this.ebookFileId}`
|
return `/api/items/${this.selectedLibraryItem.id}/ebook/${this.ebookFileId}`
|
||||||
}
|
}
|
||||||
return `${serverAddress}/api/items/${this.selectedLibraryItem.id}/ebook`
|
return `/api/items/${this.selectedLibraryItem.id}/ebook`
|
||||||
},
|
},
|
||||||
isPlayerOpen() {
|
isPlayerOpen() {
|
||||||
return this.$store.getters['getIsPlayerOpen']
|
return this.$store.getters['getIsPlayerOpen']
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<input v-model="input" ref="input" autofocus :type="type" :disabled="disabled" :readonly="readonly" autocorrect="off" autocapitalize="none" autocomplete="off" :placeholder="placeholder" class="py-2 w-full outline-none bg-primary disabled:text-fg-muted" :class="inputClass" @keyup="keyup" />
|
<input v-model="input" ref="input" :autofocus="autofocus" :type="type" :disabled="disabled" :readonly="readonly" autocorrect="off" autocapitalize="none" autocomplete="off" :placeholder="placeholder" class="py-2 w-full outline-none bg-primary disabled:text-fg-muted" :class="inputClass" @keyup="keyup" />
|
||||||
<div v-if="prependIcon" class="absolute top-0 left-0 h-full px-2 flex items-center justify-center">
|
<div v-if="prependIcon" class="absolute top-0 left-0 h-full px-2 flex items-center justify-center">
|
||||||
<span class="material-symbols text-lg">{{ prependIcon }}</span>
|
<span class="material-symbols text-lg">{{ prependIcon }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -22,6 +22,10 @@ export default {
|
|||||||
disabled: Boolean,
|
disabled: Boolean,
|
||||||
readonly: Boolean,
|
readonly: Boolean,
|
||||||
borderless: Boolean,
|
borderless: Boolean,
|
||||||
|
autofocus: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
bg: {
|
bg: {
|
||||||
type: String,
|
type: String,
|
||||||
default: 'bg'
|
default: 'bg'
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<p class="pb-0.5 text-sm font-semibold">{{ label }}</p>
|
<p class="pb-0.5 text-sm font-semibold">{{ label }}</p>
|
||||||
<ui-text-input v-model="inputValue" ref="input" :disabled="disabled" :type="type" text-size="base" class="w-full" />
|
<ui-text-input v-model="inputValue" ref="input" :disabled="disabled" :type="type" :placeholder="placeholder" :autofocus="autofocus" text-size="base" class="w-full" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -14,7 +14,12 @@ export default {
|
|||||||
type: String,
|
type: String,
|
||||||
default: 'text'
|
default: 'text'
|
||||||
},
|
},
|
||||||
disabled: Boolean
|
disabled: Boolean,
|
||||||
|
placeholder: String,
|
||||||
|
autofocus: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
}
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {}
|
return {}
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<template>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="12px" height="12px" viewBox="0 0 512 512" class="ml-1">
|
||||||
|
<path
|
||||||
|
fill="white"
|
||||||
|
d="M 89.00,40.12
|
||||||
|
C 89.00,40.12 127.00,40.12 127.00,40.12
|
||||||
|
127.00,40.12 198.00,40.12 198.00,40.12
|
||||||
|
198.00,40.12 416.00,40.12 416.00,40.12
|
||||||
|
446.58,40.05 472.95,66.42 473.00,97.00
|
||||||
|
473.00,97.00 473.00,303.00 473.00,303.00
|
||||||
|
473.00,303.00 473.00,418.00 473.00,418.00
|
||||||
|
472.65,447.55 445.06,472.95 416.00,473.00
|
||||||
|
416.00,473.00 210.00,473.00 210.00,473.00
|
||||||
|
210.00,473.00 95.00,473.00 95.00,473.00
|
||||||
|
65.45,472.65 40.05,445.06 40.00,416.00
|
||||||
|
40.00,416.00 40.00,136.00 40.00,136.00
|
||||||
|
40.00,136.00 40.00,109.00 40.00,109.00
|
||||||
|
40.00,109.00 40.00,96.00 40.00,96.00
|
||||||
|
40.07,81.58 46.89,67.14 57.01,57.01
|
||||||
|
61.17,52.86 64.86,50.13 70.00,47.31
|
||||||
|
77.25,43.33 81.02,42.18 89.00,40.12 Z
|
||||||
|
M 372.00,392.00
|
||||||
|
C 372.00,392.00 364.02,364.00 364.02,364.00
|
||||||
|
364.02,364.00 350.72,319.00 350.72,319.00
|
||||||
|
350.72,319.00 310.42,183.00 310.42,183.00
|
||||||
|
310.42,183.00 296.86,137.00 296.86,137.00
|
||||||
|
296.86,137.00 291.30,121.99 291.30,121.99
|
||||||
|
291.30,121.99 284.00,121.00 284.00,121.00
|
||||||
|
284.00,121.00 230.00,121.00 230.00,121.00
|
||||||
|
230.00,121.00 222.51,122.02 222.51,122.02
|
||||||
|
222.51,122.02 216.86,137.00 216.86,137.00
|
||||||
|
216.86,137.00 203.28,183.00 203.28,183.00
|
||||||
|
203.28,183.00 163.28,318.00 163.28,318.00
|
||||||
|
163.28,318.00 148.71,367.00 148.71,367.00
|
||||||
|
148.71,367.00 142.00,392.00 142.00,392.00
|
||||||
|
142.00,392.00 183.00,392.00 183.00,392.00
|
||||||
|
183.00,392.00 190.86,390.43 190.86,390.43
|
||||||
|
190.86,390.43 195.86,375.00 195.86,375.00
|
||||||
|
195.86,375.00 206.00,338.00 206.00,338.00
|
||||||
|
206.00,338.00 293.00,338.00 293.00,338.00
|
||||||
|
295.64,338.01 299.26,337.65 301.30,339.60
|
||||||
|
303.23,341.43 304.80,348.22 305.58,351.00
|
||||||
|
305.58,351.00 313.00,378.00 313.00,378.00
|
||||||
|
316.91,391.63 315.20,391.98 325.00,392.00
|
||||||
|
325.00,392.00 372.00,392.00 372.00,392.00 Z
|
||||||
|
M 254.00,170.00
|
||||||
|
C 254.00,170.00 256.00,170.00 256.00,170.00
|
||||||
|
256.00,170.00 263.12,197.00 263.12,197.00
|
||||||
|
263.12,197.00 282.88,268.00 282.88,268.00
|
||||||
|
282.88,268.00 290.00,296.00 290.00,296.00
|
||||||
|
290.00,296.00 219.00,296.00 219.00,296.00
|
||||||
|
219.00,296.00 230.58,253.00 230.58,253.00
|
||||||
|
230.58,253.00 254.00,170.00 254.00,170.00 Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {},
|
||||||
|
data() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
computed: {},
|
||||||
|
methods: {},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -78,15 +78,15 @@ export default {
|
|||||||
this.$store.commit('globals/updateDownloadItemPart', itemPart)
|
this.$store.commit('globals/updateDownloadItemPart', itemPart)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
async mounted() {
|
||||||
this.downloadItemListener = AbsDownloader.addListener('onDownloadItem', (data) => this.onDownloadItem(data))
|
this.downloadItemListener = await AbsDownloader.addListener('onDownloadItem', (data) => this.onDownloadItem(data))
|
||||||
this.itemPartUpdateListener = AbsDownloader.addListener('onDownloadItemPartUpdate', (data) => this.onDownloadItemPartUpdate(data))
|
this.itemPartUpdateListener = await AbsDownloader.addListener('onDownloadItemPartUpdate', (data) => this.onDownloadItemPartUpdate(data))
|
||||||
this.completeListener = AbsDownloader.addListener('onItemDownloadComplete', (data) => this.onItemDownloadComplete(data))
|
this.completeListener = await AbsDownloader.addListener('onItemDownloadComplete', (data) => this.onItemDownloadComplete(data))
|
||||||
},
|
},
|
||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
if (this.downloadItemListener) this.downloadItemListener.remove()
|
this.downloadItemListener?.remove()
|
||||||
if (this.completeListener) this.completeListener.remove()
|
this.completeListener?.remove()
|
||||||
if (this.itemPartUpdateListener) this.itemPartUpdateListener.remove()
|
this.itemPartUpdateListener?.remove()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -15,20 +15,18 @@
|
|||||||
3ABF580928059BAE005DFBE5 /* PlaybackSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ABF580828059BAE005DFBE5 /* PlaybackSession.swift */; };
|
3ABF580928059BAE005DFBE5 /* PlaybackSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ABF580828059BAE005DFBE5 /* PlaybackSession.swift */; };
|
||||||
3ABF618F2804325C0070250E /* PlayerHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ABF618E2804325C0070250E /* PlayerHandler.swift */; };
|
3ABF618F2804325C0070250E /* PlayerHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ABF618E2804325C0070250E /* PlayerHandler.swift */; };
|
||||||
3AD4FCE528043E50006DB301 /* AbsDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AD4FCE428043E50006DB301 /* AbsDatabase.swift */; };
|
3AD4FCE528043E50006DB301 /* AbsDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AD4FCE428043E50006DB301 /* AbsDatabase.swift */; };
|
||||||
3AD4FCE728043E72006DB301 /* AbsDatabase.m in Sources */ = {isa = PBXBuildFile; fileRef = 3AD4FCE628043E72006DB301 /* AbsDatabase.m */; };
|
|
||||||
3AD4FCE928043FD7006DB301 /* ServerConnectionConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AD4FCE828043FD7006DB301 /* ServerConnectionConfig.swift */; };
|
3AD4FCE928043FD7006DB301 /* ServerConnectionConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AD4FCE828043FD7006DB301 /* ServerConnectionConfig.swift */; };
|
||||||
3AD4FCEB280443DD006DB301 /* Database.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AD4FCEA280443DD006DB301 /* Database.swift */; };
|
3AD4FCEB280443DD006DB301 /* Database.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AD4FCEA280443DD006DB301 /* Database.swift */; };
|
||||||
3AD4FCED28044E6C006DB301 /* Store.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AD4FCEC28044E6C006DB301 /* Store.swift */; };
|
3AD4FCED28044E6C006DB301 /* Store.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AD4FCEC28044E6C006DB301 /* Store.swift */; };
|
||||||
3AF1970C2806E2590096F747 /* ApiClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AF1970B2806E2590096F747 /* ApiClient.swift */; };
|
3AF1970C2806E2590096F747 /* ApiClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AF1970B2806E2590096F747 /* ApiClient.swift */; };
|
||||||
3AF1970E2806E3CA0096F747 /* AbsAudioPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AF1970D2806E3CA0096F747 /* AbsAudioPlayer.swift */; };
|
3AF1970E2806E3CA0096F747 /* AbsAudioPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AF1970D2806E3CA0096F747 /* AbsAudioPlayer.swift */; };
|
||||||
3AF197102806E3DC0096F747 /* AbsAudioPlayer.m in Sources */ = {isa = PBXBuildFile; fileRef = 3AF1970F2806E3DC0096F747 /* AbsAudioPlayer.m */; };
|
|
||||||
3AFCB5E827EA240D00ECCC05 /* NowPlayingInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AFCB5E727EA240D00ECCC05 /* NowPlayingInfo.swift */; };
|
3AFCB5E827EA240D00ECCC05 /* NowPlayingInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AFCB5E727EA240D00ECCC05 /* NowPlayingInfo.swift */; };
|
||||||
4D66B952282EE822008272D4 /* AbsDownloader.m in Sources */ = {isa = PBXBuildFile; fileRef = 4D66B951282EE822008272D4 /* AbsDownloader.m */; };
|
|
||||||
4D66B954282EE87C008272D4 /* AbsDownloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D66B953282EE87C008272D4 /* AbsDownloader.swift */; };
|
4D66B954282EE87C008272D4 /* AbsDownloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D66B953282EE87C008272D4 /* AbsDownloader.swift */; };
|
||||||
4D66B956282EE951008272D4 /* AbsFileSystem.m in Sources */ = {isa = PBXBuildFile; fileRef = 4D66B955282EE951008272D4 /* AbsFileSystem.m */; };
|
|
||||||
4D66B958282EEA14008272D4 /* AbsFileSystem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D66B957282EEA14008272D4 /* AbsFileSystem.swift */; };
|
4D66B958282EEA14008272D4 /* AbsFileSystem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D66B957282EEA14008272D4 /* AbsFileSystem.swift */; };
|
||||||
4D91EEC62A40F28D004807ED /* EBookFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D91EEC52A40F28D004807ED /* EBookFile.swift */; };
|
4D91EEC62A40F28D004807ED /* EBookFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D91EEC52A40F28D004807ED /* EBookFile.swift */; };
|
||||||
4DABC04F2B0139CA000F6264 /* User.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DABC04E2B0139CA000F6264 /* User.swift */; };
|
4DABC04F2B0139CA000F6264 /* User.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DABC04E2B0139CA000F6264 /* User.swift */; };
|
||||||
|
4DB441E12E19B8BF0056C8F1 /* SecureStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DB441E02E19B8BF0056C8F1 /* SecureStorage.swift */; };
|
||||||
|
4DF6C7172DB58ABF004059F1 /* AbsLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DF6C7162DB58ABF004059F1 /* AbsLogger.swift */; };
|
||||||
4DF74912287105C600AC7814 /* DeviceSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DF74911287105C600AC7814 /* DeviceSettings.swift */; };
|
4DF74912287105C600AC7814 /* DeviceSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DF74911287105C600AC7814 /* DeviceSettings.swift */; };
|
||||||
4DFE2DA32D345C390000B204 /* MyViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DFE2DA22D345C390000B204 /* MyViewController.swift */; };
|
4DFE2DA32D345C390000B204 /* MyViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DFE2DA22D345C390000B204 /* MyViewController.swift */; };
|
||||||
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; };
|
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; };
|
||||||
@@ -90,21 +88,19 @@
|
|||||||
3ABF580828059BAE005DFBE5 /* PlaybackSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackSession.swift; sourceTree = "<group>"; };
|
3ABF580828059BAE005DFBE5 /* PlaybackSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackSession.swift; sourceTree = "<group>"; };
|
||||||
3ABF618E2804325C0070250E /* PlayerHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerHandler.swift; sourceTree = "<group>"; };
|
3ABF618E2804325C0070250E /* PlayerHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerHandler.swift; sourceTree = "<group>"; };
|
||||||
3AD4FCE428043E50006DB301 /* AbsDatabase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AbsDatabase.swift; sourceTree = "<group>"; };
|
3AD4FCE428043E50006DB301 /* AbsDatabase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AbsDatabase.swift; sourceTree = "<group>"; };
|
||||||
3AD4FCE628043E72006DB301 /* AbsDatabase.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AbsDatabase.m; sourceTree = "<group>"; };
|
|
||||||
3AD4FCE828043FD7006DB301 /* ServerConnectionConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerConnectionConfig.swift; sourceTree = "<group>"; };
|
3AD4FCE828043FD7006DB301 /* ServerConnectionConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerConnectionConfig.swift; sourceTree = "<group>"; };
|
||||||
3AD4FCEA280443DD006DB301 /* Database.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Database.swift; sourceTree = "<group>"; };
|
3AD4FCEA280443DD006DB301 /* Database.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Database.swift; sourceTree = "<group>"; };
|
||||||
3AD4FCEC28044E6C006DB301 /* Store.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Store.swift; sourceTree = "<group>"; };
|
3AD4FCEC28044E6C006DB301 /* Store.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Store.swift; sourceTree = "<group>"; };
|
||||||
3AF1970B2806E2590096F747 /* ApiClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiClient.swift; sourceTree = "<group>"; };
|
3AF1970B2806E2590096F747 /* ApiClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiClient.swift; sourceTree = "<group>"; };
|
||||||
3AF1970D2806E3CA0096F747 /* AbsAudioPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AbsAudioPlayer.swift; sourceTree = "<group>"; };
|
3AF1970D2806E3CA0096F747 /* AbsAudioPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AbsAudioPlayer.swift; sourceTree = "<group>"; };
|
||||||
3AF1970F2806E3DC0096F747 /* AbsAudioPlayer.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AbsAudioPlayer.m; sourceTree = "<group>"; };
|
|
||||||
3AFCB5E727EA240D00ECCC05 /* NowPlayingInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NowPlayingInfo.swift; sourceTree = "<group>"; };
|
3AFCB5E727EA240D00ECCC05 /* NowPlayingInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NowPlayingInfo.swift; sourceTree = "<group>"; };
|
||||||
4D66B951282EE822008272D4 /* AbsDownloader.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AbsDownloader.m; sourceTree = "<group>"; };
|
|
||||||
4D66B953282EE87C008272D4 /* AbsDownloader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AbsDownloader.swift; sourceTree = "<group>"; };
|
4D66B953282EE87C008272D4 /* AbsDownloader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AbsDownloader.swift; sourceTree = "<group>"; };
|
||||||
4D66B955282EE951008272D4 /* AbsFileSystem.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AbsFileSystem.m; sourceTree = "<group>"; };
|
|
||||||
4D66B957282EEA14008272D4 /* AbsFileSystem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AbsFileSystem.swift; sourceTree = "<group>"; };
|
4D66B957282EEA14008272D4 /* AbsFileSystem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AbsFileSystem.swift; sourceTree = "<group>"; };
|
||||||
4D8D412C26E187E400BA5F0D /* App-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "App-Bridging-Header.h"; sourceTree = "<group>"; };
|
4D8D412C26E187E400BA5F0D /* App-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "App-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||||
4D91EEC52A40F28D004807ED /* EBookFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EBookFile.swift; sourceTree = "<group>"; };
|
4D91EEC52A40F28D004807ED /* EBookFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EBookFile.swift; sourceTree = "<group>"; };
|
||||||
4DABC04E2B0139CA000F6264 /* User.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = User.swift; sourceTree = "<group>"; };
|
4DABC04E2B0139CA000F6264 /* User.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = User.swift; sourceTree = "<group>"; };
|
||||||
|
4DB441E02E19B8BF0056C8F1 /* SecureStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureStorage.swift; sourceTree = "<group>"; };
|
||||||
|
4DF6C7162DB58ABF004059F1 /* AbsLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AbsLogger.swift; sourceTree = "<group>"; };
|
||||||
4DF74911287105C600AC7814 /* DeviceSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceSettings.swift; sourceTree = "<group>"; };
|
4DF74911287105C600AC7814 /* DeviceSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceSettings.swift; sourceTree = "<group>"; };
|
||||||
4DFE2DA22D345C390000B204 /* MyViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyViewController.swift; sourceTree = "<group>"; };
|
4DFE2DA22D345C390000B204 /* MyViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyViewController.swift; sourceTree = "<group>"; };
|
||||||
50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = "<group>"; };
|
50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = "<group>"; };
|
||||||
@@ -217,13 +213,10 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
3AD4FCE428043E50006DB301 /* AbsDatabase.swift */,
|
3AD4FCE428043E50006DB301 /* AbsDatabase.swift */,
|
||||||
3AD4FCE628043E72006DB301 /* AbsDatabase.m */,
|
|
||||||
3AF1970D2806E3CA0096F747 /* AbsAudioPlayer.swift */,
|
3AF1970D2806E3CA0096F747 /* AbsAudioPlayer.swift */,
|
||||||
3AF1970F2806E3DC0096F747 /* AbsAudioPlayer.m */,
|
|
||||||
4D66B951282EE822008272D4 /* AbsDownloader.m */,
|
|
||||||
4D66B953282EE87C008272D4 /* AbsDownloader.swift */,
|
4D66B953282EE87C008272D4 /* AbsDownloader.swift */,
|
||||||
4D66B955282EE951008272D4 /* AbsFileSystem.m */,
|
|
||||||
4D66B957282EEA14008272D4 /* AbsFileSystem.swift */,
|
4D66B957282EEA14008272D4 /* AbsFileSystem.swift */,
|
||||||
|
4DF6C7162DB58ABF004059F1 /* AbsLogger.swift */,
|
||||||
);
|
);
|
||||||
path = plugins;
|
path = plugins;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -248,6 +241,7 @@
|
|||||||
3AFCB5E627EA23F700ECCC05 /* util */ = {
|
3AFCB5E627EA23F700ECCC05 /* util */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
4DB441E02E19B8BF0056C8F1 /* SecureStorage.swift */,
|
||||||
E9FA07E228C82848005520B0 /* Logger.swift */,
|
E9FA07E228C82848005520B0 /* Logger.swift */,
|
||||||
3AFCB5E727EA240D00ECCC05 /* NowPlayingInfo.swift */,
|
3AFCB5E727EA240D00ECCC05 /* NowPlayingInfo.swift */,
|
||||||
3AD4FCEA280443DD006DB301 /* Database.swift */,
|
3AD4FCEA280443DD006DB301 /* Database.swift */,
|
||||||
@@ -531,7 +525,6 @@
|
|||||||
files = (
|
files = (
|
||||||
E9D5507328AC218300C746DD /* DaoExtensions.swift in Sources */,
|
E9D5507328AC218300C746DD /* DaoExtensions.swift in Sources */,
|
||||||
E9D5506228AC1CC900C746DD /* PlayerState.swift in Sources */,
|
E9D5506228AC1CC900C746DD /* PlayerState.swift in Sources */,
|
||||||
3AD4FCE728043E72006DB301 /* AbsDatabase.m in Sources */,
|
|
||||||
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
|
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
|
||||||
EACB38122BCCA1330060DA4A /* AudioPlayerRateManager.swift in Sources */,
|
EACB38122BCCA1330060DA4A /* AudioPlayerRateManager.swift in Sources */,
|
||||||
E9FA07E328C82848005520B0 /* Logger.swift in Sources */,
|
E9FA07E328C82848005520B0 /* Logger.swift in Sources */,
|
||||||
@@ -545,6 +538,7 @@
|
|||||||
4D66B958282EEA14008272D4 /* AbsFileSystem.swift in Sources */,
|
4D66B958282EEA14008272D4 /* AbsFileSystem.swift in Sources */,
|
||||||
E9D5504C28AC1AE000C746DD /* PodcastEpisode.swift in Sources */,
|
E9D5504C28AC1AE000C746DD /* PodcastEpisode.swift in Sources */,
|
||||||
E9D5506A28AC1DF100C746DD /* LocalFile.swift in Sources */,
|
E9D5506A28AC1DF100C746DD /* LocalFile.swift in Sources */,
|
||||||
|
4DF6C7172DB58ABF004059F1 /* AbsLogger.swift in Sources */,
|
||||||
3AF1970E2806E3CA0096F747 /* AbsAudioPlayer.swift in Sources */,
|
3AF1970E2806E3CA0096F747 /* AbsAudioPlayer.swift in Sources */,
|
||||||
E9D5506F28AC1E8E00C746DD /* DownloadItem.swift in Sources */,
|
E9D5506F28AC1E8E00C746DD /* DownloadItem.swift in Sources */,
|
||||||
3AD4FCE928043FD7006DB301 /* ServerConnectionConfig.swift in Sources */,
|
3AD4FCE928043FD7006DB301 /* ServerConnectionConfig.swift in Sources */,
|
||||||
@@ -553,15 +547,14 @@
|
|||||||
3A200C1527D64D7E00CBF02E /* AudioPlayer.swift in Sources */,
|
3A200C1527D64D7E00CBF02E /* AudioPlayer.swift in Sources */,
|
||||||
4DFE2DA32D345C390000B204 /* MyViewController.swift in Sources */,
|
4DFE2DA32D345C390000B204 /* MyViewController.swift in Sources */,
|
||||||
E9D5507128AC1EC700C746DD /* DownloadItemPart.swift in Sources */,
|
E9D5507128AC1EC700C746DD /* DownloadItemPart.swift in Sources */,
|
||||||
4D66B956282EE951008272D4 /* AbsFileSystem.m in Sources */,
|
|
||||||
EACB38142BCCA1410060DA4A /* LegacyAudioPlayerRateManager.swift in Sources */,
|
EACB38142BCCA1410060DA4A /* LegacyAudioPlayerRateManager.swift in Sources */,
|
||||||
3AFCB5E827EA240D00ECCC05 /* NowPlayingInfo.swift in Sources */,
|
3AFCB5E827EA240D00ECCC05 /* NowPlayingInfo.swift in Sources */,
|
||||||
3AB34053280829BF0039308B /* Extensions.swift in Sources */,
|
3AB34053280829BF0039308B /* Extensions.swift in Sources */,
|
||||||
E9D5505828AC1C1A00C746DD /* Library.swift in Sources */,
|
E9D5505828AC1C1A00C746DD /* Library.swift in Sources */,
|
||||||
3AD4FCEB280443DD006DB301 /* Database.swift in Sources */,
|
3AD4FCEB280443DD006DB301 /* Database.swift in Sources */,
|
||||||
3AD4FCE528043E50006DB301 /* AbsDatabase.swift in Sources */,
|
3AD4FCE528043E50006DB301 /* AbsDatabase.swift in Sources */,
|
||||||
|
4DB441E12E19B8BF0056C8F1 /* SecureStorage.swift in Sources */,
|
||||||
4DABC04F2B0139CA000F6264 /* User.swift in Sources */,
|
4DABC04F2B0139CA000F6264 /* User.swift in Sources */,
|
||||||
4D66B952282EE822008272D4 /* AbsDownloader.m in Sources */,
|
|
||||||
E9D5506828AC1DC300C746DD /* LocalPodcastEpisode.swift in Sources */,
|
E9D5506828AC1DC300C746DD /* LocalPodcastEpisode.swift in Sources */,
|
||||||
EACB38162BCCA1500060DA4A /* DefaultedAudioPlayerRateManager.swift in Sources */,
|
EACB38162BCCA1500060DA4A /* DefaultedAudioPlayerRateManager.swift in Sources */,
|
||||||
E9D5505228AC1B5D00C746DD /* Chapter.swift in Sources */,
|
E9D5505228AC1B5D00C746DD /* Chapter.swift in Sources */,
|
||||||
@@ -574,7 +567,6 @@
|
|||||||
E9D5505C28AC1C6200C746DD /* LibraryFile.swift in Sources */,
|
E9D5505C28AC1C6200C746DD /* LibraryFile.swift in Sources */,
|
||||||
4DF74912287105C600AC7814 /* DeviceSettings.swift in Sources */,
|
4DF74912287105C600AC7814 /* DeviceSettings.swift in Sources */,
|
||||||
E9D5504A28AC1AA600C746DD /* Metadata.swift in Sources */,
|
E9D5504A28AC1AA600C746DD /* Metadata.swift in Sources */,
|
||||||
3AF197102806E3DC0096F747 /* AbsAudioPlayer.m in Sources */,
|
|
||||||
E9D5507528AEF93100C746DD /* PlayerSettings.swift in Sources */,
|
E9D5507528AEF93100C746DD /* PlayerSettings.swift in Sources */,
|
||||||
E9D5505028AC1B3E00C746DD /* Author.swift in Sources */,
|
E9D5505028AC1B3E00C746DD /* Author.swift in Sources */,
|
||||||
3AF1970C2806E2590096F747 /* ApiClient.swift in Sources */,
|
3AF1970C2806E2590096F747 /* ApiClient.swift in Sources */,
|
||||||
@@ -744,12 +736,12 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 36;
|
CURRENT_PROJECT_VERSION = 38;
|
||||||
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
|
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
|
||||||
INFOPLIST_FILE = App/Info.plist;
|
INFOPLIST_FILE = App/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||||
MARKETING_VERSION = 0.9.79;
|
MARKETING_VERSION = 0.9.81;
|
||||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app.dev;
|
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app.dev;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
@@ -768,12 +760,12 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 36;
|
CURRENT_PROJECT_VERSION = 38;
|
||||||
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
|
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
|
||||||
INFOPLIST_FILE = App/Info.plist;
|
INFOPLIST_FILE = App/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||||
MARKETING_VERSION = 0.9.79;
|
MARKETING_VERSION = 0.9.81;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app;
|
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||||||
// Override point for customization after application launch.
|
// Override point for customization after application launch.
|
||||||
|
|
||||||
let configuration = Realm.Configuration(
|
let configuration = Realm.Configuration(
|
||||||
schemaVersion: 19,
|
schemaVersion: 20,
|
||||||
migrationBlock: { [weak self] migration, oldSchemaVersion in
|
migrationBlock: { [weak self] migration, oldSchemaVersion in
|
||||||
if (oldSchemaVersion < 1) {
|
if (oldSchemaVersion < 1) {
|
||||||
self?.logger.log("Realm schema version was \(oldSchemaVersion)")
|
self?.logger.log("Realm schema version was \(oldSchemaVersion)")
|
||||||
@@ -65,9 +65,14 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||||||
self?.logger.log("Realm schema version was \(oldSchemaVersion)... Adding disableSleepTimerFadeOut settings")
|
self?.logger.log("Realm schema version was \(oldSchemaVersion)... Adding disableSleepTimerFadeOut settings")
|
||||||
migration.enumerateObjects(ofType: PlayerSettings.className()) { oldObject, newObject in
|
migration.enumerateObjects(ofType: PlayerSettings.className()) { oldObject, newObject in
|
||||||
newObject?["disableSleepTimerFadeOut"] = false
|
newObject?["disableSleepTimerFadeOut"] = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (oldSchemaVersion < 20) {
|
||||||
|
self?.logger.log("Realm schema version was \(oldSchemaVersion)... Adding version to ServerConnectionConfigs")
|
||||||
|
migration.enumerateObjects(ofType: ServerConnectionConfig.className()) { oldObject, newObject in
|
||||||
|
newObject?["version"] = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
Realm.Configuration.defaultConfiguration = configuration
|
Realm.Configuration.defaultConfiguration = configuration
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ class MyViewController: CAPBridgeViewController {
|
|||||||
bridge?.registerPluginInstance(AbsAudioPlayer())
|
bridge?.registerPluginInstance(AbsAudioPlayer())
|
||||||
bridge?.registerPluginInstance(AbsDownloader())
|
bridge?.registerPluginInstance(AbsDownloader())
|
||||||
bridge?.registerPluginInstance(AbsFileSystem())
|
bridge?.registerPluginInstance(AbsFileSystem())
|
||||||
|
bridge?.registerPluginInstance(AbsLogger())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,10 @@
|
|||||||
"plugins": {
|
"plugins": {
|
||||||
"CapacitorHttp": {
|
"CapacitorHttp": {
|
||||||
"enabled": false
|
"enabled": false
|
||||||
|
},
|
||||||
|
"StatusBar": {
|
||||||
|
"backgroundColor": "#232323",
|
||||||
|
"style": "DARK"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"server": {
|
"server": {
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
//
|
|
||||||
// AbsAudioPlayer.m
|
|
||||||
// App
|
|
||||||
//
|
|
||||||
// Created by Rasmus Krämer on 13.04.22.
|
|
||||||
//
|
|
||||||
|
|
||||||
#import <Foundation/Foundation.h>
|
|
||||||
#import <Capacitor/Capacitor.h>
|
|
||||||
|
|
||||||
CAP_PLUGIN(AbsAudioPlayer, "AbsAudioPlayer",
|
|
||||||
CAP_PLUGIN_METHOD(onReady, CAPPluginReturnNone);
|
|
||||||
|
|
||||||
CAP_PLUGIN_METHOD(prepareLibraryItem, CAPPluginReturnPromise);
|
|
||||||
CAP_PLUGIN_METHOD(closePlayback, CAPPluginReturnPromise);
|
|
||||||
|
|
||||||
CAP_PLUGIN_METHOD(setPlaybackSpeed, CAPPluginReturnPromise);
|
|
||||||
CAP_PLUGIN_METHOD(setChapterTrack, CAPPluginReturnPromise);
|
|
||||||
|
|
||||||
CAP_PLUGIN_METHOD(playPlayer, CAPPluginReturnPromise);
|
|
||||||
CAP_PLUGIN_METHOD(pausePlayer, CAPPluginReturnPromise);
|
|
||||||
CAP_PLUGIN_METHOD(playPause, CAPPluginReturnPromise);
|
|
||||||
|
|
||||||
CAP_PLUGIN_METHOD(seek, CAPPluginReturnPromise);
|
|
||||||
CAP_PLUGIN_METHOD(seekForward, CAPPluginReturnPromise);
|
|
||||||
CAP_PLUGIN_METHOD(seekBackward, CAPPluginReturnPromise);
|
|
||||||
|
|
||||||
CAP_PLUGIN_METHOD(getCurrentTime, CAPPluginReturnPromise);
|
|
||||||
|
|
||||||
CAP_PLUGIN_METHOD(cancelSleepTimer, CAPPluginReturnPromise);
|
|
||||||
CAP_PLUGIN_METHOD(decreaseSleepTime, CAPPluginReturnPromise);
|
|
||||||
CAP_PLUGIN_METHOD(increaseSleepTime, CAPPluginReturnPromise);
|
|
||||||
CAP_PLUGIN_METHOD(getSleepTimerTime, CAPPluginReturnPromise);
|
|
||||||
CAP_PLUGIN_METHOD(setSleepTimer, CAPPluginReturnPromise);
|
|
||||||
)
|
|
||||||
@@ -11,7 +11,29 @@ import RealmSwift
|
|||||||
import Network
|
import Network
|
||||||
|
|
||||||
@objc(AbsAudioPlayer)
|
@objc(AbsAudioPlayer)
|
||||||
public class AbsAudioPlayer: CAPPlugin {
|
public class AbsAudioPlayer: CAPPlugin, CAPBridgedPlugin {
|
||||||
|
public var identifier = "AbsAudioPlayerPlugin"
|
||||||
|
public var jsName = "AbsAudioPlayer"
|
||||||
|
public let pluginMethods: [CAPPluginMethod] = [
|
||||||
|
CAPPluginMethod(name: "onReady", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "prepareLibraryItem", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "closePlayback", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "setPlaybackSpeed", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "setChapterTrack", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "playPlayer", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "pausePlayer", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "playPause", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "seek", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "seekForward", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "seekBackward", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "getCurrentTime", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "cancelSleepTimer", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "decreaseSleepTime", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "increaseSleepTime", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "getSleepTimerTime", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "setSleepTimer", returnType: CAPPluginReturnPromise)
|
||||||
|
]
|
||||||
|
|
||||||
private let logger = AppLogger(category: "AbsAudioPlayer")
|
private let logger = AppLogger(category: "AbsAudioPlayer")
|
||||||
|
|
||||||
private var initialPlayWhenReady = false
|
private var initialPlayWhenReady = false
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
//
|
|
||||||
// AbsDatabase.m
|
|
||||||
// App
|
|
||||||
//
|
|
||||||
// Created by Rasmus Krämer on 11.04.22.
|
|
||||||
//
|
|
||||||
|
|
||||||
#import <Foundation/Foundation.h>
|
|
||||||
#import <Capacitor/Capacitor.h>
|
|
||||||
|
|
||||||
CAP_PLUGIN(AbsDatabase, "AbsDatabase",
|
|
||||||
CAP_PLUGIN_METHOD(setCurrentServerConnectionConfig, CAPPluginReturnPromise);
|
|
||||||
CAP_PLUGIN_METHOD(removeServerConnectionConfig, CAPPluginReturnPromise);
|
|
||||||
|
|
||||||
CAP_PLUGIN_METHOD(logout, CAPPluginReturnPromise);
|
|
||||||
CAP_PLUGIN_METHOD(getDeviceData, CAPPluginReturnPromise);
|
|
||||||
|
|
||||||
CAP_PLUGIN_METHOD(getLocalLibraryItems, CAPPluginReturnPromise);
|
|
||||||
CAP_PLUGIN_METHOD(getLocalLibraryItem, CAPPluginReturnPromise);
|
|
||||||
CAP_PLUGIN_METHOD(getLocalLibraryItemByLId, CAPPluginReturnPromise);
|
|
||||||
CAP_PLUGIN_METHOD(getLocalLibraryItemsInFolder, CAPPluginReturnPromise);
|
|
||||||
CAP_PLUGIN_METHOD(getAllLocalMediaProgress, CAPPluginReturnPromise);
|
|
||||||
CAP_PLUGIN_METHOD(removeLocalMediaProgress, CAPPluginReturnPromise);
|
|
||||||
CAP_PLUGIN_METHOD(syncServerMediaProgressWithLocalMediaProgress, CAPPluginReturnPromise);
|
|
||||||
CAP_PLUGIN_METHOD(syncLocalSessionsWithServer, CAPPluginReturnPromise);
|
|
||||||
CAP_PLUGIN_METHOD(updateLocalMediaProgressFinished, CAPPluginReturnPromise);
|
|
||||||
CAP_PLUGIN_METHOD(updateDeviceSettings, CAPPluginReturnPromise);
|
|
||||||
CAP_PLUGIN_METHOD(updateLocalEbookProgress, CAPPluginReturnPromise);
|
|
||||||
)
|
|
||||||
@@ -27,17 +27,57 @@ extension String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@objc(AbsDatabase)
|
@objc(AbsDatabase)
|
||||||
public class AbsDatabase: CAPPlugin {
|
public class AbsDatabase: CAPPlugin, CAPBridgedPlugin {
|
||||||
|
public var identifier = "AbsDatabasePlugin"
|
||||||
|
public var jsName = "AbsDatabase"
|
||||||
|
public let pluginMethods: [CAPPluginMethod] = [
|
||||||
|
CAPPluginMethod(name: "setCurrentServerConnectionConfig", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "removeServerConnectionConfig", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "getRefreshToken", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "clearRefreshToken", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "logout", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "getDeviceData", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "getLocalLibraryItems", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "getLocalLibraryItem", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "getLocalLibraryItemByLId", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "getLocalLibraryItemsInFolder", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "getAllLocalMediaProgress", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "removeLocalMediaProgress", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "syncServerMediaProgressWithLocalMediaProgress", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "syncLocalSessionsWithServer", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "updateLocalMediaProgressFinished", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "updateDeviceSettings", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "updateLocalEbookProgress", returnType: CAPPluginReturnPromise)
|
||||||
|
]
|
||||||
|
|
||||||
private let logger = AppLogger(category: "AbsDatabase")
|
private let logger = AppLogger(category: "AbsDatabase")
|
||||||
|
private let secureStorage = SecureStorage()
|
||||||
|
|
||||||
|
// Used to notify the webview frontend that the token has been refreshed
|
||||||
|
static var tokenRefreshCallback: ((String, [String: Any]) -> Void)?
|
||||||
|
|
||||||
|
override public func load() {
|
||||||
|
AbsDatabase.tokenRefreshCallback = { [weak self] eventName, data in
|
||||||
|
self?.notifyListeners(eventName, data: data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@objc func setCurrentServerConnectionConfig(_ call: CAPPluginCall) {
|
@objc func setCurrentServerConnectionConfig(_ call: CAPPluginCall) {
|
||||||
var id = call.getString("id")
|
var id = call.getString("id")
|
||||||
let address = call.getString("address", "")
|
let address = call.getString("address", "")
|
||||||
|
let version = call.getString("version", "")
|
||||||
let userId = call.getString("userId", "")
|
let userId = call.getString("userId", "")
|
||||||
let username = call.getString("username", "")
|
let username = call.getString("username", "")
|
||||||
let token = call.getString("token", "")
|
let token = call.getString("token", "")
|
||||||
|
let refreshToken = call.getString("refreshToken", "") // Refresh only sent after login or refresh
|
||||||
|
|
||||||
let name = "\(address) (\(username))"
|
let name = "\(address) (\(username))"
|
||||||
|
|
||||||
|
if (refreshToken != "") {
|
||||||
|
// Store refresh token securely if provided
|
||||||
|
let hasRefreshToken = secureStorage.storeRefreshToken(serverConnectionConfigId: id ?? "", refreshToken: refreshToken)
|
||||||
|
logger.log("Refresh token secured = \(hasRefreshToken)")
|
||||||
|
}
|
||||||
|
|
||||||
if id == nil {
|
if id == nil {
|
||||||
id = "\(address)@\(username)".toBase64()
|
id = "\(address)@\(username)".toBase64()
|
||||||
@@ -48,6 +88,7 @@ public class AbsDatabase: CAPPlugin {
|
|||||||
config.index = 0
|
config.index = 0
|
||||||
config.name = name
|
config.name = name
|
||||||
config.address = address
|
config.address = address
|
||||||
|
config.version = version
|
||||||
config.userId = userId
|
config.userId = userId
|
||||||
config.username = username
|
config.username = username
|
||||||
config.token = token
|
config.token = token
|
||||||
@@ -56,12 +97,35 @@ public class AbsDatabase: CAPPlugin {
|
|||||||
let savedConfig = Store.serverConfig // Fetch the latest value
|
let savedConfig = Store.serverConfig // Fetch the latest value
|
||||||
call.resolve(convertServerConnectionConfigToJSON(config: savedConfig!))
|
call.resolve(convertServerConnectionConfigToJSON(config: savedConfig!))
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func removeServerConnectionConfig(_ call: CAPPluginCall) {
|
@objc func removeServerConnectionConfig(_ call: CAPPluginCall) {
|
||||||
let id = call.getString("serverConnectionConfigId", "")
|
let id = call.getString("serverConnectionConfigId", "")
|
||||||
|
|
||||||
|
// Remove refresh token if it exists
|
||||||
|
_ = secureStorage.removeRefreshToken(serverConnectionConfigId: id)
|
||||||
|
|
||||||
Database.shared.deleteServerConnectionConfig(id: id)
|
Database.shared.deleteServerConnectionConfig(id: id)
|
||||||
|
|
||||||
call.resolve()
|
call.resolve()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc func getRefreshToken(_ call: CAPPluginCall) {
|
||||||
|
let serverConnectionConfigId = call.getString("serverConnectionConfigId", "")
|
||||||
|
|
||||||
|
let refreshToken = secureStorage.getRefreshToken(serverConnectionConfigId: serverConnectionConfigId)
|
||||||
|
if let refreshToken = refreshToken {
|
||||||
|
call.resolve(["refreshToken": refreshToken])
|
||||||
|
} else {
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func clearRefreshToken(_ call: CAPPluginCall) {
|
||||||
|
let serverConnectionConfigId = call.getString("serverConnectionConfigId", "")
|
||||||
|
|
||||||
|
let success = secureStorage.removeRefreshToken(serverConnectionConfigId: serverConnectionConfigId)
|
||||||
|
call.resolve(["success": success])
|
||||||
|
}
|
||||||
|
|
||||||
@objc func logout(_ call: CAPPluginCall) {
|
@objc func logout(_ call: CAPPluginCall) {
|
||||||
Store.serverConfig = nil
|
Store.serverConfig = nil
|
||||||
call.resolve()
|
call.resolve()
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
//
|
|
||||||
// AbsDownloader.m
|
|
||||||
// App
|
|
||||||
//
|
|
||||||
// Created by advplyr on 5/13/22.
|
|
||||||
//
|
|
||||||
|
|
||||||
#import <Foundation/Foundation.h>
|
|
||||||
#import <Capacitor/Capacitor.h>
|
|
||||||
|
|
||||||
CAP_PLUGIN(AbsDownloader, "AbsDownloader",
|
|
||||||
CAP_PLUGIN_METHOD(downloadLibraryItem, CAPPluginReturnPromise);
|
|
||||||
)
|
|
||||||
@@ -10,7 +10,12 @@ import Capacitor
|
|||||||
import RealmSwift
|
import RealmSwift
|
||||||
|
|
||||||
@objc(AbsDownloader)
|
@objc(AbsDownloader)
|
||||||
public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate {
|
public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDelegate {
|
||||||
|
public var identifier = "AbsDownloaderPlugin"
|
||||||
|
public var jsName = "AbsDownloader"
|
||||||
|
public let pluginMethods: [CAPPluginMethod] = [
|
||||||
|
CAPPluginMethod(name: "downloadLibraryItem", returnType: CAPPluginReturnPromise)
|
||||||
|
]
|
||||||
|
|
||||||
static private let downloadsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
static private let downloadsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
//
|
|
||||||
// AbsFileSystem.m
|
|
||||||
// App
|
|
||||||
//
|
|
||||||
// Created by advplyr on 5/13/22.
|
|
||||||
//
|
|
||||||
|
|
||||||
#import <Foundation/Foundation.h>
|
|
||||||
#import <Capacitor/Capacitor.h>
|
|
||||||
|
|
||||||
CAP_PLUGIN(AbsFileSystem, "AbsFileSystem",
|
|
||||||
CAP_PLUGIN_METHOD(selectFolder, CAPPluginReturnPromise);
|
|
||||||
CAP_PLUGIN_METHOD(checkFolderPermission, CAPPluginReturnPromise);
|
|
||||||
CAP_PLUGIN_METHOD(scanFolder, CAPPluginReturnPromise);
|
|
||||||
CAP_PLUGIN_METHOD(removeFolder, CAPPluginReturnPromise);
|
|
||||||
CAP_PLUGIN_METHOD(removeLocalLibraryItem, CAPPluginReturnPromise);
|
|
||||||
CAP_PLUGIN_METHOD(scanLocalLibraryItem, CAPPluginReturnPromise);
|
|
||||||
CAP_PLUGIN_METHOD(deleteItem, CAPPluginReturnPromise);
|
|
||||||
CAP_PLUGIN_METHOD(deleteTrackFromItem, CAPPluginReturnPromise);
|
|
||||||
)
|
|
||||||
@@ -9,7 +9,20 @@ import Foundation
|
|||||||
import Capacitor
|
import Capacitor
|
||||||
|
|
||||||
@objc(AbsFileSystem)
|
@objc(AbsFileSystem)
|
||||||
public class AbsFileSystem: CAPPlugin {
|
public class AbsFileSystem: CAPPlugin, CAPBridgedPlugin {
|
||||||
|
public var identifier = "AbsFileSystemPlugin"
|
||||||
|
public var jsName = "AbsFileSystem"
|
||||||
|
public let pluginMethods: [CAPPluginMethod] = [
|
||||||
|
CAPPluginMethod(name: "selectFolder", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "checkFolderPermission", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "scanFolder", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "removeFolder", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "removeLocalLibraryItem", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "scanLocalLibraryItem", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "deleteItem", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "deleteTrackFromItem", returnType: CAPPluginReturnPromise)
|
||||||
|
]
|
||||||
|
|
||||||
private let logger = AppLogger(category: "AbsFileSystem")
|
private let logger = AppLogger(category: "AbsFileSystem")
|
||||||
|
|
||||||
@objc func selectFolder(_ call: CAPPluginCall) {
|
@objc func selectFolder(_ call: CAPPluginCall) {
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
//
|
||||||
|
// AbsLogger.swift
|
||||||
|
// Audiobookshelf
|
||||||
|
//
|
||||||
|
// Created by advplyr on 4/20/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Capacitor
|
||||||
|
|
||||||
|
@objc(AbsLogger)
|
||||||
|
public class AbsLogger: CAPPlugin, CAPBridgedPlugin {
|
||||||
|
public var identifier = "AbsLoggerPlugin"
|
||||||
|
public var jsName = "AbsLogger"
|
||||||
|
public let pluginMethods: [CAPPluginMethod] = [
|
||||||
|
CAPPluginMethod(name: "info", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "error", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "getAllLogs", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "clearLogs", returnType: CAPPluginReturnPromise)
|
||||||
|
]
|
||||||
|
|
||||||
|
private let logger = AppLogger(category: "AbsLogger")
|
||||||
|
|
||||||
|
@objc func info(_ call: CAPPluginCall) {
|
||||||
|
let message = call.getString("message") ?? ""
|
||||||
|
let tag = call.getString("tag") ?? ""
|
||||||
|
|
||||||
|
logger.log("[\(tag)] \(message)")
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func error(_ call: CAPPluginCall) {
|
||||||
|
let message = call.getString("message") ?? ""
|
||||||
|
let tag = call.getString("tag") ?? ""
|
||||||
|
|
||||||
|
logger.error("[\(tag)] \(message)")
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func getAllLogs(_ call: CAPPluginCall) {
|
||||||
|
call.unimplemented("Not implemented on iOS")
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func clearLogs(_ call: CAPPluginCall) {
|
||||||
|
call.unimplemented("Not implemented on iOS")
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
-10
@@ -1,5 +1,5 @@
|
|||||||
PODS:
|
PODS:
|
||||||
- Alamofire (5.8.1)
|
- Alamofire (5.10.2)
|
||||||
- Capacitor (7.2.0):
|
- Capacitor (7.2.0):
|
||||||
- CapacitorCordova
|
- CapacitorCordova
|
||||||
- CapacitorApp (7.0.1):
|
- CapacitorApp (7.0.1):
|
||||||
@@ -25,11 +25,11 @@ PODS:
|
|||||||
- Capacitor
|
- Capacitor
|
||||||
- CordovaPlugins (6.2.1):
|
- CordovaPlugins (6.2.1):
|
||||||
- CapacitorCordova
|
- CapacitorCordova
|
||||||
- Realm (10.47.0):
|
- Realm (10.54.4):
|
||||||
- Realm/Headers (= 10.47.0)
|
- Realm/Headers (= 10.54.4)
|
||||||
- Realm/Headers (10.47.0)
|
- Realm/Headers (10.54.4)
|
||||||
- RealmSwift (10.47.0):
|
- RealmSwift (10.54.4):
|
||||||
- Realm (= 10.47.0)
|
- Realm (= 10.54.4)
|
||||||
- WebnativellcCapacitorFilesharer (7.0.4):
|
- WebnativellcCapacitorFilesharer (7.0.4):
|
||||||
- Capacitor
|
- Capacitor
|
||||||
|
|
||||||
@@ -88,7 +88,7 @@ EXTERNAL SOURCES:
|
|||||||
:path: "../../node_modules/@webnativellc/capacitor-filesharer"
|
:path: "../../node_modules/@webnativellc/capacitor-filesharer"
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
Alamofire: 3ca42e259043ee0dc5c0cdd76c4bc568b8e42af7
|
Alamofire: 7193b3b92c74a07f85569e1a6c4f4237291e7496
|
||||||
Capacitor: 106e7a4205f4618d582b886a975657c61179138d
|
Capacitor: 106e7a4205f4618d582b886a975657c61179138d
|
||||||
CapacitorApp: d63334c052278caf5d81585d80b21905c6f93f39
|
CapacitorApp: d63334c052278caf5d81585d80b21905c6f93f39
|
||||||
CapacitorBrowser: 081852cf532acf77b9d2953f3a88fe5b9711fb06
|
CapacitorBrowser: 081852cf532acf77b9d2953f3a88fe5b9711fb06
|
||||||
@@ -102,10 +102,10 @@ SPEC CHECKSUMS:
|
|||||||
CapacitorPreferences: cbf154e5e5519b7f5ab33817a334dda1e98387f9
|
CapacitorPreferences: cbf154e5e5519b7f5ab33817a334dda1e98387f9
|
||||||
CapacitorStatusBar: 275cbf2f4dfc00388f519ef80c7ec22edda342c9
|
CapacitorStatusBar: 275cbf2f4dfc00388f519ef80c7ec22edda342c9
|
||||||
CordovaPlugins: 5a72a85b45469e68556bb172409f1b6d57b27236
|
CordovaPlugins: 5a72a85b45469e68556bb172409f1b6d57b27236
|
||||||
Realm: e43fb540ae947497e3ea8a662443256920602060
|
Realm: 8b5cda39a41f17a1734da2f39c6004eb8745587a
|
||||||
RealmSwift: 8b06ed06b5d16749ae0c4d91c0cba414a9e28189
|
RealmSwift: 0b4f808fed6898f1f6c26f501f740efd80dff0b4
|
||||||
WebnativellcCapacitorFilesharer: 10b111373d4dc49608935600dcbcc14605258c73
|
WebnativellcCapacitorFilesharer: 10b111373d4dc49608935600dcbcc14605258c73
|
||||||
|
|
||||||
PODFILE CHECKSUM: 498821c0cfa2508609567fa95d7244c01cbef538
|
PODFILE CHECKSUM: 498821c0cfa2508609567fa95d7244c01cbef538
|
||||||
|
|
||||||
COCOAPODS: 1.12.1
|
COCOAPODS: 1.16.2
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ class ServerConnectionConfig: Object {
|
|||||||
@Persisted(indexed: true) var index: Int = 1
|
@Persisted(indexed: true) var index: Int = 1
|
||||||
@Persisted var name: String = ""
|
@Persisted var name: String = ""
|
||||||
@Persisted var address: String = ""
|
@Persisted var address: String = ""
|
||||||
|
@Persisted var version: String = ""
|
||||||
@Persisted var userId: String = ""
|
@Persisted var userId: String = ""
|
||||||
@Persisted var username: String = ""
|
@Persisted var username: String = ""
|
||||||
@Persisted var token: String = ""
|
@Persisted var token: String = ""
|
||||||
@@ -29,6 +30,7 @@ func convertServerConnectionConfigToJSON(config: ServerConnectionConfig) -> Dict
|
|||||||
"name": config.name,
|
"name": config.name,
|
||||||
"index": config.index,
|
"index": config.index,
|
||||||
"address": config.address,
|
"address": config.address,
|
||||||
|
"version": config.version,
|
||||||
"userId": config.userId,
|
"userId": config.userId,
|
||||||
"username": config.username,
|
"username": config.username,
|
||||||
"token": config.token,
|
"token": config.token,
|
||||||
|
|||||||
@@ -561,8 +561,15 @@ class AudioPlayer: NSObject {
|
|||||||
guard let playbackSession = self.getPlaybackSession() else { return nil }
|
guard let playbackSession = self.getPlaybackSession() else { return nil }
|
||||||
|
|
||||||
if (playbackSession.playMethod == PlayMethod.directplay.rawValue) {
|
if (playbackSession.playMethod == PlayMethod.directplay.rawValue) {
|
||||||
let urlstr = "\(Store.serverConfig!.address)/api/items/\(itemId)/file/\(ino)?token=\(Store.serverConfig!.token)"
|
// As of v2.22.0 tracks use a different endpoint
|
||||||
let url = URL(string: urlstr)!
|
// See: https://github.com/advplyr/audiobookshelf/pull/4263
|
||||||
|
let contentUrl: String
|
||||||
|
if Store.isServerVersionGreaterThanOrEqualTo("2.22.0") {
|
||||||
|
contentUrl = "\(Store.serverConfig!.address)/public/session/\(playbackSession.id)/track/\(track.index ?? 1)"
|
||||||
|
} else {
|
||||||
|
contentUrl = "\(Store.serverConfig!.address)/api/items/\(itemId)/file/\(ino)?token=\(Store.serverConfig!.token)"
|
||||||
|
}
|
||||||
|
let url = URL(string: contentUrl)!
|
||||||
return AVURLAsset(url: url)
|
return AVURLAsset(url: url)
|
||||||
} else if (playbackSession.playMethod == PlayMethod.local.rawValue) {
|
} else if (playbackSession.playMethod == PlayMethod.local.rawValue) {
|
||||||
guard let localFile = track.getLocalFile() else {
|
guard let localFile = track.getLocalFile() else {
|
||||||
@@ -577,7 +584,9 @@ class AudioPlayer: NSObject {
|
|||||||
let headers: [String: String] = [
|
let headers: [String: String] = [
|
||||||
"Authorization": "Bearer \(Store.serverConfig!.token)"
|
"Authorization": "Bearer \(Store.serverConfig!.token)"
|
||||||
]
|
]
|
||||||
return AVURLAsset(url: URL(string: "\(Store.serverConfig!.address)\(track.contentUrl ?? "")")!, options: ["AVURLAssetHTTPHeaderFieldsKey": headers])
|
|
||||||
|
let contentUrl = "\(Store.serverConfig!.address)\(track.contentUrl ?? "")"
|
||||||
|
return AVURLAsset(url: URL(string: contentUrl)!, options: ["AVURLAssetHTTPHeaderFieldsKey": headers])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import Alamofire
|
|||||||
|
|
||||||
class ApiClient {
|
class ApiClient {
|
||||||
private static let logger = AppLogger(category: "ApiClient")
|
private static let logger = AppLogger(category: "ApiClient")
|
||||||
|
private static let secureStorage = SecureStorage()
|
||||||
|
|
||||||
public static func getData(from url: URL, completion: @escaping (UIImage?) -> Void) {
|
public static func getData(from url: URL, completion: @escaping (UIImage?) -> Void) {
|
||||||
URLSession.shared.dataTask(with: url, completionHandler: {(data, response, error) in
|
URLSession.shared.dataTask(with: url, completionHandler: {(data, response, error) in
|
||||||
@@ -146,6 +147,318 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Token Refresh Handling
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles token refresh when a 401 Unauthorized response is received
|
||||||
|
* This function will:
|
||||||
|
* 1. Get the refresh token from secure storage for the current server connection
|
||||||
|
* 2. Make a request to /auth/refresh endpoint with the refresh token
|
||||||
|
* 3. Update the connection config with the new accessToken and put the refreshToken in secure storage
|
||||||
|
* 4. Retry the original request with the new access token
|
||||||
|
* 5. If refresh fails, handle logout
|
||||||
|
*/
|
||||||
|
private static func handleTokenRefresh<T: Decodable>(originalRequest: DataRequest, endpoint: String, method: HTTPMethod, parameters: Any?, decodable: T.Type, callback: ((_ param: T?) -> Void)?) {
|
||||||
|
guard let serverConfig = Store.serverConfig else {
|
||||||
|
logger.error("handleTokenRefresh: No server config available")
|
||||||
|
callback?(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.log("handleTokenRefresh: Attempting to refresh auth tokens for server \(serverConfig.name)")
|
||||||
|
|
||||||
|
// Get refresh token from secure storage
|
||||||
|
guard let refreshToken = secureStorage.getRefreshToken(serverConnectionConfigId: serverConfig.id) else {
|
||||||
|
logger.error("handleTokenRefresh: No refresh token available for server \(serverConfig.name)")
|
||||||
|
handleRefreshFailure()
|
||||||
|
callback?(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.log("handleTokenRefresh: Retrieved refresh token, attempting to refresh access token")
|
||||||
|
|
||||||
|
// Create refresh token request
|
||||||
|
let refreshHeaders: HTTPHeaders = [
|
||||||
|
"x-refresh-token": refreshToken,
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
]
|
||||||
|
|
||||||
|
let refreshRequest = AF.request("\(serverConfig.address)/auth/refresh", method: .post, headers: refreshHeaders)
|
||||||
|
|
||||||
|
refreshRequest.responseDecodable(of: RefreshResponse.self) { response in
|
||||||
|
switch response.result {
|
||||||
|
case .success(let refreshResponse):
|
||||||
|
guard let user = refreshResponse.user,
|
||||||
|
!user.accessToken.isEmpty else {
|
||||||
|
logger.error("handleTokenRefresh: No access token in refresh response for server \(serverConfig.name)")
|
||||||
|
handleRefreshFailure()
|
||||||
|
callback?(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.log("handleTokenRefresh: Successfully obtained new access token")
|
||||||
|
|
||||||
|
// Update tokens in secure storage and store
|
||||||
|
updateTokens(newAccessToken: user.accessToken, newRefreshToken: user.refreshToken ?? refreshToken, serverConnectionConfigId: serverConfig.id)
|
||||||
|
|
||||||
|
// Retry the original request with the new access token
|
||||||
|
logger.log("handleTokenRefresh: Retrying original request with new token")
|
||||||
|
retryOriginalRequest(endpoint: endpoint, method: method, parameters: parameters, decodable: decodable, newAccessToken: user.accessToken, callback: callback)
|
||||||
|
|
||||||
|
case .failure(let error):
|
||||||
|
logger.error("handleTokenRefresh: Refresh request failed for server \(serverConfig.name): \(error)")
|
||||||
|
handleRefreshFailure()
|
||||||
|
callback?(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the stored tokens with new access and refresh tokens
|
||||||
|
*/
|
||||||
|
private static func updateTokens(newAccessToken: String, newRefreshToken: String, serverConnectionConfigId: String) {
|
||||||
|
// Update the refresh token in secure storage if it's new
|
||||||
|
if newRefreshToken != secureStorage.getRefreshToken(serverConnectionConfigId: serverConnectionConfigId) {
|
||||||
|
let hasStored = secureStorage.storeRefreshToken(serverConnectionConfigId: serverConnectionConfigId, refreshToken: newRefreshToken)
|
||||||
|
logger.log("updateTokens: Updated refresh token in secure storage. Stored=\(hasStored)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update access token on server connection config
|
||||||
|
Database.shared.updateServerConnectionConfigToken(newToken: newAccessToken)
|
||||||
|
logger.log("updateTokens: Updated access token in server connection config")
|
||||||
|
|
||||||
|
logger.log("updateTokens: Successfully refreshed auth tokens for server \(Store.serverConfig?.name ?? "unknown")")
|
||||||
|
|
||||||
|
// Notify webview frontend about token refresh
|
||||||
|
if let callback = AbsDatabase.tokenRefreshCallback {
|
||||||
|
let tokenData: [String: Any] = ["accessToken": newAccessToken]
|
||||||
|
callback("onTokenRefresh", tokenData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retries the original request with the new access token
|
||||||
|
*/
|
||||||
|
private static func retryOriginalRequest<T: Decodable>(endpoint: String, method: HTTPMethod, parameters: Any?, decodable: T.Type, newAccessToken: String, callback: ((_ param: T?) -> Void)?) {
|
||||||
|
guard let serverConfig = Store.serverConfig else {
|
||||||
|
logger.error("retryOriginalRequest: No server config available")
|
||||||
|
callback?(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let headers: HTTPHeaders = [
|
||||||
|
"Authorization": "Bearer \(newAccessToken)"
|
||||||
|
]
|
||||||
|
|
||||||
|
let retryRequest: DataRequest
|
||||||
|
|
||||||
|
switch method {
|
||||||
|
case .get:
|
||||||
|
retryRequest = AF.request("\(serverConfig.address)/\(endpoint)", method: .get, headers: headers)
|
||||||
|
case .post:
|
||||||
|
if let parameters = parameters as? [String: Any] {
|
||||||
|
retryRequest = AF.request("\(serverConfig.address)/\(endpoint)", method: .post, parameters: parameters, encoding: JSONEncoding.default, headers: headers)
|
||||||
|
} else if let encodableParams = parameters as? Encodable {
|
||||||
|
retryRequest = AF.request("\(serverConfig.address)/\(endpoint)", method: .post, parameters: encodableParams, encoder: JSONParameterEncoder.default, headers: headers)
|
||||||
|
} else {
|
||||||
|
retryRequest = AF.request("\(serverConfig.address)/\(endpoint)", method: .post, headers: headers)
|
||||||
|
}
|
||||||
|
case .patch:
|
||||||
|
if let encodableParams = parameters as? Encodable {
|
||||||
|
retryRequest = AF.request("\(serverConfig.address)/\(endpoint)", method: .patch, parameters: encodableParams, encoder: JSONParameterEncoder.default, headers: headers)
|
||||||
|
} else {
|
||||||
|
retryRequest = AF.request("\(serverConfig.address)/\(endpoint)", method: .patch, headers: headers)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
logger.error("retryOriginalRequest: Unsupported method \(method)")
|
||||||
|
callback?(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle the response
|
||||||
|
retryRequest.response { response in
|
||||||
|
if let statusCode = response.response?.statusCode, (200...299).contains(statusCode) {
|
||||||
|
// Check if response has data
|
||||||
|
if let data = response.data, !data.isEmpty {
|
||||||
|
// If it is a string return nil (e.g. express returns OK for 200 status codes)
|
||||||
|
if let responseString = String(data: data, encoding: .utf8) {
|
||||||
|
logger.log("retryOriginalRequest: Got string response '\(responseString)'")
|
||||||
|
callback?(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not a string, try JSON
|
||||||
|
do {
|
||||||
|
let decodedObject = try JSONDecoder().decode(decodable, from: data)
|
||||||
|
callback?(decodedObject)
|
||||||
|
} catch {
|
||||||
|
logger.error("retryOriginalRequest: JSON decode failed: \(error)")
|
||||||
|
callback?(nil)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Empty response
|
||||||
|
logger.log("retryOriginalRequest: Empty response with success status \(statusCode)")
|
||||||
|
callback?(nil)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.error("retryOriginalRequest: Request failed with status \(response.response?.statusCode ?? 0)")
|
||||||
|
callback?(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the case when token refresh fails
|
||||||
|
* This will clear the current server connection and notify webview
|
||||||
|
*/
|
||||||
|
private static func handleRefreshFailure() {
|
||||||
|
logger.log("handleRefreshFailure: Token refresh failed, clearing session")
|
||||||
|
|
||||||
|
// Clear the current server connection
|
||||||
|
Store.serverConfig = nil
|
||||||
|
|
||||||
|
// Remove refresh token from secure storage
|
||||||
|
if let serverConfig = Store.serverConfig {
|
||||||
|
_ = secureStorage.removeRefreshToken(serverConnectionConfigId: serverConfig.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify webview frontend about token refresh failure
|
||||||
|
if let callback = AbsDatabase.tokenRefreshCallback {
|
||||||
|
callback("onTokenRefreshFailure", ["error": "Token refresh failed"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Enhanced API Methods with Token Refresh
|
||||||
|
|
||||||
|
public static func getResourceWithTokenRefresh<T: Decodable>(endpoint: String, decodable: T.Type = T.self, callback: ((_ param: T?) -> Void)?) {
|
||||||
|
if (Store.serverConfig == nil) {
|
||||||
|
logger.error("Server config not set")
|
||||||
|
callback?(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let headers: HTTPHeaders = [
|
||||||
|
"Authorization": "Bearer \(Store.serverConfig!.token)"
|
||||||
|
]
|
||||||
|
|
||||||
|
let request = AF.request("\(Store.serverConfig!.address)/\(endpoint)", method: .get, headers: headers)
|
||||||
|
|
||||||
|
request.responseDecodable(of: decodable) { response in
|
||||||
|
if let statusCode = response.response?.statusCode, statusCode == 401 {
|
||||||
|
logger.log("getResourceWithTokenRefresh: 401 Unauthorized for request to \(endpoint) - attempting token refresh")
|
||||||
|
handleTokenRefresh(originalRequest: request, endpoint: endpoint, method: .get, parameters: nil, decodable: decodable, callback: callback)
|
||||||
|
} else {
|
||||||
|
switch response.result {
|
||||||
|
case .success(let obj):
|
||||||
|
callback?(obj)
|
||||||
|
case .failure(let error):
|
||||||
|
logger.error("api request to \(endpoint) failed")
|
||||||
|
print(error)
|
||||||
|
callback?(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func postResourceWithTokenRefresh<T: Encodable, U: Decodable>(endpoint: String, parameters: T, decodable: U.Type = U.self, callback: ((_ param: U?) -> Void)?) {
|
||||||
|
if (Store.serverConfig == nil) {
|
||||||
|
logger.error("Server config not set")
|
||||||
|
callback?(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let headers: HTTPHeaders = [
|
||||||
|
"Authorization": "Bearer \(Store.serverConfig!.token)"
|
||||||
|
]
|
||||||
|
|
||||||
|
let request = AF.request("\(Store.serverConfig!.address)/\(endpoint)", method: .post, parameters: parameters, encoder: JSONParameterEncoder.default, headers: headers)
|
||||||
|
|
||||||
|
request.responseDecodable(of: decodable) { response in
|
||||||
|
if let statusCode = response.response?.statusCode, statusCode == 401 {
|
||||||
|
logger.log("postResourceWithTokenRefresh: 401 Unauthorized for request to \(endpoint) - attempting token refresh")
|
||||||
|
handleTokenRefresh(originalRequest: request, endpoint: endpoint, method: .post, parameters: parameters, decodable: decodable, callback: callback)
|
||||||
|
} else {
|
||||||
|
switch response.result {
|
||||||
|
case .success(let obj):
|
||||||
|
callback?(obj)
|
||||||
|
case .failure(let error):
|
||||||
|
logger.error("api request to \(endpoint) failed")
|
||||||
|
print(error)
|
||||||
|
callback?(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST request for endpoints that only return success/failure
|
||||||
|
*/
|
||||||
|
public static func postResourceWithTokenRefresh<T: Encodable>(endpoint: String, parameters: T, callback: ((_ success: Bool) -> Void)?) {
|
||||||
|
if (Store.serverConfig == nil) {
|
||||||
|
logger.error("Server config not set")
|
||||||
|
callback?(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let headers: HTTPHeaders = [
|
||||||
|
"Authorization": "Bearer \(Store.serverConfig!.token)"
|
||||||
|
]
|
||||||
|
|
||||||
|
let request = AF.request("\(Store.serverConfig!.address)/\(endpoint)", method: .post, parameters: parameters, encoder: JSONParameterEncoder.default, headers: headers)
|
||||||
|
|
||||||
|
request.response { response in
|
||||||
|
if let statusCode = response.response?.statusCode, statusCode == 401 {
|
||||||
|
logger.log("postResourceWithTokenRefresh: 401 Unauthorized for request to \(endpoint) - attempting token refresh")
|
||||||
|
handleTokenRefresh(originalRequest: request, endpoint: endpoint, method: .post, parameters: parameters, decodable: EmptyResponse.self) { result in
|
||||||
|
callback?(result != nil)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
switch response.result {
|
||||||
|
case .success(_):
|
||||||
|
callback?(true)
|
||||||
|
case .failure(let error):
|
||||||
|
logger.error("api request to \(endpoint) failed")
|
||||||
|
print(error)
|
||||||
|
callback?(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func patchResourceWithTokenRefresh<T: Encodable>(endpoint: String, parameters: T, callback: ((_ success: Bool) -> Void)?) {
|
||||||
|
if (Store.serverConfig == nil) {
|
||||||
|
logger.error("Server config not set")
|
||||||
|
callback?(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let headers: HTTPHeaders = [
|
||||||
|
"Authorization": "Bearer \(Store.serverConfig!.token)"
|
||||||
|
]
|
||||||
|
|
||||||
|
let request = AF.request("\(Store.serverConfig!.address)/\(endpoint)", method: .patch, parameters: parameters, encoder: JSONParameterEncoder.default, headers: headers)
|
||||||
|
|
||||||
|
request.response { response in
|
||||||
|
if let statusCode = response.response?.statusCode, statusCode == 401 {
|
||||||
|
logger.log("patchResourceWithTokenRefresh: 401 Unauthorized for request to \(endpoint) - attempting token refresh")
|
||||||
|
handleTokenRefresh(originalRequest: request, endpoint: endpoint, method: .patch, parameters: parameters, decodable: EmptyResponse.self) { result in
|
||||||
|
callback?(result != nil)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
switch response.result {
|
||||||
|
case .success(_):
|
||||||
|
callback?(true)
|
||||||
|
case .failure(let error):
|
||||||
|
logger.error("api request to \(endpoint) failed")
|
||||||
|
print(error)
|
||||||
|
callback?(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - API Functions
|
||||||
|
|
||||||
public static func startPlaybackSession(libraryItemId: String, episodeId: String?, forceTranscode:Bool, callback: @escaping (_ param: PlaybackSession) -> Void) {
|
public static func startPlaybackSession(libraryItemId: String, episodeId: String?, forceTranscode:Bool, callback: @escaping (_ param: PlaybackSession) -> Void) {
|
||||||
var endpoint = "api/items/\(libraryItemId)/play"
|
var endpoint = "api/items/\(libraryItemId)/play"
|
||||||
if episodeId != nil {
|
if episodeId != nil {
|
||||||
@@ -160,20 +473,28 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let parameters: [String: Any] = [
|
// Create an Encodable struct for the parameters
|
||||||
"forceDirectPlay": !forceTranscode ? "1" : "",
|
let parameters = PlaybackSessionRequest(
|
||||||
"forceTranscode": forceTranscode ? "1" : "",
|
forceDirectPlay: !forceTranscode ? "1" : "",
|
||||||
"mediaPlayer": "AVPlayer",
|
forceTranscode: forceTranscode ? "1" : "",
|
||||||
"deviceInfo": [
|
mediaPlayer: "AVPlayer",
|
||||||
"deviceId": UIDevice.current.identifierForVendor?.uuidString,
|
deviceInfo: DeviceInfo(
|
||||||
"manufacturer": "Apple",
|
deviceId: UIDevice.current.identifierForVendor?.uuidString,
|
||||||
"model": modelCode,
|
manufacturer: "Apple",
|
||||||
"clientVersion": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
|
model: modelCode,
|
||||||
]
|
clientVersion: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
|
||||||
]
|
)
|
||||||
ApiClient.postResource(endpoint: endpoint, parameters: parameters, decodable: PlaybackSession.self) { obj in
|
)
|
||||||
let session = obj
|
|
||||||
|
// Use the new token refresh-enabled method
|
||||||
|
postResourceWithTokenRefresh(endpoint: endpoint, parameters: parameters, decodable: PlaybackSession.self) { session in
|
||||||
|
guard let session = session else {
|
||||||
|
logger.error("startPlaybackSession: Failed to create playback session")
|
||||||
|
callback(PlaybackSession()) // Return empty session on failure
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set server connection info on the session
|
||||||
session.serverConnectionConfigId = Store.serverConfig!.id
|
session.serverConnectionConfigId = Store.serverConfig!.id
|
||||||
session.serverAddress = Store.serverConfig!.address
|
session.serverAddress = Store.serverConfig!.address
|
||||||
|
|
||||||
@@ -182,15 +503,28 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static func reportPlaybackProgress(report: PlaybackReport, sessionId: String) async -> Bool {
|
public static func reportPlaybackProgress(report: PlaybackReport, sessionId: String) async -> Bool {
|
||||||
return await postResource(endpoint: "api/session/\(sessionId)/sync", parameters: report)
|
return await withCheckedContinuation { continuation in
|
||||||
|
postResourceWithTokenRefresh(endpoint: "api/session/\(sessionId)/sync", parameters: report) { success in
|
||||||
|
continuation.resume(returning: success)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func reportLocalPlaybackProgress(_ session: PlaybackSession) async -> Bool {
|
public static func reportLocalPlaybackProgress(_ session: PlaybackSession) async -> Bool {
|
||||||
return await postResource(endpoint: "api/session/local", parameters: session)
|
return await withCheckedContinuation { continuation in
|
||||||
|
postResourceWithTokenRefresh(endpoint: "api/session/local", parameters: session) { success in
|
||||||
|
continuation.resume(returning: success)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func reportAllLocalPlaybackSessions(_ sessions: [PlaybackSession]) async -> Bool {
|
public static func reportAllLocalPlaybackSessions(_ sessions: [PlaybackSession]) async -> Bool {
|
||||||
return await postResource(endpoint: "api/session/local-all", parameters: LocalPlaybackSessionSyncAllPayload(sessions: sessions, deviceInfo: sessions.first?.deviceInfo))
|
return await withCheckedContinuation { continuation in
|
||||||
|
let payload = LocalPlaybackSessionSyncAllPayload(sessions: sessions, deviceInfo: sessions.first?.deviceInfo)
|
||||||
|
postResourceWithTokenRefresh(endpoint: "api/session/local-all", parameters: payload) { success in
|
||||||
|
continuation.resume(returning: success)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func syncLocalSessionsWithServer(isFirstSync: Bool) async {
|
public static func syncLocalSessionsWithServer(isFirstSync: Bool) async {
|
||||||
@@ -257,7 +591,7 @@ class ApiClient {
|
|||||||
public static func updateMediaProgress<T:Encodable>(libraryItemId: String, episodeId: String?, payload: T, callback: @escaping () -> Void) {
|
public static func updateMediaProgress<T:Encodable>(libraryItemId: String, episodeId: String?, payload: T, callback: @escaping () -> Void) {
|
||||||
logger.log("updateMediaProgress \(libraryItemId) \(episodeId ?? "NIL") \(payload)")
|
logger.log("updateMediaProgress \(libraryItemId) \(episodeId ?? "NIL") \(payload)")
|
||||||
let endpoint = episodeId?.isEmpty ?? true ? "api/me/progress/\(libraryItemId)" : "api/me/progress/\(libraryItemId)/\(episodeId ?? "")"
|
let endpoint = episodeId?.isEmpty ?? true ? "api/me/progress/\(libraryItemId)" : "api/me/progress/\(libraryItemId)/\(episodeId ?? "")"
|
||||||
patchResource(endpoint: endpoint, parameters: payload) { success in
|
patchResourceWithTokenRefresh(endpoint: endpoint, parameters: payload) { _ in
|
||||||
callback()
|
callback()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -265,21 +599,29 @@ class ApiClient {
|
|||||||
public static func getMediaProgress(libraryItemId: String, episodeId: String?) async -> MediaProgress? {
|
public static func getMediaProgress(libraryItemId: String, episodeId: String?) async -> MediaProgress? {
|
||||||
logger.log("getMediaProgress \(libraryItemId) \(episodeId ?? "NIL")")
|
logger.log("getMediaProgress \(libraryItemId) \(episodeId ?? "NIL")")
|
||||||
let endpoint = episodeId?.isEmpty ?? true ? "api/me/progress/\(libraryItemId)" : "api/me/progress/\(libraryItemId)/\(episodeId ?? "")"
|
let endpoint = episodeId?.isEmpty ?? true ? "api/me/progress/\(libraryItemId)" : "api/me/progress/\(libraryItemId)/\(episodeId ?? "")"
|
||||||
return await getResource(endpoint: endpoint, decodable: MediaProgress.self)
|
return await withCheckedContinuation { continuation in
|
||||||
|
getResourceWithTokenRefresh(endpoint: endpoint, decodable: MediaProgress.self) { result in
|
||||||
|
continuation.resume(returning: result)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func getCurrentUser() async -> User? {
|
public static func getCurrentUser() async -> User? {
|
||||||
logger.log("getCurrentUser")
|
logger.log("getCurrentUser")
|
||||||
return await getResource(endpoint: "api/me", decodable: User.self)
|
return await withCheckedContinuation { continuation in
|
||||||
|
getResourceWithTokenRefresh(endpoint: "api/me", decodable: User.self) { result in
|
||||||
|
continuation.resume(returning: result)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func getLibraryItemWithProgress(libraryItemId:String, episodeId:String?, callback: @escaping (_ param: LibraryItem?) -> Void) {
|
public static func getLibraryItemWithProgress(libraryItemId: String, episodeId: String?, callback: @escaping (_ param: LibraryItem?) -> Void) {
|
||||||
var endpoint = "api/items/\(libraryItemId)?expanded=1&include=progress"
|
var endpoint = "api/items/\(libraryItemId)?expanded=1&include=progress"
|
||||||
if episodeId != nil {
|
if episodeId != nil {
|
||||||
endpoint += "&episodeId=\(episodeId!)"
|
endpoint += "&episodeId=\(episodeId!)"
|
||||||
}
|
}
|
||||||
|
|
||||||
ApiClient.getResource(endpoint: endpoint, decodable: LibraryItem.self) { obj in
|
getResourceWithTokenRefresh(endpoint: endpoint, decodable: LibraryItem.self) { obj in
|
||||||
callback(obj)
|
callback(obj)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -338,3 +680,30 @@ struct Connectivity {
|
|||||||
return self.sharedInstance.isReachable
|
return self.sharedInstance.isReachable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Response Models
|
||||||
|
|
||||||
|
struct RefreshResponse: Decodable {
|
||||||
|
let user: RefreshUser?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RefreshUser: Decodable {
|
||||||
|
let accessToken: String
|
||||||
|
let refreshToken: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct EmptyResponse: Decodable {}
|
||||||
|
|
||||||
|
struct PlaybackSessionRequest: Encodable {
|
||||||
|
let forceDirectPlay: String
|
||||||
|
let forceTranscode: String
|
||||||
|
let mediaPlayer: String
|
||||||
|
let deviceInfo: DeviceInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DeviceInfo: Encodable {
|
||||||
|
let deviceId: String?
|
||||||
|
let manufacturer: String
|
||||||
|
let model: String?
|
||||||
|
let clientVersion: String?
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ class Database {
|
|||||||
try existing.update {
|
try existing.update {
|
||||||
existing.name = config.name
|
existing.name = config.name
|
||||||
existing.address = config.address
|
existing.address = config.address
|
||||||
|
existing.version = config.version
|
||||||
existing.userId = config.userId
|
existing.userId = config.userId
|
||||||
existing.username = config.username
|
existing.username = config.username
|
||||||
existing.token = config.token
|
existing.token = config.token
|
||||||
@@ -60,6 +61,19 @@ class Database {
|
|||||||
setLastActiveConfigIndex(index: config.index)
|
setLastActiveConfigIndex(index: config.index)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func updateServerConnectionConfigToken(newToken: String) {
|
||||||
|
do {
|
||||||
|
let realm = try Realm()
|
||||||
|
if let config = realm.objects(ServerConnectionConfig.self).first(where: { $0.index == getLastActiveConfigIndex() }) {
|
||||||
|
try realm.write {
|
||||||
|
config.token = newToken
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
debugPrint("Failed to update server connection config token: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public func deleteServerConnectionConfig(id: String) {
|
public func deleteServerConnectionConfig(id: String) {
|
||||||
let realm = try! Realm()
|
let realm = try! Realm()
|
||||||
|
|||||||
@@ -22,8 +22,16 @@ struct NowPlayingMetadata {
|
|||||||
return item.coverUrl
|
return item.coverUrl
|
||||||
} else {
|
} else {
|
||||||
guard let config = Store.serverConfig else { return nil }
|
guard let config = Store.serverConfig else { return nil }
|
||||||
guard let url = URL(string: "\(config.address)/api/items/\(itemId)/cover?token=\(config.token)") else { return nil }
|
|
||||||
return url
|
// As of v2.17.0 token is not needed with cover image requests
|
||||||
|
let coverUrlString: String
|
||||||
|
if Store.isServerVersionGreaterThanOrEqualTo("2.17.0") {
|
||||||
|
coverUrlString = "\(config.address)/api/items/\(itemId)/cover"
|
||||||
|
} else {
|
||||||
|
coverUrlString = "\(config.address)/api/items/\(itemId)/cover?token=\(config.token)"
|
||||||
|
}
|
||||||
|
|
||||||
|
return URL(string: coverUrlString)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
//
|
||||||
|
// SecureStorage.swift
|
||||||
|
// App
|
||||||
|
//
|
||||||
|
// Created by advplyr on 2025-07-05
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Security
|
||||||
|
|
||||||
|
class SecureStorage {
|
||||||
|
private static let tag = "SecureStorage"
|
||||||
|
private static let serviceName = "AudiobookshelfRefreshTokens"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypts and stores a refresh token for a specific server connection
|
||||||
|
*/
|
||||||
|
func storeRefreshToken(serverConnectionConfigId: String, refreshToken: String) -> Bool {
|
||||||
|
let key = "refresh_token_\(serverConnectionConfigId)"
|
||||||
|
|
||||||
|
guard let data = refreshToken.data(using: .utf8) else {
|
||||||
|
print("\(Self.tag): Failed to convert refresh token to data")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: Self.serviceName,
|
||||||
|
kSecAttrAccount as String: key,
|
||||||
|
kSecValueData as String: data,
|
||||||
|
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock
|
||||||
|
]
|
||||||
|
|
||||||
|
// First, try to delete any existing item
|
||||||
|
SecItemDelete(query as CFDictionary)
|
||||||
|
|
||||||
|
// Then add the new item
|
||||||
|
let status = SecItemAdd(query as CFDictionary, nil)
|
||||||
|
|
||||||
|
if status == errSecSuccess {
|
||||||
|
print("\(Self.tag): Successfully stored encrypted refresh token for server: \(serverConnectionConfigId)")
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
print("\(Self.tag): Failed to store refresh token for server: \(serverConnectionConfigId), status: \(status)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves and decrypts a refresh token for a specific server connection
|
||||||
|
*/
|
||||||
|
func getRefreshToken(serverConnectionConfigId: String) -> String? {
|
||||||
|
let key = "refresh_token_\(serverConnectionConfigId)"
|
||||||
|
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: Self.serviceName,
|
||||||
|
kSecAttrAccount as String: key,
|
||||||
|
kSecReturnData as String: true,
|
||||||
|
kSecMatchLimit as String: kSecMatchLimitOne
|
||||||
|
]
|
||||||
|
|
||||||
|
var result: AnyObject?
|
||||||
|
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||||
|
|
||||||
|
if status == errSecSuccess,
|
||||||
|
let data = result as? Data,
|
||||||
|
let refreshToken = String(data: data, encoding: .utf8) {
|
||||||
|
return refreshToken
|
||||||
|
} else {
|
||||||
|
print("\(Self.tag): Failed to retrieve refresh token for server: \(serverConnectionConfigId), status: \(status)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a refresh token for a specific server connection
|
||||||
|
*/
|
||||||
|
func removeRefreshToken(serverConnectionConfigId: String) -> Bool {
|
||||||
|
let key = "refresh_token_\(serverConnectionConfigId)"
|
||||||
|
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: Self.serviceName,
|
||||||
|
kSecAttrAccount as String: key
|
||||||
|
]
|
||||||
|
|
||||||
|
let status = SecItemDelete(query as CFDictionary)
|
||||||
|
|
||||||
|
if status == errSecSuccess || status == errSecItemNotFound {
|
||||||
|
print("\(Self.tag): Successfully removed refresh token for server: \(serverConnectionConfigId)")
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
print("\(Self.tag): Failed to remove refresh token for server: \(serverConnectionConfigId), status: \(status)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a refresh token exists for a specific server connection
|
||||||
|
*/
|
||||||
|
func hasRefreshToken(serverConnectionConfigId: String) -> Bool {
|
||||||
|
let key = "refresh_token_\(serverConnectionConfigId)"
|
||||||
|
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: Self.serviceName,
|
||||||
|
kSecAttrAccount as String: key,
|
||||||
|
kSecReturnData as String: false,
|
||||||
|
kSecMatchLimit as String: kSecMatchLimitOne
|
||||||
|
]
|
||||||
|
|
||||||
|
let status = SecItemCopyMatching(query as CFDictionary, nil)
|
||||||
|
return status == errSecSuccess
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,4 +29,46 @@ class Store {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the currently connected server version is >= compareVersion
|
||||||
|
* Abs server only uses major.minor.patch
|
||||||
|
* Note: Version is returned in Abs auth payloads starting v2.6.0
|
||||||
|
* Note: Version is saved with the server connection config starting after v0.9.81
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* serverVersion=2.25.1
|
||||||
|
* isServerVersionGreaterThanOrEqualTo("2.26.0") = false
|
||||||
|
*
|
||||||
|
* serverVersion=2.26.1
|
||||||
|
* isServerVersionGreaterThanOrEqualTo("2.26.0") = true
|
||||||
|
*/
|
||||||
|
public static func isServerVersionGreaterThanOrEqualTo(_ compareVersion: String) -> Bool {
|
||||||
|
guard let serverConfig = serverConfig, !serverConfig.version.isEmpty else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if compareVersion.isEmpty {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
let serverVersionParts = serverConfig.version.split(separator: ".").compactMap { Int($0) }
|
||||||
|
let compareVersionParts = compareVersion.split(separator: ".").compactMap { Int($0) }
|
||||||
|
|
||||||
|
// Compare major, minor, and patch components
|
||||||
|
let maxLength = max(serverVersionParts.count, compareVersionParts.count)
|
||||||
|
|
||||||
|
for i in 0..<maxLength {
|
||||||
|
let serverVersionComponent = i < serverVersionParts.count ? serverVersionParts[i] : 0
|
||||||
|
let compareVersionComponent = i < compareVersionParts.count ? compareVersionParts[i] : 0
|
||||||
|
|
||||||
|
if serverVersionComponent < compareVersionComponent {
|
||||||
|
return false // Server version is less than compareVersion
|
||||||
|
} else if serverVersionComponent > compareVersionComponent {
|
||||||
|
return true // Server version is greater than compareVersion
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true // versions are equal in major, minor, and patch
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+48
-28
@@ -16,6 +16,7 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { CapacitorHttp } from '@capacitor/core'
|
import { CapacitorHttp } from '@capacitor/core'
|
||||||
|
import { AbsLogger } from '@/plugins/capacitor'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
data() {
|
data() {
|
||||||
@@ -72,6 +73,9 @@ export default {
|
|||||||
currentLibraryId() {
|
currentLibraryId() {
|
||||||
return this.$store.state.libraries.currentLibraryId
|
return this.$store.state.libraries.currentLibraryId
|
||||||
},
|
},
|
||||||
|
currentLibraryName() {
|
||||||
|
return this.$store.getters['libraries/getCurrentLibraryName']
|
||||||
|
},
|
||||||
attemptingConnection: {
|
attemptingConnection: {
|
||||||
get() {
|
get() {
|
||||||
return this.$store.state.attemptingConnection
|
return this.$store.state.attemptingConnection
|
||||||
@@ -95,25 +99,11 @@ export default {
|
|||||||
|
|
||||||
await this.$store.dispatch('user/loadUserSettings')
|
await this.$store.dispatch('user/loadUserSettings')
|
||||||
},
|
},
|
||||||
async postRequest(url, data, headers, connectTimeout = 30000) {
|
|
||||||
const options = {
|
|
||||||
url,
|
|
||||||
headers,
|
|
||||||
data,
|
|
||||||
connectTimeout
|
|
||||||
}
|
|
||||||
const response = await CapacitorHttp.post(options)
|
|
||||||
console.log('[default] POST request response', response)
|
|
||||||
if (response.status >= 400) {
|
|
||||||
throw new Error(response.data)
|
|
||||||
} else {
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async attemptConnection() {
|
async attemptConnection() {
|
||||||
console.warn('[default] attemptConnection')
|
console.warn('[default] attemptConnection')
|
||||||
if (!this.networkConnected) {
|
if (!this.networkConnected) {
|
||||||
console.warn('[default] No network connection')
|
console.warn('[default] No network connection')
|
||||||
|
AbsLogger.info({ tag: 'default', message: 'attemptConnection: No network connection' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (this.attemptingConnection) {
|
if (this.attemptingConnection) {
|
||||||
@@ -134,15 +124,24 @@ export default {
|
|||||||
if (!serverConfig) {
|
if (!serverConfig) {
|
||||||
// No last server config set
|
// No last server config set
|
||||||
this.attemptingConnection = false
|
this.attemptingConnection = false
|
||||||
|
AbsLogger.info({ tag: 'default', message: 'attemptConnection: No last server config set' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[default] Got server config, attempt authorize ${serverConfig.address}`)
|
AbsLogger.info({ tag: 'default', message: `attemptConnection: Got server config, attempt authorize (${serverConfig.name})` })
|
||||||
|
|
||||||
const authRes = await this.postRequest(`${serverConfig.address}/api/authorize`, null, { Authorization: `Bearer ${serverConfig.token}` }, 6000).catch((error) => {
|
const nativeHttpOptions = {
|
||||||
console.error('[default] Server auth failed', error)
|
headers: {
|
||||||
|
Authorization: `Bearer ${serverConfig.token}`
|
||||||
|
},
|
||||||
|
connectTimeout: 6000,
|
||||||
|
serverConnectionConfig: serverConfig
|
||||||
|
}
|
||||||
|
const authRes = await this.$nativeHttp.post(`${serverConfig.address}/api/authorize`, null, nativeHttpOptions).catch((error) => {
|
||||||
|
AbsLogger.error({ tag: 'default', message: `attemptConnection: Server auth failed (${serverConfig.name})` })
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!authRes) {
|
if (!authRes) {
|
||||||
this.attemptingConnection = false
|
this.attemptingConnection = false
|
||||||
return
|
return
|
||||||
@@ -152,6 +151,22 @@ export default {
|
|||||||
this.$store.commit('setServerSettings', serverSettings)
|
this.$store.commit('setServerSettings', serverSettings)
|
||||||
this.$store.commit('libraries/setEReaderDevices', ereaderDevices)
|
this.$store.commit('libraries/setEReaderDevices', ereaderDevices)
|
||||||
|
|
||||||
|
if (this.$isValidVersion(serverSettings.version, '2.26.0')) {
|
||||||
|
// Check if the server is using the new JWT auth and is still using an old token in the server config
|
||||||
|
// If so, redirect to /connect and request to re-login
|
||||||
|
if (serverConfig.token === user.token || user.isOldToken) {
|
||||||
|
this.attemptingConnection = false
|
||||||
|
AbsLogger.info({ tag: 'default', message: `attemptConnection: Server is using new JWT auth but config is still using an old token (server version: ${serverSettings.version}) (${serverConfig.name})` })
|
||||||
|
// Clear last server config
|
||||||
|
await this.$store.dispatch('user/logout')
|
||||||
|
this.$router.push(`/connect?error=oldAuthToken&serverConnectionConfigId=${serverConfig.id}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token may have been refreshed during the authorize call so refetch from store
|
||||||
|
serverConfig.token = this.$store.getters['user/getToken'] || serverConfig.token
|
||||||
|
}
|
||||||
|
|
||||||
// Set library - Use last library if set and available fallback to default user library
|
// Set library - Use last library if set and available fallback to default user library
|
||||||
const lastLibraryId = await this.$localStore.getLastLibraryId()
|
const lastLibraryId = await this.$localStore.getLastLibraryId()
|
||||||
if (lastLibraryId && (!user.librariesAccessible.length || user.librariesAccessible.includes(lastLibraryId))) {
|
if (lastLibraryId && (!user.librariesAccessible.length || user.librariesAccessible.includes(lastLibraryId))) {
|
||||||
@@ -159,14 +174,16 @@ export default {
|
|||||||
} else if (userDefaultLibraryId) {
|
} else if (userDefaultLibraryId) {
|
||||||
this.$store.commit('libraries/setCurrentLibrary', userDefaultLibraryId)
|
this.$store.commit('libraries/setCurrentLibrary', userDefaultLibraryId)
|
||||||
}
|
}
|
||||||
|
serverConfig.version = serverSettings.version
|
||||||
const serverConnectionConfig = await this.$db.setServerConnectionConfig(serverConfig)
|
const serverConnectionConfig = await this.$db.setServerConnectionConfig(serverConfig)
|
||||||
|
|
||||||
this.$store.commit('user/setUser', user)
|
this.$store.commit('user/setUser', user)
|
||||||
|
this.$store.commit('user/setAccessToken', serverConnectionConfig.token)
|
||||||
this.$store.commit('user/setServerConnectionConfig', serverConnectionConfig)
|
this.$store.commit('user/setServerConnectionConfig', serverConnectionConfig)
|
||||||
|
|
||||||
this.$socket.connect(serverConnectionConfig.address, serverConnectionConfig.token)
|
this.$socket.connect(serverConnectionConfig.address, serverConnectionConfig.token)
|
||||||
|
|
||||||
console.log('[default] Successful connection on last saved connection config', JSON.stringify(serverConnectionConfig))
|
AbsLogger.info({ tag: 'default', message: `attemptConnection: Successful connection to last saved server config (${serverConnectionConfig.name})` })
|
||||||
await this.initLibraries()
|
await this.initLibraries()
|
||||||
this.attemptingConnection = false
|
this.attemptingConnection = false
|
||||||
},
|
},
|
||||||
@@ -186,7 +203,8 @@ export default {
|
|||||||
}
|
}
|
||||||
this.inittingLibraries = true
|
this.inittingLibraries = true
|
||||||
await this.$store.dispatch('libraries/load')
|
await this.$store.dispatch('libraries/load')
|
||||||
console.log(`[default] initLibraries loaded ${this.currentLibraryId}`)
|
|
||||||
|
AbsLogger.info({ tag: 'default', message: `initLibraries loading library ${this.currentLibraryName}` })
|
||||||
await this.$store.dispatch('libraries/fetch', this.currentLibraryId)
|
await this.$store.dispatch('libraries/fetch', this.currentLibraryId)
|
||||||
this.$eventBus.$emit('library-changed')
|
this.$eventBus.$emit('library-changed')
|
||||||
this.inittingLibraries = false
|
this.inittingLibraries = false
|
||||||
@@ -197,7 +215,7 @@ export default {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[default] Calling syncLocalSessions')
|
AbsLogger.info({ tag: 'default', message: 'Calling syncLocalSessions' })
|
||||||
const response = await this.$db.syncLocalSessionsWithServer(isFirstSync)
|
const response = await this.$db.syncLocalSessionsWithServer(isFirstSync)
|
||||||
if (response?.error) {
|
if (response?.error) {
|
||||||
console.error('[default] Failed to sync local sessions', response.error)
|
console.error('[default] Failed to sync local sessions', response.error)
|
||||||
@@ -214,12 +232,12 @@ export default {
|
|||||||
},
|
},
|
||||||
async userMediaProgressUpdated(payload) {
|
async userMediaProgressUpdated(payload) {
|
||||||
const prog = payload.data // MediaProgress
|
const prog = payload.data // MediaProgress
|
||||||
console.log(`[default] userMediaProgressUpdate checking for local media progress ${payload.id}`)
|
await AbsLogger.info({ tag: 'default', message: `userMediaProgressUpdate: Received updated media progress for current user from socket event. Media item id ${payload.id}` })
|
||||||
|
|
||||||
// Check if this media item is currently open in the player, paused, and this progress update is coming from a different session
|
// Check if this media item is currently open in the player, paused, and this progress update is coming from a different session
|
||||||
const isMediaOpenInPlayer = this.$store.getters['getIsMediaStreaming'](prog.libraryItemId, prog.episodeId)
|
const isMediaOpenInPlayer = this.$store.getters['getIsMediaStreaming'](prog.libraryItemId, prog.episodeId)
|
||||||
if (isMediaOpenInPlayer && this.$store.getters['getCurrentPlaybackSessionId'] !== payload.sessionId && !this.$store.state.playerIsPlaying) {
|
if (isMediaOpenInPlayer && this.$store.getters['getCurrentPlaybackSessionId'] !== payload.sessionId && !this.$store.state.playerIsPlaying) {
|
||||||
console.log('[default] userMediaProgressUpdated for current open media item', payload.data.currentTime)
|
await AbsLogger.info({ tag: 'default', message: `userMediaProgressUpdate: Item is currently open in player, paused and this progress update is coming from a different session. Updating playback time to ${payload.data.currentTime}` })
|
||||||
this.$eventBus.$emit('playback-time-update', payload.data.currentTime)
|
this.$eventBus.$emit('playback-time-update', payload.data.currentTime)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,12 +248,12 @@ export default {
|
|||||||
// Progress update is more recent then local progress
|
// Progress update is more recent then local progress
|
||||||
if (localProg && localProg.lastUpdate < prog.lastUpdate) {
|
if (localProg && localProg.lastUpdate < prog.lastUpdate) {
|
||||||
if (localProg.currentTime == prog.currentTime && localProg.isFinished == prog.isFinished) {
|
if (localProg.currentTime == prog.currentTime && localProg.isFinished == prog.isFinished) {
|
||||||
console.log('[default] syncing progress server lastUpdate > local lastUpdate but currentTime and isFinished is equal')
|
await AbsLogger.info({ tag: 'default', message: `userMediaProgressUpdate: server lastUpdate is more recent but progress is up-to-date (libraryItemId: ${prog.libraryItemId}${prog.episodeId ? ` episodeId: ${prog.episodeId}` : ''})` })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server progress is more up-to-date
|
// Server progress is more up-to-date
|
||||||
console.log(`[default] syncing progress from server with local item for "${prog.libraryItemId}" ${prog.episodeId ? `episode ${prog.episodeId}` : ''} | server lastUpdate=${prog.lastUpdate} > local lastUpdate=${localProg.lastUpdate}`)
|
await AbsLogger.info({ tag: 'default', message: `userMediaProgressUpdate: syncing progress from server with local item for "${prog.libraryItemId}" ${prog.episodeId ? `episode ${prog.episodeId}` : ''} | server lastUpdate=${prog.lastUpdate} > local lastUpdate=${localProg.lastUpdate}` })
|
||||||
const payload = {
|
const payload = {
|
||||||
localMediaProgressId: localProg.id,
|
localMediaProgressId: localProg.id,
|
||||||
mediaProgress: prog
|
mediaProgress: prog
|
||||||
@@ -273,7 +291,7 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (newLocalMediaProgress?.id) {
|
if (newLocalMediaProgress?.id) {
|
||||||
console.log(`[default] local media progress updated for ${newLocalMediaProgress.id}`)
|
await AbsLogger.info({ tag: 'default', message: `userMediaProgressUpdate: local media progress updated for ${newLocalMediaProgress.id}` })
|
||||||
this.$store.commit('globals/updateLocalMediaProgress', newLocalMediaProgress)
|
this.$store.commit('globals/updateLocalMediaProgress', newLocalMediaProgress)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -310,6 +328,7 @@ export default {
|
|||||||
this.$socket.on('user_media_progress_updated', this.userMediaProgressUpdated)
|
this.$socket.on('user_media_progress_updated', this.userMediaProgressUpdated)
|
||||||
|
|
||||||
if (this.$store.state.isFirstLoad) {
|
if (this.$store.state.isFirstLoad) {
|
||||||
|
AbsLogger.info({ tag: 'default', message: `mounted: initializing first load (${this.$platform} v${this.$config.version})` })
|
||||||
this.$store.commit('setIsFirstLoad', false)
|
this.$store.commit('setIsFirstLoad', false)
|
||||||
|
|
||||||
this.loadSavedSettings()
|
this.loadSavedSettings()
|
||||||
@@ -322,17 +341,18 @@ export default {
|
|||||||
await this.$store.dispatch('setupNetworkListener')
|
await this.$store.dispatch('setupNetworkListener')
|
||||||
|
|
||||||
if (this.$store.state.user.serverConnectionConfig) {
|
if (this.$store.state.user.serverConnectionConfig) {
|
||||||
|
AbsLogger.info({ tag: 'default', message: `mounted: Server connected, init libraries (${this.$store.getters['user/getServerConfigName']})` })
|
||||||
await this.initLibraries()
|
await this.initLibraries()
|
||||||
} else {
|
} else {
|
||||||
|
AbsLogger.info({ tag: 'default', message: `mounted: Server not connected, attempt connection` })
|
||||||
await this.attemptConnection()
|
await this.attemptConnection()
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[default] finished connection attempt or already connected ${!!this.user}`)
|
|
||||||
await this.syncLocalSessions(true)
|
await this.syncLocalSessions(true)
|
||||||
|
|
||||||
this.hasMounted = true
|
this.hasMounted = true
|
||||||
|
|
||||||
console.log('[default] fully initialized')
|
AbsLogger.info({ tag: 'default', message: 'mounted: fully initialized' })
|
||||||
this.$eventBus.$emit('abs-ui-ready')
|
this.$eventBus.$emit('abs-ui-ready')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-1
@@ -30,7 +30,7 @@ export default {
|
|||||||
|
|
||||||
css: ['@/assets/tailwind.css', '@/assets/app.css'],
|
css: ['@/assets/tailwind.css', '@/assets/app.css'],
|
||||||
|
|
||||||
plugins: ['@/plugins/server.js', '@/plugins/db.js', '@/plugins/localStore.js', '@/plugins/init.client.js', '@/plugins/axios.js', '@/plugins/nativeHttp.js', '@/plugins/capacitor/index.js', '@/plugins/capacitor/AbsAudioPlayer.js', '@/plugins/toast.js', '@/plugins/constants.js', '@/plugins/haptics.js', '@/plugins/i18n.js'],
|
plugins: ['@/plugins/server.js', '@/plugins/db.js', '@/plugins/localStore.js', '@/plugins/init.client.js', '@/plugins/axios.js', '@/plugins/capacitor/index.js', '@/plugins/capacitor/AbsAudioPlayer.js', '@/plugins/nativeHttp.js', '@/plugins/toast.js', '@/plugins/constants.js', '@/plugins/haptics.js', '@/plugins/i18n.js'],
|
||||||
|
|
||||||
components: true,
|
components: true,
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf-app",
|
"name": "audiobookshelf-app",
|
||||||
"version": "0.9.79-beta",
|
"version": "0.9.81-beta",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "audiobookshelf-app",
|
"name": "audiobookshelf-app",
|
||||||
"version": "0.9.79-beta",
|
"version": "0.9.81-beta",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@capacitor-community/keep-awake": "^7.0.0",
|
"@capacitor-community/keep-awake": "^7.0.0",
|
||||||
"@capacitor-community/volume-buttons": "^7.0.0",
|
"@capacitor-community/volume-buttons": "^7.0.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf-app",
|
"name": "audiobookshelf-app",
|
||||||
"version": "0.9.79-beta",
|
"version": "0.9.81-beta",
|
||||||
"author": "advplyr",
|
"author": "advplyr",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "nuxt --hostname 0.0.0.0 --port 1337",
|
"dev": "nuxt --hostname 0.0.0.0 --port 1337",
|
||||||
|
|||||||
+9
-11
@@ -4,6 +4,10 @@
|
|||||||
|
|
||||||
<ui-text-input-with-label :value="username" :label="$strings.LabelUsername" disabled class="my-2" />
|
<ui-text-input-with-label :value="username" :label="$strings.LabelUsername" disabled class="my-2" />
|
||||||
|
|
||||||
|
<div v-if="serverVersion" class="text-sm text-fg">
|
||||||
|
<p>Server version: v{{ serverVersion }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ui-btn color="primary flex items-center justify-between gap-2 ml-auto text-base mt-8" @click="logout">{{ $strings.ButtonSwitchServerUser }}<span class="material-symbols" style="font-size: 1.1rem">logout</span></ui-btn>
|
<ui-btn color="primary flex items-center justify-between gap-2 ml-auto text-base mt-8" @click="logout">{{ $strings.ButtonSwitchServerUser }}<span class="material-symbols" style="font-size: 1.1rem">logout</span></ui-btn>
|
||||||
|
|
||||||
<div class="flex justify-center items-center my-4 left-0 right-0 bottom-0 absolute">
|
<div class="flex justify-center items-center my-4 left-0 right-0 bottom-0 absolute">
|
||||||
@@ -43,22 +47,16 @@ export default {
|
|||||||
},
|
},
|
||||||
serverAddress() {
|
serverAddress() {
|
||||||
return this.serverConnectionConfig.address
|
return this.serverConnectionConfig.address
|
||||||
|
},
|
||||||
|
serverVersion() {
|
||||||
|
// Saved in server connection config after 0.9.81
|
||||||
|
return this.serverConnectionConfig.version
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
async logout() {
|
async logout() {
|
||||||
await this.$hapticsImpact()
|
await this.$hapticsImpact()
|
||||||
if (this.user) {
|
await this.$store.dispatch('user/logout')
|
||||||
await this.$nativeHttp.post('/logout').catch((error) => {
|
|
||||||
console.error(error)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
this.$socket.logout()
|
|
||||||
await this.$db.logout()
|
|
||||||
this.$localStore.removeLastLibraryId()
|
|
||||||
this.$store.commit('user/logout')
|
|
||||||
this.$store.commit('libraries/setCurrentLibrary', null)
|
|
||||||
this.$router.push('/connect')
|
this.$router.push('/connect')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export default {
|
|||||||
async loadRecentEpisodes(page = 0) {
|
async loadRecentEpisodes(page = 0) {
|
||||||
this.loadedLibraryId = this.currentLibraryId
|
this.loadedLibraryId = this.currentLibraryId
|
||||||
this.processing = true
|
this.processing = true
|
||||||
const episodePayload = await this.$nativeHttp.get(`/api/libraries/${this.currentLibraryId}/recent-episodes?limit=25&page=${page}`).catch((error) => {
|
const episodePayload = await this.$nativeHttp.get(`/api/libraries/${this.currentLibraryId}/recent-episodes?limit=50&page=${page}`).catch((error) => {
|
||||||
console.error('Failed to get recent episodes', error)
|
console.error('Failed to get recent episodes', error)
|
||||||
this.$toast.error('Failed to get recent episodes')
|
this.$toast.error('Failed to get recent episodes')
|
||||||
return null
|
return null
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="relative" @click="showFullscreenCover = true">
|
<div class="relative" @click="showFullscreenCover = true">
|
||||||
<covers-book-cover :library-item="libraryItem" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" no-bg raw @imageLoaded="coverImageLoaded" />
|
<covers-book-cover :library-item="libraryItem" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" no-bg raw @imageLoaded="coverImageLoaded" />
|
||||||
<div v-if="!isPodcast" class="absolute bottom-0 left-0 h-1 shadow-sm z-10" :class="userIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: coverWidth * progressPercent + 'px' }"></div>
|
<div v-if="!isPodcast" class="absolute bottom-0 left-0 h-1 z-10 box-shadow-progressbar" :class="userIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: coverWidth * progressPercent + 'px' }"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -31,6 +31,7 @@
|
|||||||
<div class="flex items-center justify-center">
|
<div class="flex items-center justify-center">
|
||||||
<h1 class="text-xl font-semibold">{{ title }}</h1>
|
<h1 class="text-xl font-semibold">{{ title }}</h1>
|
||||||
<widgets-explicit-indicator v-if="isExplicit" />
|
<widgets-explicit-indicator v-if="isExplicit" />
|
||||||
|
<widgets-abridged-indicator v-if="isAbridged" />
|
||||||
</div>
|
</div>
|
||||||
<p v-if="subtitle" class="text-fg text-base">{{ subtitle }}</p>
|
<p v-if="subtitle" class="text-fg text-base">{{ subtitle }}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -438,6 +439,9 @@ export default {
|
|||||||
isExplicit() {
|
isExplicit() {
|
||||||
return !!this.mediaMetadata.explicit
|
return !!this.mediaMetadata.explicit
|
||||||
},
|
},
|
||||||
|
isAbridged() {
|
||||||
|
return !!this.mediaMetadata.abridged
|
||||||
|
},
|
||||||
showPlay() {
|
showPlay() {
|
||||||
return !this.isMissing && !this.isInvalid && (this.numTracks || this.episodes.length)
|
return !this.isMissing && !this.isInvalid && (this.numTracks || this.episodes.length)
|
||||||
},
|
},
|
||||||
|
|||||||
+175
@@ -0,0 +1,175 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full h-full py-4">
|
||||||
|
<div class="flex items-center mb-2 space-x-2 px-4">
|
||||||
|
<p class="text-lg font-bold">{{ $strings.ButtonLogs }}</p>
|
||||||
|
<ui-icon-btn outlined borderless :icon="isCopied ? 'check' : 'content_copy'" @click="copyToClipboard" />
|
||||||
|
<ui-icon-btn outlined borderless icon="share" @click="shareLogs" />
|
||||||
|
<div class="flex-grow"></div>
|
||||||
|
<ui-icon-btn outlined borderless icon="more_vert" @click="showDialog = true" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-full h-[calc(100%-40px)] overflow-y-auto relative" ref="logContainer">
|
||||||
|
<div v-if="!logs.length && !isLoading" class="flex items-center justify-center h-32 p-4">
|
||||||
|
<p class="text-gray-400">{{ $strings.MessageNoLogs }}</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="hasScrolled" class="sticky top-0 left-0 w-full h-10 bg-gradient-to-t from-transparent to-bg z-10 pointer-events-none"></div>
|
||||||
|
|
||||||
|
<div v-for="(log, index) in logs" :key="log.id" class="py-2 px-4" :class="{ 'bg-white/5': index % 2 === 0 }">
|
||||||
|
<div class="flex items-center space-x-4 mb-1">
|
||||||
|
<div class="text-xs uppercase font-bold" :class="{ 'text-error': log.level === 'error', 'text-blue-500': log.level === 'info' }">{{ log.level }}</div>
|
||||||
|
<div class="text-xs text-gray-400">{{ formatEpochToDatetimeString(log.timestamp) }}</div>
|
||||||
|
<div class="flex-grow"></div>
|
||||||
|
<div class="text-xs text-gray-400">{{ log.tag }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs">{{ maskServerAddress ? log.maskedMessage : log.message }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<modals-dialog v-model="showDialog" :items="dialogItems" @action="dialogAction" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
import { AbsLogger } from '@/plugins/capacitor'
|
||||||
|
import { FileSharer } from '@webnativellc/capacitor-filesharer'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
logs: [],
|
||||||
|
isLoading: true,
|
||||||
|
isCopied: false,
|
||||||
|
hasScrolled: false,
|
||||||
|
maskServerAddress: true,
|
||||||
|
showDialog: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
dialogItems() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
text: this.maskServerAddress ? this.$strings.ButtonUnmaskServerAddress : this.$strings.ButtonMaskServerAddress,
|
||||||
|
value: 'toggle-mask-server-address',
|
||||||
|
icon: this.maskServerAddress ? 'remove_moderator' : 'shield'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.ButtonClearLogs,
|
||||||
|
value: 'clear-logs',
|
||||||
|
icon: 'delete'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async dialogAction(action) {
|
||||||
|
await this.$hapticsImpact()
|
||||||
|
|
||||||
|
if (action === 'clear-logs') {
|
||||||
|
await AbsLogger.clearLogs()
|
||||||
|
this.logs = []
|
||||||
|
} else if (action === 'toggle-mask-server-address') {
|
||||||
|
this.maskServerAddress = !this.maskServerAddress
|
||||||
|
}
|
||||||
|
this.showDialog = false
|
||||||
|
},
|
||||||
|
toggleMaskServerAddress() {
|
||||||
|
this.maskServerAddress = !this.maskServerAddress
|
||||||
|
},
|
||||||
|
async copyToClipboard() {
|
||||||
|
await this.$hapticsImpact()
|
||||||
|
this.$copyToClipboard(this.getLogsString()).then(() => {
|
||||||
|
this.isCopied = true
|
||||||
|
setTimeout(() => {
|
||||||
|
this.isCopied = false
|
||||||
|
}, 2000)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Formats an epoch timestamp to YYYY-MM-DD HH:mm:ss.SSS
|
||||||
|
* Use 24 hour time format
|
||||||
|
* @param {number} epoch
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
formatEpochToDatetimeString(epoch) {
|
||||||
|
return new Date(epoch)
|
||||||
|
.toLocaleString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
fractionalSecondDigits: 3,
|
||||||
|
hour12: false
|
||||||
|
})
|
||||||
|
.replace(',', '')
|
||||||
|
},
|
||||||
|
getLogsString() {
|
||||||
|
return this.logs
|
||||||
|
.map((log) => {
|
||||||
|
const logMessage = this.maskServerAddress ? log.maskedMessage : log.message
|
||||||
|
return `${this.formatEpochToDatetimeString(log.timestamp)} [${log.level.toUpperCase()}] ${logMessage}`
|
||||||
|
})
|
||||||
|
.join('\n')
|
||||||
|
},
|
||||||
|
async shareLogs() {
|
||||||
|
await this.$hapticsImpact()
|
||||||
|
// Share .txt file with logs
|
||||||
|
const base64Data = Buffer.from(this.getLogsString()).toString('base64')
|
||||||
|
|
||||||
|
FileSharer.share({
|
||||||
|
filename: `abs_logs_${this.$platform}_${this.$config.version}.txt`,
|
||||||
|
contentType: 'text/plain',
|
||||||
|
base64Data
|
||||||
|
}).catch((error) => {
|
||||||
|
if (error.message !== 'USER_CANCELLED') {
|
||||||
|
console.error('Failed to share', error.message)
|
||||||
|
this.$toast.error('Failed to share: ' + error.message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
scrollToBottom() {
|
||||||
|
this.$refs.logContainer.scrollTop = this.$refs.logContainer.scrollHeight
|
||||||
|
this.hasScrolled = this.$refs.logContainer.scrollTop > 0
|
||||||
|
},
|
||||||
|
maskLogMessage(message) {
|
||||||
|
return message.replace(/(https?:\/\/)\S+/g, '$1[SERVER_ADDRESS]')
|
||||||
|
},
|
||||||
|
loadLogs() {
|
||||||
|
this.isLoading = true
|
||||||
|
AbsLogger.getAllLogs()
|
||||||
|
.then((logData) => {
|
||||||
|
const logs = logData.value || []
|
||||||
|
this.logs = logs.map((log) => {
|
||||||
|
log.maskedMessage = this.maskLogMessage(log.message)
|
||||||
|
return log
|
||||||
|
})
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.scrollToBottom()
|
||||||
|
})
|
||||||
|
this.isLoading = false
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
this.isLoading = false
|
||||||
|
console.error('Failed to load logs', error)
|
||||||
|
this.$toast.error('Failed to load logs: ' + error.message)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
AbsLogger.addListener('onLog', (log) => {
|
||||||
|
log.maskedMessage = this.maskLogMessage(log.message)
|
||||||
|
this.logs.push(log)
|
||||||
|
this.logs.sort((a, b) => a.timestamp - b.timestamp)
|
||||||
|
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.scrollToBottom()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
this.loadLogs()
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
AbsLogger.removeAllListeners()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
@@ -200,7 +200,7 @@ export default {
|
|||||||
this.onMediaItemHistoryUpdatedListener = await AbsAudioPlayer.addListener('onMediaItemHistoryUpdated', this.onMediaItemHistoryUpdated)
|
this.onMediaItemHistoryUpdatedListener = await AbsAudioPlayer.addListener('onMediaItemHistoryUpdated', this.onMediaItemHistoryUpdated)
|
||||||
},
|
},
|
||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
if (this.onMediaItemHistoryUpdatedListener) this.onMediaItemHistoryUpdatedListener.remove()
|
this.onMediaItemHistoryUpdatedListener?.remove()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -100,6 +100,13 @@
|
|||||||
<p class="pl-4">{{ $strings.LabelDisableVibrateOnReset }}</p>
|
<p class="pl-4">{{ $strings.LabelDisableVibrateOnReset }}</p>
|
||||||
<span class="material-symbols text-xl ml-2" @click.stop="showInfo('disableSleepTimerResetFeedback')">info</span>
|
<span class="material-symbols text-xl ml-2" @click.stop="showInfo('disableSleepTimerResetFeedback')">info</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center py-3">
|
||||||
|
<div class="w-10 flex justify-center" @click="toggleSleepTimerAlmostDoneChime">
|
||||||
|
<ui-toggle-switch v-model="settings.enableSleepTimerAlmostDoneChime" @input="saveSettings" />
|
||||||
|
</div>
|
||||||
|
<p class="pl-4">{{ $strings.LabelSleepTimerAlmostDoneChime }}</p>
|
||||||
|
<span class="material-symbols text-xl ml-2" @click.stop="showInfo('enableSleepTimerAlmostDoneChime')">info</span>
|
||||||
|
</div>
|
||||||
<div class="flex items-center py-3">
|
<div class="flex items-center py-3">
|
||||||
<div class="w-10 flex justify-center" @click="toggleAutoSleepTimer">
|
<div class="w-10 flex justify-center" @click="toggleAutoSleepTimer">
|
||||||
<ui-toggle-switch v-model="settings.autoSleepTimer" @input="saveSettings" />
|
<ui-toggle-switch v-model="settings.autoSleepTimer" @input="saveSettings" />
|
||||||
@@ -207,6 +214,7 @@ export default {
|
|||||||
sleepTimerLength: 900000, // 15 minutes
|
sleepTimerLength: 900000, // 15 minutes
|
||||||
disableSleepTimerFadeOut: false,
|
disableSleepTimerFadeOut: false,
|
||||||
disableSleepTimerResetFeedback: false,
|
disableSleepTimerResetFeedback: false,
|
||||||
|
enableSleepTimerAlmostDoneChime: false,
|
||||||
autoSleepTimerAutoRewind: false,
|
autoSleepTimerAutoRewind: false,
|
||||||
autoSleepTimerAutoRewindTime: 300000, // 5 minutes
|
autoSleepTimerAutoRewindTime: 300000, // 5 minutes
|
||||||
languageCode: 'en-us',
|
languageCode: 'en-us',
|
||||||
@@ -234,6 +242,10 @@ export default {
|
|||||||
name: this.$strings.LabelDisableVibrateOnReset,
|
name: this.$strings.LabelDisableVibrateOnReset,
|
||||||
message: this.$strings.LabelDisableVibrateOnResetHelp
|
message: this.$strings.LabelDisableVibrateOnResetHelp
|
||||||
},
|
},
|
||||||
|
enableSleepTimerAlmostDoneChime: {
|
||||||
|
name: this.$strings.LabelSleepTimerAlmostDoneChime,
|
||||||
|
message: this.$strings.LabelSleepTimerAlmostDoneChimeHelp
|
||||||
|
},
|
||||||
autoSleepTimerAutoRewind: {
|
autoSleepTimerAutoRewind: {
|
||||||
name: this.$strings.LabelAutoSleepTimerAutoRewind,
|
name: this.$strings.LabelAutoSleepTimerAutoRewind,
|
||||||
message: this.$strings.LabelAutoSleepTimerAutoRewindHelp
|
message: this.$strings.LabelAutoSleepTimerAutoRewindHelp
|
||||||
@@ -351,6 +363,10 @@ export default {
|
|||||||
},
|
},
|
||||||
themeOptionItems() {
|
themeOptionItems() {
|
||||||
return [
|
return [
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelThemeBlack,
|
||||||
|
value: 'black'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
text: this.$strings.LabelThemeDark,
|
text: this.$strings.LabelThemeDark,
|
||||||
value: 'dark'
|
value: 'dark'
|
||||||
@@ -549,6 +565,10 @@ export default {
|
|||||||
this.settings.disableSleepTimerResetFeedback = !this.settings.disableSleepTimerResetFeedback
|
this.settings.disableSleepTimerResetFeedback = !this.settings.disableSleepTimerResetFeedback
|
||||||
this.saveSettings()
|
this.saveSettings()
|
||||||
},
|
},
|
||||||
|
toggleSleepTimerAlmostDoneChime() {
|
||||||
|
this.settings.enableSleepTimerAlmostDoneChime = !this.settings.enableSleepTimerAlmostDoneChime
|
||||||
|
this.saveSettings()
|
||||||
|
},
|
||||||
toggleDisableAutoRewind() {
|
toggleDisableAutoRewind() {
|
||||||
this.settings.disableAutoRewind = !this.settings.disableAutoRewind
|
this.settings.disableAutoRewind = !this.settings.disableAutoRewind
|
||||||
this.saveSettings()
|
this.saveSettings()
|
||||||
@@ -620,6 +640,7 @@ export default {
|
|||||||
this.settings.sleepTimerLength = !isNaN(deviceSettings.sleepTimerLength) ? deviceSettings.sleepTimerLength : 900000 // 15 minutes
|
this.settings.sleepTimerLength = !isNaN(deviceSettings.sleepTimerLength) ? deviceSettings.sleepTimerLength : 900000 // 15 minutes
|
||||||
this.settings.disableSleepTimerFadeOut = !!deviceSettings.disableSleepTimerFadeOut
|
this.settings.disableSleepTimerFadeOut = !!deviceSettings.disableSleepTimerFadeOut
|
||||||
this.settings.disableSleepTimerResetFeedback = !!deviceSettings.disableSleepTimerResetFeedback
|
this.settings.disableSleepTimerResetFeedback = !!deviceSettings.disableSleepTimerResetFeedback
|
||||||
|
this.settings.enableSleepTimerAlmostDoneChime = !!deviceSettings.enableSleepTimerAlmostDoneChime
|
||||||
|
|
||||||
this.settings.autoSleepTimerAutoRewind = !!deviceSettings.autoSleepTimerAutoRewind
|
this.settings.autoSleepTimerAutoRewind = !!deviceSettings.autoSleepTimerAutoRewind
|
||||||
this.settings.autoSleepTimerAutoRewindTime = !isNaN(deviceSettings.autoSleepTimerAutoRewindTime) ? deviceSettings.autoSleepTimerAutoRewindTime : 300000 // 5 minutes
|
this.settings.autoSleepTimerAutoRewindTime = !isNaN(deviceSettings.autoSleepTimerAutoRewindTime) ? deviceSettings.autoSleepTimerAutoRewindTime : 300000 // 5 minutes
|
||||||
|
|||||||
+113
-12
@@ -1,17 +1,50 @@
|
|||||||
export default function ({ $axios, store }) {
|
export default function ({ $axios, store, $db }) {
|
||||||
$axios.onRequest(config => {
|
// Track if we're currently refreshing to prevent multiple refresh attempts
|
||||||
|
let isRefreshing = false
|
||||||
|
let failedQueue = []
|
||||||
|
|
||||||
|
const processQueue = (error, token = null) => {
|
||||||
|
failedQueue.forEach(({ resolve, reject }) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error)
|
||||||
|
} else {
|
||||||
|
resolve(token)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
failedQueue = []
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the case when token refresh fails
|
||||||
|
* @param {string} [serverConnectionConfigId]
|
||||||
|
* @returns {Promise} - Promise that resolves when logout is complete
|
||||||
|
*/
|
||||||
|
const handleRefreshFailure = async (serverConnectionConfigId) => {
|
||||||
|
try {
|
||||||
|
console.log('[axios] Handling refresh failure - logging out user')
|
||||||
|
|
||||||
|
// Clear store
|
||||||
|
await store.dispatch('user/logout')
|
||||||
|
|
||||||
|
if (serverConnectionConfigId) {
|
||||||
|
// Clear refresh token for server connection config
|
||||||
|
await $db.clearRefreshToken(serverConnectionConfigId)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.location.pathname !== '/connect') {
|
||||||
|
window.location.href = '/connect'
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[axios] Failed to handle refresh failure:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$axios.onRequest((config) => {
|
||||||
console.log('[Axios] Making request to ' + config.url)
|
console.log('[Axios] Making request to ' + config.url)
|
||||||
if (config.url.startsWith('http:') || config.url.startsWith('https:') || config.url.startsWith('capacitor:')) {
|
if (config.url.startsWith('http:') || config.url.startsWith('https:') || config.url.startsWith('capacitor:')) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const customHeaders = store.getters['user/getCustomHeaders']
|
|
||||||
if (customHeaders) {
|
|
||||||
for (const key in customHeaders) {
|
|
||||||
config.headers.common[key] = customHeaders[key]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const bearerToken = store.getters['user/getToken']
|
const bearerToken = store.getters['user/getToken']
|
||||||
if (bearerToken) {
|
if (bearerToken) {
|
||||||
config.headers.common['Authorization'] = `Bearer ${bearerToken}`
|
config.headers.common['Authorization'] = `Bearer ${bearerToken}`
|
||||||
@@ -26,7 +59,75 @@ export default function ({ $axios, store }) {
|
|||||||
console.log('[Axios] Request out', config.url)
|
console.log('[Axios] Request out', config.url)
|
||||||
})
|
})
|
||||||
|
|
||||||
$axios.onError(error => {
|
$axios.onError(async (error) => {
|
||||||
console.error('Axios error code', error)
|
const originalRequest = error.config
|
||||||
|
const code = parseInt(error.response && error.response.status)
|
||||||
|
const message = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
|
||||||
|
|
||||||
|
console.error('Axios error', code, message)
|
||||||
|
|
||||||
|
// Handle 401 Unauthorized (token expired)
|
||||||
|
if (code === 401 && !originalRequest._retry) {
|
||||||
|
// Skip refresh for auth endpoints to prevent infinite loops
|
||||||
|
if (originalRequest.url.endsWith('/auth/refresh') || originalRequest.url.endsWith('/login')) {
|
||||||
|
await handleRefreshFailure(store.getters['user/getServerConnectionConfigId'])
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRefreshing) {
|
||||||
|
// If already refreshing, queue this request
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
failedQueue.push({ resolve, reject })
|
||||||
|
})
|
||||||
|
.then((token) => {
|
||||||
|
if (!originalRequest.headers) {
|
||||||
|
originalRequest.headers = {}
|
||||||
|
}
|
||||||
|
originalRequest.headers['Authorization'] = `Bearer ${token}`
|
||||||
|
return $axios(originalRequest)
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
return Promise.reject(err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
originalRequest._retry = true
|
||||||
|
isRefreshing = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Attempt to refresh the token
|
||||||
|
// Updates store if successful, otherwise clears store and throw error
|
||||||
|
const newAccessToken = await store.dispatch('user/refreshToken')
|
||||||
|
if (!newAccessToken) {
|
||||||
|
console.error('No new access token received')
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the original request with new token
|
||||||
|
if (!originalRequest.headers) {
|
||||||
|
originalRequest.headers = {}
|
||||||
|
}
|
||||||
|
originalRequest.headers['Authorization'] = `Bearer ${newAccessToken}`
|
||||||
|
|
||||||
|
// Process any queued requests
|
||||||
|
processQueue(null, newAccessToken)
|
||||||
|
|
||||||
|
// Retry the original request
|
||||||
|
return $axios(originalRequest)
|
||||||
|
} catch (refreshError) {
|
||||||
|
console.error('Token refresh failed:', refreshError)
|
||||||
|
|
||||||
|
// Process queued requests with error
|
||||||
|
processQueue(refreshError, null)
|
||||||
|
|
||||||
|
await handleRefreshFailure(store.getters['user/getServerConnectionConfigId'])
|
||||||
|
|
||||||
|
return Promise.reject(refreshError)
|
||||||
|
} finally {
|
||||||
|
isRefreshing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(error)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { registerPlugin, WebPlugin } from '@capacitor/core'
|
import { registerPlugin, WebPlugin } from '@capacitor/core'
|
||||||
|
import { AbsLogger } from '@/plugins/capacitor'
|
||||||
import { nanoid } from 'nanoid'
|
import { nanoid } from 'nanoid'
|
||||||
const { PlayerState } = require('../constants')
|
const { PlayerState } = require('../constants')
|
||||||
|
|
||||||
@@ -69,7 +70,12 @@ class AbsAudioPlayerWeb extends WebPlugin {
|
|||||||
const deviceInfo = {
|
const deviceInfo = {
|
||||||
deviceId: this.getDeviceId()
|
deviceId: this.getDeviceId()
|
||||||
}
|
}
|
||||||
const playbackSession = await $axios.$post(route, { deviceInfo, mediaPlayer: 'html5-mobile', forceDirectPlay: true })
|
const reqBody = {
|
||||||
|
deviceInfo,
|
||||||
|
mediaPlayer: 'html5-mobile',
|
||||||
|
forceDirectPlay: true
|
||||||
|
}
|
||||||
|
const playbackSession = await $axios.$post(route, reqBody)
|
||||||
if (playbackSession) {
|
if (playbackSession) {
|
||||||
if (startTime !== undefined && startTime !== null) playbackSession.currentTime = startTime
|
if (startTime !== undefined && startTime !== null) playbackSession.currentTime = startTime
|
||||||
this.setAudioPlayer(playbackSession, playWhenReady)
|
this.setAudioPlayer(playbackSession, playWhenReady)
|
||||||
@@ -88,6 +94,9 @@ class AbsAudioPlayerWeb extends WebPlugin {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For testing onLog events in web while on the logs page
|
||||||
|
AbsLogger.info({ tag: 'AbsAudioPlayer', message: 'playPause' })
|
||||||
|
|
||||||
if (this.player.paused) this.player.play()
|
if (this.player.paused) this.player.play()
|
||||||
else this.player.pause()
|
else this.player.pause()
|
||||||
return {
|
return {
|
||||||
@@ -241,7 +250,15 @@ class AbsAudioPlayerWeb extends WebPlugin {
|
|||||||
this.trackStartTime = Math.max(0, this.startTime - (this.currentTrack.startOffset || 0))
|
this.trackStartTime = Math.max(0, this.startTime - (this.currentTrack.startOffset || 0))
|
||||||
const serverAddressUrl = new URL(vuexStore.getters['user/getServerAddress'])
|
const serverAddressUrl = new URL(vuexStore.getters['user/getServerAddress'])
|
||||||
const serverHost = `${serverAddressUrl.protocol}//${serverAddressUrl.host}`
|
const serverHost = `${serverAddressUrl.protocol}//${serverAddressUrl.host}`
|
||||||
this.player.src = `${serverHost}${this.currentTrack.contentUrl}?token=${vuexStore.getters['user/getToken']}`
|
|
||||||
|
let sessionTrackUrl = null
|
||||||
|
if (this.currentTrack.contentUrl?.startsWith('/hls')) {
|
||||||
|
sessionTrackUrl = this.currentTrack.contentUrl
|
||||||
|
} else {
|
||||||
|
sessionTrackUrl = `/public/session/${this.playbackSession.id}/track/${this.currentTrack.index}`
|
||||||
|
}
|
||||||
|
|
||||||
|
this.player.src = `${serverHost}${sessionTrackUrl}`
|
||||||
console.log(`[AbsAudioPlayer] Loading track src ${this.player.src}`)
|
console.log(`[AbsAudioPlayer] Loading track src ${this.player.src}`)
|
||||||
this.player.load()
|
this.player.load()
|
||||||
this.player.playbackRate = this.playbackRate
|
this.player.playbackRate = this.playbackRate
|
||||||
|
|||||||
@@ -1,4 +1,17 @@
|
|||||||
import { registerPlugin, Capacitor, WebPlugin } from '@capacitor/core';
|
import { registerPlugin, Capacitor, WebPlugin } from '@capacitor/core'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} ServerConnectionConfig
|
||||||
|
* @property {string} id
|
||||||
|
* @property {number} index
|
||||||
|
* @property {string} name
|
||||||
|
* @property {string} address
|
||||||
|
* @property {string} version
|
||||||
|
* @property {string} userId
|
||||||
|
* @property {string} username
|
||||||
|
* @property {string} token
|
||||||
|
* @property {string} [refreshToken] - Only passed in when setting config, then stored in secure storage
|
||||||
|
*/
|
||||||
|
|
||||||
class AbsDatabaseWeb extends WebPlugin {
|
class AbsDatabaseWeb extends WebPlugin {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -19,17 +32,30 @@ class AbsDatabaseWeb extends WebPlugin {
|
|||||||
return deviceData
|
return deviceData
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {ServerConnectionConfig} serverConnectionConfig
|
||||||
|
* @returns {Promise<ServerConnectionConfig>}
|
||||||
|
*/
|
||||||
async setCurrentServerConnectionConfig(serverConnectionConfig) {
|
async setCurrentServerConnectionConfig(serverConnectionConfig) {
|
||||||
var deviceData = await this.getDeviceData()
|
var deviceData = await this.getDeviceData()
|
||||||
|
|
||||||
var ssc = deviceData.serverConnectionConfigs.find(_ssc => _ssc.id == serverConnectionConfig.id)
|
var ssc = deviceData.serverConnectionConfigs.find((_ssc) => _ssc.id == serverConnectionConfig.id)
|
||||||
if (ssc) {
|
if (ssc) {
|
||||||
deviceData.lastServerConnectionConfigId = ssc.id
|
deviceData.lastServerConnectionConfigId = ssc.id
|
||||||
ssc.name = `${ssc.address} (${serverConnectionConfig.username})`
|
ssc.name = `${ssc.address} (${serverConnectionConfig.username})`
|
||||||
ssc.token = serverConnectionConfig.token
|
ssc.token = serverConnectionConfig.token
|
||||||
ssc.userId = serverConnectionConfig.userId
|
ssc.userId = serverConnectionConfig.userId
|
||||||
ssc.username = serverConnectionConfig.username
|
ssc.username = serverConnectionConfig.username
|
||||||
|
ssc.version = serverConnectionConfig.version
|
||||||
ssc.customHeaders = serverConnectionConfig.customHeaders || {}
|
ssc.customHeaders = serverConnectionConfig.customHeaders || {}
|
||||||
|
|
||||||
|
if (serverConnectionConfig.refreshToken) {
|
||||||
|
console.log('[AbsDatabase] Updating refresh token...', serverConnectionConfig.refreshToken)
|
||||||
|
// Only using local storage for web version that is only used for testing
|
||||||
|
localStorage.setItem(`refresh_token_${ssc.id}`, serverConnectionConfig.refreshToken)
|
||||||
|
}
|
||||||
|
|
||||||
localStorage.setItem('device', JSON.stringify(deviceData))
|
localStorage.setItem('device', JSON.stringify(deviceData))
|
||||||
} else {
|
} else {
|
||||||
ssc = {
|
ssc = {
|
||||||
@@ -40,8 +66,16 @@ class AbsDatabaseWeb extends WebPlugin {
|
|||||||
username: serverConnectionConfig.username,
|
username: serverConnectionConfig.username,
|
||||||
address: serverConnectionConfig.address,
|
address: serverConnectionConfig.address,
|
||||||
token: serverConnectionConfig.token,
|
token: serverConnectionConfig.token,
|
||||||
|
version: serverConnectionConfig.version,
|
||||||
customHeaders: serverConnectionConfig.customHeaders || {}
|
customHeaders: serverConnectionConfig.customHeaders || {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (serverConnectionConfig.refreshToken) {
|
||||||
|
console.log('[AbsDatabase] Setting refresh token...', serverConnectionConfig.refreshToken)
|
||||||
|
// Only using local storage for web version that is only used for testing
|
||||||
|
localStorage.setItem(`refresh_token_${ssc.id}`, serverConnectionConfig.refreshToken)
|
||||||
|
}
|
||||||
|
|
||||||
deviceData.serverConnectionConfigs.push(ssc)
|
deviceData.serverConnectionConfigs.push(ssc)
|
||||||
deviceData.lastServerConnectionConfigId = ssc.id
|
deviceData.lastServerConnectionConfigId = ssc.id
|
||||||
localStorage.setItem('device', JSON.stringify(deviceData))
|
localStorage.setItem('device', JSON.stringify(deviceData))
|
||||||
@@ -49,14 +83,26 @@ class AbsDatabaseWeb extends WebPlugin {
|
|||||||
return ssc
|
return ssc
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getRefreshToken({ serverConnectionConfigId }) {
|
||||||
|
console.log('[AbsDatabase] Getting refresh token...', serverConnectionConfigId)
|
||||||
|
const refreshToken = localStorage.getItem(`refresh_token_${serverConnectionConfigId}`)
|
||||||
|
return refreshToken ? { refreshToken } : null
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearRefreshToken({ serverConnectionConfigId }) {
|
||||||
|
console.log('[AbsDatabase] Clearing refresh token...', serverConnectionConfigId)
|
||||||
|
localStorage.removeItem(`refresh_token_${serverConnectionConfigId}`)
|
||||||
|
}
|
||||||
|
|
||||||
async removeServerConnectionConfig(serverConnectionConfigCallObject) {
|
async removeServerConnectionConfig(serverConnectionConfigCallObject) {
|
||||||
var serverConnectionConfigId = serverConnectionConfigCallObject.serverConnectionConfigId
|
var serverConnectionConfigId = serverConnectionConfigCallObject.serverConnectionConfigId
|
||||||
var deviceData = await this.getDeviceData()
|
var deviceData = await this.getDeviceData()
|
||||||
deviceData.serverConnectionConfigs = deviceData.serverConnectionConfigs.filter(ssc => ssc.id != serverConnectionConfigId)
|
deviceData.serverConnectionConfigs = deviceData.serverConnectionConfigs.filter((ssc) => ssc.id != serverConnectionConfigId)
|
||||||
localStorage.setItem('device', JSON.stringify(deviceData))
|
localStorage.setItem('device', JSON.stringify(deviceData))
|
||||||
}
|
}
|
||||||
|
|
||||||
async logout() {
|
async logout() {
|
||||||
|
console.log('[AbsDatabase] Logging out...')
|
||||||
var deviceData = await this.getDeviceData()
|
var deviceData = await this.getDeviceData()
|
||||||
deviceData.lastServerConnectionConfigId = null
|
deviceData.lastServerConnectionConfigId = null
|
||||||
localStorage.setItem('device', JSON.stringify(deviceData))
|
localStorage.setItem('device', JSON.stringify(deviceData))
|
||||||
@@ -85,79 +131,81 @@ class AbsDatabaseWeb extends WebPlugin {
|
|||||||
}
|
}
|
||||||
async getLocalLibraryItems(payload) {
|
async getLocalLibraryItems(payload) {
|
||||||
return {
|
return {
|
||||||
value: [{
|
value: [
|
||||||
id: 'local_test',
|
{
|
||||||
libraryItemId: 'test34',
|
id: 'local_test',
|
||||||
serverAddress: 'https://abs.test.com',
|
libraryItemId: 'test34',
|
||||||
serverUserId: 'test56',
|
serverAddress: 'https://abs.test.com',
|
||||||
folderId: 'test1',
|
serverUserId: 'test56',
|
||||||
absolutePath: 'a',
|
folderId: 'test1',
|
||||||
contentUrl: 'c',
|
absolutePath: 'a',
|
||||||
isInvalid: false,
|
contentUrl: 'c',
|
||||||
mediaType: 'book',
|
isInvalid: false,
|
||||||
media: {
|
mediaType: 'book',
|
||||||
metadata: {
|
media: {
|
||||||
title: 'Test Book',
|
metadata: {
|
||||||
authorName: 'Test Author Name'
|
title: 'Test Book',
|
||||||
|
authorName: 'Test Author Name'
|
||||||
|
},
|
||||||
|
coverPath: null,
|
||||||
|
tags: [],
|
||||||
|
audioFiles: [],
|
||||||
|
chapters: [],
|
||||||
|
tracks: [
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
startOffset: 0,
|
||||||
|
duration: 10000,
|
||||||
|
title: 'Track Title 1',
|
||||||
|
contentUrl: 'test',
|
||||||
|
mimeType: 'audio/mpeg',
|
||||||
|
metadata: null,
|
||||||
|
isLocal: true,
|
||||||
|
localFileId: 'lf1',
|
||||||
|
audioProbeResult: {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 2,
|
||||||
|
startOffset: 0,
|
||||||
|
duration: 15000,
|
||||||
|
title: 'Track Title 2',
|
||||||
|
contentUrl: 'test2',
|
||||||
|
mimeType: 'audio/mpeg',
|
||||||
|
metadata: null,
|
||||||
|
isLocal: true,
|
||||||
|
localFileId: 'lf2',
|
||||||
|
audioProbeResult: {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 3,
|
||||||
|
startOffset: 0,
|
||||||
|
duration: 20000,
|
||||||
|
title: 'Track Title 3',
|
||||||
|
contentUrl: 'test3',
|
||||||
|
mimeType: 'audio/mpeg',
|
||||||
|
metadata: null,
|
||||||
|
isLocal: true,
|
||||||
|
localFileId: 'lf3',
|
||||||
|
audioProbeResult: {}
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
coverPath: null,
|
localFiles: [
|
||||||
tags: [],
|
|
||||||
audioFiles: [],
|
|
||||||
chapters: [],
|
|
||||||
tracks: [
|
|
||||||
{
|
{
|
||||||
index: 1,
|
id: 'lf1',
|
||||||
startOffset: 0,
|
filename: 'lf1.mp3',
|
||||||
duration: 10000,
|
|
||||||
title: 'Track Title 1',
|
|
||||||
contentUrl: 'test',
|
contentUrl: 'test',
|
||||||
|
absolutePath: 'test',
|
||||||
|
simplePath: 'test',
|
||||||
mimeType: 'audio/mpeg',
|
mimeType: 'audio/mpeg',
|
||||||
metadata: null,
|
size: 39048290
|
||||||
isLocal: true,
|
|
||||||
localFileId: 'lf1',
|
|
||||||
audioProbeResult: {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
index: 2,
|
|
||||||
startOffset: 0,
|
|
||||||
duration: 15000,
|
|
||||||
title: 'Track Title 2',
|
|
||||||
contentUrl: 'test2',
|
|
||||||
mimeType: 'audio/mpeg',
|
|
||||||
metadata: null,
|
|
||||||
isLocal: true,
|
|
||||||
localFileId: 'lf2',
|
|
||||||
audioProbeResult: {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
index: 3,
|
|
||||||
startOffset: 0,
|
|
||||||
duration: 20000,
|
|
||||||
title: 'Track Title 3',
|
|
||||||
contentUrl: 'test3',
|
|
||||||
mimeType: 'audio/mpeg',
|
|
||||||
metadata: null,
|
|
||||||
isLocal: true,
|
|
||||||
localFileId: 'lf3',
|
|
||||||
audioProbeResult: {}
|
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
},
|
coverContentUrl: null,
|
||||||
localFiles: [
|
coverAbsolutePath: null,
|
||||||
{
|
isLocal: true
|
||||||
id: 'lf1',
|
}
|
||||||
filename: 'lf1.mp3',
|
]
|
||||||
contentUrl: 'test',
|
|
||||||
absolutePath: 'test',
|
|
||||||
simplePath: 'test',
|
|
||||||
mimeType: 'audio/mpeg',
|
|
||||||
size: 39048290
|
|
||||||
}
|
|
||||||
],
|
|
||||||
coverContentUrl: null,
|
|
||||||
coverAbsolutePath: null,
|
|
||||||
isLocal: true
|
|
||||||
}]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async getLocalLibraryItemsInFolder({ folderId }) {
|
async getLocalLibraryItemsInFolder({ folderId }) {
|
||||||
@@ -167,7 +215,7 @@ class AbsDatabaseWeb extends WebPlugin {
|
|||||||
return this.getLocalLibraryItems().then((data) => data.value[0])
|
return this.getLocalLibraryItems().then((data) => data.value[0])
|
||||||
}
|
}
|
||||||
async getLocalLibraryItemByLId({ libraryItemId }) {
|
async getLocalLibraryItemByLId({ libraryItemId }) {
|
||||||
return this.getLocalLibraryItems().then((data) => data.value.find(lli => lli.libraryItemId == libraryItemId))
|
return this.getLocalLibraryItems().then((data) => data.value.find((lli) => lli.libraryItemId == libraryItemId))
|
||||||
}
|
}
|
||||||
async getAllLocalMediaProgress() {
|
async getAllLocalMediaProgress() {
|
||||||
return {
|
return {
|
||||||
@@ -182,7 +230,7 @@ class AbsDatabaseWeb extends WebPlugin {
|
|||||||
isFinished: false,
|
isFinished: false,
|
||||||
lastUpdate: 394089090,
|
lastUpdate: 394089090,
|
||||||
startedAt: 239048209,
|
startedAt: 239048209,
|
||||||
finishedAt: null,
|
finishedAt: null
|
||||||
// For local lib items from server to support server sync
|
// For local lib items from server to support server sync
|
||||||
// var serverConnectionConfigId:String?,
|
// var serverConnectionConfigId:String?,
|
||||||
// var serverAddress:String?,
|
// var serverAddress:String?,
|
||||||
@@ -240,7 +288,7 @@ class AbsDatabaseWeb extends WebPlugin {
|
|||||||
serverSyncAttempted: true,
|
serverSyncAttempted: true,
|
||||||
serverSyncSuccess: true,
|
serverSyncSuccess: true,
|
||||||
serverSyncMessage: null,
|
serverSyncMessage: null,
|
||||||
timestamp: Date.now() - (1000 * 60 * 22) + 13000 // 22 mins ago + 13s
|
timestamp: Date.now() - 1000 * 60 * 22 + 13000 // 22 mins ago + 13s
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Play',
|
name: 'Play',
|
||||||
@@ -250,7 +298,7 @@ class AbsDatabaseWeb extends WebPlugin {
|
|||||||
serverSyncAttempted: false,
|
serverSyncAttempted: false,
|
||||||
serverSyncSuccess: null,
|
serverSyncSuccess: null,
|
||||||
serverSyncMessage: null,
|
serverSyncMessage: null,
|
||||||
timestamp: Date.now() - (1000 * 60 * 22) // 22 mins ago
|
timestamp: Date.now() - 1000 * 60 * 22 // 22 mins ago
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Pause',
|
name: 'Pause',
|
||||||
@@ -260,7 +308,7 @@ class AbsDatabaseWeb extends WebPlugin {
|
|||||||
serverSyncAttempted: true,
|
serverSyncAttempted: true,
|
||||||
serverSyncSuccess: false,
|
serverSyncSuccess: false,
|
||||||
serverSyncMessage: null,
|
serverSyncMessage: null,
|
||||||
timestamp: Date.now() - (1000 * 60 * 60) + (58000) // 1 hour ago + 58s
|
timestamp: Date.now() - 1000 * 60 * 60 + 58000 // 1 hour ago + 58s
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Save',
|
name: 'Save',
|
||||||
@@ -270,7 +318,7 @@ class AbsDatabaseWeb extends WebPlugin {
|
|||||||
serverSyncAttempted: true,
|
serverSyncAttempted: true,
|
||||||
serverSyncSuccess: true,
|
serverSyncSuccess: true,
|
||||||
serverSyncMessage: null,
|
serverSyncMessage: null,
|
||||||
timestamp: Date.now() - (1000 * 60 * 60) + (45000) // 1 hour ago + 45s
|
timestamp: Date.now() - 1000 * 60 * 60 + 45000 // 1 hour ago + 45s
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Save',
|
name: 'Save',
|
||||||
@@ -280,7 +328,7 @@ class AbsDatabaseWeb extends WebPlugin {
|
|||||||
serverSyncAttempted: true,
|
serverSyncAttempted: true,
|
||||||
serverSyncSuccess: true,
|
serverSyncSuccess: true,
|
||||||
serverSyncMessage: null,
|
serverSyncMessage: null,
|
||||||
timestamp: Date.now() - (1000 * 60 * 60) + (30000) // 1 hour ago + 30s
|
timestamp: Date.now() - 1000 * 60 * 60 + 30000 // 1 hour ago + 30s
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Save',
|
name: 'Save',
|
||||||
@@ -290,7 +338,7 @@ class AbsDatabaseWeb extends WebPlugin {
|
|||||||
serverSyncAttempted: true,
|
serverSyncAttempted: true,
|
||||||
serverSyncSuccess: true,
|
serverSyncSuccess: true,
|
||||||
serverSyncMessage: null,
|
serverSyncMessage: null,
|
||||||
timestamp: Date.now() - (1000 * 60 * 60) + (15000) // 1 hour ago + 15s
|
timestamp: Date.now() - 1000 * 60 * 60 + 15000 // 1 hour ago + 15s
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Play',
|
name: 'Play',
|
||||||
@@ -300,7 +348,7 @@ class AbsDatabaseWeb extends WebPlugin {
|
|||||||
serverSyncAttempted: false,
|
serverSyncAttempted: false,
|
||||||
serverSyncSuccess: null,
|
serverSyncSuccess: null,
|
||||||
serverSyncMessage: null,
|
serverSyncMessage: null,
|
||||||
timestamp: Date.now() - (1000 * 60 * 60) // 1 hour ago
|
timestamp: Date.now() - 1000 * 60 * 60 // 1 hour ago
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Stop',
|
name: 'Stop',
|
||||||
@@ -310,7 +358,7 @@ class AbsDatabaseWeb extends WebPlugin {
|
|||||||
serverSyncAttempted: true,
|
serverSyncAttempted: true,
|
||||||
serverSyncSuccess: true,
|
serverSyncSuccess: true,
|
||||||
serverSyncMessage: null,
|
serverSyncMessage: null,
|
||||||
timestamp: Date.now() - (1000 * 60 * 60 * 25) + 10000 // 25 hours ago + 10s
|
timestamp: Date.now() - 1000 * 60 * 60 * 25 + 10000 // 25 hours ago + 10s
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Seek',
|
name: 'Seek',
|
||||||
@@ -320,7 +368,7 @@ class AbsDatabaseWeb extends WebPlugin {
|
|||||||
serverSyncAttempted: true,
|
serverSyncAttempted: true,
|
||||||
serverSyncSuccess: true,
|
serverSyncSuccess: true,
|
||||||
serverSyncMessage: null,
|
serverSyncMessage: null,
|
||||||
timestamp: Date.now() - (1000 * 60 * 60 * 25) + 2000 // 25 hours ago + 2s
|
timestamp: Date.now() - 1000 * 60 * 60 * 25 + 2000 // 25 hours ago + 2s
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Play',
|
name: 'Play',
|
||||||
@@ -330,7 +378,7 @@ class AbsDatabaseWeb extends WebPlugin {
|
|||||||
serverSyncAttempted: false,
|
serverSyncAttempted: false,
|
||||||
serverSyncSuccess: null,
|
serverSyncSuccess: null,
|
||||||
serverSyncMessage: null,
|
serverSyncMessage: null,
|
||||||
timestamp: Date.now() - (1000 * 60 * 60 * 25) // 25 hours ago
|
timestamp: Date.now() - 1000 * 60 * 60 * 25 // 25 hours ago
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Play',
|
name: 'Play',
|
||||||
@@ -340,7 +388,7 @@ class AbsDatabaseWeb extends WebPlugin {
|
|||||||
serverSyncAttempted: false,
|
serverSyncAttempted: false,
|
||||||
serverSyncSuccess: null,
|
serverSyncSuccess: null,
|
||||||
serverSyncMessage: null,
|
serverSyncMessage: null,
|
||||||
timestamp: Date.now() - (1000 * 60 * 60 * 50) // 50 hours ago
|
timestamp: Date.now() - 1000 * 60 * 60 * 50 // 50 hours ago
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -351,4 +399,4 @@ const AbsDatabase = registerPlugin('AbsDatabase', {
|
|||||||
web: () => new AbsDatabaseWeb()
|
web: () => new AbsDatabaseWeb()
|
||||||
})
|
})
|
||||||
|
|
||||||
export { AbsDatabase }
|
export { AbsDatabase }
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { registerPlugin, WebPlugin } from '@capacitor/core'
|
||||||
|
|
||||||
|
class AbsLoggerWeb extends WebPlugin {
|
||||||
|
constructor() {
|
||||||
|
super()
|
||||||
|
|
||||||
|
this.logs = []
|
||||||
|
}
|
||||||
|
|
||||||
|
saveLog(level, tag, message) {
|
||||||
|
const log = {
|
||||||
|
id: Math.random().toString(36).substring(2, 15),
|
||||||
|
tag: tag,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
level: level,
|
||||||
|
message: message
|
||||||
|
}
|
||||||
|
this.logs.push(log)
|
||||||
|
this.notifyListeners('onLog', log)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PluginMethod
|
||||||
|
async info(data) {
|
||||||
|
if (data?.message) {
|
||||||
|
this.saveLog('info', data.tag || '', data.message)
|
||||||
|
console.log('AbsLogger: info', `[${data.tag || ''}]:`, data.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PluginMethod
|
||||||
|
async error(data) {
|
||||||
|
if (data?.message) {
|
||||||
|
this.saveLog('error', data.tag || '', data.message)
|
||||||
|
console.error('AbsLogger: error', `[${data.tag || ''}]:`, data.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PluginMethod
|
||||||
|
async getAllLogs() {
|
||||||
|
return {
|
||||||
|
value: this.logs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PluginMethod
|
||||||
|
async clearLogs() {
|
||||||
|
this.logs = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const AbsLogger = registerPlugin('AbsLogger', {
|
||||||
|
web: () => new AbsLoggerWeb()
|
||||||
|
})
|
||||||
|
|
||||||
|
export { AbsLogger }
|
||||||
@@ -2,12 +2,10 @@ import Vue from 'vue'
|
|||||||
import { AbsAudioPlayer } from './AbsAudioPlayer'
|
import { AbsAudioPlayer } from './AbsAudioPlayer'
|
||||||
import { AbsDownloader } from './AbsDownloader'
|
import { AbsDownloader } from './AbsDownloader'
|
||||||
import { AbsFileSystem } from './AbsFileSystem'
|
import { AbsFileSystem } from './AbsFileSystem'
|
||||||
|
import { AbsDatabase } from './AbsDatabase'
|
||||||
|
import { AbsLogger } from './AbsLogger'
|
||||||
import { Capacitor } from '@capacitor/core'
|
import { Capacitor } from '@capacitor/core'
|
||||||
|
|
||||||
Vue.prototype.$platform = Capacitor.getPlatform()
|
Vue.prototype.$platform = Capacitor.getPlatform()
|
||||||
|
|
||||||
export {
|
export { AbsAudioPlayer, AbsDownloader, AbsFileSystem, AbsLogger, AbsDatabase }
|
||||||
AbsAudioPlayer,
|
|
||||||
AbsDownloader,
|
|
||||||
AbsFileSystem
|
|
||||||
}
|
|
||||||
|
|||||||
+44
-6
@@ -1,7 +1,7 @@
|
|||||||
import { AbsDatabase } from './capacitor/AbsDatabase'
|
import { AbsDatabase } from './capacitor/AbsDatabase'
|
||||||
|
|
||||||
class DbService {
|
class DbService {
|
||||||
constructor() { }
|
constructor() {}
|
||||||
|
|
||||||
getDeviceData() {
|
getDeviceData() {
|
||||||
return AbsDatabase.getDeviceData().then((data) => {
|
return AbsDatabase.getDeviceData().then((data) => {
|
||||||
@@ -10,6 +10,26 @@ class DbService {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves refresh token from secure storage
|
||||||
|
* @param {string} serverConnectionConfigId
|
||||||
|
* @return {Promise<string|null>}
|
||||||
|
*/
|
||||||
|
async getRefreshToken(serverConnectionConfigId) {
|
||||||
|
const refreshTokenData = await AbsDatabase.getRefreshToken({ serverConnectionConfigId })
|
||||||
|
return refreshTokenData?.refreshToken
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears refresh token from secure storage
|
||||||
|
* @param {string} serverConnectionConfigId
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
async clearRefreshToken(serverConnectionConfigId) {
|
||||||
|
const result = await AbsDatabase.clearRefreshToken({ serverConnectionConfigId })
|
||||||
|
return !!result?.success
|
||||||
|
}
|
||||||
|
|
||||||
setServerConnectionConfig(serverConnectionConfig) {
|
setServerConnectionConfig(serverConnectionConfig) {
|
||||||
return AbsDatabase.setCurrentServerConnectionConfig(serverConnectionConfig).then((data) => {
|
return AbsDatabase.setCurrentServerConnectionConfig(serverConnectionConfig).then((data) => {
|
||||||
console.log('Set server connection config', JSON.stringify(data))
|
console.log('Set server connection config', JSON.stringify(data))
|
||||||
@@ -29,10 +49,12 @@ class DbService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getLocalFolders() {
|
getLocalFolders() {
|
||||||
return AbsDatabase.getLocalFolders().then((data) => data.value).catch((error) => {
|
return AbsDatabase.getLocalFolders()
|
||||||
console.error('Failed to load', error)
|
.then((data) => data.value)
|
||||||
return null
|
.catch((error) => {
|
||||||
})
|
console.error('Failed to load', error)
|
||||||
|
return null
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
getLocalFolder(folderId) {
|
getLocalFolder(folderId) {
|
||||||
@@ -103,4 +125,20 @@ class DbService {
|
|||||||
|
|
||||||
export default ({ app, store }, inject) => {
|
export default ({ app, store }, inject) => {
|
||||||
inject('db', new DbService())
|
inject('db', new DbService())
|
||||||
}
|
|
||||||
|
// Listen for token refresh events from native app
|
||||||
|
AbsDatabase.addListener('onTokenRefresh', (data) => {
|
||||||
|
console.log('[db] onTokenRefresh', data)
|
||||||
|
store.commit('user/setAccessToken', data.accessToken)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Listen for token refresh failure events from native app
|
||||||
|
AbsDatabase.addListener('onTokenRefreshFailure', async (data) => {
|
||||||
|
console.log('[db] onTokenRefreshFailure', data)
|
||||||
|
// Clear store and redirect to login page
|
||||||
|
await store.dispatch('user/logout')
|
||||||
|
if (window.location.pathname !== '/connect') {
|
||||||
|
window.location.href = '/connect'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
+25
-6
@@ -16,12 +16,6 @@ if (Capacitor.getPlatform() != 'web') {
|
|||||||
await StatusBar.setStyle({ style: Style.Dark })
|
await StatusBar.setStyle({ style: Style.Dark })
|
||||||
}
|
}
|
||||||
setStatusBarStyleDark()
|
setStatusBarStyleDark()
|
||||||
|
|
||||||
const setStatusBarOverlays = async () => {
|
|
||||||
// Defaults to true in capacitor v7
|
|
||||||
await StatusBar.setOverlaysWebView({ overlay: false })
|
|
||||||
}
|
|
||||||
setStatusBarOverlays()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Vue.prototype.$showHideStatusBar = async (show) => {
|
Vue.prototype.$showHideStatusBar = async (show) => {
|
||||||
@@ -251,10 +245,35 @@ Vue.prototype.$sanitizeSlug = (str) => {
|
|||||||
return str
|
return str
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compares two semantic versioning strings to determine if the current version meets
|
||||||
|
* or exceeds the minimum version requirement.
|
||||||
|
* Only supports 3 part versions, e.g. "1.2.3"
|
||||||
|
*
|
||||||
|
* @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.
|
||||||
|
*/
|
||||||
|
function isValidVersion(currentVersion, minVersion) {
|
||||||
|
if (!currentVersion || !minVersion) return false
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
export default ({ store, app }, inject) => {
|
export default ({ store, app }, inject) => {
|
||||||
const eventBus = new Vue()
|
const eventBus = new Vue()
|
||||||
inject('eventBus', eventBus)
|
inject('eventBus', eventBus)
|
||||||
|
|
||||||
|
inject('isValidVersion', isValidVersion)
|
||||||
|
|
||||||
// Set theme
|
// Set theme
|
||||||
app.$localStore?.getTheme()?.then((theme) => {
|
app.$localStore?.getTheme()?.then((theme) => {
|
||||||
if (theme) {
|
if (theme) {
|
||||||
|
|||||||
+201
-8
@@ -1,10 +1,14 @@
|
|||||||
import { CapacitorHttp } from '@capacitor/core'
|
import { CapacitorHttp } from '@capacitor/core'
|
||||||
|
|
||||||
export default function ({ store }, inject) {
|
export default function ({ store, $db, $socket }, inject) {
|
||||||
const nativeHttp = {
|
const nativeHttp = {
|
||||||
request(method, _url, data, options = {}) {
|
async request(method, _url, data, options = {}) {
|
||||||
|
// When authorizing before a config is set, server config gets passed in as an option
|
||||||
|
let serverConnectionConfig = options.serverConnectionConfig || store.state.user.serverConnectionConfig
|
||||||
|
delete options.serverConnectionConfig
|
||||||
|
|
||||||
let url = _url
|
let url = _url
|
||||||
const headers = {}
|
let headers = {}
|
||||||
if (!url.startsWith('http') && !url.startsWith('capacitor')) {
|
if (!url.startsWith('http') && !url.startsWith('capacitor')) {
|
||||||
const bearerToken = store.getters['user/getToken']
|
const bearerToken = store.getters['user/getToken']
|
||||||
if (bearerToken) {
|
if (bearerToken) {
|
||||||
@@ -12,22 +16,31 @@ export default function ({ store }, inject) {
|
|||||||
} else {
|
} else {
|
||||||
console.warn('[nativeHttp] No Bearer Token for request')
|
console.warn('[nativeHttp] No Bearer Token for request')
|
||||||
}
|
}
|
||||||
const serverUrl = store.getters['user/getServerAddress']
|
if (serverConnectionConfig?.address) {
|
||||||
if (serverUrl) {
|
url = `${serverConnectionConfig.address}${url}`
|
||||||
url = `${serverUrl}${url}`
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (data) {
|
if (data) {
|
||||||
headers['Content-Type'] = 'application/json'
|
headers['Content-Type'] = 'application/json'
|
||||||
}
|
}
|
||||||
|
if (options.headers) {
|
||||||
|
headers = { ...headers, ...options.headers }
|
||||||
|
delete options.headers
|
||||||
|
}
|
||||||
console.log(`[nativeHttp] Making ${method} request to ${url}`)
|
console.log(`[nativeHttp] Making ${method} request to ${url}`)
|
||||||
|
|
||||||
return CapacitorHttp.request({
|
return CapacitorHttp.request({
|
||||||
method,
|
method,
|
||||||
url,
|
url,
|
||||||
data,
|
data,
|
||||||
headers,
|
headers,
|
||||||
...options
|
...options
|
||||||
}).then(res => {
|
}).then((res) => {
|
||||||
|
if (res.status === 401) {
|
||||||
|
console.error(`[nativeHttp] 401 status for url "${url}"`)
|
||||||
|
// Handle refresh token automatically
|
||||||
|
return this.handleTokenRefresh(method, url, data, headers, options, serverConnectionConfig)
|
||||||
|
}
|
||||||
if (res.status >= 400) {
|
if (res.status >= 400) {
|
||||||
console.error(`[nativeHttp] ${res.status} status for url "${url}"`)
|
console.error(`[nativeHttp] ${res.status} status for url "${url}"`)
|
||||||
throw new Error(res.data)
|
throw new Error(res.data)
|
||||||
@@ -35,6 +48,186 @@ export default function ({ store }, inject) {
|
|||||||
return res.data
|
return res.data
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles token refresh when a 401 Unauthorized response is received
|
||||||
|
* @param {string} method - HTTP method
|
||||||
|
* @param {string} url - Full URL
|
||||||
|
* @param {*} data - Request data
|
||||||
|
* @param {Object} headers - Request headers
|
||||||
|
* @param {Object} options - Additional options
|
||||||
|
* @param {{ id: string, address: string, version: string }} serverConnectionConfig
|
||||||
|
* @returns {Promise} - Promise that resolves with the response data
|
||||||
|
*/
|
||||||
|
async handleTokenRefresh(method, url, data, headers, options, serverConnectionConfig) {
|
||||||
|
try {
|
||||||
|
console.log('[nativeHttp] Attempting to refresh token...')
|
||||||
|
|
||||||
|
if (!serverConnectionConfig?.id) {
|
||||||
|
console.error('[nativeHttp] No server connection config ID available for token refresh')
|
||||||
|
throw new Error('No server connection available')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get refresh token from secure storage
|
||||||
|
const refreshToken = await $db.getRefreshToken(serverConnectionConfig.id)
|
||||||
|
if (!refreshToken) {
|
||||||
|
console.error('[nativeHttp] No refresh token available')
|
||||||
|
throw new Error('No refresh token available')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt to refresh the token
|
||||||
|
const newTokens = await this.refreshAccessToken(refreshToken, serverConnectionConfig.address)
|
||||||
|
if (!newTokens?.accessToken) {
|
||||||
|
console.error('[nativeHttp] Failed to refresh access token')
|
||||||
|
throw new Error('Failed to refresh access token')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the store with new tokens
|
||||||
|
await this.updateTokens(newTokens, serverConnectionConfig)
|
||||||
|
|
||||||
|
// Retry the original request with the new token
|
||||||
|
console.log('[nativeHttp] Retrying original request with new token...')
|
||||||
|
const retryResponse = await CapacitorHttp.request({
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
data,
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
Authorization: `Bearer ${newTokens.accessToken}`
|
||||||
|
},
|
||||||
|
...options
|
||||||
|
})
|
||||||
|
|
||||||
|
if (retryResponse.status >= 400) {
|
||||||
|
console.error(`[nativeHttp] Retry request failed with status ${retryResponse.status}`)
|
||||||
|
throw new Error(retryResponse.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
return retryResponse.data
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[nativeHttp] Token refresh failed:', error)
|
||||||
|
|
||||||
|
// If refresh fails, redirect to login
|
||||||
|
await this.handleRefreshFailure(serverConnectionConfig?.id)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refreshes the access token using the refresh token
|
||||||
|
* @param {string} refreshToken - The refresh token
|
||||||
|
* @param {string} serverAddress - The server address
|
||||||
|
* @returns {Promise<Object|null>} - Promise that resolves with new tokens or null
|
||||||
|
*/
|
||||||
|
async refreshAccessToken(refreshToken, serverAddress) {
|
||||||
|
try {
|
||||||
|
if (!serverAddress) {
|
||||||
|
throw new Error('No server address available')
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[nativeHttp] Refreshing access token...')
|
||||||
|
|
||||||
|
const response = await CapacitorHttp.post({
|
||||||
|
url: `${serverAddress}/auth/refresh`,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-refresh-token': refreshToken
|
||||||
|
},
|
||||||
|
data: {}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.status !== 200) {
|
||||||
|
console.error('[nativeHttp] Token refresh request failed:', response.status)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const userResponseData = response.data
|
||||||
|
if (!userResponseData.user?.accessToken) {
|
||||||
|
console.error('[nativeHttp] No access token in refresh response')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[nativeHttp] Successfully refreshed access token')
|
||||||
|
return {
|
||||||
|
accessToken: userResponseData.user.accessToken,
|
||||||
|
// Refresh token gets returned when refresh token is sent in x-refresh-token header
|
||||||
|
refreshToken: userResponseData.user.refreshToken
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[nativeHttp] Failed to refresh access token:', error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the store and secure storage with new tokens
|
||||||
|
* @param {Object} tokens - Object containing accessToken and refreshToken
|
||||||
|
* @param {{ id: string, address: string, version: string }} serverConnectionConfig
|
||||||
|
* @returns {Promise} - Promise that resolves when tokens are updated
|
||||||
|
*/
|
||||||
|
async updateTokens(tokens, serverConnectionConfig) {
|
||||||
|
try {
|
||||||
|
if (!serverConnectionConfig?.id) {
|
||||||
|
throw new Error('No server connection config ID available')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the config with new tokens
|
||||||
|
const updatedConfig = {
|
||||||
|
...serverConnectionConfig,
|
||||||
|
token: tokens.accessToken,
|
||||||
|
refreshToken: tokens.refreshToken
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save updated config to secure storage, persists refresh token in secure storage
|
||||||
|
const savedConfig = await $db.setServerConnectionConfig(updatedConfig)
|
||||||
|
|
||||||
|
// Update the store
|
||||||
|
store.commit('user/setAccessToken', tokens.accessToken)
|
||||||
|
|
||||||
|
// Re-authenticate socket if necessary
|
||||||
|
if ($socket?.connected && !$socket.isAuthenticated) {
|
||||||
|
$socket.sendAuthenticate()
|
||||||
|
} else if (!$socket) {
|
||||||
|
console.warn('[nativeHttp] Socket not available, cannot re-authenticate')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (savedConfig) {
|
||||||
|
store.commit('user/setServerConnectionConfig', savedConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[nativeHttp] Successfully updated tokens in store and secure storage')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[nativeHttp] Failed to update tokens:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the case when token refresh fails
|
||||||
|
* @param {string} [serverConnectionConfigId]
|
||||||
|
* @returns {Promise} - Promise that resolves when logout is complete
|
||||||
|
*/
|
||||||
|
async handleRefreshFailure(serverConnectionConfigId) {
|
||||||
|
try {
|
||||||
|
console.log('[nativeHttp] Handling refresh failure - logging out user')
|
||||||
|
|
||||||
|
// Clear store
|
||||||
|
await store.dispatch('user/logout')
|
||||||
|
|
||||||
|
if (serverConnectionConfigId) {
|
||||||
|
// Clear refresh token for server connection config
|
||||||
|
await $db.clearRefreshToken(serverConnectionConfigId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect to login page
|
||||||
|
if (window.location.pathname !== '/connect') {
|
||||||
|
window.location.href = '/connect'
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[nativeHttp] Failed to handle refresh failure:', error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
get(url, options = {}) {
|
get(url, options = {}) {
|
||||||
return this.request('GET', url, undefined, options)
|
return this.request('GET', url, undefined, options)
|
||||||
},
|
},
|
||||||
@@ -49,4 +242,4 @@ export default function ({ store }, inject) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
inject('nativeHttp', nativeHttp)
|
inject('nativeHttp', nativeHttp)
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-3
@@ -9,7 +9,7 @@ class ServerSocket extends EventEmitter {
|
|||||||
this.socket = null
|
this.socket = null
|
||||||
this.connected = false
|
this.connected = false
|
||||||
this.serverAddress = null
|
this.serverAddress = null
|
||||||
this.token = null
|
this.isAuthenticated = false
|
||||||
|
|
||||||
this.lastReconnectAttemptTime = 0
|
this.lastReconnectAttemptTime = 0
|
||||||
}
|
}
|
||||||
@@ -26,7 +26,6 @@ class ServerSocket extends EventEmitter {
|
|||||||
|
|
||||||
connect(serverAddress, token) {
|
connect(serverAddress, token) {
|
||||||
this.serverAddress = serverAddress
|
this.serverAddress = serverAddress
|
||||||
this.token = token
|
|
||||||
|
|
||||||
const serverUrl = new URL(serverAddress)
|
const serverUrl = new URL(serverAddress)
|
||||||
const serverHost = `${serverUrl.protocol}//${serverUrl.host}`
|
const serverHost = `${serverUrl.protocol}//${serverUrl.host}`
|
||||||
@@ -53,6 +52,7 @@ class ServerSocket extends EventEmitter {
|
|||||||
this.socket.on('connect', this.onConnect.bind(this))
|
this.socket.on('connect', this.onConnect.bind(this))
|
||||||
this.socket.on('disconnect', this.onDisconnect.bind(this))
|
this.socket.on('disconnect', this.onDisconnect.bind(this))
|
||||||
this.socket.on('init', this.onInit.bind(this))
|
this.socket.on('init', this.onInit.bind(this))
|
||||||
|
this.socket.on('auth_failed', this.onAuthFailed.bind(this))
|
||||||
this.socket.on('user_updated', this.onUserUpdated.bind(this))
|
this.socket.on('user_updated', this.onUserUpdated.bind(this))
|
||||||
this.socket.on('user_item_progress_updated', this.onUserItemProgressUpdated.bind(this))
|
this.socket.on('user_item_progress_updated', this.onUserItemProgressUpdated.bind(this))
|
||||||
this.socket.on('playlist_added', this.onPlaylistAdded.bind(this))
|
this.socket.on('playlist_added', this.onPlaylistAdded.bind(this))
|
||||||
@@ -61,6 +61,11 @@ class ServerSocket extends EventEmitter {
|
|||||||
this.socket.io.on('reconnect_failed', this.onReconnectFailed.bind(this))
|
this.socket.io.on('reconnect_failed', this.onReconnectFailed.bind(this))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sendAuthenticate() {
|
||||||
|
// Required to connect a socket to a user
|
||||||
|
this.socket.emit('auth', this.$store.getters['user/getToken'])
|
||||||
|
}
|
||||||
|
|
||||||
removeListeners() {
|
removeListeners() {
|
||||||
if (!this.socket) return
|
if (!this.socket) return
|
||||||
this.socket.removeAllListeners()
|
this.socket.removeAllListeners()
|
||||||
@@ -74,7 +79,7 @@ class ServerSocket extends EventEmitter {
|
|||||||
this.connected = true
|
this.connected = true
|
||||||
this.$store.commit('setSocketConnected', true)
|
this.$store.commit('setSocketConnected', true)
|
||||||
this.emit('connection-update', true)
|
this.emit('connection-update', true)
|
||||||
this.socket.emit('auth', this.token) // Required to connect a user with their socket
|
this.sendAuthenticate()
|
||||||
}
|
}
|
||||||
|
|
||||||
onReconnectAttempt(attemptNumber) {
|
onReconnectAttempt(attemptNumber) {
|
||||||
@@ -101,6 +106,12 @@ class ServerSocket extends EventEmitter {
|
|||||||
onInit(data) {
|
onInit(data) {
|
||||||
console.log('[SOCKET] Initial socket data received', data)
|
console.log('[SOCKET] Initial socket data received', data)
|
||||||
this.emit('initialized', true)
|
this.emit('initialized', true)
|
||||||
|
this.isAuthenticated = true
|
||||||
|
}
|
||||||
|
|
||||||
|
onAuthFailed(data) {
|
||||||
|
console.log('[SOCKET] Auth failed', data)
|
||||||
|
this.isAuthenticated = false
|
||||||
}
|
}
|
||||||
|
|
||||||
onUserUpdated(data) {
|
onUserUpdated(data) {
|
||||||
|
|||||||
+107
-16
@@ -1,7 +1,10 @@
|
|||||||
import { Browser } from '@capacitor/browser'
|
import { Browser } from '@capacitor/browser'
|
||||||
|
import { AbsLogger } from '@/plugins/capacitor'
|
||||||
|
import { CapacitorHttp } from '@capacitor/core'
|
||||||
|
|
||||||
export const state = () => ({
|
export const state = () => ({
|
||||||
user: null,
|
user: null,
|
||||||
|
accessToken: null,
|
||||||
serverConnectionConfig: null,
|
serverConnectionConfig: null,
|
||||||
settings: {
|
settings: {
|
||||||
mobileOrderBy: 'addedAt',
|
mobileOrderBy: 'addedAt',
|
||||||
@@ -17,7 +20,7 @@ export const getters = {
|
|||||||
getIsRoot: (state) => state.user && state.user.type === 'root',
|
getIsRoot: (state) => state.user && state.user.type === 'root',
|
||||||
getIsAdminOrUp: (state) => state.user && (state.user.type === 'admin' || state.user.type === 'root'),
|
getIsAdminOrUp: (state) => state.user && (state.user.type === 'admin' || state.user.type === 'root'),
|
||||||
getToken: (state) => {
|
getToken: (state) => {
|
||||||
return state.user?.token || null
|
return state.accessToken || null
|
||||||
},
|
},
|
||||||
getServerConnectionConfigId: (state) => {
|
getServerConnectionConfigId: (state) => {
|
||||||
return state.serverConnectionConfig?.id || null
|
return state.serverConnectionConfig?.id || null
|
||||||
@@ -28,19 +31,18 @@ export const getters = {
|
|||||||
getServerConfigName: (state) => {
|
getServerConfigName: (state) => {
|
||||||
return state.serverConnectionConfig?.name || null
|
return state.serverConnectionConfig?.name || null
|
||||||
},
|
},
|
||||||
getCustomHeaders: (state) => {
|
getUserMediaProgress:
|
||||||
return state.serverConnectionConfig?.customHeaders || null
|
(state) =>
|
||||||
},
|
(libraryItemId, episodeId = null) => {
|
||||||
getUserMediaProgress: (state) => (libraryItemId, episodeId = null) => {
|
if (!state.user?.mediaProgress) return null
|
||||||
if (!state.user?.mediaProgress) return null
|
return state.user.mediaProgress.find((li) => {
|
||||||
return state.user.mediaProgress.find(li => {
|
if (episodeId && li.episodeId !== episodeId) return false
|
||||||
if (episodeId && li.episodeId !== episodeId) return false
|
return li.libraryItemId == libraryItemId
|
||||||
return li.libraryItemId == libraryItemId
|
})
|
||||||
})
|
},
|
||||||
},
|
|
||||||
getUserBookmarksForItem: (state) => (libraryItemId) => {
|
getUserBookmarksForItem: (state) => (libraryItemId) => {
|
||||||
if (!state?.user?.bookmarks) return []
|
if (!state?.user?.bookmarks) return []
|
||||||
return state.user.bookmarks.filter(bm => bm.libraryItemId === libraryItemId)
|
return state.user.bookmarks.filter((bm) => bm.libraryItemId === libraryItemId)
|
||||||
},
|
},
|
||||||
getUserSetting: (state) => (key) => {
|
getUserSetting: (state) => (key) => {
|
||||||
return state.settings?.[key] || null
|
return state.settings?.[key] || null
|
||||||
@@ -53,6 +55,9 @@ export const getters = {
|
|||||||
},
|
},
|
||||||
getUserCanDownload: (state) => {
|
getUserCanDownload: (state) => {
|
||||||
return !!state.user?.permissions?.download
|
return !!state.user?.permissions?.download
|
||||||
|
},
|
||||||
|
getUserCanAccessExplicitContent: (state) => {
|
||||||
|
return !!state.user?.permissions?.accessExplicitContent
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,24 +137,110 @@ export const actions = {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error opening browser', error)
|
console.error('Error opening browser', error)
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
async logout({ state, commit }, logoutFromServer = false) {
|
||||||
|
// Logging out from server deletes the session so the refresh token is no longer valid
|
||||||
|
// Currently this is not being used to support switching servers without logging back in (assuming refresh token is still valid)
|
||||||
|
// We may want to make this change in the future
|
||||||
|
if (state.serverConnectionConfig && logoutFromServer) {
|
||||||
|
const refreshToken = await this.$db.getRefreshToken(state.serverConnectionConfig.id)
|
||||||
|
const options = {}
|
||||||
|
if (refreshToken) {
|
||||||
|
// Refresh token is used to delete the session on the server
|
||||||
|
options.headers = {
|
||||||
|
'x-refresh-token': refreshToken
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Logout from server
|
||||||
|
await this.$nativeHttp.post('/logout', null, options).catch((error) => {
|
||||||
|
console.error('Failed to logout', error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.$db.logout()
|
||||||
|
this.$socket.logout()
|
||||||
|
this.$localStore.removeLastLibraryId()
|
||||||
|
commit('logout')
|
||||||
|
commit('libraries/setCurrentLibrary', null, { root: true })
|
||||||
|
await AbsLogger.info({ tag: 'user', message: `Logged out from server ${state.serverConnectionConfig?.name || 'Not connected'}` })
|
||||||
|
},
|
||||||
|
async refreshToken({ getters, commit, state }) {
|
||||||
|
const refreshToken = await this.$db.getRefreshToken(getters.getServerConnectionConfigId)
|
||||||
|
if (!refreshToken) {
|
||||||
|
console.error('No refresh token found')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverAddress = getters.getServerAddress
|
||||||
|
|
||||||
|
const response = await CapacitorHttp.post({
|
||||||
|
url: `${serverAddress}/auth/refresh`,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-refresh-token': refreshToken
|
||||||
|
},
|
||||||
|
data: {}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.status !== 200) {
|
||||||
|
console.error('[user] Token refresh request failed:', response.status)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const userResponseData = response.data
|
||||||
|
if (!userResponseData.user?.accessToken) {
|
||||||
|
console.error('[user] No access token in refresh response')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the config with new tokens
|
||||||
|
const updatedConfig = {
|
||||||
|
...state.serverConnectionConfig,
|
||||||
|
token: userResponseData.user.accessToken,
|
||||||
|
refreshToken: userResponseData.user.refreshToken
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save updated config to secure storage, persists refresh token in secure storage
|
||||||
|
const savedConfig = await this.$db.setServerConnectionConfig(updatedConfig)
|
||||||
|
|
||||||
|
// Update the store
|
||||||
|
commit('setAccessToken', userResponseData.user.accessToken)
|
||||||
|
|
||||||
|
// Re-authenticate socket if necessary
|
||||||
|
if (this.$socket?.connected && !this.$socket.isAuthenticated) {
|
||||||
|
this.$socket.sendAuthenticate()
|
||||||
|
} else if (!this.$socket) {
|
||||||
|
console.warn('[user] Socket not available, cannot re-authenticate')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (savedConfig) {
|
||||||
|
commit('setServerConnectionConfig', savedConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
return userResponseData.user.accessToken
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const mutations = {
|
export const mutations = {
|
||||||
logout(state) {
|
logout(state) {
|
||||||
state.user = null
|
state.user = null
|
||||||
|
state.accessToken = null
|
||||||
state.serverConnectionConfig = null
|
state.serverConnectionConfig = null
|
||||||
},
|
},
|
||||||
setUser(state, user) {
|
setUser(state, user) {
|
||||||
state.user = user
|
state.user = user
|
||||||
},
|
},
|
||||||
|
setAccessToken(state, accessToken) {
|
||||||
|
console.log('[user] setAccessToken', accessToken)
|
||||||
|
state.accessToken = accessToken
|
||||||
|
},
|
||||||
removeMediaProgress(state, id) {
|
removeMediaProgress(state, id) {
|
||||||
if (!state.user) return
|
if (!state.user) return
|
||||||
state.user.mediaProgress = state.user.mediaProgress.filter(mp => mp.id != id)
|
state.user.mediaProgress = state.user.mediaProgress.filter((mp) => mp.id != id)
|
||||||
},
|
},
|
||||||
updateUserMediaProgress(state, data) {
|
updateUserMediaProgress(state, data) {
|
||||||
if (!data || !state.user) return
|
if (!data || !state.user) return
|
||||||
const mediaProgressIndex = state.user.mediaProgress.findIndex(mp => mp.id === data.id)
|
const mediaProgressIndex = state.user.mediaProgress.findIndex((mp) => mp.id === data.id)
|
||||||
if (mediaProgressIndex >= 0) {
|
if (mediaProgressIndex >= 0) {
|
||||||
state.user.mediaProgress.splice(mediaProgressIndex, 1, data)
|
state.user.mediaProgress.splice(mediaProgressIndex, 1, data)
|
||||||
} else {
|
} else {
|
||||||
@@ -174,9 +265,9 @@ export const mutations = {
|
|||||||
},
|
},
|
||||||
deleteBookmark(state, { libraryItemId, time }) {
|
deleteBookmark(state, { libraryItemId, time }) {
|
||||||
if (!state.user?.bookmarks) return
|
if (!state.user?.bookmarks) return
|
||||||
state.user.bookmarks = state.user.bookmarks.filter(bm => {
|
state.user.bookmarks = state.user.bookmarks.filter((bm) => {
|
||||||
if (bm.libraryItemId === libraryItemId && bm.time === time) return false
|
if (bm.libraryItemId === libraryItemId && bm.time === time) return false
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+298
-11
@@ -6,12 +6,13 @@
|
|||||||
"ButtonCancel": "إلغاء",
|
"ButtonCancel": "إلغاء",
|
||||||
"ButtonCancelTimer": "إلغاء المؤقت",
|
"ButtonCancelTimer": "إلغاء المؤقت",
|
||||||
"ButtonClearFilter": "تصفية الفرز",
|
"ButtonClearFilter": "تصفية الفرز",
|
||||||
|
"ButtonClearLogs": "مسح السجل",
|
||||||
"ButtonCloseFeed": "إغلاق",
|
"ButtonCloseFeed": "إغلاق",
|
||||||
"ButtonCollections": "المجموعات",
|
"ButtonCollections": "المجموعات",
|
||||||
"ButtonConnect": "اتصل",
|
"ButtonConnect": "اتصل",
|
||||||
"ButtonConnectToServer": "اتصال بالخادم",
|
"ButtonConnectToServer": "اتصال بالخادم",
|
||||||
"ButtonCreate": "إنشاء",
|
"ButtonCreate": "إنشاء",
|
||||||
"ButtonCreateBookmark": "إنشاء إشارة مرجعية",
|
"ButtonCreateBookmark": "إنشاء علامة مرجعية",
|
||||||
"ButtonCreateNewPlaylist": "إنشاء قائمة تشغيل جديدة",
|
"ButtonCreateNewPlaylist": "إنشاء قائمة تشغيل جديدة",
|
||||||
"ButtonDelete": "حذف",
|
"ButtonDelete": "حذف",
|
||||||
"ButtonDeleteLocalEpisode": "احذف الحلقة المحلية",
|
"ButtonDeleteLocalEpisode": "احذف الحلقة المحلية",
|
||||||
@@ -20,24 +21,27 @@
|
|||||||
"ButtonDisableAutoTimer": "تعطيل المؤقت التلقائي",
|
"ButtonDisableAutoTimer": "تعطيل المؤقت التلقائي",
|
||||||
"ButtonDisconnect": "فصل",
|
"ButtonDisconnect": "فصل",
|
||||||
"ButtonGoToWebClient": "اذهب إلى عميل الويب",
|
"ButtonGoToWebClient": "اذهب إلى عميل الويب",
|
||||||
"ButtonHistory": "تاريخ",
|
"ButtonHistory": "السجل التاريخي",
|
||||||
"ButtonHome": "الرئيسية",
|
"ButtonHome": "الرئيسية",
|
||||||
"ButtonIssues": "مشاكل",
|
"ButtonIssues": "مشاكل",
|
||||||
"ButtonLatest": "أحدث",
|
"ButtonLatest": "الأحدث",
|
||||||
"ButtonLibrary": "المكتبة",
|
"ButtonLibrary": "المكتبة",
|
||||||
"ButtonLocalMedia": "ملفات الوسائط المحلية",
|
"ButtonLocalMedia": "الوسائط المحلية",
|
||||||
|
"ButtonLogs": "سجلات",
|
||||||
"ButtonManageLocalFiles": "إدارة الملفات المحلية",
|
"ButtonManageLocalFiles": "إدارة الملفات المحلية",
|
||||||
|
"ButtonMaskServerAddress": "إخفاء عنوان الخادم",
|
||||||
"ButtonNewFolder": "مجلد جديد",
|
"ButtonNewFolder": "مجلد جديد",
|
||||||
"ButtonNextEpisode": "الحلقة التالية",
|
"ButtonNextEpisode": "الحلقة التالية",
|
||||||
|
"ButtonOk": "موافق",
|
||||||
"ButtonOpenFeed": "فتح التغذية",
|
"ButtonOpenFeed": "فتح التغذية",
|
||||||
"ButtonOverride": "تجاوز",
|
"ButtonOverride": "تجاوز",
|
||||||
"ButtonPause": "تَوَقَّف",
|
"ButtonPause": "إيقاف مؤقت",
|
||||||
"ButtonPlay": "تشغيل",
|
"ButtonPlay": "تشغيل",
|
||||||
"ButtonPlayEpisode": "شغل الحلقة",
|
"ButtonPlayEpisode": "تشغيل الحلقة",
|
||||||
"ButtonPlaylists": "قوائم التشغيل",
|
"ButtonPlaylists": "قوائم التشغيل",
|
||||||
"ButtonRead": "اقرأ",
|
"ButtonRead": "اقرأ",
|
||||||
"ButtonReadLess": "قلص",
|
"ButtonReadLess": "اقرأ أقل",
|
||||||
"ButtonReadMore": "المزيد",
|
"ButtonReadMore": "اقرأ أكثر",
|
||||||
"ButtonRemove": "إزالة",
|
"ButtonRemove": "إزالة",
|
||||||
"ButtonRemoveFromServer": "إزالة من الخادم",
|
"ButtonRemoveFromServer": "إزالة من الخادم",
|
||||||
"ButtonSave": "حفظ",
|
"ButtonSave": "حفظ",
|
||||||
@@ -47,21 +51,304 @@
|
|||||||
"ButtonSeries": "سلسلة",
|
"ButtonSeries": "سلسلة",
|
||||||
"ButtonSetTimer": "اضبط مؤقت",
|
"ButtonSetTimer": "اضبط مؤقت",
|
||||||
"ButtonStream": "بث",
|
"ButtonStream": "بث",
|
||||||
"ButtonSubmit": "تقديم",
|
"ButtonSubmit": "إرسال",
|
||||||
"ButtonSwitchServerUser": "تبديل الخادم/المستخدم",
|
"ButtonSwitchServerUser": "تبديل الخادم/المستخدم",
|
||||||
|
"ButtonUnmaskServerAddress": "إظهار عنوان الخادم",
|
||||||
"ButtonUserStats": "إحصائيات المستخدم",
|
"ButtonUserStats": "إحصائيات المستخدم",
|
||||||
"ButtonYes": "نعم",
|
"ButtonYes": "نعم",
|
||||||
"HeaderAccount": "الحساب",
|
"HeaderAccount": "الحساب",
|
||||||
"HeaderAdvanced": "متقدم",
|
"HeaderAdvanced": "متقدم",
|
||||||
"HeaderAudioTracks": "المسارات الصوتية",
|
"HeaderAndroidAutoSettings": "إعدادات أندرويد للسيارة",
|
||||||
|
"HeaderAudioTracks": "المقاطع الصوتية",
|
||||||
"HeaderChapters": "الفصول",
|
"HeaderChapters": "الفصول",
|
||||||
"HeaderCollection": "مجموعة",
|
"HeaderCollection": "مجموعة",
|
||||||
"HeaderCollectionItems": "عناصر المجموعة",
|
"HeaderCollectionItems": "عناصر المجموعة",
|
||||||
|
"HeaderConnectionStatus": "حالة الاتصال",
|
||||||
"HeaderDataSettings": "إعدادات البيانات",
|
"HeaderDataSettings": "إعدادات البيانات",
|
||||||
"HeaderDetails": "التفاصيل",
|
"HeaderDetails": "التفاصيل",
|
||||||
|
"HeaderDownloads": "التنزيلات",
|
||||||
"HeaderEbookFiles": "ملفات الكتب الإلكترونية",
|
"HeaderEbookFiles": "ملفات الكتب الإلكترونية",
|
||||||
"HeaderEpisodes": "الحلقات",
|
"HeaderEpisodes": "الحلقات",
|
||||||
"HeaderEreaderSettings": "إعدادات القارئ الإلكتروني",
|
"HeaderEreaderSettings": "إعدادات القارئ الإلكتروني",
|
||||||
"HeaderLatestEpisodes": "أحدث الحلقات",
|
"HeaderLatestEpisodes": "أحدث الحلقات",
|
||||||
"HeaderLibraries": "المكتبات"
|
"HeaderLibraries": "المكتبات",
|
||||||
|
"HeaderLocalFolders": "المجلدات المحلية",
|
||||||
|
"HeaderLocalLibraryItems": "عناصر المكتبة المحلية",
|
||||||
|
"HeaderNewPlaylist": "قائمة تشغيل جديدة",
|
||||||
|
"HeaderOpenRSSFeed": "عرض تغذية RSS",
|
||||||
|
"HeaderPlaybackSettings": "إعدادات التشغيل",
|
||||||
|
"HeaderPlaylist": "قائمة تشغيل",
|
||||||
|
"HeaderPlaylistItems": "عناصر قائمة التشغيل",
|
||||||
|
"HeaderProgressSyncFailed": "فشلت المزامنة",
|
||||||
|
"HeaderRSSFeed": "تغذية RSS",
|
||||||
|
"HeaderRSSFeedGeneral": "تفاصيل RSS",
|
||||||
|
"HeaderRSSFeedIsOpen": "مغذي RSS مفتوح",
|
||||||
|
"HeaderSelectDownloadLocation": "حدد موقع التنزيل",
|
||||||
|
"HeaderSettings": "إعدادات",
|
||||||
|
"HeaderSleepTimer": "مؤقت النوم",
|
||||||
|
"HeaderSleepTimerSettings": "إعدادات مؤقت النوم",
|
||||||
|
"HeaderStatsMinutesListeningChart": "الدقائق المسموعة (آخر 7 أيام)",
|
||||||
|
"HeaderStatsRecentSessions": "الجلسات الأخيرة",
|
||||||
|
"HeaderTableOfContents": "جدول المحتويات",
|
||||||
|
"HeaderUserInterfaceSettings": "إعدادات واجهة المستخدم",
|
||||||
|
"HeaderYourStats": "إحصائياتك",
|
||||||
|
"LabelAddToPlaylist": "أضف إلى قائمة التشغيل",
|
||||||
|
"LabelAddedAt": "أضيفت على",
|
||||||
|
"LabelAddedDate": "تمت الإضافة",
|
||||||
|
"LabelAll": "الكل",
|
||||||
|
"LabelAllowSeekingOnMediaControls": "السماح بالبحث عن الموضع في التحكم بإشعارات الوسائط",
|
||||||
|
"LabelAlways": "دائماً",
|
||||||
|
"LabelAndroidAutoBrowseLimitForGrouping": "حد السحب الأبجدي",
|
||||||
|
"LabelAndroidAutoBrowseLimitForGroupingHelp": "لا تستخدم السحب الأبجدي عندما يكون عدد العناصر المراد عرضها أقل من هذا العدد",
|
||||||
|
"LabelAndroidAutoBrowseSeriesSequenceOrder": "ترتيب كتب السلسلة",
|
||||||
|
"LabelAskConfirmation": "اطلب التأكيد",
|
||||||
|
"LabelAuthor": "المؤلف",
|
||||||
|
"LabelAuthorFirstLast": "المؤلف (الاسم الأول الأخير)",
|
||||||
|
"LabelAuthorLastFirst": "المؤلف (الاسم الأخير، الأول)",
|
||||||
|
"LabelAuthors": "المؤلفون",
|
||||||
|
"LabelAutoDownloadEpisodes": "تنزيل الحلقات تلقائيًا",
|
||||||
|
"LabelAutoRewindTime": "إعادة الوقت تلقائياَ",
|
||||||
|
"LabelAutoSleepTimer": "مؤقت النوم التلقائي",
|
||||||
|
"LabelAutoSleepTimerAutoRewind": "مؤقتا النوم والإرجاع التلقائيين",
|
||||||
|
"LabelAutoSleepTimerAutoRewindHelp": "عندما ينتهي مؤقت النوم التلقائي، فإن تشغيل العنصر مرة أخرى سيؤدي إلى الإرجاع التلقائي.",
|
||||||
|
"LabelAutoSleepTimerHelp": "عند تشغيل الوسائط بين أوقات البداية والنهاية المحددة، سيبدأ مؤقت النوم تلقائيًا.",
|
||||||
|
"LabelBooks": "الكتب",
|
||||||
|
"LabelChapterTrack": "مسار الفصل",
|
||||||
|
"LabelChapters": "الفصول",
|
||||||
|
"LabelClosePlayer": "إغلاق المشغل",
|
||||||
|
"LabelCollapseSeries": "إخفاء المسلسلات",
|
||||||
|
"LabelComplete": "مكتمل",
|
||||||
|
"LabelContinueBooks": "استمرار الكتب",
|
||||||
|
"LabelContinueEpisodes": "استمرار الحلقات",
|
||||||
|
"LabelContinueListening": "استمرار الاستماع",
|
||||||
|
"LabelContinueReading": "استمرار القراءة",
|
||||||
|
"LabelContinueSeries": "استمرار المسلسلات",
|
||||||
|
"LabelCustomTime": "الوقت المخصص",
|
||||||
|
"LabelDescription": "الوصف",
|
||||||
|
"LabelDisableAudioFadeOut": "تعطيل التلاشي الصوتي",
|
||||||
|
"LabelDisableAudioFadeOutHelp": "سيبدأ مستوى الصوت بالانخفاض عندما يتبقى أقل من دقيقة واحدة على مؤقت النوم. فعّل هذا الإعداد لعدم التلاشي.",
|
||||||
|
"LabelDisableAutoRewind": "تعطيل الإعادة التلقائية",
|
||||||
|
"LabelDisableShakeToReset": "تعطيل الرج لإعادة الضبط",
|
||||||
|
"LabelDisableShakeToResetHelp": "سيؤدي هزّ جهازك أثناء تشغيل المؤقت أو خلال دقيقتين من انتهاءه إلى إعادة ضبط مؤقت النوم. فعّل هذا الإعداد لتعطيل الرج لإعادة الضبط.",
|
||||||
|
"LabelDisableVibrateOnReset": "تعطيل الاهتزاز عند إعادة الضبط",
|
||||||
|
"LabelDisableVibrateOnResetHelp": "عند إعادة ضبط مؤقت النوم، سيهتز جهازك. فعّل هذا الإعداد لعدم الاهتزاز عند إعادة ضبط مؤقت النوم.",
|
||||||
|
"LabelDiscover": "استكشف",
|
||||||
|
"LabelDownload": "تنزيل",
|
||||||
|
"LabelDownloadUsingCellular": "التنزيل عبر بيانات الهاتف",
|
||||||
|
"LabelDownloaded": "تم تنزيلها",
|
||||||
|
"LabelDuration": "المدة",
|
||||||
|
"LabelEbook": "الكتاب الإلكتروني",
|
||||||
|
"LabelEbooks": "الكتب الإلكترونية",
|
||||||
|
"LabelEnable": "تمكين",
|
||||||
|
"LabelEnableMp3IndexSeeking": "تمكين البحث عن فهرس mp3",
|
||||||
|
"LabelEnableMp3IndexSeekingHelp": "يجب تفعيل هذا الإعداد فقط إذا كانت ملفات MP3 لديك لا تعمل بشكل صحيح. غالبًا ما يكون البحث غير الدقيق ناتجًا عن ملفات MP3 ذات معدل البت المتغير (VBR). سيفرض هذا الإعداد البحث عن الفهرس، حيث يتم إنشاء تعيين زمني للبايت أثناء قراءة الملف. في بعض الحالات، مع ملفات MP3 كبيرة الحجم، قد يكون هناك تأخير عند البحث قرب نهاية الملف.",
|
||||||
|
"LabelEnd": "انهاء",
|
||||||
|
"LabelEndOfChapter": "نهاية الفصل",
|
||||||
|
"LabelEndTime": "وقت النهاية",
|
||||||
|
"LabelEpisode": "الحلقة",
|
||||||
|
"LabelFeedURL": "عنوان التغذية",
|
||||||
|
"LabelFile": "الملف",
|
||||||
|
"LabelFileBirthtime": "وقت انشاء الملف",
|
||||||
|
"LabelFileModified": "تم تعديل الملف",
|
||||||
|
"LabelFilename": "اسم الملف",
|
||||||
|
"LabelFinished": "المنجزة",
|
||||||
|
"LabelFolder": "المجلد",
|
||||||
|
"LabelFontBoldness": "تعريض الخط",
|
||||||
|
"LabelFontScale": "نطاق الخط",
|
||||||
|
"LabelGenre": "التصنيف",
|
||||||
|
"LabelGenres": "التصانيف",
|
||||||
|
"LabelHapticFeedback": "ردود الفعل اللمسية",
|
||||||
|
"LabelHasEbook": "يحتوي كتاب إلكتروني",
|
||||||
|
"LabelHasSupplementaryEbook": "يحتوي كتاب إلكتروني تكميلي",
|
||||||
|
"LabelHeavy": "ثقيل",
|
||||||
|
"LabelHigh": "مرتفع",
|
||||||
|
"LabelHost": "المضيف",
|
||||||
|
"LabelInProgress": "تحت التنفيذ",
|
||||||
|
"LabelIncomplete": "غير مكتمل",
|
||||||
|
"LabelInternalAppStorage": "وحدة تخزين التطبيق الداخلي",
|
||||||
|
"LabelJumpBackwardsTime": "وقت القفز للخلف",
|
||||||
|
"LabelJumpForwardsTime": "وقت القفز للأمام",
|
||||||
|
"LabelKeepScreenAwake": "إبقاء الشاشة يقظة",
|
||||||
|
"LabelLanguage": "اللغة",
|
||||||
|
"LabelLayout": "التنسيق",
|
||||||
|
"LabelLayoutAuto": "تلقائي",
|
||||||
|
"LabelLayoutSinglePage": "صفحة واحدة",
|
||||||
|
"LabelLight": "فاتح",
|
||||||
|
"LabelLineSpacing": "تباعد الأسطر",
|
||||||
|
"LabelListenAgain": "الاستماع مجدداً",
|
||||||
|
"LabelLocalBooks": "الكتب المحلية",
|
||||||
|
"LabelLocalPodcasts": "المدونات الصوتية المحلية",
|
||||||
|
"LabelLockOrientation": "قفل الاتجاه",
|
||||||
|
"LabelLockPlayer": "قفل المشغل",
|
||||||
|
"LabelLow": "منخفض",
|
||||||
|
"LabelMediaType": "نوع الوسائط",
|
||||||
|
"LabelMedium": "متوسط",
|
||||||
|
"LabelMissing": "مفقود",
|
||||||
|
"LabelMore": "أكثر",
|
||||||
|
"LabelMoreInfo": "معلومات أكثر",
|
||||||
|
"LabelName": "الاسم",
|
||||||
|
"LabelNarrator": "الراوي",
|
||||||
|
"LabelNarrators": "الرواة",
|
||||||
|
"LabelNavigateWithVolume": "التنقل بمفاتيح التحكم بالصوت",
|
||||||
|
"LabelNavigateWithVolumeMirrored": "معكوس",
|
||||||
|
"LabelNavigateWithVolumeWhilePlaying": "السماح لمفاتيح التحكم بالصوت بالتنقل أثناء التشغيل",
|
||||||
|
"LabelNavigateWithVolumeWhilePlayingDisabled": "إيقاف",
|
||||||
|
"LabelNavigateWithVolumeWhilePlayingEnabled": "مُفعَّل",
|
||||||
|
"LabelNever": "أبداً",
|
||||||
|
"LabelNewestAuthors": "أجدد المؤلفين",
|
||||||
|
"LabelNewestEpisodes": "أجدد الحلقات",
|
||||||
|
"LabelNo": "لا",
|
||||||
|
"LabelNotFinished": "لم يتم الانتهاء",
|
||||||
|
"LabelNotStarted": "لم يتم البدء",
|
||||||
|
"LabelNumEpisodes": "{0} حلقات",
|
||||||
|
"LabelNumEpisodesIncomplete": "{0} حلقات، {1} غير مكتمل",
|
||||||
|
"LabelNumberOfEpisodes": "# من الحلقات",
|
||||||
|
"LabelOff": "ايقاف",
|
||||||
|
"LabelOn": "مُفعَّل",
|
||||||
|
"LabelPassword": "كلمة المرور",
|
||||||
|
"LabelPath": "مسار",
|
||||||
|
"LabelPlaybackDirect": "مباشر",
|
||||||
|
"LabelPlaybackLocal": "محلي",
|
||||||
|
"LabelPlaybackSpeed": "سرعة التشغيل",
|
||||||
|
"LabelPlaybackTranscode": "تحويل الترميز",
|
||||||
|
"LabelPodcast": "مدونة صوتية",
|
||||||
|
"LabelPodcasts": "بودكاست",
|
||||||
|
"LabelPreventIndexing": "منع فهرسة تغذيتك بواسطة دليل آيتونز وقوقل بودكاست",
|
||||||
|
"LabelProgress": "تقدم",
|
||||||
|
"LabelPubDate": "تاريخ النشر",
|
||||||
|
"LabelPublishYear": "سنة النشر",
|
||||||
|
"LabelPublishedDate": "منشور {0}",
|
||||||
|
"LabelRSSFeedCustomOwnerEmail": "البريد الالكتروني المخصص للمالك",
|
||||||
|
"LabelRSSFeedCustomOwnerName": "الاسم المخصص للمالك",
|
||||||
|
"LabelRSSFeedOpen": "موجز RSS مفتوح",
|
||||||
|
"LabelRSSFeedPreventIndexing": "منع الفهرسة",
|
||||||
|
"LabelRSSFeedSlug": "اسم تعريف تغذية RSS",
|
||||||
|
"LabelRandomly": "عشوائياً",
|
||||||
|
"LabelRead": "اقرأ",
|
||||||
|
"LabelReadAgain": "اقرأ مرة أخرى",
|
||||||
|
"LabelRecentSeries": "المسلسلات الحديثة",
|
||||||
|
"LabelRecentlyAdded": "المضافة حديثاً",
|
||||||
|
"LabelRemoveFromPlaylist": "إزالة من قائمة التشغيل",
|
||||||
|
"LabelScaleElapsedTimeBySpeed": "تعديل الوقت المنصرم حسب السرعة",
|
||||||
|
"LabelSeason": "الموسم",
|
||||||
|
"LabelSelectADevice": "اختيار جهاز",
|
||||||
|
"LabelSequenceAscending": "تسلسل تصاعدي",
|
||||||
|
"LabelSequenceDescending": "تسلسل تنازلي",
|
||||||
|
"LabelSeries": "المسلسلات",
|
||||||
|
"LabelServerAddress": "عنوان الخادم",
|
||||||
|
"LabelSetEbookAsPrimary": "تعيين كرئيسي",
|
||||||
|
"LabelSetEbookAsSupplementary": "تعيين كتكميلي",
|
||||||
|
"LabelShakeSensitivity": "حساسية الرج",
|
||||||
|
"LabelShowAll": "إظهار الكل",
|
||||||
|
"LabelSize": "الحجم",
|
||||||
|
"LabelSleepTimer": "مؤقت النوم",
|
||||||
|
"LabelSleepTimerAlmostDoneChime": "تشغيل نغمة عند اقتراب النهاية",
|
||||||
|
"LabelSleepTimerAlmostDoneChimeHelp": "تشغيل نغمة عندما يتبقى 30 ثانية على مؤقت النوم",
|
||||||
|
"LabelStart": "ابدأ",
|
||||||
|
"LabelStartTime": "وقت البدء",
|
||||||
|
"LabelStatsBestDay": "أفضل يوم",
|
||||||
|
"LabelStatsDailyAverage": "المتوسط اليومي",
|
||||||
|
"LabelStatsDays": "أيام",
|
||||||
|
"LabelStatsDaysListened": "أيام الاستماع",
|
||||||
|
"LabelStatsInARow": "على التوالي",
|
||||||
|
"LabelStatsItemsFinished": "العناصر المنتهية",
|
||||||
|
"LabelStatsMinutes": "دقائق",
|
||||||
|
"LabelStatsMinutesListening": "دقائق الاستماع",
|
||||||
|
"LabelStatsWeekListening": "استماع هذا الأسبوع",
|
||||||
|
"LabelStreamingUsingCellular": "البث عبر شبكة الجوال",
|
||||||
|
"LabelTag": "علامة",
|
||||||
|
"LabelTags": "علامات",
|
||||||
|
"LabelTheme": "النمط",
|
||||||
|
"LabelThemeBlack": "أسود",
|
||||||
|
"LabelThemeDark": "غامق",
|
||||||
|
"LabelThemeLight": "فاتح",
|
||||||
|
"LabelTimeRemaining": "{0} متبقية",
|
||||||
|
"LabelTitle": "عنوان",
|
||||||
|
"LabelTotalSize": "الحجم الكلي",
|
||||||
|
"LabelTotalTrack": "إجمالي المسارات",
|
||||||
|
"LabelTracks": "المسارات",
|
||||||
|
"LabelType": "نوع",
|
||||||
|
"LabelUnknown": "مجهول",
|
||||||
|
"LabelUnlockPlayer": "تفعيل المشغل",
|
||||||
|
"LabelUseBookshelfView": "تفعيل عرض الرف",
|
||||||
|
"LabelUser": "مستخدم",
|
||||||
|
"LabelUsername": "اسم المستخدم",
|
||||||
|
"LabelVeryHigh": "عالي جدًا",
|
||||||
|
"LabelVeryLow": "منخفض جدًا",
|
||||||
|
"LabelYearReviewHide": "إخفاء ملخص العام",
|
||||||
|
"LabelYearReviewShow": "عرض ملخص العام",
|
||||||
|
"LabelYourBookmarks": "علاماتك المرجعية",
|
||||||
|
"LabelYourProgress": "تقدمك",
|
||||||
|
"MessageAndroid10Downloads": "سيستخدم نظام أندرويد 10 والإصدارات الأقدم وحدة التخزين الداخلية.",
|
||||||
|
"MessageAttemptingServerConnection": "جاري محاولة الاتصال بالخادم...",
|
||||||
|
"MessageAudiobookshelfServerNotConnected": "خادم Audiobookshelf غير متصل",
|
||||||
|
"MessageAudiobookshelfServerRequired": "<strong>مهم!</strong> هذا التطبيق مصمم للعمل مع خادم Audiobookshelf تستضيفه أنت أو شخص تعرفه. هذا التطبيق لا يقدم أي محتوى.",
|
||||||
|
"MessageBookshelfEmpty": "الرف فارغ",
|
||||||
|
"MessageConfirmDeleteLocalEpisode": "هل تريد بالتأكيد إزالة الحلقة المحلية \"{0}\" من جهازك؟ لن يتأثر الملف الموجود على الخادم.",
|
||||||
|
"MessageConfirmDeleteLocalFiles": "هل تريد بالتأكيد إزالة الملفات المحلية لهذا العنصر من جهازك؟ الملفات الموجودة على الخادم وتقدمك لن يتأثرا.",
|
||||||
|
"MessageConfirmDisableAutoTimer": "هل أنت متأكد أنك تريد تعطيل المؤقت التلقائي لبقية اليوم؟ سيتم إعادة تفعيل المؤقت في نهاية فترة مؤقت النوم التلقائي هذه، أو إذا قمت بإعادة تشغيل التطبيق.",
|
||||||
|
"MessageConfirmDiscardProgress": "هل أنت متأكد أنك تريد إعادة تعيين تقدمك؟",
|
||||||
|
"MessageConfirmDownloadUsingCellular": "أنت على وشك التنزيل باستخدام بيانات الجوال. قد يشمل ذلك رسوم استخدام بيانات من شركة الاتصالات. هل ترغب في المتابعة؟",
|
||||||
|
"MessageConfirmMarkAsFinished": "هل أنت متأكد أنك تريد وضع علامة \"مكتمل\" على هذا العنصر؟",
|
||||||
|
"MessageConfirmRemoveBookmark": "هل أنت متأكد أنك تريد إزالة العلامة المرجعية؟",
|
||||||
|
"MessageConfirmStreamingUsingCellular": "أنت على وشك البث باستخدام بيانات الجوال. قد يشمل ذلك رسوم استخدام بيانات من شركة الاتصالات. هل ترغب في المتابعة؟",
|
||||||
|
"MessageDiscardProgress": "إلغاء التقدم",
|
||||||
|
"MessageDownloadCompleteProcessing": "اكتمل التنزيل. جاري المعالجة...",
|
||||||
|
"MessageDownloading": "جاري التنزيل...",
|
||||||
|
"MessageDownloadingEpisode": "جاري تنزيل الحلقة",
|
||||||
|
"MessageEpisodesQueuedForDownload": "تمت إضافة {0} حلقة (حلقات) إلى قائمة انتظار التنزيل",
|
||||||
|
"MessageFeedURLWillBe": "سيكون رابط التغذية هو {0}",
|
||||||
|
"MessageFetching": "جاري الجلب...",
|
||||||
|
"MessageFollowTheProjectOnGithub": "تابع المشروع على Github",
|
||||||
|
"MessageItemDownloadCompleteFailedToCreate": "اكتمل تنزيل العنصر ولكن فشل إنشاء عنصر المكتبة",
|
||||||
|
"MessageItemMissing": "العنصر مفقود ويجب إصلاحه على الخادم. عادةً ما يتم وضع علامة \"مفقود\" على العنصر لأن مسارات الملفات غير قابلة للوصول.",
|
||||||
|
"MessageLoading": "جاري التحميل...",
|
||||||
|
"MessageLoadingServerData": "جاري تحميل بيانات الخادم...",
|
||||||
|
"MessageLocalFolderDescription": "\"وحدة تخزين التطبيق الداخلية\" لا يمكن الوصول إليها إلا بواسطة هذا التطبيق. هذا التطبيق يدعم فقط الوسائط التي تم تنزيلها مباشرةً من خلال التطبيق. يمكن استخدام مجلدات التخزين المشتركة للسماح لتطبيقات أخرى بالوصول إلى الوسائط التي تم تنزيلها بواسطة هذا التطبيق.",
|
||||||
|
"MessageMarkAsFinished": "وضع علامة \"تم الإنتهاء\"",
|
||||||
|
"MessageMediaLinkedToADifferentServer": "الوسائط مرتبطة بخادم Audiobookshelf على عنوان مختلف ({0}). ستتم مزامنة التقدم عند الاتصال بعنوان هذا الخادم.",
|
||||||
|
"MessageMediaLinkedToADifferentUser": "الوسائط مرتبطة بهذا الخادم ولكن تم تنزيلها بواسطة مستخدم مختلف. ستتم مزامنة التقدم فقط مع المستخدم الذي قام بتنزيلها.",
|
||||||
|
"MessageMediaLinkedToServer": "مرتبط بالخادم {0}",
|
||||||
|
"MessageMediaLinkedToThisServer": "الوسائط التي تم تنزيلها مرتبطة بهذا الخادم",
|
||||||
|
"MessageMediaNotLinkedToServer": "الوسائط غير مرتبطة بخادم Audiobookshelf. لن تتم مزامنة أي تقدم.",
|
||||||
|
"MessageNoBookmarks": "لا توجد علامات مرجعية",
|
||||||
|
"MessageNoChapters": "لا توجد فصول",
|
||||||
|
"MessageNoCollections": "لا توجد مجموعات",
|
||||||
|
"MessageNoItems": "لا توجد عناصر",
|
||||||
|
"MessageNoItemsFound": "لم يتم العثور على عناصر",
|
||||||
|
"MessageNoListeningSessions": "لا توجد جلسات استماع",
|
||||||
|
"MessageNoLogs": "لا توجد سجلات",
|
||||||
|
"MessageNoMediaFolders": "لا توجد مجلدات وسائط",
|
||||||
|
"MessageNoNetworkConnection": "لا يوجد اتصال بالشبكة",
|
||||||
|
"MessageNoPodcastsFound": "لم يتم العثور على أي بودكاست",
|
||||||
|
"MessageNoSeries": "لا توجد سلاسل",
|
||||||
|
"MessageNoUpdatesWereNecessary": "لا حاجة لأي تحديثات",
|
||||||
|
"MessageNoUserPlaylists": "ليس لديك أي قوائم تشغيل",
|
||||||
|
"MessageOldServerConnectionWarning": "تكوين اتصال الخادم يستخدم معرّف مستخدم قديم. يرجى حذف اتصال الخادم هذا وإعادة إضافته.",
|
||||||
|
"MessageOldServerConnectionWarningHelp": "لقد قمتَ بإعداد الاتصال بهذا الخادم قبل ترحيل قاعدة البيانات في الإصدار 2.3.0، الصادر في يونيو 2023. سيؤدي تحديث الخادم المستقبلي إلى إلغاء إمكانية تسجيل الدخول باستخدام هذا الاتصال القديم. يُرجى حذف اتصال الخادم الحالي والاتصال مرة أخرى (باستخدام نفس عنوان الخادم وبيانات الاعتماد). إذا كانت لديك أي وسائط مُنزّلة على هذا الجهاز، فيجب تنزيلها مرة أخرى لمزامنتها مع الخادم.",
|
||||||
|
"MessagePodcastSearchField": "أدخل مصطلح البحث أو عنوان URL الخاص بتغذية RSS",
|
||||||
|
"MessageProgressSyncFailed": "فشلت آخر محاولة لإرسال تقدم استماعك إلى الخادم. ستستمر محاولات مزامنة التقدم كل 15 ثانية إلى دقيقة واحدة أثناء تشغيل الوسائط.",
|
||||||
|
"MessageReportBugsAndContribute": "أبلغ عن الأخطاء، واطلب الميزات، وساهم في",
|
||||||
|
"MessageSeriesAlreadyDownloaded": "لقد قمت بالفعل بتنزيل جميع الكتب في هذه السلسلة.",
|
||||||
|
"MessageSeriesDownloadConfirm": "هل تريد تنزيل {0} كتابًا (كتب) مفقودًا (مفقودة) مع {1} ملفًا (ملفات)، بإجمالي {2} إلى المجلد {3}؟",
|
||||||
|
"MessageSeriesDownloadConfirmIos": "هل تريد تنزيل {0} كتابًا (كتب) مفقودًا (مفقودة) مع {1} ملفًا (ملفات)، بإجمالي {2}؟",
|
||||||
|
"MessageSocketConnectedOverMeteredCellular": "تم توصيل المقبس عبر شبكة الجوال ذات الاستهلاك المحدود",
|
||||||
|
"MessageSocketConnectedOverMeteredWifi": "تم توصيل المقبس عبر شبكة الواي فاي ذات الاستهلاك المحدود",
|
||||||
|
"MessageSocketConnectedOverUnmeteredCellular": "تم توصيل المقبس عبر شبكة الجوال غير ذات الاستهلاك المحدود",
|
||||||
|
"MessageSocketConnectedOverUnmeteredWifi": "المقبس متصل عبر شبكة واي فاي غير محدودة",
|
||||||
|
"MessageSocketNotConnected": "المقبس غير متصل",
|
||||||
|
"NoteRSSFeedPodcastAppsHttps": "تحذير: تتطلب معظم تطبيقات البث الصوتي أن يكون عنوان URL الخاص بتغذية RSS يستخدم HTTPS",
|
||||||
|
"NoteRSSFeedPodcastAppsPubDate": "تحذير: حلقة واحدة أو أكثر من حلقاتك ليس لها تاريخ نشر. بعض تطبيقات البودكاست تتطلب هذا.",
|
||||||
|
"ToastBookmarkCreateFailed": "فشل في إنشاء الإشارة المرجعية",
|
||||||
|
"ToastBookmarkRemoveFailed": "فشل في إزالة الإشارة المرجعية",
|
||||||
|
"ToastBookmarkUpdateFailed": "فشل في تحديث الإشارة المرجعية",
|
||||||
|
"ToastDownloadNotAllowedOnCellular": "لا يسمح بالتنزيل على البيانات الخلوية",
|
||||||
|
"ToastItemMarkedAsFinishedFailed": "فشل وضع علامة \"مكتمل\"",
|
||||||
|
"ToastItemMarkedAsNotFinishedFailed": "فشل وضع علامة \"غير مكتمل\"",
|
||||||
|
"ToastPlaylistCreateFailed": "فشل إنشاء قائمة التشغيل",
|
||||||
|
"ToastPodcastCreateFailed": "فشل إنشاء البودكاست",
|
||||||
|
"ToastPodcastCreateSuccess": "تم إنشاء البودكاست بنجاح",
|
||||||
|
"ToastRSSFeedCloseFailed": "فشل إغلاق مغذّي RSS",
|
||||||
|
"ToastRSSFeedCloseSuccess": "تم إغلاق مغذّي RSS",
|
||||||
|
"ToastStreamingNotAllowedOnCellular": "لا يُسمح بالبث عبر البيانات الخلوية"
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-13
@@ -1,19 +1,20 @@
|
|||||||
{
|
{
|
||||||
"ButtonAdd": "Afegir",
|
"ButtonAdd": "Afegeix",
|
||||||
"ButtonAddNewServer": "Afegir un nou servidor",
|
"ButtonAddNewServer": "Afegeix un servidor nou",
|
||||||
"ButtonAuthors": "Autors",
|
"ButtonAuthors": "Autors",
|
||||||
"ButtonBack": "Enrere",
|
"ButtonBack": "Enrere",
|
||||||
"ButtonCancel": "Cancel·lar",
|
"ButtonCancel": "Cancel·la",
|
||||||
"ButtonCancelTimer": "Cancel·lar el temporitzador",
|
"ButtonCancelTimer": "Cancel·la el temporitzador",
|
||||||
"ButtonClearFilter": "Eliminar filtres",
|
"ButtonClearFilter": "Neteja el filtre",
|
||||||
"ButtonCloseFeed": "Tancar font",
|
"ButtonClearLogs": "Neteja els registres",
|
||||||
|
"ButtonCloseFeed": "Tanca el canal",
|
||||||
"ButtonCollections": "Col·leccions",
|
"ButtonCollections": "Col·leccions",
|
||||||
"ButtonConnect": "Connectar",
|
"ButtonConnect": "Connecta",
|
||||||
"ButtonConnectToServer": "Connectar al servidor",
|
"ButtonConnectToServer": "Connecta al servidor",
|
||||||
"ButtonCreate": "Crear",
|
"ButtonCreate": "Crea",
|
||||||
"ButtonCreateBookmark": "Crear marcador",
|
"ButtonCreateBookmark": "Crear marcador",
|
||||||
"ButtonCreateNewPlaylist": "Crear una nova llista de reproducció",
|
"ButtonCreateNewPlaylist": "Crear una nova llista de reproducció",
|
||||||
"ButtonDelete": "Eliminar",
|
"ButtonDelete": "Suprimeix",
|
||||||
"ButtonDeleteLocalEpisode": "Esborrar episodi local",
|
"ButtonDeleteLocalEpisode": "Esborrar episodi local",
|
||||||
"ButtonDeleteLocalFile": "Esborrar fitxer local",
|
"ButtonDeleteLocalFile": "Esborrar fitxer local",
|
||||||
"ButtonDeleteLocalItem": "Esborrar element local",
|
"ButtonDeleteLocalItem": "Esborrar element local",
|
||||||
@@ -26,7 +27,9 @@
|
|||||||
"ButtonLatest": "Últims",
|
"ButtonLatest": "Últims",
|
||||||
"ButtonLibrary": "Biblioteca",
|
"ButtonLibrary": "Biblioteca",
|
||||||
"ButtonLocalMedia": "Mitjans locals",
|
"ButtonLocalMedia": "Mitjans locals",
|
||||||
|
"ButtonLogs": "Registres",
|
||||||
"ButtonManageLocalFiles": "Gestionar fitxers locals",
|
"ButtonManageLocalFiles": "Gestionar fitxers locals",
|
||||||
|
"ButtonMaskServerAddress": "Emmascara l'adreça del servidor",
|
||||||
"ButtonNewFolder": "Nova carpeta",
|
"ButtonNewFolder": "Nova carpeta",
|
||||||
"ButtonNextEpisode": "Pròxim episodi",
|
"ButtonNextEpisode": "Pròxim episodi",
|
||||||
"ButtonOk": "D’acord",
|
"ButtonOk": "D’acord",
|
||||||
@@ -50,6 +53,7 @@
|
|||||||
"ButtonStream": "En directe",
|
"ButtonStream": "En directe",
|
||||||
"ButtonSubmit": "Enviar",
|
"ButtonSubmit": "Enviar",
|
||||||
"ButtonSwitchServerUser": "Canviar servidor/usuari",
|
"ButtonSwitchServerUser": "Canviar servidor/usuari",
|
||||||
|
"ButtonUnmaskServerAddress": "Desemmascara l'adreça del servidor",
|
||||||
"ButtonUserStats": "Estadístiques de l'usuari",
|
"ButtonUserStats": "Estadístiques de l'usuari",
|
||||||
"ButtonYes": "Sí",
|
"ButtonYes": "Sí",
|
||||||
"HeaderAccount": "Compte",
|
"HeaderAccount": "Compte",
|
||||||
@@ -88,7 +92,7 @@
|
|||||||
"HeaderTableOfContents": "Sumari",
|
"HeaderTableOfContents": "Sumari",
|
||||||
"HeaderUserInterfaceSettings": "Paràmetres de la interfície d'usuari",
|
"HeaderUserInterfaceSettings": "Paràmetres de la interfície d'usuari",
|
||||||
"HeaderYourStats": "Les vostres estadístiques",
|
"HeaderYourStats": "Les vostres estadístiques",
|
||||||
"LabelAddToPlaylist": "Afegit a la llista de reproducció",
|
"LabelAddToPlaylist": "Afegeix a la llista de reproducció",
|
||||||
"LabelAddedAt": "Afegit a",
|
"LabelAddedAt": "Afegit a",
|
||||||
"LabelAddedDate": "Afegits {0}",
|
"LabelAddedDate": "Afegits {0}",
|
||||||
"LabelAll": "Tots",
|
"LabelAll": "Tots",
|
||||||
@@ -142,6 +146,7 @@
|
|||||||
"LabelEpisode": "Episodi",
|
"LabelEpisode": "Episodi",
|
||||||
"LabelFeedURL": "Font URL",
|
"LabelFeedURL": "Font URL",
|
||||||
"LabelFile": "Fitxer",
|
"LabelFile": "Fitxer",
|
||||||
|
"LabelFileBirthtime": "Fitxer creat a",
|
||||||
"LabelFileModified": "Fitxer modificat",
|
"LabelFileModified": "Fitxer modificat",
|
||||||
"LabelFilename": "Nom del fitxer",
|
"LabelFilename": "Nom del fitxer",
|
||||||
"LabelFinished": "Acabat",
|
"LabelFinished": "Acabat",
|
||||||
@@ -194,9 +199,12 @@
|
|||||||
"LabelPlaybackSpeed": "Velocitat de reproducció",
|
"LabelPlaybackSpeed": "Velocitat de reproducció",
|
||||||
"LabelPodcast": "Pòdcast",
|
"LabelPodcast": "Pòdcast",
|
||||||
"LabelPodcasts": "Pòdcasts",
|
"LabelPodcasts": "Pòdcasts",
|
||||||
|
"LabelPreventIndexing": "Evita que el vostre canal l'indexin els directoris de pòdcasts de l'iTunes i Google",
|
||||||
"LabelProgress": "Progrés",
|
"LabelProgress": "Progrés",
|
||||||
"LabelPubDate": "Data de publicació",
|
"LabelPubDate": "Data de publicació",
|
||||||
"LabelPublishYear": "Any de publicació",
|
"LabelPublishYear": "Any de publicació",
|
||||||
|
"LabelRSSFeedPreventIndexing": "Evita la indexació",
|
||||||
|
"LabelRSSFeedSlug": "URL semàntic del canal RSS",
|
||||||
"LabelRandomly": "A l'atzar",
|
"LabelRandomly": "A l'atzar",
|
||||||
"LabelRead": "Llegit",
|
"LabelRead": "Llegit",
|
||||||
"LabelReadAgain": "Tornar a llegir",
|
"LabelReadAgain": "Tornar a llegir",
|
||||||
@@ -204,7 +212,7 @@
|
|||||||
"LabelRecentlyAdded": "Addicions recents",
|
"LabelRecentlyAdded": "Addicions recents",
|
||||||
"LabelRemoveFromPlaylist": "Eliminar de la llista de reproducció",
|
"LabelRemoveFromPlaylist": "Eliminar de la llista de reproducció",
|
||||||
"LabelSelectADevice": "Seleccioneu un dispositiu",
|
"LabelSelectADevice": "Seleccioneu un dispositiu",
|
||||||
"LabelSeries": "Sèries",
|
"LabelSeries": "Sèrie",
|
||||||
"LabelServerAddress": "Adreça del servidor",
|
"LabelServerAddress": "Adreça del servidor",
|
||||||
"LabelShowAll": "Mostra-ho tot",
|
"LabelShowAll": "Mostra-ho tot",
|
||||||
"LabelSize": "Mida",
|
"LabelSize": "Mida",
|
||||||
@@ -242,6 +250,7 @@
|
|||||||
"MessageDownloadCompleteProcessing": "S'ha completat la baixada. S'està processant...",
|
"MessageDownloadCompleteProcessing": "S'ha completat la baixada. S'està processant...",
|
||||||
"MessageDownloading": "S'està baixant...",
|
"MessageDownloading": "S'està baixant...",
|
||||||
"MessageDownloadingEpisode": "S'està baixant l'episodi",
|
"MessageDownloadingEpisode": "S'està baixant l'episodi",
|
||||||
|
"MessageFeedURLWillBe": "L'URL del canal serà {0}",
|
||||||
"MessageFetching": "S'està recuperant...",
|
"MessageFetching": "S'està recuperant...",
|
||||||
"MessageFollowTheProjectOnGithub": "Seguiu el projecte al GitHub",
|
"MessageFollowTheProjectOnGithub": "Seguiu el projecte al GitHub",
|
||||||
"MessageLoading": "S'està carregant...",
|
"MessageLoading": "S'està carregant...",
|
||||||
@@ -254,15 +263,21 @@
|
|||||||
"MessageNoItems": "Cap element",
|
"MessageNoItems": "Cap element",
|
||||||
"MessageNoItemsFound": "Cap element trobat",
|
"MessageNoItemsFound": "Cap element trobat",
|
||||||
"MessageNoListeningSessions": "Cap sessió d'escolta",
|
"MessageNoListeningSessions": "Cap sessió d'escolta",
|
||||||
|
"MessageNoLogs": "Cap registre",
|
||||||
"MessageNoMediaFolders": "Cap carpeta multimèdia",
|
"MessageNoMediaFolders": "Cap carpeta multimèdia",
|
||||||
"MessageNoNetworkConnection": "Cap connexió de xarxa",
|
"MessageNoNetworkConnection": "Cap connexió de xarxa",
|
||||||
"MessageNoPodcastsFound": "No s'ha trobat cap pòdcast",
|
"MessageNoPodcastsFound": "No s'ha trobat cap pòdcast",
|
||||||
"MessageNoSeries": "Cap sèrie",
|
"MessageNoSeries": "Cap sèrie",
|
||||||
"MessageNoUpdatesWereNecessary": "No calia cap actualització",
|
"MessageNoUpdatesWereNecessary": "No calia cap actualització",
|
||||||
"MessageNoUserPlaylists": "No teniu cap llista de reproducció",
|
"MessageNoUserPlaylists": "No teniu cap llista de reproducció",
|
||||||
|
"MessagePodcastSearchField": "Introduïu el terme de cerca o l'URL del canal RSS",
|
||||||
|
"NoteRSSFeedPodcastAppsHttps": "Avís: la majoria d'aplicacions de pòdcast requereixen que l'URL del canal RSS utilitzi HTTPS",
|
||||||
|
"NoteRSSFeedPodcastAppsPubDate": "Avís: un o més dels vostres episodis no tenen data de publicació. Algunes aplicacions de pòdcast ho requereixen.",
|
||||||
"ToastItemMarkedAsFinishedFailed": "No s'ha pogut marcar com a finalitzat",
|
"ToastItemMarkedAsFinishedFailed": "No s'ha pogut marcar com a finalitzat",
|
||||||
"ToastItemMarkedAsNotFinishedFailed": "No s'ha pogut marcar com a no finalitzat",
|
"ToastItemMarkedAsNotFinishedFailed": "No s'ha pogut marcar com a no finalitzat",
|
||||||
"ToastPlaylistCreateFailed": "No s'ha pogut crear la llista de reproducció",
|
"ToastPlaylistCreateFailed": "No s'ha pogut crear la llista de reproducció",
|
||||||
"ToastPodcastCreateFailed": "No s'ha pogut crear el pòdcast",
|
"ToastPodcastCreateFailed": "No s'ha pogut crear el pòdcast",
|
||||||
"ToastPodcastCreateSuccess": "S'ha creat el pòdcast correctament"
|
"ToastPodcastCreateSuccess": "S'ha creat el pòdcast correctament",
|
||||||
|
"ToastRSSFeedCloseFailed": "No s'ha pogut tancar el canal RSS",
|
||||||
|
"ToastRSSFeedCloseSuccess": "Canal RSS tancat"
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-3
@@ -6,6 +6,7 @@
|
|||||||
"ButtonCancel": "Zrušit",
|
"ButtonCancel": "Zrušit",
|
||||||
"ButtonCancelTimer": "Zrušit časovač",
|
"ButtonCancelTimer": "Zrušit časovač",
|
||||||
"ButtonClearFilter": "Vymazat filtr",
|
"ButtonClearFilter": "Vymazat filtr",
|
||||||
|
"ButtonClearLogs": "Vyčistit logy",
|
||||||
"ButtonCloseFeed": "Zavřít kanál",
|
"ButtonCloseFeed": "Zavřít kanál",
|
||||||
"ButtonCollections": "Kolekce",
|
"ButtonCollections": "Kolekce",
|
||||||
"ButtonConnect": "Připojit",
|
"ButtonConnect": "Připojit",
|
||||||
@@ -26,7 +27,9 @@
|
|||||||
"ButtonLatest": "Nejnovější",
|
"ButtonLatest": "Nejnovější",
|
||||||
"ButtonLibrary": "Knihovna",
|
"ButtonLibrary": "Knihovna",
|
||||||
"ButtonLocalMedia": "Místní média",
|
"ButtonLocalMedia": "Místní média",
|
||||||
|
"ButtonLogs": "Logy",
|
||||||
"ButtonManageLocalFiles": "Spravovat místní soubory",
|
"ButtonManageLocalFiles": "Spravovat místní soubory",
|
||||||
|
"ButtonMaskServerAddress": "Skrýt adresu serveru",
|
||||||
"ButtonNewFolder": "Nová složka",
|
"ButtonNewFolder": "Nová složka",
|
||||||
"ButtonNextEpisode": "Další epizoda",
|
"ButtonNextEpisode": "Další epizoda",
|
||||||
"ButtonOk": "Ok",
|
"ButtonOk": "Ok",
|
||||||
@@ -50,6 +53,7 @@
|
|||||||
"ButtonStream": "Streamovat",
|
"ButtonStream": "Streamovat",
|
||||||
"ButtonSubmit": "Odeslat",
|
"ButtonSubmit": "Odeslat",
|
||||||
"ButtonSwitchServerUser": "Přepnout server/uživatele",
|
"ButtonSwitchServerUser": "Přepnout server/uživatele",
|
||||||
|
"ButtonUnmaskServerAddress": "Odkrýt adresu serveru",
|
||||||
"ButtonUserStats": "Statistiky uživatele",
|
"ButtonUserStats": "Statistiky uživatele",
|
||||||
"ButtonYes": "Ano",
|
"ButtonYes": "Ano",
|
||||||
"HeaderAccount": "Účet",
|
"HeaderAccount": "Účet",
|
||||||
@@ -66,7 +70,7 @@
|
|||||||
"HeaderEbookFiles": "Soubory e-knih",
|
"HeaderEbookFiles": "Soubory e-knih",
|
||||||
"HeaderEpisodes": "Epizody",
|
"HeaderEpisodes": "Epizody",
|
||||||
"HeaderEreaderSettings": "Nastavení čtečky e-knih",
|
"HeaderEreaderSettings": "Nastavení čtečky e-knih",
|
||||||
"HeaderLatestEpisodes": "Nejnovější epizody",
|
"HeaderLatestEpisodes": "Nové epizody",
|
||||||
"HeaderLibraries": "Knihovny",
|
"HeaderLibraries": "Knihovny",
|
||||||
"HeaderLocalFolders": "Místní složky",
|
"HeaderLocalFolders": "Místní složky",
|
||||||
"HeaderLocalLibraryItems": "Místní položky knihovny",
|
"HeaderLocalLibraryItems": "Místní položky knihovny",
|
||||||
@@ -75,7 +79,7 @@
|
|||||||
"HeaderPlaybackSettings": "Nastavení přehrávání",
|
"HeaderPlaybackSettings": "Nastavení přehrávání",
|
||||||
"HeaderPlaylist": "Seznam skladeb",
|
"HeaderPlaylist": "Seznam skladeb",
|
||||||
"HeaderPlaylistItems": "Položky seznamu přehrávání",
|
"HeaderPlaylistItems": "Položky seznamu přehrávání",
|
||||||
"HeaderProgressSyncFailed": "Chyba při synchronizaci",
|
"HeaderProgressSyncFailed": "Chyba při synchronizaci pokroku",
|
||||||
"HeaderRSSFeed": "RSS kanál",
|
"HeaderRSSFeed": "RSS kanál",
|
||||||
"HeaderRSSFeedGeneral": "Detaily RSS",
|
"HeaderRSSFeedGeneral": "Detaily RSS",
|
||||||
"HeaderRSSFeedIsOpen": "Kanál RSS je otevřen",
|
"HeaderRSSFeedIsOpen": "Kanál RSS je otevřen",
|
||||||
@@ -94,6 +98,9 @@
|
|||||||
"LabelAll": "Vše",
|
"LabelAll": "Vše",
|
||||||
"LabelAllowSeekingOnMediaControls": "Povolit vyhledávání polohy v ovládacích prvcích oznámení médií",
|
"LabelAllowSeekingOnMediaControls": "Povolit vyhledávání polohy v ovládacích prvcích oznámení médií",
|
||||||
"LabelAlways": "Vždy",
|
"LabelAlways": "Vždy",
|
||||||
|
"LabelAndroidAutoBrowseLimitForGrouping": "Omezení abecedního rozbalovacího seznamu",
|
||||||
|
"LabelAndroidAutoBrowseLimitForGroupingHelp": "Nepoužívejte abecední řazení, pokud je k zobrazení méně než tento počet položek",
|
||||||
|
"LabelAndroidAutoBrowseSeriesSequenceOrder": "Řazení sérií knih",
|
||||||
"LabelAskConfirmation": "Požádat o potvrzení",
|
"LabelAskConfirmation": "Požádat o potvrzení",
|
||||||
"LabelAuthor": "Autor",
|
"LabelAuthor": "Autor",
|
||||||
"LabelAuthorFirstLast": "Autor (jméno a příjmení)",
|
"LabelAuthorFirstLast": "Autor (jméno a příjmení)",
|
||||||
@@ -134,7 +141,7 @@
|
|||||||
"LabelEbooks": "E-knihy",
|
"LabelEbooks": "E-knihy",
|
||||||
"LabelEnable": "Povolit",
|
"LabelEnable": "Povolit",
|
||||||
"LabelEnableMp3IndexSeeking": "Povolit indexové vyhledávání mp3",
|
"LabelEnableMp3IndexSeeking": "Povolit indexové vyhledávání mp3",
|
||||||
"LabelEnableMp3IndexSeekingHelp": "Toto nastavení by mělo být povoleno pouze v případě, že soubory MP3 nejsou správně vyhledávány. Nepřesné vyhledávání je s největší pravděpodobností způsobeno soubory MP3 s proměnlivým datovým tokem (VBR). Toto nastavení vynutí indexové vyhledávání, při kterém se při čtení souboru vytváří mapování času na bajty. V některých případech u velkých souborů MP3 dochází ke zpoždění při vyhledávání ke konci souboru.",
|
"LabelEnableMp3IndexSeekingHelp": "Toto nastavení by mělo být povoleno pouze v případě, že v souborech MP3 nelze přeskakovat. Nepřesné přeskakování je s největší pravděpodobností způsobeno soubory MP3 s proměnlivým datovým tokem (VBR). Toto nastavení vynutí indexové vyhledávání, při kterém se při čtení souboru vytváří mapování času na bajty. V některých případech u velkých souborů MP3 dochází ke zpoždění při vyhledávání ke konci souboru.",
|
||||||
"LabelEnd": "Konec",
|
"LabelEnd": "Konec",
|
||||||
"LabelEndOfChapter": "Konec kapitoly",
|
"LabelEndOfChapter": "Konec kapitoly",
|
||||||
"LabelEndTime": "Do",
|
"LabelEndTime": "Do",
|
||||||
@@ -161,6 +168,7 @@
|
|||||||
"LabelInternalAppStorage": "Interní úložiště aplikace",
|
"LabelInternalAppStorage": "Interní úložiště aplikace",
|
||||||
"LabelJumpBackwardsTime": "Délka skoku zpět v čase",
|
"LabelJumpBackwardsTime": "Délka skoku zpět v čase",
|
||||||
"LabelJumpForwardsTime": "Délka skoku vpřed v čase",
|
"LabelJumpForwardsTime": "Délka skoku vpřed v čase",
|
||||||
|
"LabelKeepScreenAwake": "Nezhasínat obrazovku",
|
||||||
"LabelLanguage": "Jazyk",
|
"LabelLanguage": "Jazyk",
|
||||||
"LabelLayout": "Rozvržení",
|
"LabelLayout": "Rozvržení",
|
||||||
"LabelLayoutAuto": "Automatické",
|
"LabelLayoutAuto": "Automatické",
|
||||||
@@ -175,6 +183,7 @@
|
|||||||
"LabelLow": "Nízké",
|
"LabelLow": "Nízké",
|
||||||
"LabelMediaType": "Typ média",
|
"LabelMediaType": "Typ média",
|
||||||
"LabelMedium": "Střední",
|
"LabelMedium": "Střední",
|
||||||
|
"LabelMissing": "Chybějící",
|
||||||
"LabelMore": "Více",
|
"LabelMore": "Více",
|
||||||
"LabelMoreInfo": "Více informací",
|
"LabelMoreInfo": "Více informací",
|
||||||
"LabelName": "Jméno",
|
"LabelName": "Jméno",
|
||||||
@@ -192,6 +201,8 @@
|
|||||||
"LabelNotFinished": "Nedokončeno",
|
"LabelNotFinished": "Nedokončeno",
|
||||||
"LabelNotStarted": "Nezahájeno",
|
"LabelNotStarted": "Nezahájeno",
|
||||||
"LabelNumEpisodes": "{0} epizod",
|
"LabelNumEpisodes": "{0} epizod",
|
||||||
|
"LabelNumEpisodesIncomplete": "{0} epizod, {1} nekompletní",
|
||||||
|
"LabelNumberOfEpisodes": "Počet epizod",
|
||||||
"LabelOff": "Vypnout",
|
"LabelOff": "Vypnout",
|
||||||
"LabelOn": "Zapnuto",
|
"LabelOn": "Zapnuto",
|
||||||
"LabelPassword": "Heslo",
|
"LabelPassword": "Heslo",
|
||||||
@@ -209,6 +220,7 @@
|
|||||||
"LabelPublishedDate": "Vydáno {0}",
|
"LabelPublishedDate": "Vydáno {0}",
|
||||||
"LabelRSSFeedCustomOwnerEmail": "Vlastní e-mail vlastníka",
|
"LabelRSSFeedCustomOwnerEmail": "Vlastní e-mail vlastníka",
|
||||||
"LabelRSSFeedCustomOwnerName": "Vlastní jméno vlastníka",
|
"LabelRSSFeedCustomOwnerName": "Vlastní jméno vlastníka",
|
||||||
|
"LabelRSSFeedOpen": "RSS kanál otevřen",
|
||||||
"LabelRSSFeedPreventIndexing": "Zabránit indexování",
|
"LabelRSSFeedPreventIndexing": "Zabránit indexování",
|
||||||
"LabelRSSFeedSlug": "Klíčové slovo kanálu RSS",
|
"LabelRSSFeedSlug": "Klíčové slovo kanálu RSS",
|
||||||
"LabelRandomly": "Náhodně",
|
"LabelRandomly": "Náhodně",
|
||||||
@@ -220,6 +232,8 @@
|
|||||||
"LabelScaleElapsedTimeBySpeed": "Škálovat uplynulý čas podle rychlosti",
|
"LabelScaleElapsedTimeBySpeed": "Škálovat uplynulý čas podle rychlosti",
|
||||||
"LabelSeason": "Sezóna",
|
"LabelSeason": "Sezóna",
|
||||||
"LabelSelectADevice": "Vyberte zařízení",
|
"LabelSelectADevice": "Vyberte zařízení",
|
||||||
|
"LabelSequenceAscending": "Řadit vzestupně",
|
||||||
|
"LabelSequenceDescending": "Řadit sestupně",
|
||||||
"LabelSeries": "Série",
|
"LabelSeries": "Série",
|
||||||
"LabelServerAddress": "Adresa serveru",
|
"LabelServerAddress": "Adresa serveru",
|
||||||
"LabelSetEbookAsPrimary": "Nastavit jako primární",
|
"LabelSetEbookAsPrimary": "Nastavit jako primární",
|
||||||
@@ -228,6 +242,8 @@
|
|||||||
"LabelShowAll": "Zobrazit vše",
|
"LabelShowAll": "Zobrazit vše",
|
||||||
"LabelSize": "Velikost",
|
"LabelSize": "Velikost",
|
||||||
"LabelSleepTimer": "Spánek po",
|
"LabelSleepTimer": "Spánek po",
|
||||||
|
"LabelSleepTimerAlmostDoneChime": "Přehrát zvukové upozornění téměř před dokončením",
|
||||||
|
"LabelSleepTimerAlmostDoneChimeHelp": "Přehrát zvukové upozornění 30 sekund před uplynutím časovače spánku",
|
||||||
"LabelStart": "Spustit",
|
"LabelStart": "Spustit",
|
||||||
"LabelStartTime": "Od",
|
"LabelStartTime": "Od",
|
||||||
"LabelStatsBestDay": "Nejlepší den",
|
"LabelStatsBestDay": "Nejlepší den",
|
||||||
@@ -243,6 +259,7 @@
|
|||||||
"LabelTag": "Štítek",
|
"LabelTag": "Štítek",
|
||||||
"LabelTags": "Štítky",
|
"LabelTags": "Štítky",
|
||||||
"LabelTheme": "Téma",
|
"LabelTheme": "Téma",
|
||||||
|
"LabelThemeBlack": "Černé",
|
||||||
"LabelThemeDark": "Tmavé",
|
"LabelThemeDark": "Tmavé",
|
||||||
"LabelThemeLight": "Světlé",
|
"LabelThemeLight": "Světlé",
|
||||||
"LabelTimeRemaining": "{0} zbývá",
|
"LabelTimeRemaining": "{0} zbývá",
|
||||||
@@ -269,6 +286,7 @@
|
|||||||
"MessageBookshelfEmpty": "Knihovna je prázdná",
|
"MessageBookshelfEmpty": "Knihovna je prázdná",
|
||||||
"MessageConfirmDeleteLocalEpisode": "Odebrat místní epizodu „{0}“ ze zařízení? Soubor na serveru zůstane nezměněný.",
|
"MessageConfirmDeleteLocalEpisode": "Odebrat místní epizodu „{0}“ ze zařízení? Soubor na serveru zůstane nezměněný.",
|
||||||
"MessageConfirmDeleteLocalFiles": "Odebrat místní soubory této položky ze zařízení? Soubory na serveru a váš pokrok nebudou ovlivněny.",
|
"MessageConfirmDeleteLocalFiles": "Odebrat místní soubory této položky ze zařízení? Soubory na serveru a váš pokrok nebudou ovlivněny.",
|
||||||
|
"MessageConfirmDisableAutoTimer": "Určitě chcete vypnout automatický časovač pro zbytek dnešního dne? Časovač bude opět aktivován po uběhnutí doby automatického spánku nebo po restartování aplikace.",
|
||||||
"MessageConfirmDiscardProgress": "Opravdu chcete zahodit svůj pokrok?",
|
"MessageConfirmDiscardProgress": "Opravdu chcete zahodit svůj pokrok?",
|
||||||
"MessageConfirmDownloadUsingCellular": "Chystáte se stahovat přes mobilní data. Toto může zahrnovat poplatky za mobilní data. Chcete pokračovat?",
|
"MessageConfirmDownloadUsingCellular": "Chystáte se stahovat přes mobilní data. Toto může zahrnovat poplatky za mobilní data. Chcete pokračovat?",
|
||||||
"MessageConfirmMarkAsFinished": "Opravdu chcete tuto položku označit jako dokončenou?",
|
"MessageConfirmMarkAsFinished": "Opravdu chcete tuto položku označit jako dokončenou?",
|
||||||
@@ -283,8 +301,10 @@
|
|||||||
"MessageFetching": "Načítání...",
|
"MessageFetching": "Načítání...",
|
||||||
"MessageFollowTheProjectOnGithub": "Sledujte projekt na GitHubu",
|
"MessageFollowTheProjectOnGithub": "Sledujte projekt na GitHubu",
|
||||||
"MessageItemDownloadCompleteFailedToCreate": "Stahování položky bylo dokončeno, ale nepodařilo se vytvořit položku v knihovně",
|
"MessageItemDownloadCompleteFailedToCreate": "Stahování položky bylo dokončeno, ale nepodařilo se vytvořit položku v knihovně",
|
||||||
|
"MessageItemMissing": "Položka chybí a musí být opravena na serveru. Typicky je položka označena jako chybějící, protože cesty k souborům nejsou přístupné.",
|
||||||
"MessageLoading": "Načítá se...",
|
"MessageLoading": "Načítá se...",
|
||||||
"MessageLoadingServerData": "Načítání dat ze serveru...",
|
"MessageLoadingServerData": "Načítání dat ze serveru...",
|
||||||
|
"MessageLocalFolderDescription": "„Interní úložiště aplikace“ je přístupné pouze této aplikaci. Tato aplikace podporuje pouze média stažená přímo prostřednictvím aplikace. Složky sdíleného úložiště lze použít k tomu, aby ostatní aplikace měly přístup k médiím staženým touto aplikací.",
|
||||||
"MessageMarkAsFinished": "Označit jako dokončené",
|
"MessageMarkAsFinished": "Označit jako dokončené",
|
||||||
"MessageMediaLinkedToADifferentServer": "Média jsou propojena se serverem Audiobookshelf na jiné adrese ({0}). Pokrok bude synchronizován, když budete připojeni k této adrese serveru.",
|
"MessageMediaLinkedToADifferentServer": "Média jsou propojena se serverem Audiobookshelf na jiné adrese ({0}). Pokrok bude synchronizován, když budete připojeni k této adrese serveru.",
|
||||||
"MessageMediaLinkedToADifferentUser": "Média jsou propojena se serverem, ale byla stažena jiným uživatelem. Pokrok bude synchronizován pouze s uživatelem, který je stáhl.",
|
"MessageMediaLinkedToADifferentUser": "Média jsou propojena se serverem, ale byla stažena jiným uživatelem. Pokrok bude synchronizován pouze s uživatelem, který je stáhl.",
|
||||||
@@ -297,13 +317,17 @@
|
|||||||
"MessageNoItems": "Žádné položky",
|
"MessageNoItems": "Žádné položky",
|
||||||
"MessageNoItemsFound": "Nebyly nalezeny žádné položky",
|
"MessageNoItemsFound": "Nebyly nalezeny žádné položky",
|
||||||
"MessageNoListeningSessions": "Žádné poslechové relace",
|
"MessageNoListeningSessions": "Žádné poslechové relace",
|
||||||
|
"MessageNoLogs": "Žádné záznamy událostí",
|
||||||
"MessageNoMediaFolders": "Žádné složky médií",
|
"MessageNoMediaFolders": "Žádné složky médií",
|
||||||
"MessageNoNetworkConnection": "Žádné připojení k síti",
|
"MessageNoNetworkConnection": "Žádné připojení k síti",
|
||||||
"MessageNoPodcastsFound": "Nebyly nalezeny žádné podcasty",
|
"MessageNoPodcastsFound": "Nebyly nalezeny žádné podcasty",
|
||||||
"MessageNoSeries": "Žádné série",
|
"MessageNoSeries": "Žádné série",
|
||||||
"MessageNoUpdatesWereNecessary": "Nebyly nutné žádné aktualizace",
|
"MessageNoUpdatesWereNecessary": "Nebyly nutné žádné aktualizace",
|
||||||
"MessageNoUserPlaylists": "Nemáte žádné seznamy skladeb",
|
"MessageNoUserPlaylists": "Nemáte žádné seznamy skladeb",
|
||||||
|
"MessageOldServerConnectionWarning": "Konfigurace připojení k serveru používá staré ID uživatele. Odstraňte a znovu přidejte toto připojení k serveru.",
|
||||||
|
"MessageOldServerConnectionWarningHelp": "Připojení k tomuto serveru jste původně nastavili před migrací databáze ve verzi 2.3.0 vydané v červnu 2023. Budoucí aktualizace serveru odstraní možnost přihlášení pomocí tohoto starého připojení. Odstraňte prosím stávající připojení k serveru a připojte se znovu (pomocí stejné adresy serveru a přihlašovacích údajů). Pokud máte v tomto zařízení stažená média, bude nutné je pro synchronizaci se serverem stáhnout znovu.",
|
||||||
"MessagePodcastSearchField": "Zadejte hledaný pojem pro RSS feed URL",
|
"MessagePodcastSearchField": "Zadejte hledaný pojem pro RSS feed URL",
|
||||||
|
"MessageProgressSyncFailed": "Poslední pokus o nahlášení průběhu poslechu na server se nezdařil. Během přehrávání médií se budou požadavky na synchronizaci pokroku nadále pokoušet každých 15 až 60 sekund.",
|
||||||
"MessageReportBugsAndContribute": "Nahlašte chyby, vyžádejte si funkce a přispěte na",
|
"MessageReportBugsAndContribute": "Nahlašte chyby, vyžádejte si funkce a přispěte na",
|
||||||
"MessageSeriesAlreadyDownloaded": "Všechny knihy v této sérii jste již stáhli.",
|
"MessageSeriesAlreadyDownloaded": "Všechny knihy v této sérii jste již stáhli.",
|
||||||
"MessageSeriesDownloadConfirm": "Stáhnout chybějící {0} knihu(y) s {1} souborem(y), celkem {2} do složky {3}?",
|
"MessageSeriesDownloadConfirm": "Stáhnout chybějící {0} knihu(y) s {1} souborem(y), celkem {2} do složky {3}?",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user