Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
185cba16eb | ||
|
|
547d086228 | ||
|
|
3a2d5647d4 | ||
|
|
0a67c9b802 | ||
|
|
0254afd5d8 | ||
|
|
853546f3d2 | ||
|
|
b8ac073247 | ||
|
|
56cb4df31a | ||
|
|
7285b0bf26 | ||
|
|
3fdc2e6dcf | ||
|
|
ed2f9d2e45 | ||
|
|
68dafbba75 | ||
|
|
8be08702c1 | ||
|
|
2c2e77f6cd | ||
|
|
12d02645fd | ||
|
|
4578339594 | ||
|
|
b81f78755d | ||
|
|
82664d6c81 | ||
|
|
9e06db58d2 | ||
|
|
de3461af52 | ||
|
|
61f676d106 | ||
|
|
82627b85a5 | ||
|
|
21bc6cc8b6 | ||
|
|
2295b1071e | ||
|
|
c75e376533 | ||
|
|
3a5e2af9e4 | ||
|
|
30f1414ea5 | ||
|
|
737364aa3a | ||
|
|
fd988afad1 | ||
|
|
56dc7f856c | ||
|
|
5e4a41b0b5 | ||
|
|
66f426f6bb | ||
|
|
03efa352f5 | ||
|
|
5c147955b7 | ||
|
|
0a55573bd0 | ||
|
|
de3812ac33 | ||
|
|
b6de52eb91 | ||
|
|
65ec1ec5d4 | ||
|
|
614118d1d3 | ||
|
|
e3999360f0 | ||
|
|
50d1b62b95 | ||
|
|
1571bcd9f3 | ||
|
|
5531ced129 | ||
|
|
2b258de5b8 | ||
|
|
4e3d3f7752 | ||
|
|
63863a8800 | ||
|
|
d594fcb08b | ||
|
|
85aab8df06 | ||
|
|
ec1585f7c1 | ||
|
|
3b3bda117c | ||
|
|
c37f2b71d4 | ||
|
|
04de5960f1 | ||
|
|
dad7ac4efd | ||
|
|
d2830c1603 | ||
|
|
6091fd6f79 | ||
|
|
c9935eca2b | ||
|
|
38eacdd7b4 | ||
|
|
1bde917b09 | ||
|
|
0859e219fa | ||
|
|
dac9279320 | ||
|
|
4fb9f6b92d | ||
|
|
fabd579247 | ||
|
|
b4ee3b80a8 | ||
|
|
a75bdce9b4 | ||
|
|
d48ac952a5 | ||
|
|
d83b2e032b | ||
|
|
46c9c3f006 | ||
|
|
4199add608 | ||
|
|
8311643e73 | ||
|
|
7775d5216f | ||
|
|
6f2fc6b5c1 | ||
|
|
235cac7ab4 | ||
|
|
93b4acfe13 | ||
|
|
bd7c73c48d | ||
|
|
45ecca7ce4 | ||
|
|
0a8c67cf13 | ||
|
|
316d3d8324 | ||
|
|
182d45b07c | ||
|
|
58d8afca38 | ||
|
|
136709d7ae | ||
|
|
b0d02aa769 | ||
|
|
ca66a45ba4 | ||
|
|
d7d4114ee6 | ||
|
|
e589c0a7ea | ||
|
|
eb2625a2df | ||
|
|
aacbacf557 | ||
|
|
0a7b129db2 | ||
|
|
c927264b62 | ||
|
|
808225351e | ||
|
|
40ee8d5221 | ||
|
|
8cc80e8378 | ||
|
|
db5f249c14 | ||
|
|
e86028e577 | ||
|
|
54bed2bee1 | ||
|
|
1de78165d0 | ||
|
|
4c9ed5e2bf | ||
|
|
a036a8bda6 | ||
|
|
5a482e603e | ||
|
|
d5eb8a341e | ||
|
|
9c74c47448 | ||
|
|
8584bebe6c | ||
|
|
39cd6c65f2 | ||
|
|
1056541dd2 | ||
|
|
f9a3a4a218 | ||
|
|
80bafd9281 | ||
|
|
c06c17d3ae | ||
|
|
2a7969fe68 | ||
|
|
decda748ba | ||
|
|
427f858be5 | ||
|
|
1043fb3f3e | ||
|
|
7c5e8cb046 | ||
|
|
5fb8baa186 | ||
|
|
f63b04451b | ||
|
|
a80722ff79 | ||
|
|
9d5cbd37c8 | ||
|
|
fada8fccb1 | ||
|
|
9d650019cd | ||
|
|
915f3c88bc | ||
|
|
140bc7fbc5 | ||
|
|
2a12c1e811 | ||
|
|
5250adb36e | ||
|
|
f548163689 | ||
|
|
589ffcecca | ||
|
|
d9c591b0ea | ||
|
|
462021225f | ||
|
|
3f0bc74c21 | ||
|
|
b2c2b625e3 | ||
|
|
d3bd3427e3 | ||
|
|
a040a73174 | ||
|
|
332d9f203e | ||
|
|
2d97329836 | ||
|
|
730d030623 | ||
|
|
2e29daab1d | ||
|
|
d7b48b4f61 | ||
|
|
24b98f10e3 | ||
|
|
5fd6f5e4cc | ||
|
|
c21af649c3 | ||
|
|
1d8a23922e | ||
|
|
68893ab1f4 | ||
|
|
dcf172bd98 | ||
|
|
6dd466a9ce | ||
|
|
ca222d4110 | ||
|
|
c9af9582e3 | ||
|
|
a3028127ce | ||
|
|
23133194b2 | ||
|
|
b84555db6c | ||
|
|
229298df1b | ||
|
|
fb270cb50b | ||
|
|
a861c880ba | ||
|
|
9712d3f527 | ||
|
|
51a7ce41de | ||
|
|
4c657c66d0 | ||
|
|
61c3fafd94 | ||
|
|
bab95530c8 | ||
|
|
68afba45bc | ||
|
|
1a52f19b32 | ||
|
|
aaef0a7e8d | ||
|
|
02d9023f9d | ||
|
|
59a27cdd4d | ||
|
|
9440782027 | ||
|
|
f19ec05780 | ||
|
|
d95b75e317 | ||
|
|
a6dec31461 | ||
|
|
b12427daa6 | ||
|
|
892686b213 | ||
|
|
81e71cd7a2 | ||
|
|
38a5e3637d | ||
|
|
eb16e769fe | ||
|
|
9417f0dd3d | ||
|
|
68d82f7a3a | ||
|
|
b8fa041dcb | ||
|
|
1df07dc58d | ||
|
|
e63358f776 | ||
|
|
9518189898 | ||
|
|
70a9cab6f2 | ||
|
|
495ec80ebd | ||
|
|
34454fee10 | ||
|
|
fbac4be4a5 | ||
|
|
a150e6109e | ||
|
|
f392075836 | ||
|
|
5d9dd394ea | ||
|
|
162c189d4f | ||
|
|
01902f8a79 | ||
|
|
399a01a937 | ||
|
|
dcaa19071f | ||
|
|
a6280805e6 | ||
|
|
3d8843109a | ||
|
|
16eaccc8e3 | ||
|
|
9df996a507 | ||
|
|
fa0b9ed6ab | ||
|
|
9b8536a581 | ||
|
|
9158f4f8f1 | ||
|
|
072f19e157 | ||
|
|
44198fa17a | ||
|
|
83ccb398c5 | ||
|
|
824e335dec | ||
|
|
e28e9d71ae | ||
|
|
46acc76f07 | ||
|
|
356fa4eb7c | ||
|
|
09a6c631cb | ||
|
|
cf19816e06 | ||
|
|
2c4b9f5a50 | ||
|
|
9e29d45fc3 | ||
|
|
b4e350a308 | ||
|
|
8274072b6a | ||
|
|
1ddd146558 | ||
|
|
2d76fc6e8c | ||
|
|
e19e541a70 | ||
|
|
3a56c82501 | ||
|
|
2f0e385ca2 | ||
|
|
9241a4729c | ||
|
|
ea331bd4ef | ||
|
|
c2b598d98e | ||
|
|
2fde05412c | ||
|
|
d8436d3273 | ||
|
|
e662dae396 | ||
|
|
e74c5d8609 | ||
|
|
3f05a8408d | ||
|
|
61f0ba4643 | ||
|
|
b773ec7690 | ||
|
|
df294730a0 | ||
|
|
ee1cd38fa2 | ||
|
|
a012328f4c | ||
|
|
1df9656191 | ||
|
|
fde2d0887e | ||
|
|
356e439715 | ||
|
|
00ee7b662e | ||
|
|
b222b2a5e5 | ||
|
|
15b212734e | ||
|
|
631ba52ffb | ||
|
|
a4b58a893d | ||
|
|
47a3fd9462 | ||
|
|
abcd56f0c5 | ||
|
|
b498b5eafa | ||
|
|
be114e7789 | ||
|
|
8c44142e70 | ||
|
|
5a655f77d2 | ||
|
|
c825f791d0 | ||
|
|
83a9d8a162 | ||
|
|
acfa2e2183 | ||
|
|
591933f3d8 | ||
|
|
982f94d955 | ||
|
|
95bf28d7c6 | ||
|
|
48a299de45 | ||
|
|
719e517dda | ||
|
|
361c55c5ac | ||
|
|
b157fff229 | ||
|
|
e23e6417ee | ||
|
|
ad77209832 | ||
|
|
4ceb427d9c | ||
|
|
16a26130f2 | ||
|
|
e7773c9e88 | ||
|
|
1b38b92177 | ||
|
|
ef180f08b0 | ||
|
|
941e823374 | ||
|
|
6de45a3b88 | ||
|
|
ceb9537b73 | ||
|
|
976bf73388 | ||
|
|
6c7f2d96f3 | ||
|
|
ae2fc1fcfb | ||
|
|
3aad7e9e07 | ||
|
|
e43f1e75c8 | ||
|
|
bc5505e12d | ||
|
|
19a2703ea4 | ||
|
|
8a8f7d4f27 | ||
|
|
e350b4970f | ||
|
|
4be28bc579 | ||
|
|
13855a8682 | ||
|
|
4be1598eca | ||
|
|
7aebcd92c3 | ||
|
|
bd8668f0bf | ||
|
|
c6a7c6fec2 | ||
|
|
e367fa8a86 | ||
|
|
ecdbabfa17 | ||
|
|
796189ad48 | ||
|
|
76d1f2b29b | ||
|
|
73599815ba | ||
|
|
66372e9743 | ||
|
|
0d7f92129b | ||
|
|
f74a1ceb47 | ||
|
|
9da49d6116 | ||
|
|
7eeb9095e8 | ||
|
|
58281bb5ce | ||
|
|
f24dd3b289 | ||
|
|
51f2e5e7ec | ||
|
|
a937800ef0 | ||
|
|
c34d14edb5 | ||
|
|
8722e91c0f | ||
|
|
8038a8ac66 | ||
|
|
69a54c5fa9 | ||
|
|
aa508887f3 |
@@ -6,3 +6,7 @@ indent_size = 2
|
|||||||
charset = utf-8
|
charset = utf-8
|
||||||
insert_final_newline = true
|
insert_final_newline = true
|
||||||
trim_trailing_whitespace = true
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.swift]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
|
|||||||
@@ -72,10 +72,10 @@ body:
|
|||||||
description: Please ensure your app is up to date. *If you are using a 3rd-party app, please reach out to them directly.*
|
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.10.0'
|
- 'Android App - 0.13.0'
|
||||||
- 'iOS App - 0.10.0'
|
- 'iOS App - 0.13.0'
|
||||||
- 'Android App - 0.9.81'
|
- 'Android App - 0.12.0'
|
||||||
- 'iOS App - 0.9.81'
|
- 'iOS App - 0.12.0'
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
|
|||||||
@@ -43,10 +43,10 @@ body:
|
|||||||
description: Please ensure your app is up to date. *If you are using a 3rd-party app, please reach out to them directly.*
|
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.10.0'
|
- 'Android App - 0.13.0'
|
||||||
- 'iOS App - 0.10.0'
|
- 'iOS App - 0.13.0'
|
||||||
- 'Android App - 0.9.81'
|
- 'Android App - 0.12.0'
|
||||||
- 'iOS App - 0.9.81'
|
- 'iOS App - 0.12.0'
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: textarea
|
- type: textarea
|
||||||
|
|||||||
@@ -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 113
|
versionCode 117
|
||||||
versionName "0.10.0-beta"
|
versionName "0.13.0-beta"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
manifestPlaceholders = [
|
manifestPlaceholders = [
|
||||||
"appAuthRedirectScheme": "com.audiobookshelf.app"
|
"appAuthRedirectScheme": "com.audiobookshelf.app"
|
||||||
|
|||||||
@@ -36,6 +36,10 @@
|
|||||||
<meta-data
|
<meta-data
|
||||||
android:name="android.appwidget.provider"
|
android:name="android.appwidget.provider"
|
||||||
android:resource="@xml/media_player_widget_info" />
|
android:resource="@xml/media_player_widget_info" />
|
||||||
|
|
||||||
|
<meta-data
|
||||||
|
android:name="com.samsung.android.appwidget.provider"
|
||||||
|
android:resource="@xml/samsung_cover_widget_info" />
|
||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
<!-- Used by Android Auto -->
|
<!-- Used by Android Auto -->
|
||||||
|
|||||||
@@ -527,4 +527,18 @@ constructor(private val playerNotificationService: PlayerNotificationService) {
|
|||||||
|
|
||||||
checkAutoSleepTimer()
|
checkAutoSleepTimer()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends the current sleep timer state to the client.
|
||||||
|
* Called when app resumes from background to sync UI state.
|
||||||
|
*/
|
||||||
|
fun sendCurrentSleepTimerState() {
|
||||||
|
if (sleepTimerRunning) {
|
||||||
|
val timeRemaining = getSleepTimerTimeRemainingSeconds(getPlaybackSpeed())
|
||||||
|
playerNotificationService.clientEventEmitter?.onSleepTimerSet(timeRemaining, isAutoSleepTimer)
|
||||||
|
} else {
|
||||||
|
// No timer running - send 0 to clear any stale UI state
|
||||||
|
playerNotificationService.clientEventEmitter?.onSleepTimerSet(0, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import org.json.JSONException
|
|||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import kotlin.coroutines.resume
|
import kotlin.coroutines.resume
|
||||||
import kotlin.coroutines.suspendCoroutine
|
import kotlin.coroutines.suspendCoroutine
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
|
||||||
class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
|
class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
|
||||||
val tag = "MediaManager"
|
val tag = "MediaManager"
|
||||||
@@ -176,21 +177,20 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
|
|||||||
* [cb] resolves when all libraries are processed
|
* [cb] resolves when all libraries are processed
|
||||||
*/
|
*/
|
||||||
fun populatePersonalizedDataForAllLibraries(cb: () -> Unit) {
|
fun populatePersonalizedDataForAllLibraries(cb: () -> Unit) {
|
||||||
serverLibraries.forEach {
|
val remaining = AtomicInteger(serverLibraries.size)
|
||||||
libraryPersonalizationsDone++
|
|
||||||
Log.d(tag, "Loading personalization for library ${it.name} - ${it.id} - ${it.mediaType}")
|
|
||||||
populatePersonalizedDataForLibrary(it.id) {
|
|
||||||
Log.d(tag, "Loaded personalization for library ${it.name} - ${it.id} - ${it.mediaType}")
|
|
||||||
libraryPersonalizationsDone--
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
while (libraryPersonalizationsDone > 0) { }
|
|
||||||
|
|
||||||
|
serverLibraries.forEach { lib ->
|
||||||
|
Log.d(tag, "Loading personalization for library ${lib.name}")
|
||||||
|
populatePersonalizedDataForLibrary(lib.id) {
|
||||||
|
Log.d(tag, "Loaded personalization for library ${lib.name}")
|
||||||
|
if (remaining.decrementAndGet() == 0) {
|
||||||
Log.d(tag, "Finished loading all library personalization data")
|
Log.d(tag, "Finished loading all library personalization data")
|
||||||
allLibraryPersonalizationsDone = true
|
allLibraryPersonalizationsDone = true
|
||||||
cb()
|
cb()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get personalized shelves from server for selected [libraryId].
|
* Get personalized shelves from server for selected [libraryId].
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ data class DownloadItemPart(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val downloadUri = Uri.parse(downloadUrl)
|
val downloadUri = Uri.parse(downloadUrl)
|
||||||
Log.d("DownloadItemPart", "Audio File Destination Uri: $destinationUri | Final Destination Uri: $finalDestinationUri | Download URI $downloadUri")
|
Log.d("DownloadItemPart", "Audio File Destination Uri: $destinationUri | Final Destination Uri: $finalDestinationUri | Server Path $serverPath")
|
||||||
return DownloadItemPart(
|
return DownloadItemPart(
|
||||||
id = DeviceManager.getBase64Id(finalDestinationFile.absolutePath),
|
id = DeviceManager.getBase64Id(finalDestinationFile.absolutePath),
|
||||||
downloadItemId,
|
downloadItemId,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.audiobookshelf.app.player
|
|||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import kotlin.math.roundToInt
|
||||||
import android.view.Surface
|
import android.view.Surface
|
||||||
import android.view.SurfaceHolder
|
import android.view.SurfaceHolder
|
||||||
import android.view.SurfaceView
|
import android.view.SurfaceView
|
||||||
@@ -53,6 +54,10 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
|||||||
private var myCurrentTrackGroups: TrackGroupArray
|
private var myCurrentTrackGroups: TrackGroupArray
|
||||||
private var myCurrentTrackSelections: TrackSelectionArray
|
private var myCurrentTrackSelections: TrackSelectionArray
|
||||||
|
|
||||||
|
// Cache for pending volume to handle async setVolume
|
||||||
|
private var pendingVolume: Int? = null
|
||||||
|
private var pendingVolumeSetAt: Long = 0L
|
||||||
|
|
||||||
var currentMediaItems:List<MediaItem> = mutableListOf()
|
var currentMediaItems:List<MediaItem> = mutableListOf()
|
||||||
var remoteMediaClient:RemoteMediaClient? = null
|
var remoteMediaClient:RemoteMediaClient? = null
|
||||||
var sessionAvailabilityListener:SessionAvailabilityListener? = null
|
var sessionAvailabilityListener:SessionAvailabilityListener? = null
|
||||||
@@ -76,7 +81,10 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
|||||||
COMMAND_GET_MEDIA_ITEMS_METADATA,
|
COMMAND_GET_MEDIA_ITEMS_METADATA,
|
||||||
COMMAND_SET_MEDIA_ITEMS_METADATA,
|
COMMAND_SET_MEDIA_ITEMS_METADATA,
|
||||||
COMMAND_CHANGE_MEDIA_ITEMS,
|
COMMAND_CHANGE_MEDIA_ITEMS,
|
||||||
COMMAND_GET_TRACKS)
|
COMMAND_GET_TRACKS,
|
||||||
|
COMMAND_GET_DEVICE_VOLUME,
|
||||||
|
COMMAND_SET_DEVICE_VOLUME,
|
||||||
|
COMMAND_ADJUST_DEVICE_VOLUME)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
var currentPlaybackState = Player.STATE_IDLE
|
var currentPlaybackState = Player.STATE_IDLE
|
||||||
@@ -861,27 +869,66 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun getDeviceVolume(): Int {
|
override fun getDeviceVolume(): Int {
|
||||||
return 0
|
// Short-lived cache without even-value quantization
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
if (pendingVolume != null && (now - pendingVolumeSetAt) < 100) {
|
||||||
|
return pendingVolume!!
|
||||||
|
}
|
||||||
|
|
||||||
|
val session = castContext.sessionManager.currentCastSession
|
||||||
|
val vol = if (session != null && !session.isMute) {
|
||||||
|
(session.volume * 100).roundToInt().coerceIn(0, 100)
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingVolume != null && vol == pendingVolume) {
|
||||||
|
pendingVolume = null
|
||||||
|
}
|
||||||
|
|
||||||
|
return vol
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun isDeviceMuted(): Boolean {
|
override fun isDeviceMuted(): Boolean {
|
||||||
return false
|
val session = castContext.sessionManager.currentCastSession
|
||||||
|
return session?.isMute ?: false
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun setDeviceVolume(volume: Int) {
|
override fun setDeviceVolume(volume: Int) {
|
||||||
|
val session = castContext.sessionManager.currentCastSession
|
||||||
|
try {
|
||||||
|
val clamped = volume.coerceIn(0, 100)
|
||||||
|
val target = (clamped / 100.0).let { kotlin.math.round(it * 100.0) / 100.0 }
|
||||||
|
|
||||||
|
// Cache for 100ms to return in getDeviceVolume during async period
|
||||||
|
pendingVolume = clamped
|
||||||
|
pendingVolumeSetAt = System.currentTimeMillis()
|
||||||
|
|
||||||
|
session?.setVolume(target)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(tag, "Failed to set cast device volume", e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun increaseDeviceVolume() {
|
override fun increaseDeviceVolume() {
|
||||||
|
val current = getDeviceVolume()
|
||||||
|
val next = current + 1
|
||||||
|
setDeviceVolume(next.coerceAtMost(100))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun decreaseDeviceVolume() {
|
override fun decreaseDeviceVolume() {
|
||||||
|
val current = getDeviceVolume()
|
||||||
|
val next = current - 1
|
||||||
|
setDeviceVolume(next.coerceAtLeast(0))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun setDeviceMuted(muted: Boolean) {
|
override fun setDeviceMuted(muted: Boolean) {
|
||||||
|
val session = castContext.sessionManager.currentCastSession
|
||||||
|
try {
|
||||||
|
session?.isMute = muted
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(tag, "Failed to set cast device mute state", e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
inner class StatusListener() : RemoteMediaClient.Callback(), SessionManagerListener<CastSession>, RemoteMediaClient.ProgressListener {
|
inner class StatusListener() : RemoteMediaClient.Callback(), SessionManagerListener<CastSession>, RemoteMediaClient.ProgressListener {
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import android.support.v4.media.MediaDescriptionCompat
|
|||||||
import android.support.v4.media.MediaMetadataCompat
|
import android.support.v4.media.MediaMetadataCompat
|
||||||
import android.support.v4.media.session.MediaControllerCompat
|
import android.support.v4.media.session.MediaControllerCompat
|
||||||
import android.support.v4.media.session.MediaSessionCompat
|
import android.support.v4.media.session.MediaSessionCompat
|
||||||
|
import androidx.media.VolumeProviderCompat
|
||||||
|
import android.media.AudioManager
|
||||||
import android.support.v4.media.session.PlaybackStateCompat
|
import android.support.v4.media.session.PlaybackStateCompat
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
@@ -94,6 +96,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
private lateinit var mediaSessionConnector: MediaSessionConnector
|
private lateinit var mediaSessionConnector: MediaSessionConnector
|
||||||
private lateinit var playerNotificationManager: PlayerNotificationManager
|
private lateinit var playerNotificationManager: PlayerNotificationManager
|
||||||
lateinit var mediaSession: MediaSessionCompat
|
lateinit var mediaSession: MediaSessionCompat
|
||||||
|
private var remoteVolumeProvider: VolumeProviderCompat? = null
|
||||||
private lateinit var transportControls: MediaControllerCompat.TransportControls
|
private lateinit var transportControls: MediaControllerCompat.TransportControls
|
||||||
|
|
||||||
lateinit var mediaManager: MediaManager
|
lateinit var mediaManager: MediaManager
|
||||||
@@ -477,6 +480,8 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
val dataSourceFactory = DefaultDataSource.Factory(ctx)
|
val dataSourceFactory = DefaultDataSource.Factory(ctx)
|
||||||
|
|
||||||
val extractorsFactory = DefaultExtractorsFactory()
|
val extractorsFactory = DefaultExtractorsFactory()
|
||||||
|
extractorsFactory.setConstantBitrateSeekingEnabled(true)
|
||||||
|
|
||||||
if (DeviceManager.deviceData.deviceSettings?.enableMp3IndexSeeking == true) {
|
if (DeviceManager.deviceData.deviceSettings?.enableMp3IndexSeeking == true) {
|
||||||
// @see
|
// @see
|
||||||
// https://exoplayer.dev/troubleshooting.html#why-is-seeking-inaccurate-in-some-mp3-files
|
// https://exoplayer.dev/troubleshooting.html#why-is-seeking-inaccurate-in-some-mp3-files
|
||||||
@@ -491,6 +496,8 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
val dataSourceFactory = DefaultHttpDataSource.Factory()
|
val dataSourceFactory = DefaultHttpDataSource.Factory()
|
||||||
|
|
||||||
val extractorsFactory = DefaultExtractorsFactory()
|
val extractorsFactory = DefaultExtractorsFactory()
|
||||||
|
extractorsFactory.setConstantBitrateSeekingEnabled(true)
|
||||||
|
|
||||||
if (DeviceManager.deviceData.deviceSettings?.enableMp3IndexSeeking == true) {
|
if (DeviceManager.deviceData.deviceSettings?.enableMp3IndexSeeking == true) {
|
||||||
// @see
|
// @see
|
||||||
// https://exoplayer.dev/troubleshooting.html#why-is-seeking-inaccurate-in-some-mp3-files
|
// https://exoplayer.dev/troubleshooting.html#why-is-seeking-inaccurate-in-some-mp3-files
|
||||||
@@ -705,11 +712,13 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
Log.d(tag, "switchToPlayer: Using Cast Player " + castPlayer?.deviceInfo)
|
Log.d(tag, "switchToPlayer: Using Cast Player " + castPlayer?.deviceInfo)
|
||||||
mediaSessionConnector.setPlayer(castPlayer)
|
mediaSessionConnector.setPlayer(castPlayer)
|
||||||
playerNotificationManager.setPlayer(castPlayer)
|
playerNotificationManager.setPlayer(castPlayer)
|
||||||
|
setMediaSessionToCastVolume()
|
||||||
castPlayer as CastPlayer
|
castPlayer as CastPlayer
|
||||||
} else {
|
} else {
|
||||||
Log.d(tag, "switchToPlayer: Using ExoPlayer")
|
Log.d(tag, "switchToPlayer: Using ExoPlayer")
|
||||||
mediaSessionConnector.setPlayer(mPlayer)
|
mediaSessionConnector.setPlayer(mPlayer)
|
||||||
playerNotificationManager.setPlayer(mPlayer)
|
playerNotificationManager.setPlayer(mPlayer)
|
||||||
|
setMediaSessionToLocalVolume()
|
||||||
mPlayer
|
mPlayer
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -726,6 +735,33 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun setMediaSessionToCastVolume() {
|
||||||
|
val currentVol = try { castPlayer?.getDeviceVolume() ?: 0 } catch (_: Exception) { 0 }
|
||||||
|
val provider = object : VolumeProviderCompat(VolumeProviderCompat.VOLUME_CONTROL_ABSOLUTE, 100, currentVol) {
|
||||||
|
override fun onSetVolumeTo(volume: Int) {
|
||||||
|
// Clamp, update UI immediately, then send to device
|
||||||
|
val clamped = volume.coerceIn(0, 100)
|
||||||
|
setCurrentVolume(clamped)
|
||||||
|
try { castPlayer?.setDeviceVolume(clamped) } catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAdjustVolume(direction: Int) {
|
||||||
|
// Use Android-provided step (−1, 0, +1). Clamp, update UI immediately, then send.
|
||||||
|
val current = currentVolume
|
||||||
|
val target = (current + direction).coerceIn(0, 100)
|
||||||
|
setCurrentVolume(target)
|
||||||
|
try { castPlayer?.setDeviceVolume(target) } catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
remoteVolumeProvider = provider
|
||||||
|
mediaSession.setPlaybackToRemote(provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setMediaSessionToLocalVolume() {
|
||||||
|
mediaSession.setPlaybackToLocal(AudioManager.STREAM_MUSIC)
|
||||||
|
remoteVolumeProvider = null
|
||||||
|
}
|
||||||
|
|
||||||
fun getCurrentTrackStartOffsetMs(): Long {
|
fun getCurrentTrackStartOffsetMs(): Long {
|
||||||
return if (currentPlayer.mediaItemCount > 1) {
|
return if (currentPlayer.mediaItemCount > 1) {
|
||||||
val windowIndex = currentPlayer.currentMediaItemIndex
|
val windowIndex = currentPlayer.currentMediaItemIndex
|
||||||
@@ -1087,6 +1123,26 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
private val DOWNLOADS_ROOT = "__DOWNLOADS__"
|
private val DOWNLOADS_ROOT = "__DOWNLOADS__"
|
||||||
private val CONTINUE_ROOT = "__CONTINUE__"
|
private val CONTINUE_ROOT = "__CONTINUE__"
|
||||||
private lateinit var browseTree: BrowseTree
|
private lateinit var browseTree: BrowseTree
|
||||||
|
private val browseTreeInitListeners = mutableListOf<() -> Unit>()
|
||||||
|
|
||||||
|
private fun waitForBrowseTree(cb: () -> Unit)
|
||||||
|
{
|
||||||
|
if (this::browseTree.isInitialized)
|
||||||
|
{
|
||||||
|
cb()
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
browseTreeInitListeners += cb
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onBrowseTreeInitialized()
|
||||||
|
{
|
||||||
|
// Called after browseTree is assigned for the first time
|
||||||
|
browseTreeInitListeners.forEach { it.invoke() }
|
||||||
|
browseTreeInitListeners.clear()
|
||||||
|
}
|
||||||
|
|
||||||
// Only allowing android auto or similar to access media browser service
|
// Only allowing android auto or similar to access media browser service
|
||||||
// normal loading of audiobooks is handled in webview (not natively)
|
// normal loading of audiobooks is handled in webview (not natively)
|
||||||
@@ -1257,6 +1313,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
mediaManager.serverLibraries,
|
mediaManager.serverLibraries,
|
||||||
mediaManager.allLibraryPersonalizationsDone
|
mediaManager.allLibraryPersonalizationsDone
|
||||||
)
|
)
|
||||||
|
onBrowseTreeInitialized()
|
||||||
val children =
|
val children =
|
||||||
browseTree[parentMediaId]?.map { item ->
|
browseTree[parentMediaId]?.map { item ->
|
||||||
Log.d(tag, "Found top menu item: ${item.description.title}")
|
Log.d(tag, "Found top menu item: ${item.description.title}")
|
||||||
@@ -1291,6 +1348,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
mediaManager.serverLibraries,
|
mediaManager.serverLibraries,
|
||||||
mediaManager.allLibraryPersonalizationsDone
|
mediaManager.allLibraryPersonalizationsDone
|
||||||
)
|
)
|
||||||
|
onBrowseTreeInitialized()
|
||||||
val children =
|
val children =
|
||||||
browseTree[parentMediaId]?.map { item ->
|
browseTree[parentMediaId]?.map { item ->
|
||||||
Log.d(tag, "Found top menu item: ${item.description.title}")
|
Log.d(tag, "Found top menu item: ${item.description.title}")
|
||||||
@@ -1303,16 +1361,34 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
AbsLogger.info(tag, "onLoadChildren: Android auto data 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)
|
||||||
|
{
|
||||||
Log.d(tag, "First load done: $firstLoadDone")
|
Log.d(tag, "First load done: $firstLoadDone")
|
||||||
if (!firstLoadDone) {
|
if (!firstLoadDone)
|
||||||
|
{
|
||||||
result.sendResult(null)
|
result.sendResult(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Wait until top-menu is initialized
|
|
||||||
while (!this::browseTree.isInitialized) {}
|
if (!this::browseTree.isInitialized)
|
||||||
val children =
|
{
|
||||||
browseTree[parentMediaId]?.map { item ->
|
// ✅ good: detach and wait for init
|
||||||
|
result.detach()
|
||||||
|
waitForBrowseTree {
|
||||||
|
val children = browseTree[parentMediaId]?.map { item ->
|
||||||
|
Log.d(tag, "[MENU: $parentMediaId] Showing list item ${item.description.title}")
|
||||||
|
MediaBrowserCompat.MediaItem(
|
||||||
|
item.description,
|
||||||
|
MediaBrowserCompat.MediaItem.FLAG_BROWSABLE
|
||||||
|
)
|
||||||
|
}
|
||||||
|
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Already initialized: just return
|
||||||
|
val children = browseTree[parentMediaId]?.map { item ->
|
||||||
Log.d(tag, "[MENU: $parentMediaId] Showing list item ${item.description.title}")
|
Log.d(tag, "[MENU: $parentMediaId] Showing list item ${item.description.title}")
|
||||||
MediaBrowserCompat.MediaItem(
|
MediaBrowserCompat.MediaItem(
|
||||||
item.description,
|
item.description,
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ class AbsAudioPlayer : Plugin() {
|
|||||||
|
|
||||||
private var isCastAvailable:Boolean = false
|
private var isCastAvailable:Boolean = false
|
||||||
|
|
||||||
|
// Track foreground state to avoid flooding WebView with events while backgrounded
|
||||||
|
private var isInForeground: Boolean = true
|
||||||
|
|
||||||
override fun load() {
|
override fun load() {
|
||||||
mainActivity = (activity as MainActivity)
|
mainActivity = (activity as MainActivity)
|
||||||
apiHandler = ApiHandler(mainActivity)
|
apiHandler = ApiHandler(mainActivity)
|
||||||
@@ -60,6 +63,8 @@ class AbsAudioPlayer : Plugin() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onMetadata(metadata: PlaybackMetadata) {
|
override fun onMetadata(metadata: PlaybackMetadata) {
|
||||||
|
// Skip frequent metadata updates when app is backgrounded to prevent event queue buildup
|
||||||
|
if (!isInForeground) return
|
||||||
notifyListeners("onMetadata", JSObject(jacksonMapper.writeValueAsString(metadata)))
|
notifyListeners("onMetadata", JSObject(jacksonMapper.writeValueAsString(metadata)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,6 +73,8 @@ class AbsAudioPlayer : Plugin() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onSleepTimerSet(sleepTimeRemaining: Int, isAutoSleepTimer:Boolean) {
|
override fun onSleepTimerSet(sleepTimeRemaining: Int, isAutoSleepTimer:Boolean) {
|
||||||
|
// Skip sleep timer updates when app is backgrounded to prevent event queue buildup
|
||||||
|
if (!isInForeground) return
|
||||||
val ret = JSObject()
|
val ret = JSObject()
|
||||||
ret.put("value", sleepTimeRemaining)
|
ret.put("value", sleepTimeRemaining)
|
||||||
ret.put("isAuto", isAutoSleepTimer)
|
ret.put("isAuto", isAutoSleepTimer)
|
||||||
@@ -75,6 +82,8 @@ class AbsAudioPlayer : Plugin() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onLocalMediaProgressUpdate(localMediaProgress: LocalMediaProgress) {
|
override fun onLocalMediaProgressUpdate(localMediaProgress: LocalMediaProgress) {
|
||||||
|
// Skip progress updates when app is backgrounded to prevent event queue buildup
|
||||||
|
if (!isInForeground) return
|
||||||
notifyListeners("onLocalMediaProgressUpdate", JSObject(jacksonMapper.writeValueAsString(localMediaProgress)))
|
notifyListeners("onLocalMediaProgressUpdate", JSObject(jacksonMapper.writeValueAsString(localMediaProgress)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,6 +127,27 @@ class AbsAudioPlayer : Plugin() {
|
|||||||
notifyListeners(evtName, ret)
|
notifyListeners(evtName, ret)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun handleOnPause() {
|
||||||
|
super.handleOnPause()
|
||||||
|
isInForeground = false
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun handleOnResume() {
|
||||||
|
super.handleOnResume()
|
||||||
|
isInForeground = true
|
||||||
|
|
||||||
|
// Send current state to UI after resume to sync up (with small delay to let WebView fully resume)
|
||||||
|
if (::playerNotificationService.isInitialized && playerNotificationService.currentPlaybackSession != null) {
|
||||||
|
Handler(Looper.getMainLooper()).postDelayed({
|
||||||
|
playerNotificationService.sendClientMetadata(PlayerState.READY)
|
||||||
|
playerNotificationService.sleepTimerManager.sendCurrentSleepTimerState()
|
||||||
|
playerNotificationService.mediaProgressSyncer.currentLocalMediaProgress?.let {
|
||||||
|
playerNotificationService.clientEventEmitter?.onLocalMediaProgressUpdate(it)
|
||||||
|
}
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun initCastManager() {
|
private fun initCastManager() {
|
||||||
val googleApi = GoogleApiAvailability.getInstance()
|
val googleApi = GoogleApiAvailability.getInstance()
|
||||||
val statusCode = googleApi.isGooglePlayServicesAvailable(mainActivity)
|
val statusCode = googleApi.isGooglePlayServicesAvailable(mainActivity)
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ class AbsDatabase : Plugin() {
|
|||||||
|
|
||||||
@PluginMethod
|
@PluginMethod
|
||||||
fun setCurrentServerConnectionConfig(call:PluginCall) {
|
fun setCurrentServerConnectionConfig(call:PluginCall) {
|
||||||
Log.d(tag, "setCurrentServerConnectionConfig ${call.data}")
|
Log.d(tag, "setCurrentServerConnectionConfig called")
|
||||||
val serverConfigPayload = jacksonMapper.readValue<ServerConnConfigPayload>(call.data.toString())
|
val serverConfigPayload = jacksonMapper.readValue<ServerConnConfigPayload>(call.data.toString())
|
||||||
var serverConnectionConfig = DeviceManager.deviceData.serverConnectionConfigs.find { it.id == serverConfigPayload.id }
|
var serverConnectionConfig = DeviceManager.deviceData.serverConnectionConfigs.find { it.id == serverConfigPayload.id }
|
||||||
|
|
||||||
@@ -558,7 +558,7 @@ class AbsDatabase : Plugin() {
|
|||||||
|
|
||||||
@PluginMethod
|
@PluginMethod
|
||||||
fun updateDeviceSettings(call:PluginCall) { // Returns device data
|
fun updateDeviceSettings(call:PluginCall) { // Returns device data
|
||||||
Log.d(tag, "updateDeviceSettings ${call.data}")
|
Log.d(tag, "updateDeviceSettings called")
|
||||||
val newDeviceSettings = jacksonMapper.readValue<DeviceSettings>(call.data.toString())
|
val newDeviceSettings = jacksonMapper.readValue<DeviceSettings>(call.data.toString())
|
||||||
|
|
||||||
Handler(Looper.getMainLooper()).post {
|
Handler(Looper.getMainLooper()).post {
|
||||||
@@ -576,7 +576,7 @@ class AbsDatabase : Plugin() {
|
|||||||
|
|
||||||
@PluginMethod
|
@PluginMethod
|
||||||
fun getMediaItemHistory(call:PluginCall) { // Returns device data
|
fun getMediaItemHistory(call:PluginCall) { // Returns device data
|
||||||
Log.d(tag, "getMediaItemHistory ${call.data}")
|
Log.d(tag, "getMediaItemHistory called")
|
||||||
val mediaId = call.getString("mediaId") ?: ""
|
val mediaId = call.getString("mediaId") ?: ""
|
||||||
|
|
||||||
GlobalScope.launch(Dispatchers.IO) {
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
|
|||||||
@@ -407,6 +407,9 @@ class ApiHandler(var ctx:Context) {
|
|||||||
if (checkAbsDatabaseNotifyListenersInitted()) {
|
if (checkAbsDatabaseNotifyListenersInitted()) {
|
||||||
val tokenJsObject = JSObject()
|
val tokenJsObject = JSObject()
|
||||||
tokenJsObject.put("error", "Token refresh failed")
|
tokenJsObject.put("error", "Token refresh failed")
|
||||||
|
if (serverConnectionConfigId.isNotEmpty()) {
|
||||||
|
tokenJsObject.put("serverConnectionConfigId", serverConnectionConfigId)
|
||||||
|
}
|
||||||
absDatabaseNotifyListeners("onTokenRefreshFailure", tokenJsObject)
|
absDatabaseNotifyListeners("onTokenRefreshFailure", tokenJsObject)
|
||||||
} else {
|
} else {
|
||||||
// Can happen if Webview is never run
|
// Can happen if Webview is never run
|
||||||
|
|||||||
@@ -11,4 +11,4 @@
|
|||||||
android:targetCellWidth="4"
|
android:targetCellWidth="4"
|
||||||
android:targetCellHeight="1"
|
android:targetCellHeight="1"
|
||||||
android:updatePeriodMillis="86400000"
|
android:updatePeriodMillis="86400000"
|
||||||
android:widgetCategory="home_screen" />
|
android:widgetCategory="home_screen|keyguard" />
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<samsung-appwidget-provider
|
||||||
|
display="sub_screen"
|
||||||
|
privacyWidget="false" />
|
||||||
@@ -39,11 +39,11 @@ class WrappingMarquee {
|
|||||||
this.setMask(true)
|
this.setMask(true)
|
||||||
|
|
||||||
let textScrollAmount = this.el.scrollWidth
|
let textScrollAmount = this.el.scrollWidth
|
||||||
this.pEl.innerHTML = this.innerText + ' '.repeat(15)
|
this.pEl.textContent = this.innerText + '\u00A0'.repeat(15) // \u00A0 = non-breaking space
|
||||||
let totalScrollAmount = this.el.scrollWidth
|
let totalScrollAmount = this.el.scrollWidth
|
||||||
let scrollDuration = totalScrollAmount * this.#scrollSpeed
|
let scrollDuration = totalScrollAmount * this.#scrollSpeed
|
||||||
|
|
||||||
this.pEl.innerHTML = this.pEl.innerHTML + this.innerText
|
this.pEl.textContent = this.pEl.textContent + this.innerText
|
||||||
|
|
||||||
let done = false
|
let done = false
|
||||||
let start, previousTimeStamp
|
let start, previousTimeStamp
|
||||||
@@ -55,13 +55,14 @@ class WrappingMarquee {
|
|||||||
const elapsed = timeStamp - start
|
const elapsed = timeStamp - start
|
||||||
|
|
||||||
if (this.isScrolling && previousTimeStamp !== timeStamp) {
|
if (this.isScrolling && previousTimeStamp !== timeStamp) {
|
||||||
const amountToMove = Math.min(elapsed / scrollDuration * totalScrollAmount, totalScrollAmount)
|
const amountToMove = Math.min((elapsed / scrollDuration) * totalScrollAmount, totalScrollAmount)
|
||||||
this.pEl.style.transform = `translateX(-${amountToMove}px)`
|
this.pEl.style.transform = `translateX(-${amountToMove}px)`
|
||||||
if (amountToMove === totalScrollAmount) done = true
|
if (amountToMove === totalScrollAmount) done = true
|
||||||
if (amountToMove > textScrollAmount) this.setMask(false)
|
if (amountToMove > textScrollAmount) this.setMask(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.isScrolling || done) { // canceled or done
|
if (!this.isScrolling || done) {
|
||||||
|
// canceled or done
|
||||||
this.isScrolling = false
|
this.isScrolling = false
|
||||||
this.pEl.style.transform = 'translateX(0px)'
|
this.pEl.style.transform = 'translateX(0px)'
|
||||||
this.pEl.innerText = this.innerText
|
this.pEl.innerText = this.innerText
|
||||||
@@ -69,7 +70,8 @@ class WrappingMarquee {
|
|||||||
if (done) {
|
if (done) {
|
||||||
this.startTimer()
|
this.startTimer()
|
||||||
}
|
}
|
||||||
} else if (elapsed < scrollDuration) { // step
|
} else if (elapsed < scrollDuration) {
|
||||||
|
// step
|
||||||
previousTimeStamp = timeStamp
|
previousTimeStamp = timeStamp
|
||||||
this.animationId = window.requestAnimationFrame(step)
|
this.animationId = window.requestAnimationFrame(step)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,14 +4,14 @@
|
|||||||
<nuxt-link v-show="!showBack" to="/" class="mr-3">
|
<nuxt-link v-show="!showBack" to="/" class="mr-3">
|
||||||
<img src="/Logo.png" class="h-10 w-10" />
|
<img src="/Logo.png" class="h-10 w-10" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
<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" aria-label="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">
|
<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">
|
<button type="button" aria-label="Show library modal" 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>
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<widgets-connection-indicator />
|
<widgets-connection-indicator />
|
||||||
@@ -21,19 +21,19 @@
|
|||||||
<widgets-download-progress-indicator />
|
<widgets-download-progress-indicator />
|
||||||
|
|
||||||
<!-- Must be connected to a server to cast, only supports media items on server -->
|
<!-- Must be connected to a server to cast, only supports media items on server -->
|
||||||
<div v-show="isCastAvailable && user" class="mx-2 cursor-pointer flex items-center" @click="castClick">
|
<button type="button" aria-label="Cast" v-show="isCastAvailable && user" class="mx-2 cursor-pointer flex items-center" @click="castClick">
|
||||||
<span class="material-symbols text-2xl leading-none">
|
<span class="material-symbols text-2xl leading-none">
|
||||||
{{ isCasting ? 'cast_connected' : 'cast' }}
|
{{ isCasting ? 'cast_connected' : 'cast' }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</button>
|
||||||
|
|
||||||
<nuxt-link v-if="user" class="mx-1.5 flex items-center h-10" to="/search">
|
<nuxt-link v-if="user" class="mx-1.5 flex items-center h-10" to="/search" aria-label="Search">
|
||||||
<span class="material-symbols text-2xl leading-none">search</span>
|
<span class="material-symbols text-2xl leading-none">search</span>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
<div class="h-7 mx-1.5">
|
<button type="button" aria-label="Toggle side drawer" class="h-7 mx-1.5" @click="clickShowSideDrawer">
|
||||||
<span class="material-symbols" style="font-size: 1.75rem" @click="clickShowSideDrawer">menu</span>
|
<span class="material-symbols" style="font-size: 1.75rem">menu</span>
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -69,16 +69,22 @@
|
|||||||
|
|
||||||
<div id="playerControls" class="absolute right-0 bottom-0 mx-auto" style="max-width: 414px">
|
<div id="playerControls" class="absolute right-0 bottom-0 mx-auto" style="max-width: 414px">
|
||||||
<div class="flex items-center max-w-full" :class="playerSettings.lockUi ? 'justify-center' : 'justify-between'">
|
<div class="flex items-center max-w-full" :class="playerSettings.lockUi ? 'justify-center' : 'justify-between'">
|
||||||
<span v-show="showFullscreen && !playerSettings.lockUi" class="material-symbols next-icon text-fg cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpChapterStart">first_page</span>
|
<span v-show="showFullscreen && !playerSettings.lockUi" class="material-symbols next-icon text-fg cursor-pointer" :class="showLoadingState ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpChapterStart">first_page</span>
|
||||||
<span v-show="!playerSettings.lockUi" class="material-symbols jump-icon text-fg cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpBackwards">{{ jumpBackwardsIcon }}</span>
|
<div v-show="!playerSettings.lockUi" class="jump-icon text-fg cursor-pointer flex flex-col items-center" :class="showLoadingState ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpBackwards">
|
||||||
|
<span class="material-symbols text-3xl leading-none">replay</span>
|
||||||
|
<span v-if="showFullscreen" class="jump-label text-[10px] font-semibold leading-tight">{{ jumpBackwardsLabel }}</span>
|
||||||
|
</div>
|
||||||
<div class="play-btn cursor-pointer shadow-sm flex items-center justify-center rounded-full text-primary mx-4 relative overflow-hidden" :style="{ backgroundColor: coverRgb }" :class="{ 'animate-spin': seekLoading }" @mousedown.prevent @mouseup.prevent @click.stop="playPauseClick">
|
<div class="play-btn cursor-pointer shadow-sm flex items-center justify-center rounded-full text-primary mx-4 relative overflow-hidden" :style="{ backgroundColor: coverRgb }" :class="{ 'animate-spin': seekLoading }" @mousedown.prevent @mouseup.prevent @click.stop="playPauseClick">
|
||||||
<div v-if="!coverBgIsLight" class="absolute top-0 left-0 w-full h-full bg-white bg-opacity-20 pointer-events-none" />
|
<div v-if="!coverBgIsLight" class="absolute top-0 left-0 w-full h-full bg-white bg-opacity-20 pointer-events-none" />
|
||||||
|
|
||||||
<span v-if="!isLoading" class="material-symbols fill" :class="{ 'text-white': coverRgb && !coverBgIsLight }">{{ seekLoading ? 'autorenew' : !isPlaying ? 'play_arrow' : 'pause' }}</span>
|
<span v-if="!showLoadingState" class="material-symbols fill" :class="{ 'text-white': coverRgb && !coverBgIsLight }">{{ seekLoading ? 'autorenew' : !isPlaying ? 'play_arrow' : 'pause' }}</span>
|
||||||
<widgets-spinner-icon v-else class="h-8 w-8" />
|
<widgets-spinner-icon v-else class="h-8 w-8" />
|
||||||
</div>
|
</div>
|
||||||
<span v-show="!playerSettings.lockUi" class="material-symbols jump-icon text-fg cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpForward">{{ jumpForwardIcon }}</span>
|
<div v-show="!playerSettings.lockUi" class="jump-icon text-fg cursor-pointer flex flex-col items-center" :class="showLoadingState ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpForward">
|
||||||
<span v-show="showFullscreen && !playerSettings.lockUi" class="material-symbols next-icon text-fg cursor-pointer" :class="nextChapter && !isLoading ? 'text-opacity-75' : 'text-opacity-10'" @click.stop="jumpNextChapter">last_page</span>
|
<span class="material-symbols text-3xl leading-none">forward_media</span>
|
||||||
|
<span v-if="showFullscreen" class="jump-label text-[10px] font-semibold leading-tight">{{ jumpForwardLabel }}</span>
|
||||||
|
</div>
|
||||||
|
<span v-show="showFullscreen && !playerSettings.lockUi" class="material-symbols next-icon text-fg cursor-pointer" :class="nextChapter && !showLoadingState ? 'text-opacity-75' : 'text-opacity-10'" @click.stop="jumpNextChapter">last_page</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -88,7 +94,7 @@
|
|||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<p class="font-mono text-fg" style="font-size: 0.8rem">{{ timeRemainingPretty }}</p>
|
<p class="font-mono text-fg" style="font-size: 0.8rem">{{ timeRemainingPretty }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div ref="track" class="h-1.5 w-full bg-track/50 relative rounded-full" :class="{ 'animate-pulse': isLoading }" @click.stop>
|
<div ref="track" class="h-1.5 w-full bg-track/50 relative rounded-full" :class="{ 'animate-pulse': showLoadingState }" @click.stop>
|
||||||
<div ref="readyTrack" class="h-full bg-track-buffered absolute top-0 left-0 rounded-full pointer-events-none" />
|
<div ref="readyTrack" class="h-full bg-track-buffered absolute top-0 left-0 rounded-full pointer-events-none" />
|
||||||
<div ref="bufferedTrack" class="h-full bg-track absolute top-0 left-0 rounded-full pointer-events-none" />
|
<div ref="bufferedTrack" class="h-full bg-track absolute top-0 left-0 rounded-full pointer-events-none" />
|
||||||
<div ref="playedTrack" class="h-full bg-track-cursor absolute top-0 left-0 rounded-full pointer-events-none" />
|
<div ref="playedTrack" class="h-full bg-track-cursor absolute top-0 left-0 rounded-full pointer-events-none" />
|
||||||
@@ -108,8 +114,9 @@
|
|||||||
import { Capacitor } from '@capacitor/core'
|
import { Capacitor } from '@capacitor/core'
|
||||||
import { AbsAudioPlayer } from '@/plugins/capacitor'
|
import { AbsAudioPlayer } from '@/plugins/capacitor'
|
||||||
import { Dialog } from '@capacitor/dialog'
|
import { Dialog } from '@capacitor/dialog'
|
||||||
import { FastAverageColor } from 'fast-average-color'
|
import { getAverageColorFromCoverUrl } from '@/utils/coverAverageColor'
|
||||||
import WrappingMarquee from '@/assets/WrappingMarquee.js'
|
import WrappingMarquee from '@/assets/WrappingMarquee.js'
|
||||||
|
import jumpLabelMixin from '@/mixins/jumpLabel'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
@@ -121,6 +128,7 @@ export default {
|
|||||||
sleepTimeRemaining: Number,
|
sleepTimeRemaining: Number,
|
||||||
serverLibraryItemId: String
|
serverLibraryItemId: String
|
||||||
},
|
},
|
||||||
|
mixins: [jumpLabelMixin],
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
windowHeight: 0,
|
windowHeight: 0,
|
||||||
@@ -149,6 +157,7 @@ export default {
|
|||||||
lockUi: false
|
lockUi: false
|
||||||
},
|
},
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
isCheckingServerProgress: false,
|
||||||
isDraggingCursor: false,
|
isDraggingCursor: false,
|
||||||
draggingTouchStartX: 0,
|
draggingTouchStartX: 0,
|
||||||
draggingTouchStartTime: 0,
|
draggingTouchStartTime: 0,
|
||||||
@@ -221,11 +230,11 @@ export default {
|
|||||||
|
|
||||||
return items
|
return items
|
||||||
},
|
},
|
||||||
jumpForwardIcon() {
|
jumpForwardLabel() {
|
||||||
return this.$store.getters['globals/getJumpForwardIcon'](this.jumpForwardTime)
|
return this.getJumpLabel(this.jumpForwardTime)
|
||||||
},
|
},
|
||||||
jumpBackwardsIcon() {
|
jumpBackwardsLabel() {
|
||||||
return this.$store.getters['globals/getJumpBackwardsIcon'](this.jumpBackwardsTime)
|
return this.getJumpLabel(this.jumpBackwardsTime)
|
||||||
},
|
},
|
||||||
jumpForwardTime() {
|
jumpForwardTime() {
|
||||||
return this.$store.getters['getJumpForwardTime']
|
return this.$store.getters['getJumpForwardTime']
|
||||||
@@ -262,6 +271,9 @@ export default {
|
|||||||
return 190 * heightScale
|
return 190 * heightScale
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
showLoadingState() {
|
||||||
|
return this.isLoading || this.isCheckingServerProgress
|
||||||
|
},
|
||||||
showCastBtn() {
|
showCastBtn() {
|
||||||
return this.$store.state.isCastAvailable
|
return this.$store.state.isCastAvailable
|
||||||
},
|
},
|
||||||
@@ -400,17 +412,14 @@ export default {
|
|||||||
},
|
},
|
||||||
async coverImageLoaded(fullCoverUrl) {
|
async coverImageLoaded(fullCoverUrl) {
|
||||||
if (!fullCoverUrl) return
|
if (!fullCoverUrl) return
|
||||||
|
const avg = await getAverageColorFromCoverUrl(this, fullCoverUrl)
|
||||||
const fac = new FastAverageColor()
|
if (!avg) {
|
||||||
fac
|
this.coverRgb = 'rgb(55, 56, 56)'
|
||||||
.getColorAsync(fullCoverUrl)
|
this.coverBgIsLight = false
|
||||||
.then((color) => {
|
} else {
|
||||||
this.coverRgb = color.rgba
|
this.coverRgb = avg.rgba
|
||||||
this.coverBgIsLight = color.isLight
|
this.coverBgIsLight = avg.isLight
|
||||||
})
|
}
|
||||||
.catch((e) => {
|
|
||||||
console.log(e)
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
clickTitleAndAuthor() {
|
clickTitleAndAuthor() {
|
||||||
if (!this.showFullscreen) return
|
if (!this.showFullscreen) return
|
||||||
@@ -453,13 +462,13 @@ export default {
|
|||||||
},
|
},
|
||||||
async jumpNextChapter() {
|
async jumpNextChapter() {
|
||||||
await this.$hapticsImpact()
|
await this.$hapticsImpact()
|
||||||
if (this.isLoading) return
|
if (this.showLoadingState) return
|
||||||
if (!this.nextChapter) return
|
if (!this.nextChapter) return
|
||||||
this.seek(this.nextChapter.start)
|
this.seek(this.nextChapter.start)
|
||||||
},
|
},
|
||||||
async jumpChapterStart() {
|
async jumpChapterStart() {
|
||||||
await this.$hapticsImpact()
|
await this.$hapticsImpact()
|
||||||
if (this.isLoading) return
|
if (this.showLoadingState) return
|
||||||
if (!this.currentChapter) {
|
if (!this.currentChapter) {
|
||||||
return this.restart()
|
return this.restart()
|
||||||
}
|
}
|
||||||
@@ -489,12 +498,12 @@ export default {
|
|||||||
},
|
},
|
||||||
async jumpBackwards() {
|
async jumpBackwards() {
|
||||||
await this.$hapticsImpact()
|
await this.$hapticsImpact()
|
||||||
if (this.isLoading) return
|
if (this.showLoadingState) return
|
||||||
AbsAudioPlayer.seekBackward({ value: this.jumpBackwardsTime })
|
AbsAudioPlayer.seekBackward({ value: this.jumpBackwardsTime })
|
||||||
},
|
},
|
||||||
async jumpForward() {
|
async jumpForward() {
|
||||||
await this.$hapticsImpact()
|
await this.$hapticsImpact()
|
||||||
if (this.isLoading) return
|
if (this.showLoadingState) return
|
||||||
AbsAudioPlayer.seekForward({ value: this.jumpForwardTime })
|
AbsAudioPlayer.seekForward({ value: this.jumpForwardTime })
|
||||||
},
|
},
|
||||||
setStreamReady() {
|
setStreamReady() {
|
||||||
@@ -581,8 +590,12 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ptWidth = Math.round(percentDone * this.trackWidth)
|
const ptWidth = Math.round(percentDone * this.trackWidth)
|
||||||
|
if (this.$refs.playedTrack) {
|
||||||
this.$refs.playedTrack.style.width = ptWidth + 'px'
|
this.$refs.playedTrack.style.width = ptWidth + 'px'
|
||||||
|
}
|
||||||
|
if (this.$refs.bufferedTrack) {
|
||||||
this.$refs.bufferedTrack.style.width = Math.round(bufferedPercent * this.trackWidth) + 'px'
|
this.$refs.bufferedTrack.style.width = Math.round(bufferedPercent * this.trackWidth) + 'px'
|
||||||
|
}
|
||||||
|
|
||||||
if (this.$refs.trackCursor) {
|
if (this.$refs.trackCursor) {
|
||||||
this.$refs.trackCursor.style.left = ptWidth - 14 + 'px'
|
this.$refs.trackCursor.style.left = ptWidth - 14 + 'px'
|
||||||
@@ -594,7 +607,7 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
seek(time) {
|
seek(time) {
|
||||||
if (this.isLoading) return
|
if (this.showLoadingState) return
|
||||||
if (this.seekLoading) {
|
if (this.seekLoading) {
|
||||||
console.error('Already seek loading', this.seekedTime)
|
console.error('Already seek loading', this.seekedTime)
|
||||||
return
|
return
|
||||||
@@ -626,11 +639,14 @@ export default {
|
|||||||
},
|
},
|
||||||
async playPauseClick() {
|
async playPauseClick() {
|
||||||
await this.$hapticsImpact()
|
await this.$hapticsImpact()
|
||||||
if (this.isLoading) return
|
if (this.showLoadingState) return
|
||||||
|
|
||||||
this.isPlaying = !!((await AbsAudioPlayer.playPause()) || {}).playing
|
this.isPlaying = !!((await AbsAudioPlayer.playPause()) || {}).playing
|
||||||
this.isEnded = false
|
this.isEnded = false
|
||||||
},
|
},
|
||||||
|
setIsCheckingServerProgress(value) {
|
||||||
|
this.isCheckingServerProgress = !!value
|
||||||
|
},
|
||||||
play() {
|
play() {
|
||||||
AbsAudioPlayer.playPlayer()
|
AbsAudioPlayer.playPlayer()
|
||||||
this.startPlayInterval()
|
this.startPlayInterval()
|
||||||
@@ -1087,6 +1103,9 @@ export default {
|
|||||||
margin: 0px 0px;
|
margin: 0px 0px;
|
||||||
font-size: 1.6rem;
|
font-size: 1.6rem;
|
||||||
}
|
}
|
||||||
|
#playerControls .jump-label {
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
#playerControls .play-btn {
|
#playerControls .play-btn {
|
||||||
transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1);
|
transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1);
|
||||||
transition-property: padding, margin, height, width, min-width, min-height;
|
transition-property: padding, margin, height, width, min-width, min-height;
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ export default {
|
|||||||
},
|
},
|
||||||
isIos() {
|
isIos() {
|
||||||
return this.$platform === 'ios'
|
return this.$platform === 'ios'
|
||||||
|
},
|
||||||
|
currentPlaybackSession() {
|
||||||
|
return this.$store.state.currentPlaybackSession
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -304,48 +307,129 @@ export default {
|
|||||||
this.$refs.audioPlayer?.seek(currentTime)
|
this.$refs.audioPlayer?.seek(currentTime)
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* When device gains focus then refresh the timestamps in the audio player
|
* Fetch the current user's media progress from the server for a given library item / episode.
|
||||||
|
* Returns the server media progress object, or null if the request fails, times out, or the
|
||||||
|
* response doesn't match the requested library item.
|
||||||
|
*
|
||||||
|
* The audio player's loading state is shown while the request is in flight so the user
|
||||||
|
* doesn't tap play before we have a chance to update the timestamps. The request timeout
|
||||||
|
* is 7 seconds so a slow/unresponsive server doesn't block the user for long.
|
||||||
*/
|
*/
|
||||||
deviceFocused(hasFocus) {
|
async getServerMediaProgressForCurrentSession() {
|
||||||
if (!this.$store.state.currentPlaybackSession) return
|
if (!this.$store.state.user.user || !this.$store.state.networkConnected) return null
|
||||||
|
const libraryItemId = this.currentPlaybackSession?.libraryItemId
|
||||||
|
const episodeId = this.currentPlaybackSession?.episodeId
|
||||||
|
if (!libraryItemId) return null
|
||||||
|
|
||||||
|
if (this.$refs.audioPlayer?.isCheckingServerProgress) {
|
||||||
|
console.log('[AudioPlayerContainer] getServerMediaProgressForCurrentSession: already checking server progress')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = episodeId ? `/api/me/progress/${libraryItemId}/${episodeId}` : `/api/me/progress/${libraryItemId}`
|
||||||
|
|
||||||
|
this.$refs.audioPlayer?.setIsCheckingServerProgress(true)
|
||||||
|
try {
|
||||||
|
const data = await this.$nativeHttp.get(url, { connectTimeout: 7000, readTimeout: 7000 })
|
||||||
|
if (!data || data.libraryItemId !== libraryItemId) return null
|
||||||
|
return data
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[AudioPlayerContainer] Failed to get server media progress', error)
|
||||||
|
return null
|
||||||
|
} finally {
|
||||||
|
this.$refs.audioPlayer?.setIsCheckingServerProgress(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getLocalMediaProgressForCurrentSession() {
|
||||||
|
if (!this.currentPlaybackSession) return null
|
||||||
|
return this.$store.getters['globals/getLocalMediaProgressById'](this.currentPlaybackSession.localLibraryItem?.id, this.currentPlaybackSession.localEpisodeId)
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Sync the server media progress with the local media progress
|
||||||
|
*/
|
||||||
|
async syncServerMediaProgressWithLocalMediaProgress(localMediaProgressId, serverMediaProgress) {
|
||||||
|
try {
|
||||||
|
const newLocalMediaProgress = await this.$db.syncServerMediaProgressWithLocalMediaProgress({
|
||||||
|
localMediaProgressId,
|
||||||
|
mediaProgress: serverMediaProgress
|
||||||
|
})
|
||||||
|
if (newLocalMediaProgress?.id) {
|
||||||
|
this.$store.commit('globals/updateLocalMediaProgress', newLocalMediaProgress)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[AudioPlayerContainer] Failed to sync server progress with local media progress', error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Check if the server media progress is more recent than the local media progress and sync if so
|
||||||
|
*/
|
||||||
|
async checkSyncServerProgressWithLocalProgress(localMediaProgress) {
|
||||||
|
if (!localMediaProgress) return
|
||||||
|
console.log('[AudioPlayerContainer] checkSyncServerProgressWithLocalProgress: checking server media progress for local media item open in player')
|
||||||
|
const serverMediaProgress = await this.getServerMediaProgressForCurrentSession()
|
||||||
|
if (!serverMediaProgress?.lastUpdate || serverMediaProgress.lastUpdate <= localMediaProgress.lastUpdate) return
|
||||||
|
|
||||||
|
console.log('[AudioPlayerContainer] checkSyncServerProgressWithLocalProgress: server progress is more recent than local progress. Server current time:', serverMediaProgress.currentTime, 'vs local', localMediaProgress.currentTime, `(server lastUpdate=${serverMediaProgress.lastUpdate} > local lastUpdate=${localMediaProgress.lastUpdate})`)
|
||||||
|
if (!this.$refs.audioPlayer?.isPlaying && serverMediaProgress.currentTime !== localMediaProgress.currentTime) {
|
||||||
|
// Use seek() so the native audio player's current session is updated
|
||||||
|
this.$refs.audioPlayer.seek(serverMediaProgress.currentTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.syncServerMediaProgressWithLocalMediaProgress(localMediaProgress.id, serverMediaProgress)
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* When socket is reconnected after a delay, if a local media item is open in the player (paused)
|
||||||
|
* we fetch the server media progress and sync it if it is more recent than the local progress
|
||||||
|
*
|
||||||
|
* If there is no socket connection we may have missed external progress updates
|
||||||
|
*/
|
||||||
|
async socketReconnected() {
|
||||||
|
if (!this.currentPlaybackSession) return
|
||||||
|
// dont update timestamps if player is playing
|
||||||
|
if (this.$refs.audioPlayer?.isPlaying) return
|
||||||
|
|
||||||
if (hasFocus) {
|
|
||||||
if (!this.$refs.audioPlayer?.isPlaying) {
|
|
||||||
const playbackSession = this.$store.state.currentPlaybackSession
|
|
||||||
if (this.$refs.audioPlayer.isLocalPlayMethod) {
|
if (this.$refs.audioPlayer.isLocalPlayMethod) {
|
||||||
const localLibraryItemId = playbackSession.localLibraryItem?.id
|
const localMediaProgress = this.getLocalMediaProgressForCurrentSession()
|
||||||
const localEpisodeId = playbackSession.localEpisodeId
|
if (!localMediaProgress) {
|
||||||
if (!localLibraryItemId) {
|
console.error('[AudioPlayerContainer] socket reconnected: Local media progress not found')
|
||||||
console.error('[AudioPlayerContainer] device visibility: no local library item for session', JSON.stringify(playbackSession))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const localMediaProgress = this.$store.state.globals.localMediaProgress.find((mp) => {
|
|
||||||
if (localEpisodeId) return mp.localEpisodeId === localEpisodeId
|
await this.checkSyncServerProgressWithLocalProgress(localMediaProgress)
|
||||||
return mp.localLibraryItemId === localLibraryItemId
|
}
|
||||||
})
|
},
|
||||||
if (localMediaProgress) {
|
/**
|
||||||
|
* When device re-gains focus then refresh the timestamps in the audio player
|
||||||
|
* if local item is open then fetch the server media progress and update if more recent
|
||||||
|
*/
|
||||||
|
async deviceFocused(hasFocus) {
|
||||||
|
if (!this.currentPlaybackSession || !hasFocus) return
|
||||||
|
// dont update timestamps if player is playing
|
||||||
|
if (this.$refs.audioPlayer?.isPlaying) return
|
||||||
|
|
||||||
|
if (this.$refs.audioPlayer.isLocalPlayMethod) {
|
||||||
|
const localMediaProgress = this.getLocalMediaProgressForCurrentSession()
|
||||||
|
if (!localMediaProgress) {
|
||||||
|
console.error('[AudioPlayerContainer] device visibility: Local media progress not found')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
console.log('[AudioPlayerContainer] device visibility: found local media progress', localMediaProgress.currentTime, 'last time in player is', this.currentTime)
|
console.log('[AudioPlayerContainer] device visibility: found local media progress', localMediaProgress.currentTime, 'last time in player is', this.currentTime)
|
||||||
this.$refs.audioPlayer.currentTime = localMediaProgress.currentTime
|
this.$refs.audioPlayer.currentTime = localMediaProgress.currentTime
|
||||||
this.$refs.audioPlayer.timeupdate()
|
this.$refs.audioPlayer.timeupdate()
|
||||||
|
|
||||||
|
await this.checkSyncServerProgressWithLocalProgress(localMediaProgress)
|
||||||
} else {
|
} else {
|
||||||
console.error('[AudioPlayerContainer] device visibility: Local media progress not found')
|
// server item so fetch server media progress and update player time
|
||||||
}
|
console.log('[AudioPlayerContainer] device visibility: checking server media progress for server media item open in player')
|
||||||
} else {
|
const data = await this.getServerMediaProgressForCurrentSession()
|
||||||
const libraryItemId = playbackSession.libraryItemId
|
if (!data) return
|
||||||
const episodeId = playbackSession.episodeId
|
if (!this.$refs.audioPlayer?.isPlaying) {
|
||||||
const url = episodeId ? `/api/me/progress/${libraryItemId}/${episodeId}` : `/api/me/progress/${libraryItemId}`
|
|
||||||
this.$nativeHttp
|
|
||||||
.get(url)
|
|
||||||
.then((data) => {
|
|
||||||
if (!this.$refs.audioPlayer?.isPlaying && data.libraryItemId === libraryItemId) {
|
|
||||||
console.log('[AudioPlayerContainer] device visibility: got server media progress', data.currentTime, 'last time in player is', this.currentTime)
|
console.log('[AudioPlayerContainer] device visibility: got server media progress', data.currentTime, 'last time in player is', this.currentTime)
|
||||||
this.$refs.audioPlayer.currentTime = data.currentTime
|
// Only seek if the difference is greater than 1 second
|
||||||
this.$refs.audioPlayer.timeupdate()
|
if (Math.abs(data.currentTime - this.currentTime) > 1) {
|
||||||
}
|
// Use seek() so the native audio player's current session is updated
|
||||||
})
|
this.$refs.audioPlayer.seek(data.currentTime)
|
||||||
.catch((error) => {
|
|
||||||
console.error('[AudioPlayerContainer] device visibility: Failed to get progress', error)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -368,6 +452,7 @@ export default {
|
|||||||
this.$eventBus.$on('user-settings', this.settingsUpdated)
|
this.$eventBus.$on('user-settings', this.settingsUpdated)
|
||||||
this.$eventBus.$on('playback-time-update', this.playbackTimeUpdate)
|
this.$eventBus.$on('playback-time-update', this.playbackTimeUpdate)
|
||||||
this.$eventBus.$on('device-focus-update', this.deviceFocused)
|
this.$eventBus.$on('device-focus-update', this.deviceFocused)
|
||||||
|
this.$eventBus.$on('socket-reconnected', this.socketReconnected)
|
||||||
},
|
},
|
||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
this.onLocalMediaProgressUpdateListener?.remove()
|
this.onLocalMediaProgressUpdateListener?.remove()
|
||||||
@@ -383,6 +468,7 @@ export default {
|
|||||||
this.$eventBus.$off('user-settings', this.settingsUpdated)
|
this.$eventBus.$off('user-settings', this.settingsUpdated)
|
||||||
this.$eventBus.$off('playback-time-update', this.playbackTimeUpdate)
|
this.$eventBus.$off('playback-time-update', this.playbackTimeUpdate)
|
||||||
this.$eventBus.$off('device-focus-update', this.deviceFocused)
|
this.$eventBus.$off('device-focus-update', this.deviceFocused)
|
||||||
|
this.$eventBus.$off('socket-reconnected', this.socketReconnected)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -3,19 +3,16 @@
|
|||||||
<div class="absolute top-0 left-0 w-full h-full bg-black transition-opacity duration-200" :class="show ? 'bg-opacity-60 pointer-events-auto' : 'bg-opacity-0'" @click="clickBackground" />
|
<div class="absolute top-0 left-0 w-full h-full bg-black transition-opacity duration-200" :class="show ? 'bg-opacity-60 pointer-events-auto' : 'bg-opacity-0'" @click="clickBackground" />
|
||||||
<div class="absolute top-0 right-0 w-64 h-full bg-bg transform transition-transform py-6 pointer-events-auto" :class="show ? '' : 'translate-x-64'" @click.stop>
|
<div class="absolute top-0 right-0 w-64 h-full bg-bg transform transition-transform py-6 pointer-events-auto" :class="show ? '' : 'translate-x-64'" @click.stop>
|
||||||
<div class="px-6 mb-4">
|
<div class="px-6 mb-4">
|
||||||
<p v-if="user" class="text-base">
|
<p v-if="user" class="text-base" v-html="$getString('HeaderWelcome', [username])" />
|
||||||
Welcome,
|
|
||||||
<strong>{{ username }}</strong>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="w-full overflow-y-auto">
|
<div class="w-full overflow-y-auto">
|
||||||
<template v-for="item in navItems">
|
<template v-for="item in navItems">
|
||||||
<button v-if="item.action" :key="item.text" class="w-full hover:bg-bg/60 flex items-center py-3 px-6 text-fg-muted" @click="clickAction(item.action)">
|
<button v-if="item.action" :key="item.text" :tabindex="show ? 0 : -1" class="w-full hover:bg-bg/60 flex items-center py-3 px-6 text-fg-muted" @click="clickAction(item.action)">
|
||||||
<span class="material-symbols fill text-lg">{{ item.icon }}</span>
|
<span class="material-symbols fill text-lg">{{ item.icon }}</span>
|
||||||
<p class="pl-4">{{ item.text }}</p>
|
<p class="pl-4">{{ item.text }}</p>
|
||||||
</button>
|
</button>
|
||||||
<nuxt-link v-else :to="item.to" :key="item.text" class="w-full hover:bg-bg/60 flex items-center py-3 px-6 text-fg" :class="currentRoutePath.startsWith(item.to) ? 'bg-bg-hover/50' : 'text-fg-muted'">
|
<nuxt-link v-else :to="item.to" :key="item.text" :tabindex="show ? 0 : -1" class="w-full hover:bg-bg/60 flex items-center py-3 px-6 text-fg" :class="currentRoutePath.startsWith(item.to) ? 'bg-bg-hover/50' : 'text-fg-muted'">
|
||||||
<span class="material-symbols fill text-lg">{{ item.icon }}</span>
|
<span class="material-symbols fill text-lg">{{ item.icon }}</span>
|
||||||
<p class="pl-4">{{ item.text }}</p>
|
<p class="pl-4">{{ item.text }}</p>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
@@ -134,14 +131,12 @@ export default {
|
|||||||
to: '/settings'
|
to: '/settings'
|
||||||
})
|
})
|
||||||
|
|
||||||
if (this.$platform !== 'ios') {
|
|
||||||
items.push({
|
items.push({
|
||||||
icon: 'bug_report',
|
icon: 'bug_report',
|
||||||
iconOutlined: true,
|
iconOutlined: true,
|
||||||
text: this.$strings.ButtonLogs,
|
text: this.$strings.ButtonLogs,
|
||||||
to: '/logs'
|
to: '/logs'
|
||||||
})
|
})
|
||||||
}
|
|
||||||
|
|
||||||
if (this.serverConnectionConfig) {
|
if (this.serverConnectionConfig) {
|
||||||
items.push({
|
items.push({
|
||||||
@@ -185,11 +180,18 @@ export default {
|
|||||||
await this.$hapticsImpact()
|
await this.$hapticsImpact()
|
||||||
await this.logout()
|
await this.logout()
|
||||||
|
|
||||||
|
// Redirect to home page
|
||||||
if (this.$route.name !== 'bookshelf') {
|
if (this.$route.name !== 'bookshelf') {
|
||||||
this.$router.replace('/bookshelf')
|
this.$router.replace('/bookshelf')
|
||||||
} else {
|
|
||||||
location.reload()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If player is open and not playing locally, then close the player
|
||||||
|
if (this.$store.getters['getIsPlayerOpen']) {
|
||||||
|
this.$eventBus.$emit('close-stream')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close side drawer
|
||||||
|
this.show = false
|
||||||
},
|
},
|
||||||
touchstart(e) {
|
touchstart(e) {
|
||||||
this.touchEvent = new TouchEvent(e)
|
this.touchEvent = new TouchEvent(e)
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex h-full px-1 overflow-hidden">
|
||||||
|
<covers-book-cover :library-item="libraryItem" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
|
<div class="grow px-2 audiobookSearchCardContent">
|
||||||
|
<p class="truncate text-sm">{{ title }}</p>
|
||||||
|
<p v-if="subtitle" class="truncate text-xs text-gray-300">{{ subtitle }}</p>
|
||||||
|
<p class="text-xs text-gray-200 truncate">{{ $getString('LabelByAuthor', [authorName]) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
episode: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
},
|
||||||
|
libraryItem: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
bookCoverAspectRatio() {
|
||||||
|
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||||
|
},
|
||||||
|
coverWidth() {
|
||||||
|
if (this.bookCoverAspectRatio === 1) return 50 * 1.2
|
||||||
|
return 50
|
||||||
|
},
|
||||||
|
media() {
|
||||||
|
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||||
|
},
|
||||||
|
mediaMetadata() {
|
||||||
|
return this.media.metadata || {}
|
||||||
|
},
|
||||||
|
mediaType() {
|
||||||
|
return this.libraryItem ? this.libraryItem.mediaType : null
|
||||||
|
},
|
||||||
|
isPodcast() {
|
||||||
|
return this.mediaType === 'podcast'
|
||||||
|
},
|
||||||
|
title() {
|
||||||
|
return this.episode?.title || 'No Title'
|
||||||
|
},
|
||||||
|
subtitle() {
|
||||||
|
return this.mediaMetadata.title
|
||||||
|
},
|
||||||
|
authorName() {
|
||||||
|
return this.mediaMetadata.author
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.audiobookSearchCardContent {
|
||||||
|
width: calc(100% - 80px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,16 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex h-full px-1 overflow-hidden">
|
<div class="flex h-full px-1 overflow-hidden">
|
||||||
<covers-book-cover :library-item="libraryItem" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<covers-book-cover :library-item="libraryItem" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
<div class="flex-grow px-2 audiobookSearchCardContent">
|
<div class="grow px-2 audiobookSearchCardContent">
|
||||||
<p v-if="matchKey !== 'title'" class="truncate text-sm">{{ title }}</p>
|
<p class="truncate text-sm">{{ title }}</p>
|
||||||
<p v-else class="truncate text-sm" v-html="matchHtml" />
|
<p v-if="subtitle" class="truncate text-xs text-gray-300">{{ subtitle }}</p>
|
||||||
|
<p class="text-xs text-gray-200 truncate">{{ $getString('LabelByAuthor', [authorName]) }}</p>
|
||||||
<p v-if="matchKey === 'subtitle'" class="truncate text-xs text-fg-muted">{{ matchHtml }}</p>
|
|
||||||
|
|
||||||
<p v-if="matchKey !== 'authors'" class="text-xs text-fg truncate">by {{ authorName }}</p>
|
|
||||||
<p v-else class="truncate text-xs text-fg" v-html="matchHtml" />
|
|
||||||
|
|
||||||
<div v-if="matchKey === 'series' || matchKey === 'tags' || matchKey === 'isbn' || matchKey === 'asin' || matchKey === 'episode' || matchKey === 'narrators'" class="m-0 p-0 truncate text-xs" v-html="matchHtml" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -22,9 +16,7 @@ export default {
|
|||||||
type: Object,
|
type: Object,
|
||||||
default: () => {}
|
default: () => {}
|
||||||
},
|
},
|
||||||
search: String,
|
search: String
|
||||||
matchKey: String,
|
|
||||||
matchText: String
|
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {}
|
return {}
|
||||||
@@ -58,35 +50,6 @@ export default {
|
|||||||
authorName() {
|
authorName() {
|
||||||
if (this.isPodcast) return this.mediaMetadata.author
|
if (this.isPodcast) return this.mediaMetadata.author
|
||||||
return this.mediaMetadata.authorName
|
return this.mediaMetadata.authorName
|
||||||
},
|
|
||||||
matchHtml() {
|
|
||||||
if (!this.matchText || !this.search) return ''
|
|
||||||
if (this.matchKey === 'subtitle') return ''
|
|
||||||
var matchSplit = this.matchText.toLowerCase().split(this.search.toLowerCase().trim())
|
|
||||||
if (matchSplit.length < 2) return ''
|
|
||||||
|
|
||||||
var html = ''
|
|
||||||
var totalLenSoFar = 0
|
|
||||||
for (let i = 0; i < matchSplit.length - 1; i++) {
|
|
||||||
var indexOf = matchSplit[i].length
|
|
||||||
var firstPart = this.matchText.substr(totalLenSoFar, indexOf)
|
|
||||||
var actualWasThere = this.matchText.substr(totalLenSoFar + indexOf, this.search.length)
|
|
||||||
totalLenSoFar += indexOf + this.search.length
|
|
||||||
|
|
||||||
html += `${firstPart}<strong class="text-warning">${actualWasThere}</strong>`
|
|
||||||
}
|
|
||||||
var lastPart = this.matchText.substr(totalLenSoFar)
|
|
||||||
html += lastPart
|
|
||||||
|
|
||||||
if (this.matchKey === 'episode') return `<p class="truncate">Episode: ${html}</p>`
|
|
||||||
if (this.matchKey === 'tags') return `<p class="truncate">Tags: ${html}</p>`
|
|
||||||
if (this.matchKey === 'subtitle') return `<p class="truncate">${html}</p>`
|
|
||||||
if (this.matchKey === 'authors') return `by ${html}`
|
|
||||||
if (this.matchKey === 'isbn') return `<p class="truncate">ISBN: ${html}</p>`
|
|
||||||
if (this.matchKey === 'asin') return `<p class="truncate">ASIN: ${html}</p>`
|
|
||||||
if (this.matchKey === 'series') return `<p class="truncate">Series: ${html}</p>`
|
|
||||||
if (this.matchKey === 'narrators') return `<p class="truncate">Narrator: ${html}</p>`
|
|
||||||
return `${html}`
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {},
|
methods: {},
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div ref="card" :id="`book-card-${index}`" :style="{ minWidth: width + 'px', maxWidth: width + 'px', height: height + 'px' }" class="rounded-sm z-10 bg-primary cursor-pointer box-shadow-book" @click="clickCard">
|
<div ref="card" tabindex="0" :id="`book-card-${index}`" :style="{ minWidth: width + 'px', maxWidth: width + 'px', height: height + 'px' }" class="rounded-sm z-10 bg-primary cursor-pointer box-shadow-book" @click="clickCard">
|
||||||
<!-- When cover image does not fill -->
|
<!-- When cover image does not fill -->
|
||||||
<div v-show="showCoverBg" class="absolute top-0 left-0 w-full h-full overflow-hidden rounded-sm bg-primary">
|
<div v-show="showCoverBg" class="absolute top-0 left-0 w-full h-full overflow-hidden rounded-sm bg-primary">
|
||||||
<div class="absolute cover-bg" ref="coverBg" />
|
<div class="absolute cover-bg" ref="coverBg" />
|
||||||
|
|||||||
@@ -4,13 +4,16 @@
|
|||||||
<!-- list of server connection configs -->
|
<!-- list of server connection configs -->
|
||||||
<template v-if="!showForm">
|
<template v-if="!showForm">
|
||||||
<div v-for="config in serverConnectionConfigs" :key="config.id" class="border-b border-fg/10 py-4">
|
<div v-for="config in serverConnectionConfigs" :key="config.id" class="border-b border-fg/10 py-4">
|
||||||
<div class="flex items-center my-1 relative" @click="connectToServer(config)">
|
<div class="flex items-center my-1 relative space-x-2" @click="connectToServer(config)">
|
||||||
<span class="material-symbols text-xl text-fg-muted">dns</span>
|
<div class="grow inline-flex items-center overflow-hidden">
|
||||||
<p class="pl-3 pr-6 text-base text-fg">{{ config.name }}</p>
|
<p class="text-base text-fg truncate">{{ config.name }}</p>
|
||||||
|
</div>
|
||||||
<div class="absolute top-0 right-0 h-full px-4 flex items-center" @click.stop="editServerConfig(config)">
|
<div class="h-full w-6 flex items-center" @click.stop="editServerConfig(config)">
|
||||||
<span class="material-symbols text-2xl text-fg-muted">more_vert</span>
|
<span class="material-symbols text-2xl text-fg-muted">more_vert</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="h-full w-6 flex items-center" @click.stop="removeServerConfigClick(config)">
|
||||||
|
<span class="material-symbols fill text-1.5xl text-fg-muted">delete</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- warning message if server connection config is using an old user id -->
|
<!-- warning message if server connection config is using an old user id -->
|
||||||
<div v-if="!checkIdUuid(config.userId)" class="flex flex-nowrap justify-between items-center space-x-4 pt-4">
|
<div v-if="!checkIdUuid(config.userId)" class="flex flex-nowrap justify-between items-center space-x-4 pt-4">
|
||||||
@@ -30,7 +33,7 @@
|
|||||||
<!-- form to add a new server connection config -->
|
<!-- form to add a new server connection config -->
|
||||||
<div v-else class="w-full">
|
<div v-else class="w-full">
|
||||||
<!-- server address input -->
|
<!-- server address input -->
|
||||||
<form v-if="!showAuth" @submit.prevent="submit" novalidate class="w-full">
|
<form v-if="!showAuth" @submit.prevent="submit(false)" novalidate class="w-full">
|
||||||
<div v-if="serverConnectionConfigs.length" class="flex items-center mb-4" @click="showServerList">
|
<div v-if="serverConnectionConfigs.length" class="flex items-center mb-4" @click="showServerList">
|
||||||
<span class="material-symbols text-fg-muted">arrow_back</span>
|
<span class="material-symbols text-fg-muted">arrow_back</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -57,7 +60,7 @@
|
|||||||
<ui-text-input v-model="password" type="password" :disabled="processing" :placeholder="$strings.LabelPassword" class="w-full mb-2 text-lg" />
|
<ui-text-input v-model="password" type="password" :disabled="processing" :placeholder="$strings.LabelPassword" class="w-full mb-2 text-lg" />
|
||||||
|
|
||||||
<div class="flex items-center pt-2">
|
<div class="flex items-center pt-2">
|
||||||
<ui-icon-btn v-if="serverConfig.id" small bg-color="error" icon="delete" type="button" @click="removeServerConfigClick" />
|
<ui-icon-btn v-if="serverConfig.id" bg-color="error" icon="delete" type="button" @click="removeServerConfigClick(serverConfig)" />
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<ui-btn :disabled="processing || !networkConnected" type="submit" class="mt-1 h-10">{{ networkConnected ? $strings.ButtonSubmit : $strings.MessageNoNetworkConnection }}</ui-btn>
|
<ui-btn :disabled="processing || !networkConnected" type="submit" class="mt-1 h-10">{{ networkConnected ? $strings.ButtonSubmit : $strings.MessageNoNetworkConnection }}</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
@@ -76,9 +79,6 @@
|
|||||||
|
|
||||||
<div :class="processing ? 'opacity-100' : 'opacity-0 pointer-events-none'" class="fixed w-full h-full top-0 left-0 bg-black/75 flex items-center justify-center z-30 transition-opacity duration-500">
|
<div :class="processing ? 'opacity-100' : 'opacity-0 pointer-events-none'" class="fixed w-full h-full top-0 left-0 bg-black/75 flex items-center justify-center z-30 transition-opacity duration-500">
|
||||||
<div>
|
<div>
|
||||||
<div class="absolute top-0 left-0 w-full p-6 flex items-center flex-col justify-center z-0 short:hidden">
|
|
||||||
<img src="/Logo.png" class="h-20 w-20 mb-2" />
|
|
||||||
</div>
|
|
||||||
<svg class="animate-spin w-16 h-16" viewBox="0 0 24 24">
|
<svg class="animate-spin w-16 h-16" viewBox="0 0 24 24">
|
||||||
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
|
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -412,7 +412,7 @@ export default {
|
|||||||
throw new Error('Authentication failed with the provided token.')
|
throw new Error('Authentication failed with the provided token.')
|
||||||
}
|
}
|
||||||
|
|
||||||
const duplicateConfig = this.serverConnectionConfigs.find((scc) => scc.address === this.serverConfig.address && scc.username === payload.user.username)
|
const duplicateConfig = this.serverConnectionConfigs.find((scc) => scc.address === this.serverConfig.address && scc.username === payload.user.username && scc.id !== this.serverConfig.id)
|
||||||
if (duplicateConfig) {
|
if (duplicateConfig) {
|
||||||
throw new Error('Config already exists for this address and username.')
|
throw new Error('Config already exists for this address and username.')
|
||||||
}
|
}
|
||||||
@@ -471,22 +471,26 @@ export default {
|
|||||||
// Will NOT include access token and refresh token
|
// Will NOT include access token and refresh token
|
||||||
this.setUserAndConnection(payload)
|
this.setUserAndConnection(payload)
|
||||||
} else {
|
} else {
|
||||||
this.showAuth = true
|
let error = this.error
|
||||||
|
if (await this.submit(true)) {
|
||||||
|
this.showForm = true
|
||||||
|
this.error = error
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async removeServerConfigClick() {
|
async removeServerConfigClick(serverConfig) {
|
||||||
if (!this.serverConfig.id) return
|
if (!serverConfig.id) return
|
||||||
await this.$hapticsImpact()
|
await this.$hapticsImpact()
|
||||||
|
|
||||||
const { value } = await Dialog.confirm({
|
const { value } = await Dialog.confirm({
|
||||||
title: 'Confirm',
|
title: this.$strings.HeaderConfirm,
|
||||||
message: `Remove this server config?`
|
message: this.$strings.MessageConfirmDeleteServerConfig
|
||||||
})
|
})
|
||||||
if (value) {
|
if (value) {
|
||||||
this.processing = true
|
this.processing = true
|
||||||
await this.$db.removeServerConnectionConfig(this.serverConfig.id)
|
await this.$db.removeServerConnectionConfig(serverConfig.id)
|
||||||
const updatedDeviceData = { ...this.deviceData }
|
const updatedDeviceData = { ...this.deviceData }
|
||||||
updatedDeviceData.serverConnectionConfigs = this.deviceData.serverConnectionConfigs.filter((scc) => scc.id != this.serverConfig.id)
|
updatedDeviceData.serverConnectionConfigs = this.deviceData.serverConnectionConfigs.filter((scc) => scc.id != serverConfig.id)
|
||||||
this.$store.commit('setDeviceData', updatedDeviceData)
|
this.$store.commit('setDeviceData', updatedDeviceData)
|
||||||
|
|
||||||
this.serverConfig = {
|
this.serverConfig = {
|
||||||
@@ -498,14 +502,17 @@ export default {
|
|||||||
this.processing = false
|
this.processing = false
|
||||||
this.showAuth = false
|
this.showAuth = false
|
||||||
this.showForm = !this.serverConnectionConfigs.length
|
this.showForm = !this.serverConnectionConfigs.length
|
||||||
|
this.error = null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
editServerConfig(serverConfig) {
|
async editServerConfig(serverConfig) {
|
||||||
this.serverConfig = {
|
this.serverConfig = {
|
||||||
...serverConfig
|
...serverConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (await this.submit(true)) {
|
||||||
this.showForm = true
|
this.showForm = true
|
||||||
this.showAuth = true
|
}
|
||||||
},
|
},
|
||||||
async newServerConfigClick() {
|
async newServerConfigClick() {
|
||||||
await this.$hapticsImpact()
|
await this.$hapticsImpact()
|
||||||
@@ -651,9 +658,8 @@ export default {
|
|||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
async submit() {
|
async submit(preventAutoLogin = false) {
|
||||||
if (!this.networkConnected) return
|
if (!this.networkConnected || !this.serverConfig.address) return false
|
||||||
if (!this.serverConfig.address) return
|
|
||||||
|
|
||||||
const initialAddress = this.serverConfig.address
|
const initialAddress = this.serverConfig.address
|
||||||
// Did the user specify a protocol?
|
// Did the user specify a protocol?
|
||||||
@@ -666,6 +672,7 @@ export default {
|
|||||||
this.authMethods = []
|
this.authMethods = []
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
console.log('[ServerConnectForm] submit tryServerUrl: ' + this.serverConfig.address)
|
||||||
// Try the server URL. If it fails and the protocol was not provided, try with http instead of https
|
// Try the server URL. If it fails and the protocol was not provided, try with http instead of https
|
||||||
const statusData = await this.tryServerUrl(this.serverConfig.address, !protocolProvided)
|
const statusData = await this.tryServerUrl(this.serverConfig.address, !protocolProvided)
|
||||||
if (this.validateLoginFormResponse(statusData, this.serverConfig.address, protocolProvided)) {
|
if (this.validateLoginFormResponse(statusData, this.serverConfig.address, protocolProvided)) {
|
||||||
@@ -674,11 +681,12 @@ export default {
|
|||||||
this.oauth.buttonText = statusData.data.authFormData?.authOpenIDButtonText || 'Login with OpenID'
|
this.oauth.buttonText = statusData.data.authFormData?.authOpenIDButtonText || 'Login with OpenID'
|
||||||
this.serverConfig.version = statusData.data.serverVersion
|
this.serverConfig.version = statusData.data.serverVersion
|
||||||
|
|
||||||
if (statusData.data.authFormData?.authOpenIDAutoLaunch) {
|
if (statusData.data.authFormData?.authOpenIDAutoLaunch && !preventAutoLogin) {
|
||||||
this.clickLoginWithOpenId()
|
this.clickLoginWithOpenId()
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
} else {
|
} else {
|
||||||
|
console.log('[ServerConnectForm] submit validateLoginFormResponse failed: ' + this.serverConfig.address)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -835,7 +843,7 @@ export default {
|
|||||||
async setUserAndConnection({ user, userDefaultLibraryId, serverSettings, ereaderDevices }) {
|
async setUserAndConnection({ user, userDefaultLibraryId, serverSettings, ereaderDevices }) {
|
||||||
if (!user) return
|
if (!user) return
|
||||||
|
|
||||||
console.log('Successfully logged in', JSON.stringify(user))
|
console.log('Successfully logged in: ' + user.username)
|
||||||
|
|
||||||
this.$store.commit('setServerSettings', serverSettings)
|
this.$store.commit('setServerSettings', serverSettings)
|
||||||
this.$store.commit('libraries/setEReaderDevices', ereaderDevices)
|
this.$store.commit('libraries/setEReaderDevices', ereaderDevices)
|
||||||
@@ -852,7 +860,7 @@ export default {
|
|||||||
} else {
|
} else {
|
||||||
// Detect if the connection config is using the old token. If so, force re-login
|
// Detect if the connection config is using the old token. If so, force re-login
|
||||||
if (this.serverConfig.token === user.token || user.isOldToken) {
|
if (this.serverConfig.token === user.token || user.isOldToken) {
|
||||||
this.setForceReloginForNewAuth()
|
this.setForceRelogin('oldAuthToken')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -926,20 +934,30 @@ export default {
|
|||||||
this.processing = false
|
this.processing = false
|
||||||
return authRes
|
return authRes
|
||||||
},
|
},
|
||||||
async setForceReloginForNewAuth() {
|
async setForceRelogin(error) {
|
||||||
|
console.log('[ServerConnectForm] setForceRelogin', error, this.serverConfig.address)
|
||||||
// This calls /status on the server and sets the auth methods
|
// This calls /status on the server and sets the auth methods
|
||||||
const result = await this.submit()
|
const result = await this.submit(true)
|
||||||
if (result) {
|
if (result) {
|
||||||
|
this.showForm = true
|
||||||
|
|
||||||
|
if (error === 'oldAuthToken') {
|
||||||
this.error = this.$strings.MessageOldServerAuthReLoginRequired
|
this.error = this.$strings.MessageOldServerAuthReLoginRequired
|
||||||
|
} else if (error === 'refreshTokenFailed') {
|
||||||
|
this.error = this.$strings.MessageFailedToRefreshToken
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
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.serverConnectionConfigId) {
|
||||||
if (this.$route.query.error === 'oldAuthToken' && this.$route.query.serverConnectionConfigId) {
|
// Handle force re-login for servers using new JWT auth but still using an old token OR refresh token failed
|
||||||
this.serverConfig = this.serverConnectionConfigs.find((scc) => scc.id === this.$route.query.serverConnectionConfigId)
|
const _serverConfig = this.serverConnectionConfigs.find((scc) => scc.id === this.$route.query.serverConnectionConfigId)
|
||||||
if (this.serverConfig) {
|
if (_serverConfig) {
|
||||||
this.setForceReloginForNewAuth()
|
this.serverConfig = { ..._serverConfig }
|
||||||
|
this.setForceRelogin(this.$route.query.error)
|
||||||
return
|
return
|
||||||
|
} else {
|
||||||
|
console.error('[ServerConnectForm] init with serverConnectionConfigId but no serverConfig found', this.$route.query.serverConnectionConfigId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<modals-modal v-model="show" :width="400" height="100%">
|
<modals-modal v-model="show" :width="400" max-width="95%" height="100%">
|
||||||
<template #outer>
|
<template #outer>
|
||||||
<div v-if="currentChapter" class="absolute top-10 left-4 z-40 pt-1" style="max-width: 80%">
|
<div v-if="currentChapter" class="absolute top-10 left-4 z-40 pt-1" style="max-width: 80%">
|
||||||
<p class="text-white text-lg truncate">{{ chapters.length }} {{ $strings.LabelChapters }}</p>
|
<p class="text-white text-lg truncate">{{ chapters.length }} {{ $strings.LabelChapters }}</p>
|
||||||
@@ -8,19 +8,31 @@
|
|||||||
|
|
||||||
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
|
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
|
||||||
<div ref="container" class="w-full overflow-x-hidden overflow-y-auto bg-secondary rounded-lg border border-fg/20" style="max-height: 75%" @click.stop>
|
<div ref="container" class="w-full overflow-x-hidden overflow-y-auto bg-secondary rounded-lg border border-fg/20" style="max-height: 75%" @click.stop>
|
||||||
|
<div class="sticky top-0 z-10 bg-secondary grid grid-cols-[1fr_auto_auto] gap-2 px-3 py-2 border-b border-fg/10 transition-shadow" :class="{ 'shadow-md': isScrolled }">
|
||||||
|
<div>
|
||||||
|
<p class="text-fg-muted text-sm">{{ $strings.LabelChapters }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-right" style="min-width: 60px">
|
||||||
|
<p class="text-fg-muted text-sm" style="letter-spacing: -0.5px">{{ $strings.LabelStart }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-right" style="min-width: 60px">
|
||||||
|
<p class="text-fg-muted text-sm" style="letter-spacing: -0.5px">{{ $strings.LabelDuration }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||||
<template v-for="chapter in chapters">
|
<li v-for="chapter in chapters" :key="chapter.id" :id="`chapter-row-${chapter.id}`" class="text-fg select-none relative cursor-pointer" :class="currentChapterId === chapter.id ? 'bg-primary bg-opacity-80' : ''" role="option" @click="clickedOption(chapter)">
|
||||||
<li :key="chapter.id" :id="`chapter-row-${chapter.id}`" class="text-fg select-none relative py-4 cursor-pointer" :class="currentChapterId === chapter.id ? 'bg-primary bg-opacity-80' : ''" role="option" @click="clickedOption(chapter)">
|
<div class="grid grid-cols-[1fr_auto_auto] gap-2 px-3 py-3 items-start">
|
||||||
<div class="relative flex items-center pl-3 pr-20">
|
<p class="font-normal line-clamp-2 text-sm text-fg/80">{{ chapter.title }}</p>
|
||||||
<p class="font-normal block truncate text-sm text-fg/80">{{ chapter.title }}</p>
|
<div class="text-right" style="min-width: 60px">
|
||||||
<div class="absolute top-0 right-3 -mt-0.5">
|
<span class="font-mono text-fg-muted text-sm" style="letter-spacing: -0.5px">{{ $secondsToTimestamp(chapter.start / _playbackRate) }}</span>
|
||||||
<span class="font-mono text-fg-muted leading-3 text-sm" style="letter-spacing: -0.5px">{{ $secondsToTimestamp(chapter.start / _playbackRate) }}</span>
|
</div>
|
||||||
|
<div class="text-right" style="min-width: 60px">
|
||||||
|
<span class="font-mono text-fg-muted text-sm" style="letter-spacing: -0.5px">{{ $secondsToTimestamp(Math.max(0, chapter.end - chapter.start) / _playbackRate) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-show="chapter.id === currentChapterId" class="w-0.5 h-full absolute top-0 left-0 bg-yellow-400" />
|
<div v-show="chapter.id === currentChapterId" class="w-0.5 h-full absolute top-0 left-0 bg-yellow-400" />
|
||||||
</li>
|
</li>
|
||||||
</template>
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -42,7 +54,9 @@ export default {
|
|||||||
playbackRate: Number
|
playbackRate: Number
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {}
|
return {
|
||||||
|
isScrolled: false
|
||||||
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
value(newVal) {
|
value(newVal) {
|
||||||
@@ -87,8 +101,25 @@ export default {
|
|||||||
container.scrollTo({ top: offsetTop - containerHeight / 2 })
|
container.scrollTo({ top: offsetTop - containerHeight / 2 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
handleScroll() {
|
||||||
|
const container = this.$refs.container
|
||||||
|
if (container) {
|
||||||
|
this.isScrolled = container.scrollTop > 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {}
|
mounted() {
|
||||||
|
const container = this.$refs.container
|
||||||
|
if (container) {
|
||||||
|
container.addEventListener('scroll', this.handleScroll)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
const container = this.$refs.container
|
||||||
|
if (container) {
|
||||||
|
container.removeEventListener('scroll', this.handleScroll)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div ref="wrapper" class="modal modal-bg w-screen fixed bottom-0 left-0 flex items-center justify-center z-50" :class="threeQuartersScreen ? 'h-[75vh] min-h-[400px]' : 'h-screen'" @click.stop @touchstart.stop @touchend.stop>
|
<div ref="wrapper" class="modal modal-bg w-screen fixed bottom-0 left-0 flex items-center justify-center z-50" :class="threeQuartersScreen ? 'h-[75vh] min-h-[400px] short:min-h-0 short:h-screen' : 'h-screen'" @click.stop @touchstart.stop @touchend.stop>
|
||||||
<div ref="content" class="relative text-fg h-full w-full bg-bg">
|
<div ref="content" class="relative text-fg h-full w-full bg-bg">
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -303,7 +303,7 @@ export default {
|
|||||||
// Show confirm if item has progress since it will reset
|
// Show confirm if item has progress since it will reset
|
||||||
if (this.userItemProgress && this.userItemProgress.progress > 0 && !this.userIsFinished) {
|
if (this.userItemProgress && this.userItemProgress.progress > 0 && !this.userIsFinished) {
|
||||||
const { value } = await Dialog.confirm({
|
const { value } = await Dialog.confirm({
|
||||||
title: 'Confirm',
|
title: this.$strings.HeaderConfirm,
|
||||||
message: this.$strings.MessageConfirmMarkAsFinished
|
message: this.$strings.MessageConfirmMarkAsFinished
|
||||||
})
|
})
|
||||||
if (!value) return
|
if (!value) return
|
||||||
@@ -369,7 +369,7 @@ export default {
|
|||||||
await this.$hapticsImpact()
|
await this.$hapticsImpact()
|
||||||
|
|
||||||
const { value } = await Dialog.confirm({
|
const { value } = await Dialog.confirm({
|
||||||
title: 'Confirm',
|
title: this.$strings.HeaderConfirm,
|
||||||
message: this.$strings.MessageConfirmDiscardProgress
|
message: this.$strings.MessageConfirmDiscardProgress
|
||||||
})
|
})
|
||||||
if (value) {
|
if (value) {
|
||||||
@@ -407,7 +407,7 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { value } = await Dialog.confirm({
|
const { value } = await Dialog.confirm({
|
||||||
title: 'Confirm',
|
title: this.$strings.HeaderConfirm,
|
||||||
message: this.$getString('MessageConfirmDeleteLocalEpisode', [localFile.basePath])
|
message: this.$getString('MessageConfirmDeleteLocalEpisode', [localFile.basePath])
|
||||||
})
|
})
|
||||||
if (value) {
|
if (value) {
|
||||||
@@ -432,7 +432,7 @@ export default {
|
|||||||
await this.$hapticsImpact()
|
await this.$hapticsImpact()
|
||||||
|
|
||||||
const { value } = await Dialog.confirm({
|
const { value } = await Dialog.confirm({
|
||||||
title: 'Confirm',
|
title: this.$strings.HeaderConfirm,
|
||||||
message: this.$strings.MessageConfirmDeleteLocalFiles
|
message: this.$strings.MessageConfirmDeleteLocalFiles
|
||||||
})
|
})
|
||||||
if (value) {
|
if (value) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<modals-modal v-model="show" width="90%">
|
<modals-modal v-model="show" width="90%">
|
||||||
<div class="w-full h-full bg-primary rounded-lg border border-fg/20">
|
<div class="w-full h-full bg-primary rounded-lg border border-fg/20">
|
||||||
<ul class="w-full rounded-lg text-base" role="listbox" aria-labelledby="listbox-label">
|
<ul class="w-full rounded-lg text-base max-h-[70vh] overflow-y-auto overscroll-contain" role="listbox" aria-labelledby="listbox-label">
|
||||||
<template v-for="item in items">
|
<template v-for="item in items">
|
||||||
<li :key="item.value" class="text-fg select-none relative py-4 pr-9 cursor-pointer" :class="item.value === selected ? 'bg-bg bg-opacity-50' : ''" role="option" @click="clickedOption(item.value)">
|
<li :key="item.value" class="text-fg select-none relative py-4 pr-9 cursor-pointer" :class="item.value === selected ? 'bg-bg bg-opacity-50' : ''" role="option" @click="clickedOption(item.value)">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
@@ -64,6 +64,18 @@ export default {
|
|||||||
text: this.$strings.LabelFileModified,
|
text: this.$strings.LabelFileModified,
|
||||||
value: 'mtimeMs'
|
value: 'mtimeMs'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelLibrarySortByProgress,
|
||||||
|
value: 'progress'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelLibrarySortByProgressStarted,
|
||||||
|
value: 'progress.createdAt'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelLibrarySortByProgressFinished,
|
||||||
|
value: 'progress.finishedAt'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
text: this.$strings.LabelRandomly,
|
text: this.$strings.LabelRandomly,
|
||||||
value: 'random'
|
value: 'random'
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ export default {
|
|||||||
async cancelSleepTimer() {
|
async cancelSleepTimer() {
|
||||||
if (this.isAuto) {
|
if (this.isAuto) {
|
||||||
const { value } = await Dialog.confirm({
|
const { value } = await Dialog.confirm({
|
||||||
title: 'Confirm',
|
title: this.$strings.HeaderConfirm,
|
||||||
message: this.$strings.MessageConfirmDisableAutoTimer
|
message: this.$strings.MessageConfirmDisableAutoTimer
|
||||||
})
|
})
|
||||||
if (!value) return
|
if (!value) return
|
||||||
|
|||||||
@@ -12,9 +12,9 @@
|
|||||||
<div class="w-full relative">
|
<div class="w-full relative">
|
||||||
<h1 class="text-lg mb-4">{{ $strings.HeaderRSSFeedIsOpen }}</h1>
|
<h1 class="text-lg mb-4">{{ $strings.HeaderRSSFeedIsOpen }}</h1>
|
||||||
|
|
||||||
<ui-text-input v-model="currentFeed.feedUrl" class="text-sm" readonly />
|
<ui-text-input v-model="fullFeedUrl" class="text-sm" readonly />
|
||||||
|
|
||||||
<span class="material-symbols absolute right-2 bottom-2 p-0.5 text-base" :class="linkCopied ? 'text-success' : 'text-fg-muted'" @click="copyToClipboard(currentFeed.feedUrl)">{{ linkCopied ? 'check' : 'content_copy' }}</span>
|
<span class="material-symbols absolute right-2 bottom-2 p-0.5 text-base" :class="linkCopied ? 'text-success' : 'text-fg-muted'" @click="copyToClipboard(fullFeedUrl)">{{ linkCopied ? 'check' : 'content_copy' }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="currentFeed.meta" class="mt-5">
|
<div v-if="currentFeed.meta" class="mt-5">
|
||||||
@@ -119,6 +119,10 @@ export default {
|
|||||||
demoFeedUrl() {
|
demoFeedUrl() {
|
||||||
return `${this.serverAddress}/feed/${this.newFeedSlug}`
|
return `${this.serverAddress}/feed/${this.newFeedSlug}`
|
||||||
},
|
},
|
||||||
|
fullFeedUrl() {
|
||||||
|
if (!this.currentFeed) return ''
|
||||||
|
return `${this.serverAddress}${this.currentFeed.feedUrl}`
|
||||||
|
},
|
||||||
isHttp() {
|
isHttp() {
|
||||||
return !!this.serverAddress?.startsWith('http://')
|
return !!this.serverAddress?.startsWith('http://')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export default {
|
|||||||
isRefreshingUI: false,
|
isRefreshingUI: false,
|
||||||
ereaderSettings: {
|
ereaderSettings: {
|
||||||
theme: 'dark',
|
theme: 'dark',
|
||||||
|
font: 'serif',
|
||||||
fontScale: 100,
|
fontScale: 100,
|
||||||
lineSpacing: 115,
|
lineSpacing: 115,
|
||||||
textStroke: 0
|
textStroke: 0
|
||||||
@@ -134,6 +135,7 @@ export default {
|
|||||||
|
|
||||||
const fontScale = settings.fontScale || 100
|
const fontScale = settings.fontScale || 100
|
||||||
this.rendition.themes.fontSize(`${fontScale}%`)
|
this.rendition.themes.fontSize(`${fontScale}%`)
|
||||||
|
this.rendition.themes.font(settings.font)
|
||||||
this.rendition.spread(settings.spread || 'auto')
|
this.rendition.spread(settings.spread || 'auto')
|
||||||
},
|
},
|
||||||
goToChapter(href) {
|
goToChapter(href) {
|
||||||
|
|||||||
@@ -173,16 +173,18 @@ export default {
|
|||||||
try {
|
try {
|
||||||
console.log('[PdfReader] Handling refresh failure - logging out user')
|
console.log('[PdfReader] Handling refresh failure - logging out user')
|
||||||
|
|
||||||
|
const serverConnectionConfigId = this.$store.getters['user/getServerConnectionConfigId']
|
||||||
|
|
||||||
// Clear store
|
// Clear store
|
||||||
await this.$store.dispatch('user/logout')
|
await this.$store.dispatch('user/logout')
|
||||||
|
|
||||||
if (this.$store.getters['user/getServerConnectionConfigId']) {
|
if (serverConnectionConfigId) {
|
||||||
// Clear refresh token for server connection config
|
// Clear refresh token for server connection config
|
||||||
await this.$db.clearRefreshToken(this.$store.getters['user/getServerConnectionConfigId'])
|
await this.$db.clearRefreshToken(serverConnectionConfigId)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (window.location.pathname !== '/connect') {
|
if (window.location.pathname !== '/connect') {
|
||||||
window.location.href = '/connect'
|
window.location.href = '/connect?error=refreshTokenFailed&serverConnectionConfigId=' + serverConnectionConfigId
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[PdfReader] Failed to handle refresh failure:', error)
|
console.error('[PdfReader] Failed to handle refresh failure:', error)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="show" :data-theme="ereaderTheme" class="group fixed top-0 left-0 right-0 layout-wrapper w-full z-40 pt-8 data-[theme=black]:bg-black data-[theme=black]:text-white data-[theme=dark]:bg-[#232323] data-[theme=dark]:text-white data-[theme=light]:bg-white data-[theme=light]:text-black" :class="{ 'reader-player-open': isPlayerOpen }">
|
<div v-if="show" :data-theme="ereaderTheme" class="group fixed top-0 left-0 right-0 layout-wrapper w-full z-40 pt-8 data-[theme=black]:bg-black data-[theme=black]:text-white data-[theme=dark]:bg-[#232323] data-[theme=dark]:text-white data-[theme=light]:bg-white data-[theme=light]:text-black" :class="{ 'reader-player-open': isPlayerOpen }">
|
||||||
<!-- toolbar -->
|
<!-- toolbar -->
|
||||||
<div class="h-32 pt-10 w-full px-2 fixed top-0 left-0 z-30 transition-transform bg-bg text-fg" :class="showingToolbar ? 'translate-y-0' : '-translate-y-32'" :style="{ boxShadow: showingToolbar ? '0px 8px 8px #11111155' : '' }" @touchstart.stop @mousedown.stop @touchend.stop @mouseup.stop>
|
<div class="w-full px-2 fixed top-0 left-0 z-30 transition-transform bg-bg text-fg" :class="`${showingToolbar ? 'translate-y-0' : '-translate-y-36'} ${isIos ? 'pt-14 h-36' : 'pt-10 h-32'}`" :style="{ boxShadow: showingToolbar ? '0px 8px 8px #11111155' : '' }" @touchstart.stop @mousedown.stop @touchend.stop @mouseup.stop>
|
||||||
<div class="flex items-center mb-2">
|
<div class="flex items-center mb-2">
|
||||||
<button type="button" class="inline-flex mx-2" @click.stop="show = false">
|
<button type="button" class="inline-flex mx-2" @click.stop="show = false">
|
||||||
<span class="material-symbols text-3xl text-fg">chevron_left</span>
|
<span class="material-symbols text-3xl text-fg">chevron_left</span>
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
<span class="material-symbols">close</span>
|
<span class="material-symbols">close</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full overflow-y-auto overflow-x-hidden h-full max-h-[calc(75vh-85px)]">
|
<div class="w-full overflow-y-auto overflow-x-hidden h-[calc(75vh-85px)] min-h-[320px] short:min-h-0 short:h-[calc(100vh-85px)]">
|
||||||
<div class="w-full h-full px-4">
|
<div class="w-full h-full px-4">
|
||||||
<div class="flex items-center mb-6">
|
<div class="flex items-center mb-6">
|
||||||
<div class="w-32">
|
<div class="w-32">
|
||||||
@@ -70,6 +70,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<ui-toggle-btns v-model="ereaderSettings.theme" name="theme" :items="themeItems" @input="settingsUpdated" />
|
<ui-toggle-btns v-model="ereaderSettings.theme" name="theme" :items="themeItems" @input="settingsUpdated" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center mb-6">
|
||||||
|
<div class="w-32">
|
||||||
|
<p class="text-sm">{{ $strings.LabelFontFamily }}</p>
|
||||||
|
</div>
|
||||||
|
<ui-toggle-btns v-model="ereaderSettings.font" name="font" :items="fontItems" @input="settingsUpdated" />
|
||||||
|
</div>
|
||||||
<div class="flex items-center mb-6">
|
<div class="flex items-center mb-6">
|
||||||
<div class="w-32">
|
<div class="w-32">
|
||||||
<p class="text-sm">{{ $strings.LabelFontScale }}</p>
|
<p class="text-sm">{{ $strings.LabelFontScale }}</p>
|
||||||
@@ -141,6 +147,7 @@ export default {
|
|||||||
isInittingWatchVolume: false,
|
isInittingWatchVolume: false,
|
||||||
ereaderSettings: {
|
ereaderSettings: {
|
||||||
theme: 'dark',
|
theme: 'dark',
|
||||||
|
font: 'serif',
|
||||||
fontScale: 100,
|
fontScale: 100,
|
||||||
lineSpacing: 115,
|
lineSpacing: 115,
|
||||||
spread: 'auto',
|
spread: 'auto',
|
||||||
@@ -180,6 +187,9 @@ export default {
|
|||||||
this.$store.commit('setShowReader', val)
|
this.$store.commit('setShowReader', val)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
isIos() {
|
||||||
|
return this.$platform === 'ios'
|
||||||
|
},
|
||||||
title() {
|
title() {
|
||||||
return this.mediaMetadata.title || 'No Title'
|
return this.mediaMetadata.title || 'No Title'
|
||||||
},
|
},
|
||||||
@@ -252,6 +262,18 @@ export default {
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
fontItems() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelFontFamilySans,
|
||||||
|
value: 'sans-serif'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelFontFamilySerif,
|
||||||
|
value: 'serif'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
readerComponentName() {
|
readerComponentName() {
|
||||||
if (this.ebookType === 'epub') return 'readers-epub-reader'
|
if (this.ebookType === 'epub') return 'readers-epub-reader'
|
||||||
else if (this.ebookType === 'mobi') return 'readers-mobi-reader'
|
else if (this.ebookType === 'mobi') return 'readers-mobi-reader'
|
||||||
|
|||||||
@@ -183,10 +183,16 @@ export default {
|
|||||||
daysInARow() {
|
daysInARow() {
|
||||||
var count = 0
|
var count = 0
|
||||||
while (true) {
|
while (true) {
|
||||||
var _date = this.$addDaysToToday(count * -1)
|
const _date = this.$addDaysToToday(count * -1 - 1)
|
||||||
var datestr = this.$formatJsDate(_date, 'yyyy-MM-dd')
|
const datestr = this.$formatJsDate(_date, 'yyyy-MM-dd')
|
||||||
|
|
||||||
if (!this.listeningStatsDays[datestr] || this.listeningStatsDays[datestr] === 0) {
|
if (!this.listeningStatsDays[datestr] || this.listeningStatsDays[datestr] === 0) {
|
||||||
|
// don't require listening today to count towards days in a row, but do count it if already listened today
|
||||||
|
const today = this.$formatJsDate(new Date(), 'yyyy-MM-dd')
|
||||||
|
if (this.listeningStatsDays[today]) {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
|
||||||
return count
|
return count
|
||||||
}
|
}
|
||||||
count++
|
count++
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th class="text-left">{{ $strings.LabelTitle }}</th>
|
<th class="text-left">{{ $strings.LabelTitle }}</th>
|
||||||
<th class="text-center w-16">{{ $strings.LabelStart }}</th>
|
<th class="text-center w-16">{{ $strings.LabelStart }}</th>
|
||||||
|
<th class="text-center w-16">{{ $strings.LabelDuration }}</th>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-for="chapter in chapters" :key="chapter.id">
|
<tr v-for="chapter in chapters" :key="chapter.id">
|
||||||
<td>
|
<td>
|
||||||
@@ -23,6 +24,9 @@
|
|||||||
<td class="font-mono text-center underline w-16" @click.stop="goToTimestamp(chapter.start)">
|
<td class="font-mono text-center underline w-16" @click.stop="goToTimestamp(chapter.start)">
|
||||||
{{ $secondsToTimestamp(chapter.start) }}
|
{{ $secondsToTimestamp(chapter.start) }}
|
||||||
</td>
|
</td>
|
||||||
|
<td class="font-mono text-center">
|
||||||
|
{{ $secondsToTimestamp(Math.max(0, chapter.end - chapter.start)) }}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</transition>
|
</transition>
|
||||||
|
|||||||
@@ -228,8 +228,8 @@ export default {
|
|||||||
methods: {
|
methods: {
|
||||||
async clearDownloadQueue() {
|
async clearDownloadQueue() {
|
||||||
const { value } = await Dialog.confirm({
|
const { value } = await Dialog.confirm({
|
||||||
title: 'Confirm',
|
title: this.$strings.HeaderConfirm,
|
||||||
message: `Are you sure you want to clear episode download queue?`
|
message: this.$strings.MessageConfirmDeleteEpisodeDownloadQueue
|
||||||
})
|
})
|
||||||
|
|
||||||
if (value) {
|
if (value) {
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<p>Zur Verwendung mit Ihrem Audiobookshelf-Server. Audiobookshelf ist ein kostenloser Open-Source-Hörbuch- und Podcast-Manager.</p><p>Verbinden Sie sich mit Ihrem selbst gehosteten Audiobookshelf-Server und streamen Sie Ihre Hörbücher und Podcasts direkt auf Ihr Gerät.</p>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Hörbuch- und Podcast-Player für Ihren selbst gehosteten Audiobookshelf-Server
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<p>For use with your audiobookshelf server. Audiobookshelf is a free and open source audiobook and podcast manager.</p><p>Connect to your self-hosted audiobookshelf server and stream your audiobooks and podcasts directly to your device.</p>
|
||||||
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 45 KiB |
@@ -0,0 +1 @@
|
|||||||
|
Audiobook and podcast player for your self-hosted Audiobookshelf server
|
||||||
@@ -36,6 +36,7 @@
|
|||||||
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
|
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
|
||||||
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
|
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
|
||||||
65643F4DAA661FB0B247247E /* Pods_Audiobookshelf.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8F9A7CCC479333E44DC314BE /* Pods_Audiobookshelf.framework */; };
|
65643F4DAA661FB0B247247E /* Pods_Audiobookshelf.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8F9A7CCC479333E44DC314BE /* Pods_Audiobookshelf.framework */; };
|
||||||
|
6F2516272E68DA3000F40541 /* LogEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2516262E68DA3000F40541 /* LogEntry.swift */; };
|
||||||
E9D5504628AC1A3900C746DD /* LibraryItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5504528AC1A3900C746DD /* LibraryItem.swift */; };
|
E9D5504628AC1A3900C746DD /* LibraryItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5504528AC1A3900C746DD /* LibraryItem.swift */; };
|
||||||
E9D5504828AC1A7A00C746DD /* MediaType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5504728AC1A7A00C746DD /* MediaType.swift */; };
|
E9D5504828AC1A7A00C746DD /* MediaType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5504728AC1A7A00C746DD /* MediaType.swift */; };
|
||||||
E9D5504A28AC1AA600C746DD /* Metadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5504928AC1AA600C746DD /* Metadata.swift */; };
|
E9D5504A28AC1AA600C746DD /* Metadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5504928AC1AA600C746DD /* Metadata.swift */; };
|
||||||
@@ -111,6 +112,7 @@
|
|||||||
504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||||
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
|
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
|
||||||
|
6F2516262E68DA3000F40541 /* LogEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogEntry.swift; sourceTree = "<group>"; };
|
||||||
8F9A7CCC479333E44DC314BE /* Pods_Audiobookshelf.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Audiobookshelf.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
8F9A7CCC479333E44DC314BE /* Pods_Audiobookshelf.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Audiobookshelf.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
BE96D57E131924D520D57057 /* Pods-Audiobookshelf.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Audiobookshelf.debug.xcconfig"; path = "Target Support Files/Pods-Audiobookshelf/Pods-Audiobookshelf.debug.xcconfig"; sourceTree = "<group>"; };
|
BE96D57E131924D520D57057 /* Pods-Audiobookshelf.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Audiobookshelf.debug.xcconfig"; path = "Target Support Files/Pods-Audiobookshelf/Pods-Audiobookshelf.debug.xcconfig"; sourceTree = "<group>"; };
|
||||||
D2F7F575384A63F1C47DE984 /* Pods-Audiobookshelf.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Audiobookshelf.release.xcconfig"; path = "Target Support Files/Pods-Audiobookshelf/Pods-Audiobookshelf.release.xcconfig"; sourceTree = "<group>"; };
|
D2F7F575384A63F1C47DE984 /* Pods-Audiobookshelf.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Audiobookshelf.release.xcconfig"; path = "Target Support Files/Pods-Audiobookshelf/Pods-Audiobookshelf.release.xcconfig"; sourceTree = "<group>"; };
|
||||||
@@ -231,6 +233,7 @@
|
|||||||
E9D5506128AC1CC900C746DD /* PlayerState.swift */,
|
E9D5506128AC1CC900C746DD /* PlayerState.swift */,
|
||||||
4DF74911287105C600AC7814 /* DeviceSettings.swift */,
|
4DF74911287105C600AC7814 /* DeviceSettings.swift */,
|
||||||
E9D5507428AEF93100C746DD /* PlayerSettings.swift */,
|
E9D5507428AEF93100C746DD /* PlayerSettings.swift */,
|
||||||
|
6F2516262E68DA3000F40541 /* LogEntry.swift */,
|
||||||
E9D5506328AC1D3F00C746DD /* server */,
|
E9D5506328AC1D3F00C746DD /* server */,
|
||||||
E9D5506428AC1D5800C746DD /* local */,
|
E9D5506428AC1D5800C746DD /* local */,
|
||||||
E9D5506D28AC1E7400C746DD /* download */,
|
E9D5506D28AC1E7400C746DD /* download */,
|
||||||
@@ -533,6 +536,7 @@
|
|||||||
3ABF580928059BAE005DFBE5 /* PlaybackSession.swift in Sources */,
|
3ABF580928059BAE005DFBE5 /* PlaybackSession.swift in Sources */,
|
||||||
E9D5506628AC1D7300C746DD /* LocalLibraryItem.swift in Sources */,
|
E9D5506628AC1D7300C746DD /* LocalLibraryItem.swift in Sources */,
|
||||||
E9D5504628AC1A3900C746DD /* LibraryItem.swift in Sources */,
|
E9D5504628AC1A3900C746DD /* LibraryItem.swift in Sources */,
|
||||||
|
6F2516272E68DA3000F40541 /* LogEntry.swift in Sources */,
|
||||||
3ABF618F2804325C0070250E /* PlayerHandler.swift in Sources */,
|
3ABF618F2804325C0070250E /* PlayerHandler.swift in Sources */,
|
||||||
3AD4FCED28044E6C006DB301 /* Store.swift in Sources */,
|
3AD4FCED28044E6C006DB301 /* Store.swift in Sources */,
|
||||||
4D66B958282EEA14008272D4 /* AbsFileSystem.swift in Sources */,
|
4D66B958282EEA14008272D4 /* AbsFileSystem.swift in Sources */,
|
||||||
@@ -736,12 +740,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 = 39;
|
CURRENT_PROJECT_VERSION = 43;
|
||||||
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.10.0;
|
MARKETING_VERSION = 0.13.0;
|
||||||
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)";
|
||||||
@@ -760,12 +764,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 = 39;
|
CURRENT_PROJECT_VERSION = 43;
|
||||||
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.10.0;
|
MARKETING_VERSION = 0.13.0;
|
||||||
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 = "";
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import RealmSwift
|
|||||||
@UIApplicationMain
|
@UIApplicationMain
|
||||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||||
|
|
||||||
private let logger = AppLogger(category: "AppDelegate")
|
|
||||||
|
|
||||||
lazy var window: UIWindow? = UIWindow(frame: UIScreen.main.bounds)
|
lazy var window: UIWindow? = UIWindow(frame: UIScreen.main.bounds)
|
||||||
var backgroundCompletionHandler: (() -> Void)?
|
var backgroundCompletionHandler: (() -> Void)?
|
||||||
|
|
||||||
@@ -17,13 +15,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||||||
schemaVersion: 20,
|
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)")
|
AbsLogger.info(message: "Realm schema version was \(oldSchemaVersion)")
|
||||||
migration.enumerateObjects(ofType: DeviceSettings.className()) { oldObject, newObject in
|
migration.enumerateObjects(ofType: DeviceSettings.className()) { oldObject, newObject in
|
||||||
newObject?["enableAltView"] = false
|
newObject?["enableAltView"] = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (oldSchemaVersion < 4) {
|
if (oldSchemaVersion < 4) {
|
||||||
self?.logger.log("Realm schema version was \(oldSchemaVersion)... Reindexing server configs")
|
AbsLogger.info(message: "Realm schema version was \(oldSchemaVersion)... Reindexing server configs")
|
||||||
var indexCounter = 1
|
var indexCounter = 1
|
||||||
migration.enumerateObjects(ofType: ServerConnectionConfig.className()) { oldObject, newObject in
|
migration.enumerateObjects(ofType: ServerConnectionConfig.className()) { oldObject, newObject in
|
||||||
newObject?["index"] = indexCounter
|
newObject?["index"] = indexCounter
|
||||||
@@ -31,44 +29,44 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (oldSchemaVersion < 5) {
|
if (oldSchemaVersion < 5) {
|
||||||
self?.logger.log("Realm schema version was \(oldSchemaVersion)... Adding lockOrientation setting")
|
AbsLogger.info(message: "Realm schema version was \(oldSchemaVersion)... Adding lockOrientation setting")
|
||||||
migration.enumerateObjects(ofType: DeviceSettings.className()) { oldObject, newObject in
|
migration.enumerateObjects(ofType: DeviceSettings.className()) { oldObject, newObject in
|
||||||
newObject?["lockOrientation"] = "NONE"
|
newObject?["lockOrientation"] = "NONE"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (oldSchemaVersion < 6) {
|
if (oldSchemaVersion < 6) {
|
||||||
self?.logger.log("Realm schema version was \(oldSchemaVersion)... Adding hapticFeedback setting")
|
AbsLogger.info(message: "Realm schema version was \(oldSchemaVersion)... Adding hapticFeedback setting")
|
||||||
migration.enumerateObjects(ofType: DeviceSettings.className()) { oldObject, newObject in
|
migration.enumerateObjects(ofType: DeviceSettings.className()) { oldObject, newObject in
|
||||||
newObject?["hapticFeedback"] = "LIGHT"
|
newObject?["hapticFeedback"] = "LIGHT"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (oldSchemaVersion < 15) {
|
if (oldSchemaVersion < 15) {
|
||||||
self?.logger.log("Realm schema version was \(oldSchemaVersion)... Adding languageCode setting")
|
AbsLogger.info(message: "Realm schema version was \(oldSchemaVersion)... Adding languageCode setting")
|
||||||
migration.enumerateObjects(ofType: DeviceSettings.className()) { oldObject, newObject in
|
migration.enumerateObjects(ofType: DeviceSettings.className()) { oldObject, newObject in
|
||||||
newObject?["languageCode"] = "en-us"
|
newObject?["languageCode"] = "en-us"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (oldSchemaVersion < 16) {
|
if (oldSchemaVersion < 16) {
|
||||||
self?.logger.log("Realm schema version was \(oldSchemaVersion)... Adding chapterTrack setting")
|
AbsLogger.info(message: "Realm schema version was \(oldSchemaVersion)... Adding chapterTrack setting")
|
||||||
migration.enumerateObjects(ofType: PlayerSettings.className()) { oldObject, newObject in
|
migration.enumerateObjects(ofType: PlayerSettings.className()) { oldObject, newObject in
|
||||||
newObject?["chapterTrack"] = false
|
newObject?["chapterTrack"] = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (oldSchemaVersion < 17) {
|
if (oldSchemaVersion < 17) {
|
||||||
self?.logger.log("Realm schema version was \(oldSchemaVersion)... Adding downloadUsingCellular and streamingUsingCellular settings")
|
AbsLogger.info(message: "Realm schema version was \(oldSchemaVersion)... Adding downloadUsingCellular and streamingUsingCellular settings")
|
||||||
migration.enumerateObjects(ofType: PlayerSettings.className()) { oldObject, newObject in
|
migration.enumerateObjects(ofType: PlayerSettings.className()) { oldObject, newObject in
|
||||||
newObject?["downloadUsingCellular"] = "ALWAYS"
|
newObject?["downloadUsingCellular"] = "ALWAYS"
|
||||||
newObject?["streamingUsingCellular"] = "ALWAYS"
|
newObject?["streamingUsingCellular"] = "ALWAYS"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (oldSchemaVersion < 18) {
|
if (oldSchemaVersion < 18) {
|
||||||
self?.logger.log("Realm schema version was \(oldSchemaVersion)... Adding disableSleepTimerFadeOut settings")
|
AbsLogger.info(message: "Realm schema version was \(oldSchemaVersion)... Adding disableSleepTimerFadeOut settings")
|
||||||
migration.enumerateObjects(ofType: PlayerSettings.className()) { oldObject, newObject in
|
migration.enumerateObjects(ofType: PlayerSettings.className()) { oldObject, newObject in
|
||||||
newObject?["disableSleepTimerFadeOut"] = false
|
newObject?["disableSleepTimerFadeOut"] = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (oldSchemaVersion < 20) {
|
if (oldSchemaVersion < 20) {
|
||||||
self?.logger.log("Realm schema version was \(oldSchemaVersion)... Adding version to ServerConnectionConfigs")
|
AbsLogger.info(message: "Realm schema version was \(oldSchemaVersion)... Adding version to ServerConnectionConfigs")
|
||||||
migration.enumerateObjects(ofType: ServerConnectionConfig.className()) { oldObject, newObject in
|
migration.enumerateObjects(ofType: ServerConnectionConfig.className()) { oldObject, newObject in
|
||||||
newObject?["version"] = ""
|
newObject?["version"] = ""
|
||||||
}
|
}
|
||||||
@@ -88,22 +86,22 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||||||
func applicationDidEnterBackground(_ application: UIApplication) {
|
func applicationDidEnterBackground(_ application: UIApplication) {
|
||||||
// Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later.
|
// Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later.
|
||||||
// If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits.
|
// If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits.
|
||||||
logger.log("Audiobookself is now in the background")
|
AbsLogger.info(message: "Audiobookself is now in the background")
|
||||||
}
|
}
|
||||||
|
|
||||||
func applicationWillEnterForeground(_ application: UIApplication) {
|
func applicationWillEnterForeground(_ application: UIApplication) {
|
||||||
// Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background.
|
// Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background.
|
||||||
logger.log("Audiobookself is now in the foreground")
|
AbsLogger.info(message: "Audiobookself is now in the foreground")
|
||||||
}
|
}
|
||||||
|
|
||||||
func applicationDidBecomeActive(_ application: UIApplication) {
|
func applicationDidBecomeActive(_ application: UIApplication) {
|
||||||
// Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
|
// Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
|
||||||
logger.log("Audiobookself is now active")
|
AbsLogger.info(message: "Audiobookself is now active")
|
||||||
}
|
}
|
||||||
|
|
||||||
func applicationWillTerminate(_ application: UIApplication) {
|
func applicationWillTerminate(_ application: UIApplication) {
|
||||||
// Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
|
// Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
|
||||||
logger.log("Audiobookself is terminating")
|
AbsLogger.info(message: "Audiobookself is terminating")
|
||||||
}
|
}
|
||||||
|
|
||||||
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
|
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
|
||||||
|
|||||||
@@ -34,8 +34,6 @@ public class AbsAudioPlayer: CAPPlugin, CAPBridgedPlugin {
|
|||||||
CAPPluginMethod(name: "setSleepTimer", returnType: CAPPluginReturnPromise)
|
CAPPluginMethod(name: "setSleepTimer", returnType: CAPPluginReturnPromise)
|
||||||
]
|
]
|
||||||
|
|
||||||
private let logger = AppLogger(category: "AbsAudioPlayer")
|
|
||||||
|
|
||||||
private var initialPlayWhenReady = false
|
private var initialPlayWhenReady = false
|
||||||
private var monitor: NWPathMonitor?
|
private var monitor: NWPathMonitor?
|
||||||
private let queue = DispatchQueue.global(qos: .background)
|
private let queue = DispatchQueue.global(qos: .background)
|
||||||
@@ -82,7 +80,7 @@ public class AbsAudioPlayer: CAPPlugin, CAPBridgedPlugin {
|
|||||||
let startTimeOverride = call.getDouble("startTime")
|
let startTimeOverride = call.getDouble("startTime")
|
||||||
|
|
||||||
if libraryItemId == nil {
|
if libraryItemId == nil {
|
||||||
logger.error("provide library item id")
|
AbsLogger.error(message: "No id provided")
|
||||||
return call.resolve()
|
return call.resolve()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,7 +91,7 @@ public class AbsAudioPlayer: CAPPlugin, CAPBridgedPlugin {
|
|||||||
let item = Database.shared.getLocalLibraryItem(localLibraryItemId: libraryItemId!)
|
let item = Database.shared.getLocalLibraryItem(localLibraryItemId: libraryItemId!)
|
||||||
let episode = item?.getPodcastEpisode(episodeId: episodeId)
|
let episode = item?.getPodcastEpisode(episodeId: episodeId)
|
||||||
guard let playbackSession = item?.getPlaybackSession(episode: episode) else {
|
guard let playbackSession = item?.getPlaybackSession(episode: episode) else {
|
||||||
logger.error("Failed to get local playback session")
|
AbsLogger.error(message: "Failed to get local playback session")
|
||||||
return call.resolve([:])
|
return call.resolve([:])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,7 +103,7 @@ public class AbsAudioPlayer: CAPPlugin, CAPBridgedPlugin {
|
|||||||
try self.startPlaybackSession(playbackSession, playWhenReady: playWhenReady, playbackRate: playbackRate)
|
try self.startPlaybackSession(playbackSession, playWhenReady: playWhenReady, playbackRate: playbackRate)
|
||||||
call.resolve(try playbackSession.asDictionary())
|
call.resolve(try playbackSession.asDictionary())
|
||||||
} catch(let exception) {
|
} catch(let exception) {
|
||||||
logger.error("Failed to start session")
|
AbsLogger.error(message: "Failed to start session for local item: \(exception)")
|
||||||
debugPrint(exception)
|
debugPrint(exception)
|
||||||
call.resolve([:])
|
call.resolve([:])
|
||||||
}
|
}
|
||||||
@@ -119,7 +117,7 @@ public class AbsAudioPlayer: CAPPlugin, CAPBridgedPlugin {
|
|||||||
try self?.startPlaybackSession(session, playWhenReady: playWhenReady, playbackRate: playbackRate)
|
try self?.startPlaybackSession(session, playWhenReady: playWhenReady, playbackRate: playbackRate)
|
||||||
call.resolve(try session.asDictionary())
|
call.resolve(try session.asDictionary())
|
||||||
} catch(let exception) {
|
} catch(let exception) {
|
||||||
self?.logger.error("Failed to start session")
|
AbsLogger.error(message: "Failed to start streaming session")
|
||||||
debugPrint(exception)
|
debugPrint(exception)
|
||||||
call.resolve([:])
|
call.resolve([:])
|
||||||
}
|
}
|
||||||
@@ -127,19 +125,22 @@ public class AbsAudioPlayer: CAPPlugin, CAPBridgedPlugin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stops playback and closes session.
|
||||||
@objc func closePlayback(_ call: CAPPluginCall) {
|
@objc func closePlayback(_ call: CAPPluginCall) {
|
||||||
logger.log("Close playback")
|
AbsLogger.info(message: "Close playback")
|
||||||
|
|
||||||
PlayerHandler.stopPlayback()
|
PlayerHandler.stopPlayback()
|
||||||
call.resolve()
|
call.resolve()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@objc func getCurrentTime(_ call: CAPPluginCall) {
|
@objc func getCurrentTime(_ call: CAPPluginCall) {
|
||||||
call.resolve([
|
call.resolve([
|
||||||
"value": PlayerHandler.getCurrentTime() ?? 0,
|
"value": PlayerHandler.getCurrentTime() ?? 0,
|
||||||
"bufferedTime": PlayerHandler.getCurrentTime() ?? 0,
|
"bufferedTime": PlayerHandler.getCurrentTime() ?? 0,
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func setPlaybackSpeed(_ call: CAPPluginCall) {
|
@objc func setPlaybackSpeed(_ call: CAPPluginCall) {
|
||||||
let playbackRate = call.getFloat("value", 1.0)
|
let playbackRate = call.getFloat("value", 1.0)
|
||||||
let settings = PlayerSettings.main()
|
let settings = PlayerSettings.main()
|
||||||
@@ -152,7 +153,7 @@ public class AbsAudioPlayer: CAPPlugin, CAPBridgedPlugin {
|
|||||||
|
|
||||||
@objc func setChapterTrack(_ call: CAPPluginCall) {
|
@objc func setChapterTrack(_ call: CAPPluginCall) {
|
||||||
let chapterTrack = call.getBool("enabled", true)
|
let chapterTrack = call.getBool("enabled", true)
|
||||||
logger.log(String(chapterTrack))
|
AbsLogger.info(message: String(chapterTrack))
|
||||||
let settings = PlayerSettings.main()
|
let settings = PlayerSettings.main()
|
||||||
try? settings.update {
|
try? settings.update {
|
||||||
settings.chapterTrack = chapterTrack
|
settings.chapterTrack = chapterTrack
|
||||||
@@ -225,7 +226,7 @@ public class AbsAudioPlayer: CAPPlugin, CAPBridgedPlugin {
|
|||||||
|
|
||||||
let seconds = time / 1000
|
let seconds = time / 1000
|
||||||
|
|
||||||
logger.log("chapter time: \(isChapterTime)")
|
AbsLogger.info(message: "chapter time: \(isChapterTime)")
|
||||||
if isChapterTime {
|
if isChapterTime {
|
||||||
PlayerHandler.setChapterSleepTime(stopAt: seconds)
|
PlayerHandler.setChapterSleepTime(stopAt: seconds)
|
||||||
return call.resolve([ "success": true ])
|
return call.resolve([ "success": true ])
|
||||||
@@ -262,7 +263,7 @@ public class AbsAudioPlayer: CAPPlugin, CAPBridgedPlugin {
|
|||||||
guard let localMediaProgressId = PlayerHandler.getPlaybackSession()?.localMediaProgressId else { return }
|
guard let localMediaProgressId = PlayerHandler.getPlaybackSession()?.localMediaProgressId else { return }
|
||||||
guard let localMediaProgress = Database.shared.getLocalMediaProgress(localMediaProgressId: localMediaProgressId) else { return }
|
guard let localMediaProgress = Database.shared.getLocalMediaProgress(localMediaProgressId: localMediaProgressId) else { return }
|
||||||
guard let progressUpdate = try? localMediaProgress.asDictionary() else { return }
|
guard let progressUpdate = try? localMediaProgress.asDictionary() else { return }
|
||||||
logger.log("Sending local progress back to the UI")
|
AbsLogger.info(message: "Sending local progress back to the UI")
|
||||||
self.notifyListeners("onLocalMediaProgressUpdate", data: progressUpdate)
|
self.notifyListeners("onLocalMediaProgressUpdate", data: progressUpdate)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,7 +273,7 @@ public class AbsAudioPlayer: CAPPlugin, CAPBridgedPlugin {
|
|||||||
let playWhenReady = PlayerHandler.getPlayWhenReady()
|
let playWhenReady = PlayerHandler.getPlayWhenReady()
|
||||||
let libraryItemId = session?.libraryItemId ?? ""
|
let libraryItemId = session?.libraryItemId ?? ""
|
||||||
let episodeId = session?.episodeId ?? nil
|
let episodeId = session?.episodeId ?? nil
|
||||||
logger.log("Forcing Transcode")
|
AbsLogger.info(message: "Forcing Transcode")
|
||||||
|
|
||||||
// If direct playing then fallback to transcode
|
// If direct playing then fallback to transcode
|
||||||
ApiClient.startPlaybackSession(libraryItemId: libraryItemId, episodeId: episodeId, forceTranscode: true) { [weak self] session in
|
ApiClient.startPlaybackSession(libraryItemId: libraryItemId, episodeId: episodeId, forceTranscode: true) { [weak self] session in
|
||||||
@@ -283,7 +284,7 @@ public class AbsAudioPlayer: CAPPlugin, CAPBridgedPlugin {
|
|||||||
self.sendPlaybackSession(session: try session.asDictionary())
|
self.sendPlaybackSession(session: try session.asDictionary())
|
||||||
self.sendMetadata()
|
self.sendMetadata()
|
||||||
} catch(let exception) {
|
} catch(let exception) {
|
||||||
self?.logger.error("Failed to start transcoded session")
|
AbsLogger.error(message: "Failed to start transcoded session")
|
||||||
debugPrint(exception)
|
debugPrint(exception)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ public class AbsDatabase: CAPPlugin, CAPBridgedPlugin {
|
|||||||
CAPPluginMethod(name: "updateLocalEbookProgress", returnType: CAPPluginReturnPromise)
|
CAPPluginMethod(name: "updateLocalEbookProgress", returnType: CAPPluginReturnPromise)
|
||||||
]
|
]
|
||||||
|
|
||||||
private let logger = AppLogger(category: "AbsDatabase")
|
|
||||||
private let secureStorage = SecureStorage()
|
private let secureStorage = SecureStorage()
|
||||||
|
|
||||||
// Used to notify the webview frontend that the token has been refreshed
|
// Used to notify the webview frontend that the token has been refreshed
|
||||||
@@ -73,14 +72,14 @@ public class AbsDatabase: CAPPlugin, CAPBridgedPlugin {
|
|||||||
|
|
||||||
let name = "\(address) (\(username))"
|
let name = "\(address) (\(username))"
|
||||||
|
|
||||||
|
if id == nil {
|
||||||
|
id = "\(address)@\(username)".toBase64()
|
||||||
|
}
|
||||||
|
|
||||||
if (refreshToken != "") {
|
if (refreshToken != "") {
|
||||||
// Store refresh token securely if provided
|
// Store refresh token securely if provided
|
||||||
let hasRefreshToken = secureStorage.storeRefreshToken(serverConnectionConfigId: id ?? "", refreshToken: refreshToken)
|
let hasRefreshToken = secureStorage.storeRefreshToken(serverConnectionConfigId: id ?? "", refreshToken: refreshToken)
|
||||||
logger.log("Refresh token secured = \(hasRefreshToken)")
|
AbsLogger.info(message: "Refresh token secured = \(hasRefreshToken)")
|
||||||
}
|
|
||||||
|
|
||||||
if id == nil {
|
|
||||||
id = "\(address)@\(username)".toBase64()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let config = ServerConnectionConfig()
|
let config = ServerConnectionConfig()
|
||||||
@@ -148,15 +147,16 @@ public class AbsDatabase: CAPPlugin, CAPBridgedPlugin {
|
|||||||
let items = Database.shared.getLocalLibraryItems()
|
let items = Database.shared.getLocalLibraryItems()
|
||||||
call.resolve([ "value": try items.asDictionaryArray()])
|
call.resolve([ "value": try items.asDictionaryArray()])
|
||||||
} catch(let exception) {
|
} catch(let exception) {
|
||||||
logger.error("error while readling local library items")
|
AbsLogger.error(message: "error reading local library items \(exception)")
|
||||||
debugPrint(exception)
|
debugPrint(exception)
|
||||||
call.resolve()
|
call.resolve()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func getLocalLibraryItem(_ call: CAPPluginCall) {
|
@objc func getLocalLibraryItem(_ call: CAPPluginCall) {
|
||||||
|
let id = call.getString("id") ?? ""
|
||||||
do {
|
do {
|
||||||
let item = Database.shared.getLocalLibraryItem(localLibraryItemId: call.getString("id") ?? "")
|
let item = Database.shared.getLocalLibraryItem(localLibraryItemId: id)
|
||||||
switch item {
|
switch item {
|
||||||
case .some(let foundItem):
|
case .some(let foundItem):
|
||||||
call.resolve(try foundItem.asDictionary())
|
call.resolve(try foundItem.asDictionary())
|
||||||
@@ -164,7 +164,7 @@ public class AbsDatabase: CAPPlugin, CAPBridgedPlugin {
|
|||||||
call.resolve()
|
call.resolve()
|
||||||
}
|
}
|
||||||
} catch(let exception) {
|
} catch(let exception) {
|
||||||
logger.error("error while readling local library items")
|
AbsLogger.error(message: "error reading local library item[\(id)] \(exception)")
|
||||||
debugPrint(exception)
|
debugPrint(exception)
|
||||||
call.resolve()
|
call.resolve()
|
||||||
}
|
}
|
||||||
@@ -180,8 +180,7 @@ public class AbsDatabase: CAPPlugin, CAPBridgedPlugin {
|
|||||||
call.resolve()
|
call.resolve()
|
||||||
}
|
}
|
||||||
} catch(let exception) {
|
} catch(let exception) {
|
||||||
logger.error("error while readling local library items")
|
AbsLogger.error(message: "error while readling local library items: \(exception)", error: exception)
|
||||||
debugPrint(exception)
|
|
||||||
call.resolve()
|
call.resolve()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -194,8 +193,7 @@ public class AbsDatabase: CAPPlugin, CAPBridgedPlugin {
|
|||||||
do {
|
do {
|
||||||
call.resolve([ "value": try Database.shared.getAllLocalMediaProgress().asDictionaryArray() ])
|
call.resolve([ "value": try Database.shared.getAllLocalMediaProgress().asDictionaryArray() ])
|
||||||
} catch {
|
} catch {
|
||||||
logger.error("Error while loading local media progress")
|
AbsLogger.error(message: "Error while loading local media progress", error: error)
|
||||||
debugPrint(error)
|
|
||||||
call.resolve(["value": []])
|
call.resolve(["value": []])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -212,7 +210,7 @@ public class AbsDatabase: CAPPlugin, CAPBridgedPlugin {
|
|||||||
|
|
||||||
@objc func syncLocalSessionsWithServer(_ call: CAPPluginCall) {
|
@objc func syncLocalSessionsWithServer(_ call: CAPPluginCall) {
|
||||||
let isFirstSync = call.getBool("isFirstSync", false)
|
let isFirstSync = call.getBool("isFirstSync", false)
|
||||||
logger.log("syncLocalSessionsWithServer: Starting (First sync: \(isFirstSync))")
|
AbsLogger.info(message: "Starting syncLocalSessionsWithServer isFirstSync=\(isFirstSync)")
|
||||||
guard Store.serverConfig != nil else {
|
guard Store.serverConfig != nil else {
|
||||||
call.reject("syncLocalSessionsWithServer not connected to server")
|
call.reject("syncLocalSessionsWithServer not connected to server")
|
||||||
return call.resolve()
|
return call.resolve()
|
||||||
@@ -244,12 +242,13 @@ public class AbsDatabase: CAPPlugin, CAPBridgedPlugin {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.log("syncServerMediaProgressWithLocalMediaProgress: Saving local media progress")
|
AbsLogger.info(message: "Saving local media progress \(serverMediaProgress)")
|
||||||
try localMediaProgress.updateFromServerMediaProgress(serverMediaProgress)
|
try localMediaProgress.updateFromServerMediaProgress(serverMediaProgress)
|
||||||
|
|
||||||
call.resolve(try localMediaProgress.asDictionary())
|
call.resolve(try localMediaProgress.asDictionary())
|
||||||
} catch {
|
} catch {
|
||||||
call.reject("Failed to sync media progress")
|
call.reject("Failed to sync media progress")
|
||||||
|
AbsLogger.error(message: "Failed to sync: \(error)")
|
||||||
debugPrint(error)
|
debugPrint(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -264,7 +263,7 @@ public class AbsDatabase: CAPPlugin, CAPBridgedPlugin {
|
|||||||
localMediaProgressId += "-\(localEpisodeId ?? "")"
|
localMediaProgressId += "-\(localEpisodeId ?? "")"
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.log("updateLocalMediaProgressFinished \(localMediaProgressId) | Is Finished: \(isFinished)")
|
AbsLogger.info(message: "\(localMediaProgressId): isFinished=\(isFinished)")
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let localMediaProgress = try LocalMediaProgress.fetchOrCreateLocalMediaProgress(localMediaProgressId: localMediaProgressId, localLibraryItemId: localLibraryItemId, localEpisodeId: localEpisodeId)
|
let localMediaProgress = try LocalMediaProgress.fetchOrCreateLocalMediaProgress(localMediaProgressId: localMediaProgressId, localLibraryItemId: localLibraryItemId, localEpisodeId: localEpisodeId)
|
||||||
@@ -337,7 +336,7 @@ public class AbsDatabase: CAPPlugin, CAPBridgedPlugin {
|
|||||||
let ebookLocation = call.getString("ebookLocation", "")
|
let ebookLocation = call.getString("ebookLocation", "")
|
||||||
let ebookProgress = call.getDouble("ebookProgress", 0.0)
|
let ebookProgress = call.getDouble("ebookProgress", 0.0)
|
||||||
|
|
||||||
logger.log("updateLocalEbookProgress \(localLibraryItemId ?? "Unknown") | ebookLocation: \(ebookLocation) | ebookProgress: \(ebookProgress)")
|
AbsLogger.info(message: "\(localLibraryItemId ?? "Unknown"): ebookLocation=\(ebookLocation) ebookProgress=\(ebookProgress)")
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let localMediaProgress = try LocalMediaProgress.fetchOrCreateLocalMediaProgress(localMediaProgressId: localLibraryItemId, localLibraryItemId: localLibraryItemId, localEpisodeId: nil)
|
let localMediaProgress = try LocalMediaProgress.fetchOrCreateLocalMediaProgress(localMediaProgressId: localLibraryItemId, localLibraryItemId: localLibraryItemId, localEpisodeId: nil)
|
||||||
|
|||||||
@@ -19,8 +19,6 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
|
|||||||
|
|
||||||
static private let downloadsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
static private let downloadsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||||
|
|
||||||
private let logger = AppLogger(category: "AbsDownloader")
|
|
||||||
|
|
||||||
private lazy var session: URLSession = {
|
private lazy var session: URLSession = {
|
||||||
let config = URLSessionConfiguration.background(withIdentifier: "AbsDownloader")
|
let config = URLSessionConfiguration.background(withIdentifier: "AbsDownloader")
|
||||||
let queue = OperationQueue()
|
let queue = OperationQueue()
|
||||||
@@ -31,31 +29,69 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
|
|||||||
private var downloadItemProgress = [String: DownloadItem]()
|
private var downloadItemProgress = [String: DownloadItem]()
|
||||||
private var monitoringProgressTimer: Timer?
|
private var monitoringProgressTimer: Timer?
|
||||||
|
|
||||||
|
// Download queue management
|
||||||
|
private let downloadQueueLock = NSLock()
|
||||||
|
private var pendingDownloadTasks: [DownloadItemPartTask] = []
|
||||||
|
private var activeDownloadTasks: Set<String> = [] // Track active task IDs
|
||||||
|
private let maxConcurrentDownloads = 3
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - Download Queue Management
|
||||||
|
|
||||||
|
private func startNextDownloadInQueue() {
|
||||||
|
downloadQueueLock.lock()
|
||||||
|
defer { downloadQueueLock.unlock() }
|
||||||
|
|
||||||
|
// Start downloads up to the max concurrent limit
|
||||||
|
while activeDownloadTasks.count < maxConcurrentDownloads && !pendingDownloadTasks.isEmpty {
|
||||||
|
let nextTask = pendingDownloadTasks.removeFirst()
|
||||||
|
activeDownloadTasks.insert(nextTask.partId)
|
||||||
|
AbsLogger.info(message: "Starting download for \(nextTask.filename) (\(activeDownloadTasks.count)/\(maxConcurrentDownloads) active, \(pendingDownloadTasks.count) pending)")
|
||||||
|
nextTask.task.resume()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func markDownloadTaskCompleted(_ taskId: String) {
|
||||||
|
downloadQueueLock.lock()
|
||||||
|
activeDownloadTasks.remove(taskId)
|
||||||
|
downloadQueueLock.unlock()
|
||||||
|
|
||||||
|
// Try to start the next download
|
||||||
|
startNextDownloadInQueue()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// MARK: - Progress handling
|
// MARK: - Progress handling
|
||||||
|
|
||||||
public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
|
public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
|
||||||
handleDownloadTaskUpdate(downloadTask: downloadTask) { downloadItem, downloadItemPart in
|
handleDownloadTaskUpdate(downloadTask: downloadTask) { downloadItem, downloadItemPart in
|
||||||
let realm = try Realm()
|
let realm = try Realm()
|
||||||
|
let partId = downloadItemPart.id
|
||||||
|
|
||||||
|
// Get fresh reference to the object in this realm
|
||||||
|
guard let liveDownloadItemPart = realm.object(ofType: DownloadItemPart.self, forPrimaryKey: partId) else {
|
||||||
|
throw LibraryItemDownloadError.downloadItemPartNotFound
|
||||||
|
}
|
||||||
|
|
||||||
try realm.write {
|
try realm.write {
|
||||||
downloadItemPart.bytesDownloaded = downloadItemPart.fileSize
|
liveDownloadItemPart.bytesDownloaded = liveDownloadItemPart.fileSize
|
||||||
downloadItemPart.progress = 100
|
liveDownloadItemPart.progress = 100
|
||||||
downloadItemPart.completed = true
|
liveDownloadItemPart.completed = true
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
// Move the downloaded file into place
|
// Move the downloaded file into place
|
||||||
guard let destinationUrl = downloadItemPart.destinationURL else {
|
guard let destinationUrl = liveDownloadItemPart.destinationURL else {
|
||||||
throw LibraryItemDownloadError.downloadItemPartDestinationUrlNotDefined
|
throw LibraryItemDownloadError.downloadItemPartDestinationUrlNotDefined
|
||||||
}
|
}
|
||||||
try? FileManager.default.removeItem(at: destinationUrl)
|
try? FileManager.default.removeItem(at: destinationUrl)
|
||||||
try FileManager.default.moveItem(at: location, to: destinationUrl)
|
try FileManager.default.moveItem(at: location, to: destinationUrl)
|
||||||
try realm.write {
|
try realm.write {
|
||||||
downloadItemPart.moved = true
|
liveDownloadItemPart.moved = true
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
try realm.write {
|
try realm.write {
|
||||||
downloadItemPart.failed = true
|
liveDownloadItemPart.failed = true
|
||||||
}
|
}
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
@@ -65,13 +101,26 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
|
|||||||
public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
||||||
handleDownloadTaskUpdate(downloadTask: task) { downloadItem, downloadItemPart in
|
handleDownloadTaskUpdate(downloadTask: task) { downloadItem, downloadItemPart in
|
||||||
if let error = error {
|
if let error = error {
|
||||||
try Realm().write {
|
let realm = try Realm()
|
||||||
downloadItemPart.completed = true
|
let partId = downloadItemPart.id
|
||||||
downloadItemPart.failed = true
|
|
||||||
|
// Get fresh reference to the object in this realm
|
||||||
|
guard let liveDownloadItemPart = realm.object(ofType: DownloadItemPart.self, forPrimaryKey: partId) else {
|
||||||
|
throw LibraryItemDownloadError.downloadItemPartNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
try realm.write {
|
||||||
|
liveDownloadItemPart.completed = true
|
||||||
|
liveDownloadItemPart.failed = true
|
||||||
}
|
}
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mark this task as completed and start the next download in queue
|
||||||
|
if let taskId = task.taskDescription {
|
||||||
|
markDownloadTaskCompleted(taskId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
|
public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
|
||||||
@@ -81,9 +130,17 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
|
|||||||
|
|
||||||
// Only update the progress if we received accurate progress data
|
// Only update the progress if we received accurate progress data
|
||||||
if percentDownloaded >= 0.0 && percentDownloaded <= 100.0 {
|
if percentDownloaded >= 0.0 && percentDownloaded <= 100.0 {
|
||||||
try Realm().write {
|
let realm = try Realm()
|
||||||
downloadItemPart.bytesDownloaded = Double(totalBytesWritten)
|
let partId = downloadItemPart.id
|
||||||
downloadItemPart.progress = percentDownloaded
|
|
||||||
|
// Get fresh reference to the object in this realm
|
||||||
|
guard let liveDownloadItemPart = realm.object(ofType: DownloadItemPart.self, forPrimaryKey: partId) else {
|
||||||
|
throw LibraryItemDownloadError.downloadItemPartNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
try realm.write {
|
||||||
|
liveDownloadItemPart.bytesDownloaded = Double(totalBytesWritten)
|
||||||
|
liveDownloadItemPart.progress = percentDownloaded
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -104,7 +161,7 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
|
|||||||
private func handleDownloadTaskUpdate(downloadTask: URLSessionTask, progressHandler: DownloadProgressHandler) {
|
private func handleDownloadTaskUpdate(downloadTask: URLSessionTask, progressHandler: DownloadProgressHandler) {
|
||||||
do {
|
do {
|
||||||
guard let downloadItemPartId = downloadTask.taskDescription else { throw LibraryItemDownloadError.noTaskDescription }
|
guard let downloadItemPartId = downloadTask.taskDescription else { throw LibraryItemDownloadError.noTaskDescription }
|
||||||
logger.log("Received download update for \(downloadItemPartId)")
|
AbsLogger.info(message: "Received download update for \(downloadItemPartId)")
|
||||||
|
|
||||||
// Find the download item
|
// Find the download item
|
||||||
let downloadItem = Database.shared.getDownloadItem(downloadItemPartId: downloadItemPartId)
|
let downloadItem = Database.shared.getDownloadItem(downloadItemPartId: downloadItemPartId)
|
||||||
@@ -119,7 +176,7 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
|
|||||||
try progressHandler(downloadItem, part)
|
try progressHandler(downloadItem, part)
|
||||||
try? self.notifyListeners("onDownloadItemPartUpdate", data: part.asDictionary())
|
try? self.notifyListeners("onDownloadItemPartUpdate", data: part.asDictionary())
|
||||||
} catch {
|
} catch {
|
||||||
logger.error("Error while processing progress")
|
AbsLogger.error(message: "Error while processing progress")
|
||||||
debugPrint(error)
|
debugPrint(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,7 +187,7 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
|
|||||||
}
|
}
|
||||||
self.notifyDownloadProgress()
|
self.notifyDownloadProgress()
|
||||||
} catch {
|
} catch {
|
||||||
logger.error("DownloadItemError")
|
AbsLogger.error(message: "DownloadItemError")
|
||||||
debugPrint(error)
|
debugPrint(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -138,18 +195,18 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
|
|||||||
// We want to handle updating the UI in the background and throttled so we don't overload the UI with progress updates
|
// We want to handle updating the UI in the background and throttled so we don't overload the UI with progress updates
|
||||||
private func notifyDownloadProgress() {
|
private func notifyDownloadProgress() {
|
||||||
if self.monitoringProgressTimer?.isValid ?? false {
|
if self.monitoringProgressTimer?.isValid ?? false {
|
||||||
logger.log("Already monitoring progress, no need to start timer again")
|
AbsLogger.info(message: "Already monitoring progress, no need to start timer again")
|
||||||
} else {
|
} else {
|
||||||
DispatchQueue.runOnMainQueue {
|
DispatchQueue.runOnMainQueue {
|
||||||
self.monitoringProgressTimer = Timer.scheduledTimer(withTimeInterval: 0.2, repeats: true, block: { [unowned self] t in
|
self.monitoringProgressTimer = Timer.scheduledTimer(withTimeInterval: 0.2, repeats: true, block: { [unowned self] t in
|
||||||
self.logger.log("Starting monitoring download progress...")
|
AbsLogger.info(message: "Starting monitoring download progress...")
|
||||||
|
|
||||||
// Fetch active downloads in a thread-safe way
|
// Fetch active downloads in a thread-safe way
|
||||||
func fetchActiveDownloads() -> [String: DownloadItem]? {
|
func fetchActiveDownloads() -> [String: DownloadItem]? {
|
||||||
self.progressStatusQueue.sync {
|
self.progressStatusQueue.sync {
|
||||||
let activeDownloads = self.downloadItemProgress
|
let activeDownloads = self.downloadItemProgress
|
||||||
if activeDownloads.isEmpty {
|
if activeDownloads.isEmpty {
|
||||||
logger.log("Finishing monitoring download progress...")
|
AbsLogger.info(message: "Finishing monitoring download progress...")
|
||||||
t.invalidate()
|
t.invalidate()
|
||||||
}
|
}
|
||||||
return activeDownloads
|
return activeDownloads
|
||||||
@@ -184,7 +241,7 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
|
|||||||
|
|
||||||
if ( downloadItem.didDownloadSuccessfully() ) {
|
if ( downloadItem.didDownloadSuccessfully() ) {
|
||||||
ApiClient.getLibraryItemWithProgress(libraryItemId: downloadItem.libraryItemId!, episodeId: downloadItem.episodeId) { [weak self] libraryItem in
|
ApiClient.getLibraryItemWithProgress(libraryItemId: downloadItem.libraryItemId!, episodeId: downloadItem.episodeId) { [weak self] libraryItem in
|
||||||
guard let libraryItem = libraryItem else { self?.logger.error("LibraryItem not found"); return }
|
guard let libraryItem = libraryItem else { AbsLogger.error(message: "LibraryItem not found"); return }
|
||||||
let localDirectory = libraryItem.id
|
let localDirectory = libraryItem.id
|
||||||
var coverFile: String?
|
var coverFile: String?
|
||||||
|
|
||||||
@@ -231,12 +288,12 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
|
|||||||
var episodeId = call.getString("episodeId")
|
var episodeId = call.getString("episodeId")
|
||||||
if ( episodeId == "null" ) { episodeId = nil }
|
if ( episodeId == "null" ) { episodeId = nil }
|
||||||
|
|
||||||
logger.log("Download library item \(libraryItemId ?? "N/A") / episode \(episodeId ?? "N/A")")
|
AbsLogger.info(message: "Download library item \(libraryItemId ?? "N/A") / episode \(episodeId ?? "N/A")")
|
||||||
guard let libraryItemId = libraryItemId else { return call.resolve(["error": "libraryItemId not specified"]) }
|
guard let libraryItemId = libraryItemId else { return call.resolve(["error": "libraryItemId not specified"]) }
|
||||||
|
|
||||||
ApiClient.getLibraryItemWithProgress(libraryItemId: libraryItemId, episodeId: episodeId) { [weak self] libraryItem in
|
ApiClient.getLibraryItemWithProgress(libraryItemId: libraryItemId, episodeId: episodeId) { [weak self] libraryItem in
|
||||||
if let libraryItem = libraryItem {
|
if let libraryItem = libraryItem {
|
||||||
self?.logger.log("Got library item from server \(libraryItem.id)")
|
AbsLogger.info(message: "Got library item from server \(libraryItem.id)")
|
||||||
do {
|
do {
|
||||||
if let episodeId = episodeId {
|
if let episodeId = episodeId {
|
||||||
// Download a podcast episode
|
// Download a podcast episode
|
||||||
@@ -310,14 +367,19 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
|
|||||||
// Persist in the database before status start coming in
|
// Persist in the database before status start coming in
|
||||||
try Database.shared.saveDownloadItem(downloadItem)
|
try Database.shared.saveDownloadItem(downloadItem)
|
||||||
|
|
||||||
// Start all the downloads
|
// Add all tasks to the download queue
|
||||||
for task in tasks {
|
downloadQueueLock.lock()
|
||||||
task.task.resume()
|
pendingDownloadTasks.append(contentsOf: tasks)
|
||||||
}
|
downloadQueueLock.unlock()
|
||||||
|
|
||||||
|
AbsLogger.info(message: "Added \(tasks.count) tasks to download queue. Starting downloads...")
|
||||||
|
|
||||||
|
// Start downloading (up to maxConcurrentDownloads at a time)
|
||||||
|
startNextDownloadInQueue()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startLibraryItemTrackDownload(downloadItemId: String, item: LibraryItem, position: Int, track: AudioTrack, episode: PodcastEpisode?) throws -> DownloadItemPartTask {
|
private func startLibraryItemTrackDownload(downloadItemId: String, item: LibraryItem, position: Int, track: AudioTrack, episode: PodcastEpisode?) throws -> DownloadItemPartTask {
|
||||||
logger.log("TRACK \(track.contentUrl!)")
|
AbsLogger.info(message: "TRACK \(track.contentUrl!)")
|
||||||
|
|
||||||
// If we don't name metadata, then we can't proceed
|
// If we don't name metadata, then we can't proceed
|
||||||
guard let filename = track.metadata?.filename else {
|
guard let filename = track.metadata?.filename else {
|
||||||
@@ -334,7 +396,7 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
|
|||||||
// Store the id on the task so the download item can be pulled from the database later
|
// Store the id on the task so the download item can be pulled from the database later
|
||||||
task.taskDescription = part.id
|
task.taskDescription = part.id
|
||||||
|
|
||||||
return DownloadItemPartTask(part: part, task: task)
|
return DownloadItemPartTask(part: part, task: task, partId: part.id, filename: filename)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startLibraryItemEbookDownload(downloadItemId: String, item: LibraryItem, ebookFile: EBookFile) throws -> DownloadItemPartTask {
|
private func startLibraryItemEbookDownload(downloadItemId: String, item: LibraryItem, ebookFile: EBookFile) throws -> DownloadItemPartTask {
|
||||||
@@ -349,7 +411,7 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
|
|||||||
// Store the id on the task so the download item can be pulled from the database later
|
// Store the id on the task so the download item can be pulled from the database later
|
||||||
task.taskDescription = part.id
|
task.taskDescription = part.id
|
||||||
|
|
||||||
return DownloadItemPartTask(part: part, task: task)
|
return DownloadItemPartTask(part: part, task: task, partId: part.id, filename: filename)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startLibraryItemCoverDownload(downloadItemId: String, item: LibraryItem) throws -> DownloadItemPartTask {
|
private func startLibraryItemCoverDownload(downloadItemId: String, item: LibraryItem) throws -> DownloadItemPartTask {
|
||||||
@@ -369,7 +431,7 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
|
|||||||
// Store the id on the task so the download item can be pulled from the database later
|
// Store the id on the task so the download item can be pulled from the database later
|
||||||
task.taskDescription = part.id
|
task.taskDescription = part.id
|
||||||
|
|
||||||
return DownloadItemPartTask(part: part, task: task)
|
return DownloadItemPartTask(part: part, task: task, partId: part.id, filename: filename)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func urlForTrack(item: LibraryItem, track: AudioTrack) -> URL {
|
private func urlForTrack(item: LibraryItem, track: AudioTrack) -> URL {
|
||||||
@@ -393,10 +455,10 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
|
|||||||
|
|
||||||
private func createLibraryItemFileDirectory(item: LibraryItem) throws -> String {
|
private func createLibraryItemFileDirectory(item: LibraryItem) throws -> String {
|
||||||
let itemDirectory = item.id
|
let itemDirectory = item.id
|
||||||
logger.log("ITEM DIR \(itemDirectory)")
|
AbsLogger.info(message: "ITEM DIR \(itemDirectory)")
|
||||||
|
|
||||||
guard AbsDownloader.itemDownloadFolder(path: itemDirectory) != nil else {
|
guard AbsDownloader.itemDownloadFolder(path: itemDirectory) != nil else {
|
||||||
logger.error("Failed to CREATE LI DIRECTORY \(itemDirectory)")
|
AbsLogger.error(message: "Failed to CREATE LI DIRECTORY \(itemDirectory)")
|
||||||
throw LibraryItemDownloadError.failedDirectory
|
throw LibraryItemDownloadError.failedDirectory
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -418,7 +480,7 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
|
|||||||
|
|
||||||
return itemFolder
|
return itemFolder
|
||||||
} catch {
|
} catch {
|
||||||
AppLogger().error("Failed to CREATE LI DIRECTORY \(error)")
|
AbsLogger.error(message: "Failed to CREATE LI DIRECTORY \(error)", error: error)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -433,6 +495,8 @@ typealias DownloadProgressHandler = (_ downloadItem: DownloadItem, _ downloadIte
|
|||||||
struct DownloadItemPartTask {
|
struct DownloadItemPartTask {
|
||||||
let part: DownloadItemPart
|
let part: DownloadItemPart
|
||||||
let task: URLSessionDownloadTask
|
let task: URLSessionDownloadTask
|
||||||
|
let partId: String // Cache the ID to avoid cross-thread Realm access
|
||||||
|
let filename: String // Cache the filename to avoid cross-thread Realm access
|
||||||
}
|
}
|
||||||
|
|
||||||
enum LibraryItemDownloadError: String, Error {
|
enum LibraryItemDownloadError: String, Error {
|
||||||
|
|||||||
@@ -23,12 +23,10 @@ public class AbsFileSystem: CAPPlugin, CAPBridgedPlugin {
|
|||||||
CAPPluginMethod(name: "deleteTrackFromItem", returnType: CAPPluginReturnPromise)
|
CAPPluginMethod(name: "deleteTrackFromItem", returnType: CAPPluginReturnPromise)
|
||||||
]
|
]
|
||||||
|
|
||||||
private let logger = AppLogger(category: "AbsFileSystem")
|
|
||||||
|
|
||||||
@objc func selectFolder(_ call: CAPPluginCall) {
|
@objc func selectFolder(_ call: CAPPluginCall) {
|
||||||
let mediaType = call.getString("mediaType")
|
let mediaType = call.getString("mediaType")
|
||||||
|
|
||||||
logger.log("Select Folder for media type \(mediaType ?? "UNSET")")
|
AbsLogger.info(message: "Select Folder for media type \(mediaType ?? "UNSET")")
|
||||||
|
|
||||||
call.unavailable("Not available on iOS")
|
call.unavailable("Not available on iOS")
|
||||||
}
|
}
|
||||||
@@ -36,7 +34,7 @@ public class AbsFileSystem: CAPPlugin, CAPBridgedPlugin {
|
|||||||
@objc func checkFolderPermission(_ call: CAPPluginCall) {
|
@objc func checkFolderPermission(_ call: CAPPluginCall) {
|
||||||
let folderUrl = call.getString("folderUrl")
|
let folderUrl = call.getString("folderUrl")
|
||||||
|
|
||||||
logger.log("checkFolderPermission for folder \(folderUrl ?? "UNSET")")
|
AbsLogger.info(message: "checkFolderPermission for folder \(folderUrl ?? "UNSET")")
|
||||||
|
|
||||||
call.unavailable("Not available on iOS")
|
call.unavailable("Not available on iOS")
|
||||||
}
|
}
|
||||||
@@ -45,7 +43,7 @@ public class AbsFileSystem: CAPPlugin, CAPBridgedPlugin {
|
|||||||
let folderId = call.getString("folderId")
|
let folderId = call.getString("folderId")
|
||||||
let forceAudioProbe = call.getBool("forceAudioProbe", false)
|
let forceAudioProbe = call.getBool("forceAudioProbe", false)
|
||||||
|
|
||||||
logger.log("scanFolder \(folderId ?? "UNSET") | Force Probe = \(forceAudioProbe)")
|
AbsLogger.info(message: "scanFolder \(folderId ?? "UNSET") | Force Probe = \(forceAudioProbe)")
|
||||||
|
|
||||||
call.unavailable("Not available on iOS")
|
call.unavailable("Not available on iOS")
|
||||||
}
|
}
|
||||||
@@ -53,7 +51,7 @@ public class AbsFileSystem: CAPPlugin, CAPBridgedPlugin {
|
|||||||
@objc func removeFolder(_ call: CAPPluginCall) {
|
@objc func removeFolder(_ call: CAPPluginCall) {
|
||||||
let folderId = call.getString("folderId")
|
let folderId = call.getString("folderId")
|
||||||
|
|
||||||
logger.log("removeFolder \(folderId ?? "UNSET")")
|
AbsLogger.info(message: "removeFolder \(folderId ?? "UNSET")")
|
||||||
|
|
||||||
call.unavailable("Not available on iOS")
|
call.unavailable("Not available on iOS")
|
||||||
}
|
}
|
||||||
@@ -61,7 +59,7 @@ public class AbsFileSystem: CAPPlugin, CAPBridgedPlugin {
|
|||||||
@objc func removeLocalLibraryItem(_ call: CAPPluginCall) {
|
@objc func removeLocalLibraryItem(_ call: CAPPluginCall) {
|
||||||
let localLibraryItemId = call.getString("localLibraryItemId")
|
let localLibraryItemId = call.getString("localLibraryItemId")
|
||||||
|
|
||||||
logger.log("removeLocalLibraryItem \(localLibraryItemId ?? "UNSET")")
|
AbsLogger.info(message: "removeLocalLibraryItem \(localLibraryItemId ?? "UNSET")")
|
||||||
|
|
||||||
call.unavailable("Not available on iOS")
|
call.unavailable("Not available on iOS")
|
||||||
}
|
}
|
||||||
@@ -70,7 +68,7 @@ public class AbsFileSystem: CAPPlugin, CAPBridgedPlugin {
|
|||||||
let localLibraryItemId = call.getString("localLibraryItemId")
|
let localLibraryItemId = call.getString("localLibraryItemId")
|
||||||
let forceAudioProbe = call.getBool("forceAudioProbe", false)
|
let forceAudioProbe = call.getBool("forceAudioProbe", false)
|
||||||
|
|
||||||
logger.log("scanLocalLibraryItem \(localLibraryItemId ?? "UNSET") | Force Probe = \(forceAudioProbe)")
|
AbsLogger.info(message: "scanLocalLibraryItem \(localLibraryItemId ?? "UNSET") | Force Probe = \(forceAudioProbe)")
|
||||||
|
|
||||||
call.unavailable("Not available on iOS")
|
call.unavailable("Not available on iOS")
|
||||||
}
|
}
|
||||||
@@ -79,7 +77,7 @@ public class AbsFileSystem: CAPPlugin, CAPBridgedPlugin {
|
|||||||
let localLibraryItemId = call.getString("id")
|
let localLibraryItemId = call.getString("id")
|
||||||
let contentUrl = call.getString("contentUrl")
|
let contentUrl = call.getString("contentUrl")
|
||||||
|
|
||||||
logger.log("deleteItem \(localLibraryItemId ?? "UNSET") url \(contentUrl ?? "UNSET")")
|
AbsLogger.info(message: "deleteItem \(localLibraryItemId ?? "UNSET") url \(contentUrl ?? "UNSET")")
|
||||||
|
|
||||||
var success = false
|
var success = false
|
||||||
do {
|
do {
|
||||||
@@ -89,7 +87,7 @@ public class AbsFileSystem: CAPPlugin, CAPBridgedPlugin {
|
|||||||
success = true
|
success = true
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
logger.error("Failed to delete \(error)")
|
AbsLogger.error(message: "Failed to delete \(error)")
|
||||||
success = false
|
success = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,7 +98,7 @@ public class AbsFileSystem: CAPPlugin, CAPBridgedPlugin {
|
|||||||
let localLibraryItemId = call.getString("id")
|
let localLibraryItemId = call.getString("id")
|
||||||
let trackLocalFileId = call.getString("trackLocalFileId")
|
let trackLocalFileId = call.getString("trackLocalFileId")
|
||||||
|
|
||||||
logger.log("deleteTrackFromItem \(localLibraryItemId ?? "UNSET") track file \(trackLocalFileId ?? "UNSET")")
|
AbsLogger.info(message: "deleteTrackFromItem \(localLibraryItemId ?? "UNSET") track file \(trackLocalFileId ?? "UNSET")")
|
||||||
|
|
||||||
var success = false
|
var success = false
|
||||||
if let localLibraryItemId = localLibraryItemId, let trackLocalFileId = trackLocalFileId, let item = Database.shared.getLocalLibraryItem(localLibraryItemId: localLibraryItemId) {
|
if let localLibraryItemId = localLibraryItemId, let trackLocalFileId = trackLocalFileId, let item = Database.shared.getLocalLibraryItem(localLibraryItemId: localLibraryItemId) {
|
||||||
@@ -120,12 +118,12 @@ public class AbsFileSystem: CAPPlugin, CAPBridgedPlugin {
|
|||||||
success = true
|
success = true
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
logger.error("Failed to delete \(error)")
|
AbsLogger.error(message: "Failed to delete \(error)")
|
||||||
success = false
|
success = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
logger.error("Failed to delete \(error)")
|
AbsLogger.error(message: "Failed to delete \(error)")
|
||||||
success = false
|
success = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,16 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Capacitor
|
import Capacitor
|
||||||
|
|
||||||
|
enum AbsLogLevel {
|
||||||
|
case info
|
||||||
|
case warn
|
||||||
|
case error
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileName(file: String = #file) -> String {
|
||||||
|
return (file as NSString).lastPathComponent
|
||||||
|
}
|
||||||
|
|
||||||
@objc(AbsLogger)
|
@objc(AbsLogger)
|
||||||
public class AbsLogger: CAPPlugin, CAPBridgedPlugin {
|
public class AbsLogger: CAPPlugin, CAPBridgedPlugin {
|
||||||
public var identifier = "AbsLoggerPlugin"
|
public var identifier = "AbsLoggerPlugin"
|
||||||
@@ -19,29 +29,96 @@ public class AbsLogger: CAPPlugin, CAPBridgedPlugin {
|
|||||||
CAPPluginMethod(name: "clearLogs", returnType: CAPPluginReturnPromise)
|
CAPPluginMethod(name: "clearLogs", returnType: CAPPluginReturnPromise)
|
||||||
]
|
]
|
||||||
|
|
||||||
private let logger = AppLogger(category: "AbsLogger")
|
private static let shared: AbsLogger = {
|
||||||
|
AbsLogger()
|
||||||
|
}()
|
||||||
|
|
||||||
|
public static func info(_ tag: String = #file, message: String) {
|
||||||
|
try? shared.info(tag: fileName(file: tag), message: message)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func error(_ tag: String = #file, message: String, error: Error? = nil) {
|
||||||
|
try? shared.error(tag: fileName(file: tag), message: message)
|
||||||
|
}
|
||||||
|
|
||||||
|
private let loggers: Dictionary<String, AppLogger> = [:]
|
||||||
|
|
||||||
|
private func _logger(_ tag: String) -> AppLogger {
|
||||||
|
return loggers[tag, default: AppLogger(category: tag)]
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveLogEntry(_ level: AbsLogLevel, tag: String, message: String) throws {
|
||||||
|
let entry = LogEntry()
|
||||||
|
entry.tag = tag
|
||||||
|
entry.message = message
|
||||||
|
entry.level = "\(level)"
|
||||||
|
entry.timestamp = Int(Date().timeIntervalSince1970 * 1000)
|
||||||
|
try Database.shared.saveLog(entry)
|
||||||
|
|
||||||
|
self.notifyListeners("onLog", data: [
|
||||||
|
"id": entry.id,
|
||||||
|
"tag": entry.tag,
|
||||||
|
"message": entry.message,
|
||||||
|
"level": entry.level,
|
||||||
|
"timestamp": entry.timestamp
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
public func info(tag: String = "\(#file)", message: String) throws {
|
||||||
|
_logger(tag).log("[\(tag)] \(message)")
|
||||||
|
try saveLogEntry(.info, tag: tag, message: message)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func error(tag: String = "\(#file)", message: String, error: Error? = nil) throws {
|
||||||
|
_logger(tag).error("[\(tag)] \(message)")
|
||||||
|
if let error = error { _logger(tag).error(error) }
|
||||||
|
try saveLogEntry(.error, tag: tag, message: message)
|
||||||
|
}
|
||||||
|
|
||||||
@objc func info(_ call: CAPPluginCall) {
|
@objc func info(_ call: CAPPluginCall) {
|
||||||
let message = call.getString("message") ?? ""
|
let message = call.getString("message") ?? ""
|
||||||
let tag = call.getString("tag") ?? ""
|
let tag = call.getString("tag") ?? ""
|
||||||
|
|
||||||
logger.log("[\(tag)] \(message)")
|
do {
|
||||||
|
try info(tag: tag, message: message)
|
||||||
call.resolve()
|
call.resolve()
|
||||||
|
} catch {
|
||||||
|
call.reject("Failed to log \(message)", "101", error)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func error(_ call: CAPPluginCall) {
|
@objc func error(_ call: CAPPluginCall) {
|
||||||
let message = call.getString("message") ?? ""
|
let message = call.getString("message") ?? ""
|
||||||
let tag = call.getString("tag") ?? ""
|
let tag = call.getString("tag") ?? ""
|
||||||
|
|
||||||
logger.error("[\(tag)] \(message)")
|
do {
|
||||||
|
try error(tag: tag, message: message)
|
||||||
call.resolve()
|
call.resolve()
|
||||||
|
} catch {
|
||||||
|
call.reject("Failed to log \(message)", "101", error)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func getAllLogs(_ call: CAPPluginCall) {
|
@objc func getAllLogs(_ call: CAPPluginCall) {
|
||||||
call.unimplemented("Not implemented on iOS")
|
do {
|
||||||
|
let logs = Database.shared.getAllLogs()
|
||||||
|
call.resolve([ "value": try logs.asDictionaryArray()])
|
||||||
|
} catch {
|
||||||
|
call.reject("Failed to get logs", "100", error)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func clearLogs(_ call: CAPPluginCall) {
|
@objc func clearLogs(_ call: CAPPluginCall) {
|
||||||
call.unimplemented("Not implemented on iOS")
|
do {
|
||||||
|
try Database.shared.clearLogs()
|
||||||
|
call.resolve()
|
||||||
|
} catch {
|
||||||
|
call.reject("Failed to clear logs", "100", error)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,11 +25,11 @@ PODS:
|
|||||||
- Capacitor
|
- Capacitor
|
||||||
- CordovaPlugins (6.2.1):
|
- CordovaPlugins (6.2.1):
|
||||||
- CapacitorCordova
|
- CapacitorCordova
|
||||||
- Realm (10.54.4):
|
- Realm (10.54.6):
|
||||||
- Realm/Headers (= 10.54.4)
|
- Realm/Headers (= 10.54.6)
|
||||||
- Realm/Headers (10.54.4)
|
- Realm/Headers (10.54.6)
|
||||||
- RealmSwift (10.54.4):
|
- RealmSwift (10.54.6):
|
||||||
- Realm (= 10.54.4)
|
- Realm (= 10.54.6)
|
||||||
- WebnativellcCapacitorFilesharer (7.0.4):
|
- WebnativellcCapacitorFilesharer (7.0.4):
|
||||||
- Capacitor
|
- Capacitor
|
||||||
|
|
||||||
@@ -89,22 +89,22 @@ EXTERNAL SOURCES:
|
|||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
Alamofire: 7193b3b92c74a07f85569e1a6c4f4237291e7496
|
Alamofire: 7193b3b92c74a07f85569e1a6c4f4237291e7496
|
||||||
Capacitor: 106e7a4205f4618d582b886a975657c61179138d
|
Capacitor: 03bc7cbdde6a629a8b910a9d7d78c3cc7ed09ea7
|
||||||
CapacitorApp: d63334c052278caf5d81585d80b21905c6f93f39
|
CapacitorApp: febecbb9582cb353aed037e18ec765141f880fe9
|
||||||
CapacitorBrowser: 081852cf532acf77b9d2953f3a88fe5b9711fb06
|
CapacitorBrowser: 6299776d496e968505464884d565992faa20444a
|
||||||
CapacitorClipboard: b98aead5dc7ec595547fc2c5d75bacd2ae3338bc
|
CapacitorClipboard: 70bfdb42b877b320a6e511ab94fa7a6a55d57ecb
|
||||||
CapacitorCommunityKeepAwake: 00dfd8fa3cca0df003c9a3e2cd7bee678aeec68b
|
CapacitorCommunityKeepAwake: ae762ce29b53147d28cfcaae5273cd1db0c38fc4
|
||||||
CapacitorCommunityVolumeButtons: 8a0443a202ed659688d85f4d44d66f42f62f2b56
|
CapacitorCommunityVolumeButtons: 1b84f7abf29cd9476cef9e8979b2854a64d2eed5
|
||||||
CapacitorCordova: 5967b9ba03915ef1d585469d6e31f31dc49be96f
|
CapacitorCordova: 5967b9ba03915ef1d585469d6e31f31dc49be96f
|
||||||
CapacitorDialog: 9b934329026b2b0ffa56939bb06df3c67541a2ab
|
CapacitorDialog: 0e09f242f6c3f5e82e4dc76b20f2a056be57a579
|
||||||
CapacitorHaptics: 70e47470fa1a6bd6338cd102552e3846b7f9a1b3
|
CapacitorHaptics: 1f1e17041f435d8ead9ff2a34edd592c6aa6a8d6
|
||||||
CapacitorNetwork: 07ec4c69c1bb696f41c23e00d31bda1bbb221bba
|
CapacitorNetwork: 15cb4385f0913a8ceb5e9a4d7af1ec554bdb8de8
|
||||||
CapacitorPreferences: cbf154e5e5519b7f5ab33817a334dda1e98387f9
|
CapacitorPreferences: 6c98117d4d7508034a4af9db64d6b26fc75d7b94
|
||||||
CapacitorStatusBar: 275cbf2f4dfc00388f519ef80c7ec22edda342c9
|
CapacitorStatusBar: 6e7af040d8fc4dd655999819625cae9c2d74c36f
|
||||||
CordovaPlugins: 5a72a85b45469e68556bb172409f1b6d57b27236
|
CordovaPlugins: 2ecbba09775516c41764dbf78ade612427311b7e
|
||||||
Realm: 8b5cda39a41f17a1734da2f39c6004eb8745587a
|
Realm: b1b3bc68162fa242132eb7eefbf91d7c40f36a85
|
||||||
RealmSwift: 0b4f808fed6898f1f6c26f501f740efd80dff0b4
|
RealmSwift: 456cfd82a4f23dff8e3456980999331ab69bbf3e
|
||||||
WebnativellcCapacitorFilesharer: 10b111373d4dc49608935600dcbcc14605258c73
|
WebnativellcCapacitorFilesharer: e3a5930240633db3335040251d66aac6762ff111
|
||||||
|
|
||||||
PODFILE CHECKSUM: 498821c0cfa2508609567fa95d7244c01cbef538
|
PODFILE CHECKSUM: 498821c0cfa2508609567fa95d7244c01cbef538
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
//
|
||||||
|
// LogEntry.swift
|
||||||
|
// Audiobookshelf
|
||||||
|
//
|
||||||
|
// Created by Christopher Jensen-Reimann on 9/3/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import RealmSwift
|
||||||
|
|
||||||
|
class LogEntry: Object, Codable {
|
||||||
|
@Persisted(primaryKey: true) var id: String = UUID().uuidString
|
||||||
|
@Persisted var tag: String = ""
|
||||||
|
@Persisted var level: String = ""
|
||||||
|
@Persisted var message: String = ""
|
||||||
|
@Persisted var timestamp: Int = 0
|
||||||
|
}
|
||||||
@@ -26,7 +26,6 @@ enum PlayerStatus: Int {
|
|||||||
|
|
||||||
class AudioPlayer: NSObject {
|
class AudioPlayer: NSObject {
|
||||||
internal let queue = DispatchQueue(label: "ABSAudioPlayerQueue")
|
internal let queue = DispatchQueue(label: "ABSAudioPlayerQueue")
|
||||||
internal let logger = AppLogger(category: "AudioPlayer")
|
|
||||||
|
|
||||||
private var status: PlayerStatus
|
private var status: PlayerStatus
|
||||||
internal var rateManager: AudioPlayerRateManager
|
internal var rateManager: AudioPlayerRateManager
|
||||||
@@ -81,7 +80,7 @@ class AudioPlayer: NSObject {
|
|||||||
|
|
||||||
let playbackSession = self.getPlaybackSession()
|
let playbackSession = self.getPlaybackSession()
|
||||||
guard let playbackSession = playbackSession else {
|
guard let playbackSession = playbackSession else {
|
||||||
logger.error("Failed to fetch playback session. Player will not initialize")
|
AbsLogger.error(message:"Failed to fetch playback session. Player will not initialize")
|
||||||
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.failed.rawValue), object: nil)
|
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.failed.rawValue), object: nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -115,10 +114,10 @@ class AudioPlayer: NSObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.currentTrackIndex = getItemIndexForTime(time: playbackSession.currentTime)
|
self.currentTrackIndex = getItemIndexForTime(time: playbackSession.currentTime)
|
||||||
logger.log("Starting track index \(self.currentTrackIndex) for start time \(playbackSession.currentTime)")
|
AbsLogger.info(message:"Starting track index \(self.currentTrackIndex) for start time \(playbackSession.currentTime)")
|
||||||
|
|
||||||
let playerItems = self.allPlayerItems[self.currentTrackIndex..<self.allPlayerItems.count]
|
let playerItems = self.allPlayerItems[self.currentTrackIndex..<self.allPlayerItems.count]
|
||||||
logger.log("Setting player items \(playerItems.count)")
|
AbsLogger.info(message:"Setting player items \(playerItems.count)")
|
||||||
|
|
||||||
for item in Array(playerItems) {
|
for item in Array(playerItems) {
|
||||||
self.audioPlayer.insert(item, after:self.audioPlayer.items().last)
|
self.audioPlayer.insert(item, after:self.audioPlayer.items().last)
|
||||||
@@ -128,7 +127,7 @@ class AudioPlayer: NSObject {
|
|||||||
setupQueueObserver()
|
setupQueueObserver()
|
||||||
setupQueueItemStatusObserver()
|
setupQueueItemStatusObserver()
|
||||||
|
|
||||||
logger.log("Audioplayer ready")
|
AbsLogger.info(message:"Audioplayer ready")
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
@@ -146,8 +145,7 @@ class AudioPlayer: NSObject {
|
|||||||
do {
|
do {
|
||||||
try AVAudioSession.sharedInstance().setActive(false)
|
try AVAudioSession.sharedInstance().setActive(false)
|
||||||
} catch {
|
} catch {
|
||||||
logger.error("Failed to set AVAudioSession inactive")
|
AbsLogger.error(message: "Failed to set AVAudioSession inactive", error: error)
|
||||||
logger.error(error)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
self.removeAudioSessionNotifications()
|
self.removeAudioSessionNotifications()
|
||||||
@@ -253,14 +251,14 @@ class AudioPlayer: NSObject {
|
|||||||
self.audioPlayer.currentItem.map { item in
|
self.audioPlayer.currentItem.map { item in
|
||||||
self.currentTrackIndex = self.allPlayerItems.firstIndex(of:item) ?? 0
|
self.currentTrackIndex = self.allPlayerItems.firstIndex(of:item) ?? 0
|
||||||
if (self.currentTrackIndex != prevTrackIndex) {
|
if (self.currentTrackIndex != prevTrackIndex) {
|
||||||
self.logger.log("New Current track index \(self.currentTrackIndex)")
|
AbsLogger.info(message:"New Current track index \(self.currentTrackIndex)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupQueueItemStatusObserver() {
|
private func setupQueueItemStatusObserver() {
|
||||||
logger.log("queueStatusObserver: Setting up")
|
AbsLogger.info(message:"queueStatusObserver: Setting up")
|
||||||
|
|
||||||
// Listen for player item updates
|
// Listen for player item updates
|
||||||
self.queueItemStatusObserver?.invalidate()
|
self.queueItemStatusObserver?.invalidate()
|
||||||
@@ -275,13 +273,13 @@ class AudioPlayer: NSObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func handleQueueItemStatus(playerItem: AVPlayerItem) {
|
private func handleQueueItemStatus(playerItem: AVPlayerItem) {
|
||||||
logger.log("queueStatusObserver: Current item status changed")
|
AbsLogger.info(message:"queueStatusObserver: Current item status changed")
|
||||||
guard let playbackSession = self.getPlaybackSession() else {
|
guard let playbackSession = self.getPlaybackSession() else {
|
||||||
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.failed.rawValue), object: nil)
|
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.failed.rawValue), object: nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (playerItem.status == .readyToPlay) {
|
if (playerItem.status == .readyToPlay) {
|
||||||
logger.log("queueStatusObserver: Current Item Ready to play. PlayWhenReady: \(self.playWhenReady)")
|
AbsLogger.info(message:"queueStatusObserver: Current Item Ready to play. PlayWhenReady: \(self.playWhenReady)")
|
||||||
|
|
||||||
// Seek the player before initializing, so a currentTime of 0 does not appear in MediaProgress / session
|
// Seek the player before initializing, so a currentTime of 0 does not appear in MediaProgress / session
|
||||||
let firstReady = self.status == .uninitialized
|
let firstReady = self.status == .uninitialized
|
||||||
@@ -300,7 +298,7 @@ class AudioPlayer: NSObject {
|
|||||||
self.status = .paused
|
self.status = .paused
|
||||||
}
|
}
|
||||||
} else if (playerItem.status == .failed) {
|
} else if (playerItem.status == .failed) {
|
||||||
logger.error("queueStatusObserver: FAILED \(playerItem.error?.localizedDescription ?? "")")
|
AbsLogger.error(message:"queueStatusObserver: FAILED \(playerItem.error?.localizedDescription ?? "")")
|
||||||
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.failed.rawValue), object: nil)
|
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.failed.rawValue), object: nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -332,7 +330,7 @@ class AudioPlayer: NSObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func resumePlayback() {
|
private func resumePlayback() {
|
||||||
logger.log("PLAY: Resuming playback")
|
AbsLogger.info(message:"PLAY: Resuming playback")
|
||||||
|
|
||||||
self.markAudioSessionAs(active: true)
|
self.markAudioSessionAs(active: true)
|
||||||
DispatchQueue.runOnMainQueue {
|
DispatchQueue.runOnMainQueue {
|
||||||
@@ -351,7 +349,7 @@ class AudioPlayer: NSObject {
|
|||||||
public func pause() {
|
public func pause() {
|
||||||
guard self.isInitialized() else { return }
|
guard self.isInitialized() else { return }
|
||||||
|
|
||||||
logger.log("PAUSE: Pausing playback")
|
AbsLogger.info(message:"PAUSE: Pausing playback")
|
||||||
DispatchQueue.runOnMainQueue {
|
DispatchQueue.runOnMainQueue {
|
||||||
self.audioPlayer.pause()
|
self.audioPlayer.pause()
|
||||||
}
|
}
|
||||||
@@ -371,7 +369,7 @@ class AudioPlayer: NSObject {
|
|||||||
public func startFadeOut() {
|
public func startFadeOut() {
|
||||||
guard self.isInitialized() else { return }
|
guard self.isInitialized() else { return }
|
||||||
guard let currentTime = self.getCurrentTime() else { return }
|
guard let currentTime = self.getCurrentTime() else { return }
|
||||||
logger.log("fadeOut: Fading out playback")
|
AbsLogger.info(message:"fadeOut: Fading out playback")
|
||||||
|
|
||||||
// Define fade parameters.
|
// Define fade parameters.
|
||||||
let fadeDuration: Float = 60.0 // total fade duration in seconds
|
let fadeDuration: Float = 60.0 // total fade duration in seconds
|
||||||
@@ -409,7 +407,7 @@ class AudioPlayer: NSObject {
|
|||||||
// Ensure volume is exactly zero and end fade.
|
// Ensure volume is exactly zero and end fade.
|
||||||
self.audioPlayer.volume = targetVolume
|
self.audioPlayer.volume = targetVolume
|
||||||
t.invalidate()
|
t.invalidate()
|
||||||
self.logger.log("Fadeout: Fade complete, pausing playback")
|
AbsLogger.info(message:"Fadeout: Fade complete, pausing playback")
|
||||||
self.pause()
|
self.pause()
|
||||||
self.audioPlayer.volume = initialVolume
|
self.audioPlayer.volume = initialVolume
|
||||||
self.seek(currentTime, from: "fadeOut")
|
self.seek(currentTime, from: "fadeOut")
|
||||||
@@ -419,12 +417,12 @@ class AudioPlayer: NSObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public func seek(_ to: Double, from: String) {
|
public func seek(_ to: Double, from: String) {
|
||||||
logger.log("SEEK: Seek to \(to) from \(from)")
|
AbsLogger.info(message:"SEEK: Seek to \(to) from \(from)")
|
||||||
|
|
||||||
guard let playbackSession = self.getPlaybackSession() else { return }
|
guard let playbackSession = self.getPlaybackSession() else { return }
|
||||||
|
|
||||||
let indexOfSeek = getItemIndexForTime(time: to)
|
let indexOfSeek = getItemIndexForTime(time: to)
|
||||||
logger.log("SEEK: Seek to index \(indexOfSeek) | Current index \(self.currentTrackIndex)")
|
AbsLogger.info(message:"SEEK: Seek to index \(indexOfSeek) | Current index \(self.currentTrackIndex)")
|
||||||
|
|
||||||
if self.audioPlayer.currentItem == nil {
|
if self.audioPlayer.currentItem == nil {
|
||||||
self.currentTrackIndex = indexOfSeek
|
self.currentTrackIndex = indexOfSeek
|
||||||
@@ -488,16 +486,16 @@ class AudioPlayer: NSObject {
|
|||||||
let currentTrack = playbackSession.audioTracks[self.currentTrackIndex]
|
let currentTrack = playbackSession.audioTracks[self.currentTrackIndex]
|
||||||
let ctso = currentTrack.startOffset ?? 0.0
|
let ctso = currentTrack.startOffset ?? 0.0
|
||||||
let trackEnd = ctso + currentTrack.duration
|
let trackEnd = ctso + currentTrack.duration
|
||||||
logger.log("SEEK: Seeking in current item \(to) (track START = \(ctso) END = \(trackEnd))")
|
AbsLogger.info(message:"SEEK: Seeking in current item \(to) (track START = \(ctso) END = \(trackEnd))")
|
||||||
|
|
||||||
let boundedTime = min(max(to, ctso), trackEnd)
|
let boundedTime = min(max(to, ctso), trackEnd)
|
||||||
let seekTime = boundedTime - ctso
|
let seekTime = boundedTime - ctso
|
||||||
|
|
||||||
DispatchQueue.runOnMainQueue {
|
DispatchQueue.runOnMainQueue {
|
||||||
self.audioPlayer.seek(to: CMTime(seconds: seekTime, preferredTimescale: 1000)) { [weak self] completed in
|
self.audioPlayer.seek(to: CMTime(seconds: seekTime, preferredTimescale: 1000)) { [weak self] completed in
|
||||||
self?.logger.log("SEEK: Completion handler called")
|
AbsLogger.info(message:"SEEK: Completion handler called")
|
||||||
guard completed else {
|
guard completed else {
|
||||||
self?.logger.log("SEEK: WARNING: seeking not completed (to \(seekTime)")
|
AbsLogger.info(message:"SEEK: WARNING: seeking not completed (to \(seekTime)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
@@ -574,7 +572,7 @@ class AudioPlayer: NSObject {
|
|||||||
} 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 {
|
||||||
// Worst case we can stream the file
|
// Worst case we can stream the file
|
||||||
logger.log("Unable to play local file. Resulting to streaming \(track.localFileId ?? "Unknown")")
|
AbsLogger.info(message:"Unable to play local file. Resulting to streaming \(track.localFileId ?? "Unknown")")
|
||||||
let urlstr = "\(Store.serverConfig!.address)/api/items/\(itemId)/file/\(ino)?token=\(Store.serverConfig!.token)"
|
let urlstr = "\(Store.serverConfig!.address)/api/items/\(itemId)/file/\(ino)?token=\(Store.serverConfig!.token)"
|
||||||
let url = URL(string: urlstr)!
|
let url = URL(string: urlstr)!
|
||||||
return AVURLAsset(url: url)
|
return AVURLAsset(url: url)
|
||||||
@@ -594,8 +592,7 @@ class AudioPlayer: NSObject {
|
|||||||
do {
|
do {
|
||||||
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio)
|
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio)
|
||||||
} catch {
|
} catch {
|
||||||
logger.error("Failed to set AVAudioSession category")
|
AbsLogger.error(message: "Failed to set AVAudioSession category", error: error)
|
||||||
logger.error(error)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -603,7 +600,7 @@ class AudioPlayer: NSObject {
|
|||||||
do {
|
do {
|
||||||
try AVAudioSession.sharedInstance().setActive(active)
|
try AVAudioSession.sharedInstance().setActive(active)
|
||||||
} catch {
|
} catch {
|
||||||
logger.error("Failed to set audio session as active=\(active)")
|
AbsLogger.error(message:"Failed to set audio session as active=\(active)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -620,7 +617,7 @@ class AudioPlayer: NSObject {
|
|||||||
let reasonValue = userInfo[AVAudioSessionInterruptionReasonKey] as? UInt ?? 0
|
let reasonValue = userInfo[AVAudioSessionInterruptionReasonKey] as? UInt ?? 0
|
||||||
let reason = AVAudioSession.InterruptionReason(rawValue: reasonValue)
|
let reason = AVAudioSession.InterruptionReason(rawValue: reasonValue)
|
||||||
if (reason == .appWasSuspended) {
|
if (reason == .appWasSuspended) {
|
||||||
logger.log("AVAudioSession was suspended")
|
AbsLogger.info(message:"AVAudioSession was suspended")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -812,14 +809,14 @@ class AudioPlayer: NSObject {
|
|||||||
if context == &playerContext {
|
if context == &playerContext {
|
||||||
if keyPath == #keyPath(AVPlayer.currentItem) {
|
if keyPath == #keyPath(AVPlayer.currentItem) {
|
||||||
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.update.rawValue), object: nil)
|
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.update.rawValue), object: nil)
|
||||||
logger.log("WARNING: Item ended")
|
AbsLogger.info(message:"WARNING: Item ended")
|
||||||
|
|
||||||
if audioPlayer.currentItem == nil {
|
if audioPlayer.currentItem == nil {
|
||||||
// if the queue is rebuilding, we expect the current item may be nil
|
// if the queue is rebuilding, we expect the current item may be nil
|
||||||
if self.isRebuildingQueue {
|
if self.isRebuildingQueue {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
logger.log("Player ended or next item is nil, marking ended")
|
AbsLogger.info(message:"Player ended or next item is nil, marking ended")
|
||||||
self.markAudioSessionAs(active: false)
|
self.markAudioSessionAs(active: false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ extension AudioPlayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public func setSleepTimer(secondsUntilSleep: Double) {
|
public func setSleepTimer(secondsUntilSleep: Double) {
|
||||||
logger.log("SLEEP TIMER: Sleeping in \(secondsUntilSleep) seconds")
|
AbsLogger.info(message: "SLEEP TIMER: Sleeping in \(secondsUntilSleep) seconds")
|
||||||
self.removeSleepTimer()
|
self.removeSleepTimer()
|
||||||
self.sleepTimeRemaining = secondsUntilSleep
|
self.sleepTimeRemaining = secondsUntilSleep
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ extension AudioPlayer {
|
|||||||
guard let currentTime = self.getCurrentTime() else { return }
|
guard let currentTime = self.getCurrentTime() else { return }
|
||||||
guard stopAt >= currentTime else { return }
|
guard stopAt >= currentTime else { return }
|
||||||
|
|
||||||
logger.log("SLEEP TIMER: Scheduling for chapter end \(stopAt)")
|
AbsLogger.info(message: "SLEEP TIMER: Scheduling for chapter end \(stopAt)")
|
||||||
|
|
||||||
// Schedule the observation time
|
// Schedule the observation time
|
||||||
self.sleepTimeChapterStopAt = stopAt
|
self.sleepTimeChapterStopAt = stopAt
|
||||||
@@ -138,7 +138,7 @@ extension AudioPlayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func handleSleepEnd() {
|
private func handleSleepEnd() {
|
||||||
logger.log("SLEEP TIMER: Pausing audio")
|
AbsLogger.info(message: "SLEEP TIMER: Pausing audio")
|
||||||
self.pause()
|
self.pause()
|
||||||
self.removeSleepTimer()
|
self.removeSleepTimer()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import AVFoundation
|
|||||||
|
|
||||||
@available(iOS 16.0, *)
|
@available(iOS 16.0, *)
|
||||||
class DefaultedAudioPlayerRateManager: NSObject, AudioPlayerRateManager {
|
class DefaultedAudioPlayerRateManager: NSObject, AudioPlayerRateManager {
|
||||||
internal let logger = AppLogger(category: "DefaultedAudioPlayerRateManager")
|
|
||||||
|
|
||||||
internal var audioPlayer: AVPlayer
|
internal var audioPlayer: AVPlayer
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import Foundation
|
|||||||
import AVFoundation
|
import AVFoundation
|
||||||
|
|
||||||
class LegacyAudioPlayerRateManager: NSObject, AudioPlayerRateManager {
|
class LegacyAudioPlayerRateManager: NSObject, AudioPlayerRateManager {
|
||||||
internal let logger = AppLogger(category: "AudioPlayer")
|
|
||||||
|
|
||||||
internal var audioPlayer: AVPlayer
|
internal var audioPlayer: AVPlayer
|
||||||
|
|
||||||
@@ -54,7 +53,7 @@ class LegacyAudioPlayerRateManager: NSObject, AudioPlayerRateManager {
|
|||||||
let playbackSpeedChanged = rate > 0.0 && rate != self.defaultRate && !(observed && rate == 1)
|
let playbackSpeedChanged = rate > 0.0 && rate != self.defaultRate && !(observed && rate == 1)
|
||||||
|
|
||||||
if self.audioPlayer.rate != rate {
|
if self.audioPlayer.rate != rate {
|
||||||
logger.log("setPlaybakRate rate changed from \(self.audioPlayer.rate) to \(rate)")
|
AbsLogger.info(message: "setPlaybakRate rate changed from \(self.audioPlayer.rate) to \(rate)")
|
||||||
DispatchQueue.runOnMainQueue {
|
DispatchQueue.runOnMainQueue {
|
||||||
self.audioPlayer.rate = rate
|
self.audioPlayer.rate = rate
|
||||||
}
|
}
|
||||||
@@ -73,7 +72,7 @@ class LegacyAudioPlayerRateManager: NSObject, AudioPlayerRateManager {
|
|||||||
public override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
|
public override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
|
||||||
if context == &managerContext {
|
if context == &managerContext {
|
||||||
if keyPath == #keyPath(AVPlayer.rate) {
|
if keyPath == #keyPath(AVPlayer.rate) {
|
||||||
logger.log("playerContext observer player rate")
|
AbsLogger.info(message: "playerContext observer player rate")
|
||||||
self.handlePlaybackRateChange(change?[.newKey] as? Float ?? 1.0, observed: true)
|
self.handlePlaybackRateChange(change?[.newKey] as? Float ?? 1.0, observed: true)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -14,8 +14,6 @@ class PlayerProgress {
|
|||||||
|
|
||||||
private static var TIME_BETWEEN_SESSION_SYNC_IN_SECONDS = 15.0
|
private static var TIME_BETWEEN_SESSION_SYNC_IN_SECONDS = 15.0
|
||||||
|
|
||||||
private let logger = AppLogger(category: "PlayerProgress")
|
|
||||||
|
|
||||||
private init() {}
|
private init() {}
|
||||||
|
|
||||||
|
|
||||||
@@ -30,8 +28,7 @@ class PlayerProgress {
|
|||||||
try await updateServerSessionFromLocalSession(session, rateLimitSync: !isStopping)
|
try await updateServerSessionFromLocalSession(session, rateLimitSync: !isStopping)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
logger.error("Failed to syncFromPlayer")
|
AbsLogger.error(message: "Failed to syncFromPlayer \(error)", error: error)
|
||||||
logger.error(error)
|
|
||||||
}
|
}
|
||||||
await UIApplication.shared.endBackgroundTask(backgroundToken)
|
await UIApplication.shared.endBackgroundTask(backgroundToken)
|
||||||
}
|
}
|
||||||
@@ -76,7 +73,7 @@ class PlayerProgress {
|
|||||||
|
|
||||||
try localMediaProgress.updateFromPlaybackSession(session)
|
try localMediaProgress.updateFromPlaybackSession(session)
|
||||||
|
|
||||||
logger.log("Local progress saved to the database")
|
AbsLogger.info(message:"Local progress saved to the database")
|
||||||
|
|
||||||
// Send the local progress back to front-end
|
// Send the local progress back to front-end
|
||||||
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.localProgress.rawValue), object: nil)
|
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.localProgress.rawValue), object: nil)
|
||||||
@@ -126,7 +123,7 @@ class PlayerProgress {
|
|||||||
session = session.freeze()
|
session = session.freeze()
|
||||||
|
|
||||||
guard safeToSync else { return }
|
guard safeToSync else { return }
|
||||||
logger.log("Sending sessionId(\(session.id)) to server with currentTime(\(session.currentTime))")
|
AbsLogger.info(message:"Sending sessionId(\(session.id)) to server with currentTime(\(session.currentTime))")
|
||||||
|
|
||||||
var success = false
|
var success = false
|
||||||
if session.isLocal {
|
if session.isLocal {
|
||||||
@@ -155,25 +152,25 @@ class PlayerProgress {
|
|||||||
|
|
||||||
// TODO: Unused for now
|
// TODO: Unused for now
|
||||||
private func updateLocalSessionFromServerMediaProgress() async throws {
|
private func updateLocalSessionFromServerMediaProgress() async throws {
|
||||||
logger.log("updateLocalSessionFromServerMediaProgress: Checking if local media progress was updated on server")
|
AbsLogger.info(message:"updateLocalSessionFromServerMediaProgress: Checking if local media progress was updated on server")
|
||||||
guard let session = try Realm(queue: nil).objects(PlaybackSession.self).last(where: {
|
guard let session = try Realm(queue: nil).objects(PlaybackSession.self).last(where: {
|
||||||
$0.isActiveSession == true && $0.serverConnectionConfigId == Store.serverConfig?.id
|
$0.isActiveSession == true && $0.serverConnectionConfigId == Store.serverConfig?.id
|
||||||
})?.freeze() else {
|
})?.freeze() else {
|
||||||
logger.log("updateLocalSessionFromServerMediaProgress: Failed to get session")
|
AbsLogger.info(message:"updateLocalSessionFromServerMediaProgress: Failed to get session")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch the current progress
|
// Fetch the current progress
|
||||||
let progress = await ApiClient.getMediaProgress(libraryItemId: session.libraryItemId!, episodeId: session.episodeId)
|
let progress = await ApiClient.getMediaProgress(libraryItemId: session.libraryItemId!, episodeId: session.episodeId)
|
||||||
guard let progress = progress else {
|
guard let progress = progress else {
|
||||||
logger.log("updateLocalSessionFromServerMediaProgress: No progress object")
|
AbsLogger.info(message:"updateLocalSessionFromServerMediaProgress: No progress object")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine which session is newer
|
// Determine which session is newer
|
||||||
let serverLastUpdate = progress.lastUpdate
|
let serverLastUpdate = progress.lastUpdate
|
||||||
guard let localLastUpdate = session.updatedAt else {
|
guard let localLastUpdate = session.updatedAt else {
|
||||||
logger.log("updateLocalSessionFromServerMediaProgress: No local session updatedAt")
|
AbsLogger.info(message:"updateLocalSessionFromServerMediaProgress: No local session updatedAt")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let serverCurrentTime = progress.currentTime
|
let serverCurrentTime = progress.currentTime
|
||||||
@@ -184,16 +181,16 @@ class PlayerProgress {
|
|||||||
|
|
||||||
// Update the session, if needed
|
// Update the session, if needed
|
||||||
if serverIsNewerThanLocal && currentTimeIsDifferent {
|
if serverIsNewerThanLocal && currentTimeIsDifferent {
|
||||||
logger.log("updateLocalSessionFromServerMediaProgress: Server has newer time than local serverLastUpdate=\(serverLastUpdate) localLastUpdate=\(localLastUpdate)")
|
AbsLogger.info(message:"updateLocalSessionFromServerMediaProgress: Server has newer time than local serverLastUpdate=\(serverLastUpdate) localLastUpdate=\(localLastUpdate)")
|
||||||
guard let session = session.thaw() else { return }
|
guard let session = session.thaw() else { return }
|
||||||
try session.update {
|
try session.update {
|
||||||
session.currentTime = serverCurrentTime
|
session.currentTime = serverCurrentTime
|
||||||
session.updatedAt = serverLastUpdate
|
session.updatedAt = serverLastUpdate
|
||||||
}
|
}
|
||||||
logger.log("updateLocalSessionFromServerMediaProgress: Updated session currentTime newCurrentTime=\(serverCurrentTime) previousCurrentTime=\(localCurrentTime)")
|
AbsLogger.info(message:"updateLocalSessionFromServerMediaProgress: Updated session currentTime newCurrentTime=\(serverCurrentTime) previousCurrentTime=\(localCurrentTime)")
|
||||||
PlayerHandler.seek(amount: session.currentTime)
|
PlayerHandler.seek(amount: session.currentTime)
|
||||||
} else {
|
} else {
|
||||||
logger.log("updateLocalSessionFromServerMediaProgress: Local session does not need updating; local has latest progress")
|
AbsLogger.info(message:"updateLocalSessionFromServerMediaProgress: Local session does not need updating; local has latest progress")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import Foundation
|
|||||||
import Alamofire
|
import Alamofire
|
||||||
|
|
||||||
class ApiClient {
|
class ApiClient {
|
||||||
private static let logger = AppLogger(category: "ApiClient")
|
|
||||||
private static let secureStorage = SecureStorage()
|
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) {
|
||||||
@@ -22,7 +21,7 @@ class ApiClient {
|
|||||||
|
|
||||||
public static func postResource<T: Decodable>(endpoint: String, parameters: [String: Any], decodable: T.Type = T.self, callback: ((_ param: T) -> Void)?) {
|
public static func postResource<T: Decodable>(endpoint: String, parameters: [String: Any], decodable: T.Type = T.self, callback: ((_ param: T) -> Void)?) {
|
||||||
if (Store.serverConfig == nil) {
|
if (Store.serverConfig == nil) {
|
||||||
logger.error("Server config not set")
|
AbsLogger.error(message: "Server config not set")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,7 +34,7 @@ class ApiClient {
|
|||||||
case .success(let obj):
|
case .success(let obj):
|
||||||
callback?(obj)
|
callback?(obj)
|
||||||
case .failure(let error):
|
case .failure(let error):
|
||||||
logger.error("api request to \(endpoint) failed")
|
AbsLogger.error(message: "api request to \(endpoint) failed")
|
||||||
print(error)
|
print(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -43,7 +42,7 @@ class ApiClient {
|
|||||||
|
|
||||||
public static func postResource<T: Encodable, U: Decodable>(endpoint: String, parameters: T, decodable: U.Type = U.self, callback: ((_ param: U) -> Void)?) {
|
public static func postResource<T: Encodable, U: Decodable>(endpoint: String, parameters: T, decodable: U.Type = U.self, callback: ((_ param: U) -> Void)?) {
|
||||||
if (Store.serverConfig == nil) {
|
if (Store.serverConfig == nil) {
|
||||||
logger.error("Server config not set")
|
AbsLogger.error(message: "Server config not set")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,7 +55,7 @@ class ApiClient {
|
|||||||
case .success(let obj):
|
case .success(let obj):
|
||||||
callback?(obj)
|
callback?(obj)
|
||||||
case .failure(let error):
|
case .failure(let error):
|
||||||
logger.error("api request to \(endpoint) failed")
|
AbsLogger.error(message: "api request to \(endpoint) failed")
|
||||||
print(error)
|
print(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -72,7 +71,7 @@ class ApiClient {
|
|||||||
|
|
||||||
public static func postResource<T:Encodable>(endpoint: String, parameters: T, callback: ((_ success: Bool) -> Void)?) {
|
public static func postResource<T:Encodable>(endpoint: String, parameters: T, callback: ((_ success: Bool) -> Void)?) {
|
||||||
if (Store.serverConfig == nil) {
|
if (Store.serverConfig == nil) {
|
||||||
logger.error("Server config not set")
|
AbsLogger.error(message: "Server config not set")
|
||||||
callback?(false)
|
callback?(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -86,7 +85,7 @@ class ApiClient {
|
|||||||
case .success(_):
|
case .success(_):
|
||||||
callback?(true)
|
callback?(true)
|
||||||
case .failure(let error):
|
case .failure(let error):
|
||||||
logger.error("api request to \(endpoint) failed")
|
AbsLogger.error(message: "api request to \(endpoint) failed")
|
||||||
print(error)
|
print(error)
|
||||||
|
|
||||||
callback?(false)
|
callback?(false)
|
||||||
@@ -96,7 +95,7 @@ class ApiClient {
|
|||||||
|
|
||||||
public static func patchResource<T: Encodable>(endpoint: String, parameters: T, callback: ((_ success: Bool) -> Void)?) {
|
public static func patchResource<T: Encodable>(endpoint: String, parameters: T, callback: ((_ success: Bool) -> Void)?) {
|
||||||
if (Store.serverConfig == nil) {
|
if (Store.serverConfig == nil) {
|
||||||
logger.error("Server config not set")
|
AbsLogger.error(message: "Server config not set")
|
||||||
callback?(false)
|
callback?(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -110,7 +109,7 @@ class ApiClient {
|
|||||||
case .success(_):
|
case .success(_):
|
||||||
callback?(true)
|
callback?(true)
|
||||||
case .failure(let error):
|
case .failure(let error):
|
||||||
logger.error("api request to \(endpoint) failed")
|
AbsLogger.error(message: "api request to \(endpoint) failed")
|
||||||
print(error)
|
print(error)
|
||||||
callback?(false)
|
callback?(false)
|
||||||
}
|
}
|
||||||
@@ -127,7 +126,7 @@ class ApiClient {
|
|||||||
|
|
||||||
public static func getResource<T: Decodable>(endpoint: String, decodable: T.Type = T.self, callback: ((_ param: T?) -> Void)?) {
|
public static func getResource<T: Decodable>(endpoint: String, decodable: T.Type = T.self, callback: ((_ param: T?) -> Void)?) {
|
||||||
if (Store.serverConfig == nil) {
|
if (Store.serverConfig == nil) {
|
||||||
logger.error("Server config not set")
|
AbsLogger.error(message: "Server config not set")
|
||||||
callback?(nil)
|
callback?(nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -141,7 +140,7 @@ class ApiClient {
|
|||||||
case .success(let obj):
|
case .success(let obj):
|
||||||
callback?(obj)
|
callback?(obj)
|
||||||
case .failure(let error):
|
case .failure(let error):
|
||||||
logger.error("api request to \(endpoint) failed")
|
AbsLogger.error(message: "api request to \(endpoint) failed")
|
||||||
print(error)
|
print(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -160,22 +159,22 @@ class ApiClient {
|
|||||||
*/
|
*/
|
||||||
private static func handleTokenRefresh<T: Decodable>(originalRequest: DataRequest, endpoint: String, method: HTTPMethod, parameters: Any?, decodable: T.Type, callback: ((_ param: T?) -> Void)?) {
|
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 {
|
guard let serverConfig = Store.serverConfig else {
|
||||||
logger.error("handleTokenRefresh: No server config available")
|
AbsLogger.error(message: "handleTokenRefresh: No server config available")
|
||||||
callback?(nil)
|
callback?(nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.log("handleTokenRefresh: Attempting to refresh auth tokens for server \(serverConfig.name)")
|
AbsLogger.info(message: "handleTokenRefresh: Attempting to refresh auth tokens for server \(serverConfig.name)")
|
||||||
|
|
||||||
// Get refresh token from secure storage
|
// Get refresh token from secure storage
|
||||||
guard let refreshToken = secureStorage.getRefreshToken(serverConnectionConfigId: serverConfig.id) else {
|
guard let refreshToken = secureStorage.getRefreshToken(serverConnectionConfigId: serverConfig.id) else {
|
||||||
logger.error("handleTokenRefresh: No refresh token available for server \(serverConfig.name)")
|
AbsLogger.error(message: "handleTokenRefresh: No refresh token available for server \(serverConfig.name)")
|
||||||
handleRefreshFailure()
|
handleRefreshFailure()
|
||||||
callback?(nil)
|
callback?(nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.log("handleTokenRefresh: Retrieved refresh token, attempting to refresh access token")
|
AbsLogger.info(message: "handleTokenRefresh: Retrieved refresh token, attempting to refresh access token")
|
||||||
|
|
||||||
// Create refresh token request
|
// Create refresh token request
|
||||||
let refreshHeaders: HTTPHeaders = [
|
let refreshHeaders: HTTPHeaders = [
|
||||||
@@ -190,23 +189,23 @@ class ApiClient {
|
|||||||
case .success(let refreshResponse):
|
case .success(let refreshResponse):
|
||||||
guard let user = refreshResponse.user,
|
guard let user = refreshResponse.user,
|
||||||
!user.accessToken.isEmpty else {
|
!user.accessToken.isEmpty else {
|
||||||
logger.error("handleTokenRefresh: No access token in refresh response for server \(serverConfig.name)")
|
AbsLogger.error(message: "handleTokenRefresh: No access token in refresh response for server \(serverConfig.name)")
|
||||||
handleRefreshFailure()
|
handleRefreshFailure()
|
||||||
callback?(nil)
|
callback?(nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.log("handleTokenRefresh: Successfully obtained new access token")
|
AbsLogger.info(message: "handleTokenRefresh: Successfully obtained new access token")
|
||||||
|
|
||||||
// Update tokens in secure storage and store
|
// Update tokens in secure storage and store
|
||||||
updateTokens(newAccessToken: user.accessToken, newRefreshToken: user.refreshToken ?? refreshToken, serverConnectionConfigId: serverConfig.id)
|
updateTokens(newAccessToken: user.accessToken, newRefreshToken: user.refreshToken ?? refreshToken, serverConnectionConfigId: serverConfig.id)
|
||||||
|
|
||||||
// Retry the original request with the new access token
|
// Retry the original request with the new access token
|
||||||
logger.log("handleTokenRefresh: Retrying original request with new token")
|
AbsLogger.info(message: "handleTokenRefresh: Retrying original request with new token")
|
||||||
retryOriginalRequest(endpoint: endpoint, method: method, parameters: parameters, decodable: decodable, newAccessToken: user.accessToken, callback: callback)
|
retryOriginalRequest(endpoint: endpoint, method: method, parameters: parameters, decodable: decodable, newAccessToken: user.accessToken, callback: callback)
|
||||||
|
|
||||||
case .failure(let error):
|
case .failure(let error):
|
||||||
logger.error("handleTokenRefresh: Refresh request failed for server \(serverConfig.name): \(error)")
|
AbsLogger.error(message: "handleTokenRefresh: Refresh request failed for server \(serverConfig.name): \(error)")
|
||||||
handleRefreshFailure()
|
handleRefreshFailure()
|
||||||
callback?(nil)
|
callback?(nil)
|
||||||
}
|
}
|
||||||
@@ -220,14 +219,14 @@ class ApiClient {
|
|||||||
// Update the refresh token in secure storage if it's new
|
// Update the refresh token in secure storage if it's new
|
||||||
if newRefreshToken != secureStorage.getRefreshToken(serverConnectionConfigId: serverConnectionConfigId) {
|
if newRefreshToken != secureStorage.getRefreshToken(serverConnectionConfigId: serverConnectionConfigId) {
|
||||||
let hasStored = secureStorage.storeRefreshToken(serverConnectionConfigId: serverConnectionConfigId, refreshToken: newRefreshToken)
|
let hasStored = secureStorage.storeRefreshToken(serverConnectionConfigId: serverConnectionConfigId, refreshToken: newRefreshToken)
|
||||||
logger.log("updateTokens: Updated refresh token in secure storage. Stored=\(hasStored)")
|
AbsLogger.info(message: "updateTokens: Updated refresh token in secure storage. Stored=\(hasStored)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update access token on server connection config
|
// Update access token on server connection config
|
||||||
Database.shared.updateServerConnectionConfigToken(newToken: newAccessToken)
|
Database.shared.updateServerConnectionConfigToken(newToken: newAccessToken)
|
||||||
logger.log("updateTokens: Updated access token in server connection config")
|
AbsLogger.info(message: "updateTokens: Updated access token in server connection config")
|
||||||
|
|
||||||
logger.log("updateTokens: Successfully refreshed auth tokens for server \(Store.serverConfig?.name ?? "unknown")")
|
AbsLogger.info(message: "updateTokens: Successfully refreshed auth tokens for server \(Store.serverConfig?.name ?? "unknown")")
|
||||||
|
|
||||||
// Notify webview frontend about token refresh
|
// Notify webview frontend about token refresh
|
||||||
if let callback = AbsDatabase.tokenRefreshCallback {
|
if let callback = AbsDatabase.tokenRefreshCallback {
|
||||||
@@ -241,7 +240,7 @@ class ApiClient {
|
|||||||
*/
|
*/
|
||||||
private static func retryOriginalRequest<T: Decodable>(endpoint: String, method: HTTPMethod, parameters: Any?, decodable: T.Type, newAccessToken: String, callback: ((_ param: T?) -> Void)?) {
|
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 {
|
guard let serverConfig = Store.serverConfig else {
|
||||||
logger.error("retryOriginalRequest: No server config available")
|
AbsLogger.error(message: "retryOriginalRequest: No server config available")
|
||||||
callback?(nil)
|
callback?(nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -270,7 +269,7 @@ class ApiClient {
|
|||||||
retryRequest = AF.request("\(serverConfig.address)/\(endpoint)", method: .patch, headers: headers)
|
retryRequest = AF.request("\(serverConfig.address)/\(endpoint)", method: .patch, headers: headers)
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
logger.error("retryOriginalRequest: Unsupported method \(method)")
|
AbsLogger.error(message: "retryOriginalRequest: Unsupported method \(method)")
|
||||||
callback?(nil)
|
callback?(nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -282,7 +281,7 @@ class ApiClient {
|
|||||||
if let data = response.data, !data.isEmpty {
|
if let data = response.data, !data.isEmpty {
|
||||||
// If it is a string return nil (e.g. express returns OK for 200 status codes)
|
// If it is a string return nil (e.g. express returns OK for 200 status codes)
|
||||||
if let responseString = String(data: data, encoding: .utf8) {
|
if let responseString = String(data: data, encoding: .utf8) {
|
||||||
logger.log("retryOriginalRequest: Got string response '\(responseString)'")
|
AbsLogger.info(message: "retryOriginalRequest: Got string response '\(responseString)'")
|
||||||
callback?(nil)
|
callback?(nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -292,16 +291,16 @@ class ApiClient {
|
|||||||
let decodedObject = try JSONDecoder().decode(decodable, from: data)
|
let decodedObject = try JSONDecoder().decode(decodable, from: data)
|
||||||
callback?(decodedObject)
|
callback?(decodedObject)
|
||||||
} catch {
|
} catch {
|
||||||
logger.error("retryOriginalRequest: JSON decode failed: \(error)")
|
AbsLogger.error(message: "retryOriginalRequest: JSON decode failed: \(error)", error: error)
|
||||||
callback?(nil)
|
callback?(nil)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Empty response
|
// Empty response
|
||||||
logger.log("retryOriginalRequest: Empty response with success status \(statusCode)")
|
AbsLogger.info(message: "retryOriginalRequest: Empty response with success status \(statusCode)")
|
||||||
callback?(nil)
|
callback?(nil)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.error("retryOriginalRequest: Request failed with status \(response.response?.statusCode ?? 0)")
|
AbsLogger.error(message: "retryOriginalRequest: Request failed with status \(response.response?.statusCode ?? 0)")
|
||||||
callback?(nil)
|
callback?(nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -312,7 +311,7 @@ class ApiClient {
|
|||||||
* This will clear the current server connection and notify webview
|
* This will clear the current server connection and notify webview
|
||||||
*/
|
*/
|
||||||
private static func handleRefreshFailure() {
|
private static func handleRefreshFailure() {
|
||||||
logger.log("handleRefreshFailure: Token refresh failed, clearing session")
|
AbsLogger.info(message: "handleRefreshFailure: Token refresh failed, clearing session")
|
||||||
|
|
||||||
// Clear the current server connection
|
// Clear the current server connection
|
||||||
Store.serverConfig = nil
|
Store.serverConfig = nil
|
||||||
@@ -332,7 +331,7 @@ class ApiClient {
|
|||||||
|
|
||||||
public static func getResourceWithTokenRefresh<T: Decodable>(endpoint: String, decodable: T.Type = T.self, callback: ((_ param: T?) -> Void)?) {
|
public static func getResourceWithTokenRefresh<T: Decodable>(endpoint: String, decodable: T.Type = T.self, callback: ((_ param: T?) -> Void)?) {
|
||||||
if (Store.serverConfig == nil) {
|
if (Store.serverConfig == nil) {
|
||||||
logger.error("Server config not set")
|
AbsLogger.error(message: "Server config not set")
|
||||||
callback?(nil)
|
callback?(nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -345,14 +344,14 @@ class ApiClient {
|
|||||||
|
|
||||||
request.responseDecodable(of: decodable) { response in
|
request.responseDecodable(of: decodable) { response in
|
||||||
if let statusCode = response.response?.statusCode, statusCode == 401 {
|
if let statusCode = response.response?.statusCode, statusCode == 401 {
|
||||||
logger.log("getResourceWithTokenRefresh: 401 Unauthorized for request to \(endpoint) - attempting token refresh")
|
AbsLogger.info(message: "getResourceWithTokenRefresh: 401 Unauthorized for request to \(endpoint) - attempting token refresh")
|
||||||
handleTokenRefresh(originalRequest: request, endpoint: endpoint, method: .get, parameters: nil, decodable: decodable, callback: callback)
|
handleTokenRefresh(originalRequest: request, endpoint: endpoint, method: .get, parameters: nil, decodable: decodable, callback: callback)
|
||||||
} else {
|
} else {
|
||||||
switch response.result {
|
switch response.result {
|
||||||
case .success(let obj):
|
case .success(let obj):
|
||||||
callback?(obj)
|
callback?(obj)
|
||||||
case .failure(let error):
|
case .failure(let error):
|
||||||
logger.error("api request to \(endpoint) failed")
|
AbsLogger.error(message: "api request to \(endpoint) failed")
|
||||||
print(error)
|
print(error)
|
||||||
callback?(nil)
|
callback?(nil)
|
||||||
}
|
}
|
||||||
@@ -362,7 +361,7 @@ class ApiClient {
|
|||||||
|
|
||||||
public static func postResourceWithTokenRefresh<T: Encodable, U: Decodable>(endpoint: String, parameters: T, decodable: U.Type = U.self, callback: ((_ param: U?) -> Void)?) {
|
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) {
|
if (Store.serverConfig == nil) {
|
||||||
logger.error("Server config not set")
|
AbsLogger.error(message: "Server config not set")
|
||||||
callback?(nil)
|
callback?(nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -375,14 +374,14 @@ class ApiClient {
|
|||||||
|
|
||||||
request.responseDecodable(of: decodable) { response in
|
request.responseDecodable(of: decodable) { response in
|
||||||
if let statusCode = response.response?.statusCode, statusCode == 401 {
|
if let statusCode = response.response?.statusCode, statusCode == 401 {
|
||||||
logger.log("postResourceWithTokenRefresh: 401 Unauthorized for request to \(endpoint) - attempting token refresh")
|
AbsLogger.info(message: "postResourceWithTokenRefresh: 401 Unauthorized for request to \(endpoint) - attempting token refresh")
|
||||||
handleTokenRefresh(originalRequest: request, endpoint: endpoint, method: .post, parameters: parameters, decodable: decodable, callback: callback)
|
handleTokenRefresh(originalRequest: request, endpoint: endpoint, method: .post, parameters: parameters, decodable: decodable, callback: callback)
|
||||||
} else {
|
} else {
|
||||||
switch response.result {
|
switch response.result {
|
||||||
case .success(let obj):
|
case .success(let obj):
|
||||||
callback?(obj)
|
callback?(obj)
|
||||||
case .failure(let error):
|
case .failure(let error):
|
||||||
logger.error("api request to \(endpoint) failed")
|
AbsLogger.error(message: "api request to \(endpoint) failed")
|
||||||
print(error)
|
print(error)
|
||||||
callback?(nil)
|
callback?(nil)
|
||||||
}
|
}
|
||||||
@@ -395,7 +394,7 @@ class ApiClient {
|
|||||||
*/
|
*/
|
||||||
public static func postResourceWithTokenRefresh<T: Encodable>(endpoint: String, parameters: T, callback: ((_ success: Bool) -> Void)?) {
|
public static func postResourceWithTokenRefresh<T: Encodable>(endpoint: String, parameters: T, callback: ((_ success: Bool) -> Void)?) {
|
||||||
if (Store.serverConfig == nil) {
|
if (Store.serverConfig == nil) {
|
||||||
logger.error("Server config not set")
|
AbsLogger.error(message: "Server config not set")
|
||||||
callback?(false)
|
callback?(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -408,7 +407,7 @@ class ApiClient {
|
|||||||
|
|
||||||
request.response { response in
|
request.response { response in
|
||||||
if let statusCode = response.response?.statusCode, statusCode == 401 {
|
if let statusCode = response.response?.statusCode, statusCode == 401 {
|
||||||
logger.log("postResourceWithTokenRefresh: 401 Unauthorized for request to \(endpoint) - attempting token refresh")
|
AbsLogger.info(message: "postResourceWithTokenRefresh: 401 Unauthorized for request to \(endpoint) - attempting token refresh")
|
||||||
handleTokenRefresh(originalRequest: request, endpoint: endpoint, method: .post, parameters: parameters, decodable: EmptyResponse.self) { result in
|
handleTokenRefresh(originalRequest: request, endpoint: endpoint, method: .post, parameters: parameters, decodable: EmptyResponse.self) { result in
|
||||||
callback?(result != nil)
|
callback?(result != nil)
|
||||||
}
|
}
|
||||||
@@ -417,7 +416,7 @@ class ApiClient {
|
|||||||
case .success(_):
|
case .success(_):
|
||||||
callback?(true)
|
callback?(true)
|
||||||
case .failure(let error):
|
case .failure(let error):
|
||||||
logger.error("api request to \(endpoint) failed")
|
AbsLogger.error(message: "api request to \(endpoint) failed")
|
||||||
print(error)
|
print(error)
|
||||||
callback?(false)
|
callback?(false)
|
||||||
}
|
}
|
||||||
@@ -427,7 +426,7 @@ class ApiClient {
|
|||||||
|
|
||||||
public static func patchResourceWithTokenRefresh<T: Encodable>(endpoint: String, parameters: T, callback: ((_ success: Bool) -> Void)?) {
|
public static func patchResourceWithTokenRefresh<T: Encodable>(endpoint: String, parameters: T, callback: ((_ success: Bool) -> Void)?) {
|
||||||
if (Store.serverConfig == nil) {
|
if (Store.serverConfig == nil) {
|
||||||
logger.error("Server config not set")
|
AbsLogger.error(message: "Server config not set")
|
||||||
callback?(false)
|
callback?(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -440,7 +439,7 @@ class ApiClient {
|
|||||||
|
|
||||||
request.response { response in
|
request.response { response in
|
||||||
if let statusCode = response.response?.statusCode, statusCode == 401 {
|
if let statusCode = response.response?.statusCode, statusCode == 401 {
|
||||||
logger.log("patchResourceWithTokenRefresh: 401 Unauthorized for request to \(endpoint) - attempting token refresh")
|
AbsLogger.info(message: "patchResourceWithTokenRefresh: 401 Unauthorized for request to \(endpoint) - attempting token refresh")
|
||||||
handleTokenRefresh(originalRequest: request, endpoint: endpoint, method: .patch, parameters: parameters, decodable: EmptyResponse.self) { result in
|
handleTokenRefresh(originalRequest: request, endpoint: endpoint, method: .patch, parameters: parameters, decodable: EmptyResponse.self) { result in
|
||||||
callback?(result != nil)
|
callback?(result != nil)
|
||||||
}
|
}
|
||||||
@@ -449,7 +448,7 @@ class ApiClient {
|
|||||||
case .success(_):
|
case .success(_):
|
||||||
callback?(true)
|
callback?(true)
|
||||||
case .failure(let error):
|
case .failure(let error):
|
||||||
logger.error("api request to \(endpoint) failed")
|
AbsLogger.error(message: "api request to \(endpoint) failed")
|
||||||
print(error)
|
print(error)
|
||||||
callback?(false)
|
callback?(false)
|
||||||
}
|
}
|
||||||
@@ -489,7 +488,7 @@ class ApiClient {
|
|||||||
// Use the new token refresh-enabled method
|
// Use the new token refresh-enabled method
|
||||||
postResourceWithTokenRefresh(endpoint: endpoint, parameters: parameters, decodable: PlaybackSession.self) { session in
|
postResourceWithTokenRefresh(endpoint: endpoint, parameters: parameters, decodable: PlaybackSession.self) { session in
|
||||||
guard let session = session else {
|
guard let session = session else {
|
||||||
logger.error("startPlaybackSession: Failed to create playback session")
|
AbsLogger.error(message: "startPlaybackSession: Failed to create playback session")
|
||||||
callback(PlaybackSession()) // Return empty session on failure
|
callback(PlaybackSession()) // Return empty session on failure
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -533,14 +532,14 @@ class ApiClient {
|
|||||||
let localMediaProgressList = Database.shared.getAllLocalMediaProgress().filter {
|
let localMediaProgressList = Database.shared.getAllLocalMediaProgress().filter {
|
||||||
$0.serverConnectionConfigId == Store.serverConfig?.id
|
$0.serverConnectionConfigId == Store.serverConfig?.id
|
||||||
}.map { $0.freeze() }
|
}.map { $0.freeze() }
|
||||||
logger.log("syncLocalSessionsWithServer: Found \(localMediaProgressList.count) local media progress for server")
|
AbsLogger.info(message: "syncLocalSessionsWithServer: Found \(localMediaProgressList.count) local media progress for server")
|
||||||
|
|
||||||
if (localMediaProgressList.isEmpty) {
|
if (localMediaProgressList.isEmpty) {
|
||||||
logger.log("syncLocalSessionsWithServer: No local progress to sync")
|
AbsLogger.info(message: "syncLocalSessionsWithServer: No local progress to sync")
|
||||||
} else {
|
} else {
|
||||||
let currentUser = await ApiClient.getCurrentUser()
|
let currentUser = await ApiClient.getCurrentUser()
|
||||||
guard let currentUser = currentUser else {
|
guard let currentUser = currentUser else {
|
||||||
logger.log("syncLocalSessionsWithServer: No User")
|
AbsLogger.info(message: "syncLocalSessionsWithServer: No User")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try currentUser.mediaProgress.forEach { mediaProgress in
|
try currentUser.mediaProgress.forEach { mediaProgress in
|
||||||
@@ -552,12 +551,12 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (localMediaProgress != nil && mediaProgress.lastUpdate > localMediaProgress!.lastUpdate) {
|
if (localMediaProgress != nil && mediaProgress.lastUpdate > localMediaProgress!.lastUpdate) {
|
||||||
logger.log("syncLocalSessionsWithServer: Updating local media progress \(localMediaProgress!.id) with server media progress")
|
AbsLogger.info(message: "syncLocalSessionsWithServer: Updating local media progress \(localMediaProgress!.id) with server media progress")
|
||||||
if let localMediaProgress = localMediaProgress?.thaw() {
|
if let localMediaProgress = localMediaProgress?.thaw() {
|
||||||
try localMediaProgress.updateFromServerMediaProgress(mediaProgress)
|
try localMediaProgress.updateFromServerMediaProgress(mediaProgress)
|
||||||
}
|
}
|
||||||
} else if (localMediaProgress != nil) {
|
} else if (localMediaProgress != nil) {
|
||||||
logger.log("syncLocalSessionsWithServer: Local progress for \(localMediaProgress!.id) is more recent then server progress")
|
AbsLogger.info(message: "syncLocalSessionsWithServer: Local progress for \(localMediaProgress!.id) is more recent then server progress")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -566,13 +565,13 @@ class ApiClient {
|
|||||||
let playbackSessions = Database.shared.getAllPlaybackSessions().filter {
|
let playbackSessions = Database.shared.getAllPlaybackSessions().filter {
|
||||||
$0.serverConnectionConfigId == Store.serverConfig?.id
|
$0.serverConnectionConfigId == Store.serverConfig?.id
|
||||||
}.map { $0.freeze() }
|
}.map { $0.freeze() }
|
||||||
logger.log("syncLocalSessionsWithServer: Found \(playbackSessions.count) playback sessions for server (first sync: \(isFirstSync))")
|
AbsLogger.info(message: "syncLocalSessionsWithServer: Found \(playbackSessions.count) playback sessions for server (first sync: \(isFirstSync))")
|
||||||
if (!playbackSessions.isEmpty) {
|
if (!playbackSessions.isEmpty) {
|
||||||
let success = await ApiClient.reportAllLocalPlaybackSessions(playbackSessions)
|
let success = await ApiClient.reportAllLocalPlaybackSessions(playbackSessions)
|
||||||
if (success) {
|
if (success) {
|
||||||
// Remove sessions from db
|
// Remove sessions from db
|
||||||
try playbackSessions.forEach { session in
|
try playbackSessions.forEach { session in
|
||||||
logger.log("syncLocalSessionsWithServer: Handling \(session.displayTitle ?? "") (\(session.id)) \(session.isActiveSession)")
|
AbsLogger.info(message: "syncLocalSessionsWithServer: Handling \(session.displayTitle ?? "") (\(session.id)) \(session.isActiveSession)")
|
||||||
// On first sync then remove all sessions
|
// On first sync then remove all sessions
|
||||||
if (!session.isActiveSession || isFirstSync) {
|
if (!session.isActiveSession || isFirstSync) {
|
||||||
if let session = session.thaw() {
|
if let session = session.thaw() {
|
||||||
@@ -589,7 +588,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)")
|
AbsLogger.info(message: "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 ?? "")"
|
||||||
patchResourceWithTokenRefresh(endpoint: endpoint, parameters: payload) { _ in
|
patchResourceWithTokenRefresh(endpoint: endpoint, parameters: payload) { _ in
|
||||||
callback()
|
callback()
|
||||||
@@ -597,7 +596,7 @@ 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")")
|
AbsLogger.info(message: "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 withCheckedContinuation { continuation in
|
return await withCheckedContinuation { continuation in
|
||||||
getResourceWithTokenRefresh(endpoint: endpoint, decodable: MediaProgress.self) { result in
|
getResourceWithTokenRefresh(endpoint: endpoint, decodable: MediaProgress.self) { result in
|
||||||
@@ -607,7 +606,7 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static func getCurrentUser() async -> User? {
|
public static func getCurrentUser() async -> User? {
|
||||||
logger.log("getCurrentUser")
|
AbsLogger.info(message: "getCurrentUser")
|
||||||
return await withCheckedContinuation { continuation in
|
return await withCheckedContinuation { continuation in
|
||||||
getResourceWithTokenRefresh(endpoint: "api/me", decodable: User.self) { result in
|
getResourceWithTokenRefresh(endpoint: "api/me", decodable: User.self) { result in
|
||||||
continuation.resume(returning: result)
|
continuation.resume(returning: result)
|
||||||
|
|||||||
@@ -13,9 +13,13 @@ class Database {
|
|||||||
return Database()
|
return Database()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private let logger = AppLogger(category: "Database")
|
private init() {
|
||||||
|
do {
|
||||||
private init() {}
|
try cleanExpiredLogs()
|
||||||
|
} catch {
|
||||||
|
debugPrint(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public func setServerConnectionConfig(config: ServerConnectionConfig) {
|
public func setServerConnectionConfig(config: ServerConnectionConfig) {
|
||||||
let config = config
|
let config = config
|
||||||
@@ -33,7 +37,7 @@ class Database {
|
|||||||
existing.token = config.token
|
existing.token = config.token
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
logger.error("failed to update server config")
|
AbsLogger.error("setServerConn", message: "failed to update server config")
|
||||||
debugPrint(error)
|
debugPrint(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,7 +58,7 @@ class Database {
|
|||||||
realm.add(config)
|
realm.add(config)
|
||||||
}
|
}
|
||||||
} catch(let exception) {
|
} catch(let exception) {
|
||||||
logger.error("failed to save server config")
|
AbsLogger.error(message: "failed to save server config")
|
||||||
debugPrint(exception)
|
debugPrint(exception)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,7 +90,7 @@ class Database {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch(let exception) {
|
} catch(let exception) {
|
||||||
logger.error("failed to delete server config")
|
AbsLogger.error(message: "failed to delete server config")
|
||||||
debugPrint(exception)
|
debugPrint(exception)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -117,7 +121,7 @@ class Database {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch(let exception) {
|
} catch(let exception) {
|
||||||
logger.error("failed to save server config active index")
|
AbsLogger.error(message: "failed to save server config active index")
|
||||||
debugPrint(exception)
|
debugPrint(exception)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -137,7 +141,7 @@ class Database {
|
|||||||
realm.add(deviceSettings)
|
realm.add(deviceSettings)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
logger.error("failed to save device settings")
|
AbsLogger.error(message: "failed to save device settings")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,4 +279,54 @@ class Database {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func saveLog(_ log: LogEntry) throws {
|
||||||
|
let realm = try Realm()
|
||||||
|
return try realm.write { realm.add(log) }
|
||||||
|
}
|
||||||
|
|
||||||
|
public func getAllLogs() -> [LogEntry] {
|
||||||
|
do {
|
||||||
|
let realm = try Realm()
|
||||||
|
return realm.objects(LogEntry.self).toArray()
|
||||||
|
} catch {
|
||||||
|
debugPrint(error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func clearLogs() throws {
|
||||||
|
do {
|
||||||
|
let realm = try! Realm()
|
||||||
|
try realm.write {
|
||||||
|
realm.objects(LogEntry.self).forEach { log in
|
||||||
|
realm.delete(log)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
AbsLogger.error(message: "\(error)", error: error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cleanExpiredLogs() throws {
|
||||||
|
let realm = try Realm()
|
||||||
|
let numberOfHoursToKeep = 48
|
||||||
|
let keepLogCutoff = Date().addingTimeInterval(TimeInterval(-1 * numberOfHoursToKeep * 3600))
|
||||||
|
|
||||||
|
let allLogs = getAllLogs()
|
||||||
|
var logsRemoved = 0
|
||||||
|
try? realm.write {
|
||||||
|
allLogs.forEach { log in
|
||||||
|
if log.timestamp < Int(keepLogCutoff.timeIntervalSince1970) {
|
||||||
|
realm.delete(log)
|
||||||
|
logsRemoved += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if logsRemoved > 0 {
|
||||||
|
AbsLogger.info(message: "cleanLogs: Removed \(logsRemoved) logs older than \(numberOfHoursToKeep) hours")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,8 +65,7 @@ extension CAPPluginCall {
|
|||||||
let json = try JSONSerialization.data(withJSONObject: value)
|
let json = try JSONSerialization.data(withJSONObject: value)
|
||||||
return try JSONDecoder().decode(type, from: json)
|
return try JSONDecoder().decode(type, from: json)
|
||||||
} catch {
|
} catch {
|
||||||
AppLogger().error("Failed to get json for \(key)")
|
AbsLogger.error(message: "Failed to get json for \(key)", error: error)
|
||||||
debugPrint(error)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,3 +105,80 @@ extension URL {
|
|||||||
return attributes?[.creationDate] as? Date
|
return attributes?[.creationDate] as? Date
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - RealmSwift helpers
|
||||||
|
// From https://github.com/realm/realm-swift/issues/5859#issuecomment-589026869
|
||||||
|
|
||||||
|
extension Results {
|
||||||
|
func toArray<T: Object>() -> [T] {
|
||||||
|
var array = [T]()
|
||||||
|
|
||||||
|
for i in 0 ..< self.count {
|
||||||
|
if let result = self[i] as? T {
|
||||||
|
array.append(result.detached())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension List {
|
||||||
|
func toArray<T: Object>() -> [T] {
|
||||||
|
var array = [T]()
|
||||||
|
|
||||||
|
for i in 0 ..< self.count {
|
||||||
|
if let result = self[i] as? T {
|
||||||
|
array.append(result.detached())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol DetachableObject: AnyObject {
|
||||||
|
func detached() -> Self
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Object: DetachableObject {
|
||||||
|
func detached() -> Self {
|
||||||
|
let detached = type(of: self).init()
|
||||||
|
for property in objectSchema.properties {
|
||||||
|
guard let value = value(forKey: property.name) else { continue }
|
||||||
|
if property.isArray == true {
|
||||||
|
//Realm List property support
|
||||||
|
let detachable = value as? DetachableObject
|
||||||
|
detached.setValue(detachable?.detached(), forKey: property.name)
|
||||||
|
} else if property.type == .object {
|
||||||
|
//Realm Object property support
|
||||||
|
let detachable = value as? DetachableObject
|
||||||
|
detached.setValue(detachable?.detached(), forKey: property.name)
|
||||||
|
} else {
|
||||||
|
detached.setValue(value, forKey: property.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return detached
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension List: DetachableObject {
|
||||||
|
func detached() -> List<Element> {
|
||||||
|
let result = List<Element>()
|
||||||
|
|
||||||
|
forEach {
|
||||||
|
if let detachable = $0 as? DetachableObject {
|
||||||
|
let detached = detachable.detached() as! Element
|
||||||
|
result.append(detached)
|
||||||
|
} else {
|
||||||
|
result.append($0) //Primtives are pass by value; don't need to recreate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func toArray() -> [Element] {
|
||||||
|
return Array(self.detached())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export default {
|
|||||||
inittingLibraries: false,
|
inittingLibraries: false,
|
||||||
hasMounted: false,
|
hasMounted: false,
|
||||||
disconnectTime: 0,
|
disconnectTime: 0,
|
||||||
|
socketDisconnectedTime: 0,
|
||||||
timeLostFocus: 0,
|
timeLostFocus: 0,
|
||||||
currentLang: null
|
currentLang: null
|
||||||
}
|
}
|
||||||
@@ -44,7 +45,7 @@ export default {
|
|||||||
} else {
|
} else {
|
||||||
var timeSinceDisconnect = Date.now() - this.disconnectTime
|
var timeSinceDisconnect = Date.now() - this.disconnectTime
|
||||||
if (timeSinceDisconnect > 5000) {
|
if (timeSinceDisconnect > 5000) {
|
||||||
console.log('Time since disconnect was', timeSinceDisconnect, 'sync with server')
|
console.log('[default] Time since disconnect was', timeSinceDisconnect, 'sync with server')
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.syncLocalSessions(false)
|
this.syncLocalSessions(false)
|
||||||
}, 4000)
|
}, 4000)
|
||||||
@@ -55,6 +56,28 @@ export default {
|
|||||||
this.disconnectTime = Date.now()
|
this.disconnectTime = Date.now()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
socketConnected: {
|
||||||
|
handler(newVal, oldVal) {
|
||||||
|
if (!this.hasMounted) {
|
||||||
|
// watcher runs before mount, handling libraries/connection should be handled in mount
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (newVal) {
|
||||||
|
// if we havent been receiving socket events then external progress updates may have been missed
|
||||||
|
const timeSinceDisconnect = Date.now() - this.socketDisconnectedTime
|
||||||
|
if (timeSinceDisconnect > 30000 && this.isPlayerOpen) {
|
||||||
|
console.log('[default] socket reconnected after ' + timeSinceDisconnect + 'ms and player is open, triggering server media progress sync')
|
||||||
|
// used for triggering a server media progress sync if local media item is open in player
|
||||||
|
this.$eventBus.$emit('socket-reconnected')
|
||||||
|
} else {
|
||||||
|
console.log('[default] socket reconnected after ' + timeSinceDisconnect + 'ms')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('[default] socket disconnected')
|
||||||
|
this.socketDisconnectedTime = Date.now()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -67,6 +90,9 @@ export default {
|
|||||||
networkConnected() {
|
networkConnected() {
|
||||||
return this.$store.state.networkConnected
|
return this.$store.state.networkConnected
|
||||||
},
|
},
|
||||||
|
socketConnected() {
|
||||||
|
return this.$store.state.socketConnected
|
||||||
|
},
|
||||||
user() {
|
user() {
|
||||||
return this.$store.state.user.user
|
return this.$store.state.user.user
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export default {
|
|||||||
this.$strings.MessageConfirmStreamingUsingCellular
|
this.$strings.MessageConfirmStreamingUsingCellular
|
||||||
|
|
||||||
const { value } = await Dialog.confirm({
|
const { value } = await Dialog.confirm({
|
||||||
title: 'Confirm',
|
title: this.$strings.HeaderConfirm,
|
||||||
message
|
message
|
||||||
})
|
})
|
||||||
return value
|
return value
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export default {
|
||||||
|
methods: {
|
||||||
|
getJumpLabel(seconds) {
|
||||||
|
const val = Number(seconds)
|
||||||
|
if (isNaN(val)) return ''
|
||||||
|
const useMinutes = val >= 120 // keep 60s as seconds
|
||||||
|
const key = useMinutes ? 'UnitMinutesShort' : 'UnitSecondsShort'
|
||||||
|
const unitValue = useMinutes ? val / 60 : val
|
||||||
|
return this.$getString(key, [this.$formatNumber(unitValue)])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf-app",
|
"name": "audiobookshelf-app",
|
||||||
"version": "0.10.0-beta",
|
"version": "0.13.0-beta",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "audiobookshelf-app",
|
"name": "audiobookshelf-app",
|
||||||
"version": "0.10.0-beta",
|
"version": "0.13.0-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,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf-app",
|
"name": "audiobookshelf-app",
|
||||||
"version": "0.10.0-beta",
|
"version": "0.13.0-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",
|
||||||
|
|||||||
@@ -74,6 +74,12 @@ export default {
|
|||||||
console.log(`[categories] networkConnected false so fetching categories`)
|
console.log(`[categories] networkConnected false so fetching categories`)
|
||||||
this.fetchCategories()
|
this.fetchCategories()
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
user(newVal, oldVal) {
|
||||||
|
if ((newVal && !oldVal) || (!newVal && oldVal)) {
|
||||||
|
console.log(`[categories] user changed so fetching categories`)
|
||||||
|
this.fetchCategories()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ export default {
|
|||||||
|
|
||||||
// Show confirmation dialog and start downloading if user chooses so
|
// Show confirmation dialog and start downloading if user chooses so
|
||||||
const { value } = await Dialog.confirm({
|
const { value } = await Dialog.confirm({
|
||||||
title: 'Confirm',
|
title: this.$strings.HeaderConfirm,
|
||||||
message: startDownloadMessage
|
message: startDownloadMessage
|
||||||
})
|
})
|
||||||
if (value) {
|
if (value) {
|
||||||
|
|||||||
@@ -37,9 +37,9 @@ export default {
|
|||||||
computed: {},
|
computed: {},
|
||||||
methods: {
|
methods: {
|
||||||
async init() {
|
async init() {
|
||||||
|
await this.$store.dispatch('setupNetworkListener')
|
||||||
this.deviceData = await this.$db.getDeviceData()
|
this.deviceData = await this.$db.getDeviceData()
|
||||||
this.$store.commit('setDeviceData', this.deviceData)
|
this.$store.commit('setDeviceData', this.deviceData)
|
||||||
await this.$store.dispatch('setupNetworkListener')
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
<span v-else class="font-semibold text-fg">{{ Math.round(itemPart.progress) }}%</span>
|
<span v-else class="font-semibold text-fg">{{ Math.round(itemPart.progress) }}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow px-2">
|
<div class="flex-grow px-2">
|
||||||
<p class="truncate">{{ itemPart.filename }}</p>
|
<p class="break-all">{{ itemPart.filename }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -346,7 +346,7 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { value } = await Dialog.confirm({
|
const { value } = await Dialog.confirm({
|
||||||
title: 'Confirm',
|
title: this.$strings.HeaderConfirm,
|
||||||
message: this.$getString('MessageConfirmDeleteLocalEpisode', [localFile.basePath])
|
message: this.$getString('MessageConfirmDeleteLocalEpisode', [localFile.basePath])
|
||||||
})
|
})
|
||||||
if (value) {
|
if (value) {
|
||||||
@@ -514,7 +514,7 @@ export default {
|
|||||||
await this.$hapticsImpact()
|
await this.$hapticsImpact()
|
||||||
|
|
||||||
const { value } = await Dialog.confirm({
|
const { value } = await Dialog.confirm({
|
||||||
title: 'Confirm',
|
title: this.$strings.HeaderConfirm,
|
||||||
message: this.$strings.MessageConfirmDiscardProgress
|
message: this.$strings.MessageConfirmDiscardProgress
|
||||||
})
|
})
|
||||||
if (value) {
|
if (value) {
|
||||||
@@ -575,8 +575,8 @@ export default {
|
|||||||
await this.$hapticsImpact()
|
await this.$hapticsImpact()
|
||||||
|
|
||||||
const { value } = await Dialog.confirm({
|
const { value } = await Dialog.confirm({
|
||||||
title: 'Confirm',
|
title: this.$strings.HeaderConfirm,
|
||||||
message: `Are you sure you want to delete episode "${this.title}" from the server?\nWarning: This will delete the audio file.`
|
message: this.$getString('MessageConfirmDeleteServerEpisode', [this.title])
|
||||||
})
|
})
|
||||||
|
|
||||||
if (value) {
|
if (value) {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 />
|
||||||
<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 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>
|
||||||
@@ -172,7 +172,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import { Dialog } from '@capacitor/dialog'
|
import { Dialog } from '@capacitor/dialog'
|
||||||
import { AbsFileSystem, AbsDownloader } from '@/plugins/capacitor'
|
import { AbsFileSystem, AbsDownloader } from '@/plugins/capacitor'
|
||||||
import { FastAverageColor } from 'fast-average-color'
|
import { getAverageColorFromCoverUrl } from '@/utils/coverAverageColor'
|
||||||
import cellularPermissionHelpers from '@/mixins/cellularPermissionHelpers'
|
import cellularPermissionHelpers from '@/mixins/cellularPermissionHelpers'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@@ -496,17 +496,10 @@ export default {
|
|||||||
},
|
},
|
||||||
async coverImageLoaded(fullCoverUrl) {
|
async coverImageLoaded(fullCoverUrl) {
|
||||||
if (!fullCoverUrl) return
|
if (!fullCoverUrl) return
|
||||||
|
const avg = await getAverageColorFromCoverUrl(this, fullCoverUrl)
|
||||||
const fac = new FastAverageColor()
|
if (!avg) return
|
||||||
fac
|
this.coverRgb = avg.rgba
|
||||||
.getColorAsync(fullCoverUrl)
|
this.coverBgIsLight = avg.isLight
|
||||||
.then((color) => {
|
|
||||||
this.coverRgb = color.rgba
|
|
||||||
this.coverBgIsLight = color.isLight
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
console.log(e)
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
moreButtonPress() {
|
moreButtonPress() {
|
||||||
this.showMoreMenu = true
|
this.showMoreMenu = true
|
||||||
@@ -535,7 +528,11 @@ export default {
|
|||||||
|
|
||||||
if (this.isPodcast) {
|
if (this.isPodcast) {
|
||||||
this.episodes.sort((a, b) => {
|
this.episodes.sort((a, b) => {
|
||||||
|
if (this.podcastType === 'serial') {
|
||||||
|
return String(a.publishedAt).localeCompare(String(b.publishedAt), undefined, { numeric: true, sensitivity: 'base' })
|
||||||
|
} else {
|
||||||
return String(b.publishedAt).localeCompare(String(a.publishedAt), undefined, { numeric: true, sensitivity: 'base' })
|
return String(b.publishedAt).localeCompare(String(a.publishedAt), undefined, { numeric: true, sensitivity: 'base' })
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
let episode = this.episodes.find((ep) => {
|
let episode = this.episodes.find((ep) => {
|
||||||
@@ -584,8 +581,8 @@ export default {
|
|||||||
// If start time and is not already streaming then ask for confirmation
|
// If start time and is not already streaming then ask for confirmation
|
||||||
if (startTime !== null && startTime !== undefined && !this.$store.getters['getIsMediaStreaming'](libraryItemId, null)) {
|
if (startTime !== null && startTime !== undefined && !this.$store.getters['getIsMediaStreaming'](libraryItemId, null)) {
|
||||||
const { value } = await Dialog.confirm({
|
const { value } = await Dialog.confirm({
|
||||||
title: 'Confirm',
|
title: this.$strings.HeaderConfirm,
|
||||||
message: `Start playback for "${this.title}" at ${this.$secondsToTimestamp(startTime)}?`
|
message: this.$getString('MessageConfirmPlaybackTime', [this.title, this.$secondsToTimestamp(startTime)])
|
||||||
})
|
})
|
||||||
if (!value) return
|
if (!value) return
|
||||||
}
|
}
|
||||||
@@ -673,7 +670,7 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const { value } = await Dialog.confirm({
|
const { value } = await Dialog.confirm({
|
||||||
title: 'Confirm',
|
title: this.$strings.HeaderConfirm,
|
||||||
message: startDownloadMessage
|
message: startDownloadMessage
|
||||||
})
|
})
|
||||||
if (value) {
|
if (value) {
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ export default {
|
|||||||
deleteMessage = `Are you sure you want to remove this folder and ${this.localLibraryItems.length} items? (does not delete anything in your file system)`
|
deleteMessage = `Are you sure you want to remove this folder and ${this.localLibraryItems.length} items? (does not delete anything in your file system)`
|
||||||
}
|
}
|
||||||
const { value } = await Dialog.confirm({
|
const { value } = await Dialog.confirm({
|
||||||
title: 'Confirm',
|
title: this.$strings.HeaderConfirm,
|
||||||
message: deleteMessage
|
message: deleteMessage
|
||||||
})
|
})
|
||||||
if (value) {
|
if (value) {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-if="!isAndroid10OrBelow || overrideFolderRestriction" class="flex border-t border-fg/10 my-4 py-4">
|
<div v-if="!isAndroid10OrBelow || overrideFolderRestriction" class="flex border-t border-fg/10 my-4 py-4">
|
||||||
<div class="flex-grow pr-1">
|
<div class="flex-grow pr-1">
|
||||||
<ui-dropdown v-model="newFolderMediaType" placeholder="Select media type" :items="mediaTypeItems" />
|
<ui-dropdown v-model="newFolderMediaType" :placeholder="$strings.LabelSelectMediaType" :items="mediaTypeItems" />
|
||||||
</div>
|
</div>
|
||||||
<ui-btn small class="w-28" color="success" @click="selectFolder">{{ $strings.ButtonNewFolder }}</ui-btn>
|
<ui-btn small class="w-28" color="success" @click="selectFolder">{{ $strings.ButtonNewFolder }}</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -369,7 +369,7 @@ export default {
|
|||||||
confirmMessage += ' The file on the server will be unaffected.'
|
confirmMessage += ' The file on the server will be unaffected.'
|
||||||
}
|
}
|
||||||
const { value } = await Dialog.confirm({
|
const { value } = await Dialog.confirm({
|
||||||
title: 'Confirm',
|
title: this.$strings.HeaderConfirm,
|
||||||
message: confirmMessage
|
message: confirmMessage
|
||||||
})
|
})
|
||||||
if (value) {
|
if (value) {
|
||||||
@@ -395,7 +395,7 @@ export default {
|
|||||||
confirmMessage += ' The file on the server will be unaffected.'
|
confirmMessage += ' The file on the server will be unaffected.'
|
||||||
}
|
}
|
||||||
const { value } = await Dialog.confirm({
|
const { value } = await Dialog.confirm({
|
||||||
title: 'Confirm',
|
title: this.$strings.HeaderConfirm,
|
||||||
message: confirmMessage
|
message: confirmMessage
|
||||||
})
|
})
|
||||||
if (value) {
|
if (value) {
|
||||||
@@ -412,7 +412,7 @@ export default {
|
|||||||
confirmMessage += ' The files on the server and your progress will be unaffected.'
|
confirmMessage += ' The files on the server and your progress will be unaffected.'
|
||||||
}
|
}
|
||||||
const { value } = await Dialog.confirm({
|
const { value } = await Dialog.confirm({
|
||||||
title: 'Confirm',
|
title: this.$strings.HeaderConfirm,
|
||||||
message: confirmMessage
|
message: confirmMessage
|
||||||
})
|
})
|
||||||
if (value) {
|
if (value) {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
<div class="flex-grow"></div>
|
<div class="flex-grow"></div>
|
||||||
<div class="text-xs text-gray-400">{{ log.tag }}</div>
|
<div class="text-xs text-gray-400">{{ log.tag }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs">{{ maskServerAddress ? log.maskedMessage : log.message }}</div>
|
<div class="text-xs break-words">{{ maskServerAddress ? log.maskedMessage : log.message }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -14,16 +14,25 @@
|
|||||||
<template v-for="item in bookResults">
|
<template v-for="item in bookResults">
|
||||||
<div :key="item.libraryItem.id" class="w-full h-16 py-1">
|
<div :key="item.libraryItem.id" class="w-full h-16 py-1">
|
||||||
<nuxt-link :to="`/item/${item.libraryItem.id}`">
|
<nuxt-link :to="`/item/${item.libraryItem.id}`">
|
||||||
<cards-item-search-card :library-item="item.libraryItem" :match-key="item.matchKey" :match-text="item.matchText" :search="lastSearch" />
|
<cards-item-search-card :library-item="item.libraryItem" :search="lastSearch" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<p v-if="podcastResults.length" class="uppercase text-xs text-fg-muted my-1 px-1 font-semibold">{{ $strings.LabelPodcasts }}</p>
|
<p v-if="podcastResults.length" class="font-semibold text-sm mb-1 mt-2">{{ $strings.LabelPodcasts }}</p>
|
||||||
<template v-for="item in podcastResults">
|
<template v-for="item in podcastResults">
|
||||||
<div :key="item.libraryItem.id" class="text-fg select-none relative py-1">
|
<div :key="item.libraryItem.id" class="text-fg select-none relative py-1">
|
||||||
<nuxt-link :to="`/item/${item.libraryItem.id}`">
|
<nuxt-link :to="`/item/${item.libraryItem.id}`">
|
||||||
<cards-item-search-card :library-item="item.libraryItem" :match-key="item.matchKey" :match-text="item.matchText" :search="lastSearch" />
|
<cards-item-search-card :library-item="item.libraryItem" :search="lastSearch" />
|
||||||
|
</nuxt-link>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<p v-if="episodeResults.length" class="font-semibold text-sm mb-1 mt-2">{{ $strings.HeaderEpisodes }}</p>
|
||||||
|
<template v-for="item in episodeResults">
|
||||||
|
<div :key="item.libraryItem.recentEpisode.id" class="text-fg select-none relative py-1">
|
||||||
|
<nuxt-link :to="`/item/${item.libraryItem.id}/${item.libraryItem.recentEpisode.id}`">
|
||||||
|
<cards-episode-search-card :episode="item.libraryItem.recentEpisode" :library-item="item.libraryItem" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -77,6 +86,7 @@ export default {
|
|||||||
isFetching: false,
|
isFetching: false,
|
||||||
bookResults: [],
|
bookResults: [],
|
||||||
podcastResults: [],
|
podcastResults: [],
|
||||||
|
episodeResults: [],
|
||||||
seriesResults: [],
|
seriesResults: [],
|
||||||
authorResults: [],
|
authorResults: [],
|
||||||
narratorResults: [],
|
narratorResults: [],
|
||||||
@@ -91,7 +101,7 @@ export default {
|
|||||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||||
},
|
},
|
||||||
totalResults() {
|
totalResults() {
|
||||||
return this.bookResults.length + this.seriesResults.length + this.authorResults.length + this.podcastResults.length + this.narratorResults.length + this.tagResults.length
|
return this.bookResults.length + this.seriesResults.length + this.authorResults.length + this.podcastResults.length + this.narratorResults.length + this.tagResults.length + this.episodeResults.length
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -104,6 +114,7 @@ export default {
|
|||||||
if (!this.lastSearch) {
|
if (!this.lastSearch) {
|
||||||
this.bookResults = []
|
this.bookResults = []
|
||||||
this.podcastResults = []
|
this.podcastResults = []
|
||||||
|
this.episodeResults = []
|
||||||
this.seriesResults = []
|
this.seriesResults = []
|
||||||
this.authorResults = []
|
this.authorResults = []
|
||||||
this.narratorResults = []
|
this.narratorResults = []
|
||||||
@@ -125,6 +136,7 @@ export default {
|
|||||||
|
|
||||||
this.bookResults = results?.book || []
|
this.bookResults = results?.book || []
|
||||||
this.podcastResults = results?.podcast || []
|
this.podcastResults = results?.podcast || []
|
||||||
|
this.episodeResults = results?.episodes || []
|
||||||
this.seriesResults = results?.series || []
|
this.seriesResults = results?.series || []
|
||||||
this.authorResults = results?.authors || []
|
this.authorResults = results?.authors || []
|
||||||
this.narratorResults = results?.narrators || []
|
this.narratorResults = results?.narrators || []
|
||||||
|
|||||||
@@ -36,24 +36,24 @@
|
|||||||
|
|
||||||
<!-- Playback settings -->
|
<!-- Playback settings -->
|
||||||
<p class="uppercase text-xs font-semibold text-fg-muted mb-2 mt-10">{{ $strings.HeaderPlaybackSettings }}</p>
|
<p class="uppercase text-xs font-semibold text-fg-muted mb-2 mt-10">{{ $strings.HeaderPlaybackSettings }}</p>
|
||||||
|
<div class="py-3 flex items-center">
|
||||||
|
<p class="pr-4 w-36">{{ $strings.LabelJumpBackwardsTime }}</p>
|
||||||
|
<div @click.stop="showJumpBackwardsOptions">
|
||||||
|
<ui-text-input :value="jumpBackwardsOption" readonly append-icon="expand_more" style="width: 145px; max-width: 145px" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="py-3 flex items-center">
|
||||||
|
<p class="pr-4 w-36">{{ $strings.LabelJumpForwardsTime }}</p>
|
||||||
|
<div @click.stop="showJumpForwardOptions">
|
||||||
|
<ui-text-input :value="jumpForwardOption" readonly append-icon="expand_more" style="width: 145px; max-width: 145px" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="flex items-center py-3">
|
<div class="flex items-center py-3">
|
||||||
<div class="w-10 flex justify-center" @click="toggleDisableAutoRewind">
|
<div class="w-10 flex justify-center" @click="toggleDisableAutoRewind">
|
||||||
<ui-toggle-switch v-model="settings.disableAutoRewind" @input="saveSettings" />
|
<ui-toggle-switch v-model="settings.disableAutoRewind" @input="saveSettings" />
|
||||||
</div>
|
</div>
|
||||||
<p class="pl-4">{{ $strings.LabelDisableAutoRewind }}</p>
|
<p class="pl-4">{{ $strings.LabelDisableAutoRewind }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center py-3">
|
|
||||||
<div class="w-10 flex justify-center" @click="toggleJumpBackwards">
|
|
||||||
<span class="material-symbols text-4xl">{{ currentJumpBackwardsTimeIcon }}</span>
|
|
||||||
</div>
|
|
||||||
<p class="pl-4">{{ $strings.LabelJumpBackwardsTime }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center py-3">
|
|
||||||
<div class="w-10 flex justify-center" @click="toggleJumpForward">
|
|
||||||
<span class="material-symbols text-4xl">{{ currentJumpForwardTimeIcon }}</span>
|
|
||||||
</div>
|
|
||||||
<p class="pl-4">{{ $strings.LabelJumpForwardsTime }}</p>
|
|
||||||
</div>
|
|
||||||
<div v-if="!isiOS" class="flex items-center py-3">
|
<div v-if="!isiOS" class="flex items-center py-3">
|
||||||
<div class="w-10 flex justify-center" @click="toggleEnableMp3IndexSeeking">
|
<div class="w-10 flex justify-center" @click="toggleEnableMp3IndexSeeking">
|
||||||
<ui-toggle-switch v-model="settings.enableMp3IndexSeeking" @input="saveSettings" />
|
<ui-toggle-switch v-model="settings.enableMp3IndexSeeking" @input="saveSettings" />
|
||||||
@@ -179,7 +179,7 @@
|
|||||||
<ui-loading-indicator />
|
<ui-loading-indicator />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<modals-dialog v-model="showMoreMenuDialog" :items="moreMenuItems" @action="clickMenuAction" />
|
<modals-dialog v-model="showMoreMenuDialog" :items="moreMenuItems" :selected="moreMenuSelected" @action="clickMenuAction" />
|
||||||
<modals-sleep-timer-length-modal v-model="showSleepTimerLengthModal" @change="sleepTimerLengthModalSelection" />
|
<modals-sleep-timer-length-modal v-model="showSleepTimerLengthModal" @change="sleepTimerLengthModalSelection" />
|
||||||
<modals-auto-sleep-timer-rewind-length-modal v-model="showAutoSleepTimerRewindLengthModal" @change="showAutoSleepTimerRewindLengthModalSelection" />
|
<modals-auto-sleep-timer-rewind-length-modal v-model="showAutoSleepTimerRewindLengthModal" @change="showAutoSleepTimerRewindLengthModalSelection" />
|
||||||
</div>
|
</div>
|
||||||
@@ -187,8 +187,10 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { Dialog } from '@capacitor/dialog'
|
import { Dialog } from '@capacitor/dialog'
|
||||||
|
import jumpLabelMixin from '@/mixins/jumpLabel'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
mixins: [jumpLabelMixin],
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
loading: false,
|
loading: false,
|
||||||
@@ -352,15 +354,21 @@ export default {
|
|||||||
isiOS() {
|
isiOS() {
|
||||||
return this.$platform === 'ios'
|
return this.$platform === 'ios'
|
||||||
},
|
},
|
||||||
jumpForwardItems() {
|
jumpForwardSecondsOptions() {
|
||||||
return this.$store.state.globals.jumpForwardItems || []
|
return this.$store.state.globals.jumpForwardSecondsOptions || []
|
||||||
},
|
},
|
||||||
jumpBackwardsItems() {
|
jumpBackwardsSecondsOptions() {
|
||||||
return this.$store.state.globals.jumpBackwardsItems || []
|
return this.$store.state.globals.jumpBackwardsSecondsOptions || []
|
||||||
},
|
},
|
||||||
languageOptionItems() {
|
languageOptionItems() {
|
||||||
return this.$languageCodeOptions || []
|
return this.$languageCodeOptions || []
|
||||||
},
|
},
|
||||||
|
jumpForwardOption() {
|
||||||
|
return this.getJumpLabel(this.settings.jumpForwardTime)
|
||||||
|
},
|
||||||
|
jumpBackwardsOption() {
|
||||||
|
return this.getJumpLabel(this.settings.jumpBackwardsTime)
|
||||||
|
},
|
||||||
themeOptionItems() {
|
themeOptionItems() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -377,20 +385,6 @@ export default {
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
currentJumpForwardTimeIcon() {
|
|
||||||
return this.jumpForwardItems[this.currentJumpForwardTimeIndex].icon
|
|
||||||
},
|
|
||||||
currentJumpForwardTimeIndex() {
|
|
||||||
var index = this.jumpForwardItems.findIndex((jfi) => jfi.value === this.settings.jumpForwardTime)
|
|
||||||
return index >= 0 ? index : 1
|
|
||||||
},
|
|
||||||
currentJumpBackwardsTimeIcon() {
|
|
||||||
return this.jumpBackwardsItems[this.currentJumpBackwardsTimeIndex].icon
|
|
||||||
},
|
|
||||||
currentJumpBackwardsTimeIndex() {
|
|
||||||
var index = this.jumpBackwardsItems.findIndex((jfi) => jfi.value === this.settings.jumpBackwardsTime)
|
|
||||||
return index >= 0 ? index : 1
|
|
||||||
},
|
|
||||||
shakeSensitivityOption() {
|
shakeSensitivityOption() {
|
||||||
const item = this.shakeSensitivityItems.find((i) => i.value === this.settings.shakeSensitivity)
|
const item = this.shakeSensitivityItems.find((i) => i.value === this.settings.shakeSensitivity)
|
||||||
return item?.text || 'Error'
|
return item?.text || 'Error'
|
||||||
@@ -434,7 +428,29 @@ export default {
|
|||||||
else if (this.moreMenuSetting === 'downloadUsingCellular') return this.downloadUsingCellularItems
|
else if (this.moreMenuSetting === 'downloadUsingCellular') return this.downloadUsingCellularItems
|
||||||
else if (this.moreMenuSetting === 'streamingUsingCellular') return this.streamingUsingCellularItems
|
else if (this.moreMenuSetting === 'streamingUsingCellular') return this.streamingUsingCellularItems
|
||||||
else if (this.moreMenuSetting === 'androidAutoBrowseSeriesSequenceOrder') return this.androidAutoBrowseSeriesSequenceOrderItems
|
else if (this.moreMenuSetting === 'androidAutoBrowseSeriesSequenceOrder') return this.androidAutoBrowseSeriesSequenceOrderItems
|
||||||
|
else if (this.moreMenuSetting === 'jumpForward')
|
||||||
|
return this.jumpForwardSecondsOptions.map((value) => ({
|
||||||
|
text: this.getJumpLabel(value),
|
||||||
|
value: value
|
||||||
|
}))
|
||||||
|
else if (this.moreMenuSetting === 'jumpBackwards')
|
||||||
|
return this.jumpBackwardsSecondsOptions.map((value) => ({
|
||||||
|
text: this.getJumpLabel(value),
|
||||||
|
value: value
|
||||||
|
}))
|
||||||
return []
|
return []
|
||||||
|
},
|
||||||
|
moreMenuSelected() {
|
||||||
|
if (this.moreMenuSetting === 'jumpForward') return this.settings.jumpForwardTime
|
||||||
|
if (this.moreMenuSetting === 'jumpBackwards') return this.settings.jumpBackwardsTime
|
||||||
|
if (this.moreMenuSetting === 'language') return this.settings.languageCode
|
||||||
|
if (this.moreMenuSetting === 'theme') return this.theme
|
||||||
|
if (this.moreMenuSetting === 'downloadUsingCellular') return this.settings.downloadUsingCellular
|
||||||
|
if (this.moreMenuSetting === 'streamingUsingCellular') return this.settings.streamingUsingCellular
|
||||||
|
if (this.moreMenuSetting === 'androidAutoBrowseSeriesSequenceOrder') return this.settings.androidAutoBrowseSeriesSequenceOrder
|
||||||
|
if (this.moreMenuSetting === 'shakeSensitivity') return this.settings.shakeSensitivity
|
||||||
|
if (this.moreMenuSetting === 'hapticFeedback') return this.settings.hapticFeedback
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -468,6 +484,14 @@ export default {
|
|||||||
this.moreMenuSetting = 'theme'
|
this.moreMenuSetting = 'theme'
|
||||||
this.showMoreMenuDialog = true
|
this.showMoreMenuDialog = true
|
||||||
},
|
},
|
||||||
|
showJumpForwardOptions() {
|
||||||
|
this.moreMenuSetting = 'jumpForward'
|
||||||
|
this.showMoreMenuDialog = true
|
||||||
|
},
|
||||||
|
showJumpBackwardsOptions() {
|
||||||
|
this.moreMenuSetting = 'jumpBackwards'
|
||||||
|
this.showMoreMenuDialog = true
|
||||||
|
},
|
||||||
showDownloadUsingCellularOptions() {
|
showDownloadUsingCellularOptions() {
|
||||||
this.moreMenuSetting = 'downloadUsingCellular'
|
this.moreMenuSetting = 'downloadUsingCellular'
|
||||||
this.showMoreMenuDialog = true
|
this.showMoreMenuDialog = true
|
||||||
@@ -503,6 +527,12 @@ export default {
|
|||||||
} else if (this.moreMenuSetting === 'androidAutoBrowseSeriesSequenceOrder') {
|
} else if (this.moreMenuSetting === 'androidAutoBrowseSeriesSequenceOrder') {
|
||||||
this.settings.androidAutoBrowseSeriesSequenceOrder = action
|
this.settings.androidAutoBrowseSeriesSequenceOrder = action
|
||||||
this.saveSettings()
|
this.saveSettings()
|
||||||
|
} else if (this.moreMenuSetting === 'jumpForward') {
|
||||||
|
this.settings.jumpForwardTime = action
|
||||||
|
this.saveSettings()
|
||||||
|
} else if (this.moreMenuSetting === 'jumpBackwards') {
|
||||||
|
this.settings.jumpBackwardsTime = action
|
||||||
|
this.saveSettings()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
saveTheme(theme) {
|
saveTheme(theme) {
|
||||||
@@ -598,17 +628,6 @@ export default {
|
|||||||
this.$setOrientationLock(this.settings.lockOrientation)
|
this.$setOrientationLock(this.settings.lockOrientation)
|
||||||
this.saveSettings()
|
this.saveSettings()
|
||||||
},
|
},
|
||||||
toggleJumpForward() {
|
|
||||||
var next = (this.currentJumpForwardTimeIndex + 1) % 3
|
|
||||||
this.settings.jumpForwardTime = this.jumpForwardItems[next].value
|
|
||||||
this.saveSettings()
|
|
||||||
},
|
|
||||||
toggleJumpBackwards() {
|
|
||||||
var next = (this.currentJumpBackwardsTimeIndex + 4) % 3
|
|
||||||
if (next > 2) return
|
|
||||||
this.settings.jumpBackwardsTime = this.jumpBackwardsItems[next].value
|
|
||||||
this.saveSettings()
|
|
||||||
},
|
|
||||||
async saveSettings() {
|
async saveSettings() {
|
||||||
await this.$hapticsImpact()
|
await this.$hapticsImpact()
|
||||||
const updatedDeviceData = await this.$db.updateDeviceSettings({ ...this.settings })
|
const updatedDeviceData = await this.$db.updateDeviceSettings({ ...this.settings })
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export default function ({ $axios, store, $db }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (window.location.pathname !== '/connect') {
|
if (window.location.pathname !== '/connect') {
|
||||||
window.location.href = '/connect'
|
window.location.href = '/connect?error=refreshTokenFailed&serverConnectionConfigId=' + serverConnectionConfigId
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[axios] Failed to handle refresh failure:', error)
|
console.error('[axios] Failed to handle refresh failure:', error)
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ class AbsDatabaseWeb extends WebPlugin {
|
|||||||
ssc.customHeaders = serverConnectionConfig.customHeaders || {}
|
ssc.customHeaders = serverConnectionConfig.customHeaders || {}
|
||||||
|
|
||||||
if (serverConnectionConfig.refreshToken) {
|
if (serverConnectionConfig.refreshToken) {
|
||||||
console.log('[AbsDatabase] Updating refresh token...', serverConnectionConfig.refreshToken)
|
console.log('[AbsDatabase] Updating refresh token...')
|
||||||
// Only using local storage for web version that is only used for testing
|
// Only using local storage for web version that is only used for testing
|
||||||
localStorage.setItem(`refresh_token_${ssc.id}`, serverConnectionConfig.refreshToken)
|
localStorage.setItem(`refresh_token_${ssc.id}`, serverConnectionConfig.refreshToken)
|
||||||
}
|
}
|
||||||
@@ -71,7 +71,7 @@ class AbsDatabaseWeb extends WebPlugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (serverConnectionConfig.refreshToken) {
|
if (serverConnectionConfig.refreshToken) {
|
||||||
console.log('[AbsDatabase] Setting refresh token...', serverConnectionConfig.refreshToken)
|
console.log('[AbsDatabase] Setting refresh token...')
|
||||||
// Only using local storage for web version that is only used for testing
|
// Only using local storage for web version that is only used for testing
|
||||||
localStorage.setItem(`refresh_token_${ssc.id}`, serverConnectionConfig.refreshToken)
|
localStorage.setItem(`refresh_token_${ssc.id}`, serverConnectionConfig.refreshToken)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,17 +128,16 @@ export default ({ app, store }, inject) => {
|
|||||||
|
|
||||||
// Listen for token refresh events from native app
|
// Listen for token refresh events from native app
|
||||||
AbsDatabase.addListener('onTokenRefresh', (data) => {
|
AbsDatabase.addListener('onTokenRefresh', (data) => {
|
||||||
console.log('[db] onTokenRefresh', data)
|
|
||||||
store.commit('user/setAccessToken', data.accessToken)
|
store.commit('user/setAccessToken', data.accessToken)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Listen for token refresh failure events from native app
|
// Listen for token refresh failure events from native app
|
||||||
AbsDatabase.addListener('onTokenRefreshFailure', async (data) => {
|
AbsDatabase.addListener('onTokenRefreshFailure', async (data) => {
|
||||||
console.log('[db] onTokenRefreshFailure', data)
|
console.log('[db] onTokenRefreshFailure')
|
||||||
// Clear store and redirect to login page
|
// Clear store and redirect to login page
|
||||||
await store.dispatch('user/logout')
|
await store.dispatch('user/logout')
|
||||||
if (window.location.pathname !== '/connect') {
|
if (window.location.pathname !== '/connect') {
|
||||||
window.location.href = '/connect'
|
window.location.href = '/connect?error=refreshTokenFailed&serverConnectionConfigId=' + data.serverConnectionConfigId || ''
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ const defaultCode = 'en-us'
|
|||||||
let $localStore = null
|
let $localStore = null
|
||||||
|
|
||||||
const languageCodeMap = {
|
const languageCodeMap = {
|
||||||
|
be: { label: 'Беларуская', dateFnsLocale: 'be' },
|
||||||
bn: { label: 'বাংলা', dateFnsLocale: 'bn' },
|
bn: { label: 'বাংলা', dateFnsLocale: 'bn' },
|
||||||
|
bg: { label: 'Български', dateFnsLocale: 'bg' },
|
||||||
ca: { label: 'Català', dateFnsLocale: 'ca' },
|
ca: { label: 'Català', dateFnsLocale: 'ca' },
|
||||||
cs: { label: 'Čeština', dateFnsLocale: 'cs' },
|
cs: { label: 'Čeština', dateFnsLocale: 'cs' },
|
||||||
da: { label: 'Dansk', dateFnsLocale: 'da' },
|
da: { label: 'Dansk', dateFnsLocale: 'da' },
|
||||||
@@ -25,6 +27,7 @@ const languageCodeMap = {
|
|||||||
ru: { label: 'Русский', dateFnsLocale: 'ru' },
|
ru: { label: 'Русский', dateFnsLocale: 'ru' },
|
||||||
sl: { label: 'Slovenščina', dateFnsLocale: 'sl' },
|
sl: { label: 'Slovenščina', dateFnsLocale: 'sl' },
|
||||||
sv: { label: 'Svenska', dateFnsLocale: 'sv' },
|
sv: { label: 'Svenska', dateFnsLocale: 'sv' },
|
||||||
|
tr: { label: 'Türkçe', dateFnsLocale: 'tr' },
|
||||||
uk: { label: 'Українська', dateFnsLocale: 'uk' },
|
uk: { label: 'Українська', dateFnsLocale: 'uk' },
|
||||||
'vi-vn': { label: 'Tiếng Việt', dateFnsLocale: 'vi' },
|
'vi-vn': { label: 'Tiếng Việt', dateFnsLocale: 'vi' },
|
||||||
'zh-cn': { label: '简体中文 (Simplified Chinese)', dateFnsLocale: 'zhCN' }
|
'zh-cn': { label: '简体中文 (Simplified Chinese)', dateFnsLocale: 'zhCN' }
|
||||||
|
|||||||
@@ -314,8 +314,8 @@ export default ({ store, app }, inject) => {
|
|||||||
}
|
}
|
||||||
if (!canGoBack) {
|
if (!canGoBack) {
|
||||||
const { value } = await Dialog.confirm({
|
const { value } = await Dialog.confirm({
|
||||||
title: 'Confirm',
|
title: eventBus.$strings.HeaderConfirm,
|
||||||
message: `Did you want to exit the app?`
|
message: eventBus.$strings.MessageConfirmAppExit
|
||||||
})
|
})
|
||||||
if (value) {
|
if (value) {
|
||||||
App.exitApp()
|
App.exitApp()
|
||||||
|
|||||||
@@ -43,7 +43,8 @@ export default function ({ store, $db, $socket }, inject) {
|
|||||||
}
|
}
|
||||||
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)
|
const message = typeof res.data === 'string' ? res.data : `HTTP ${res.status}`
|
||||||
|
throw new Error(message)
|
||||||
}
|
}
|
||||||
return res.data
|
return res.data
|
||||||
})
|
})
|
||||||
@@ -100,7 +101,8 @@ export default function ({ store, $db, $socket }, inject) {
|
|||||||
|
|
||||||
if (retryResponse.status >= 400) {
|
if (retryResponse.status >= 400) {
|
||||||
console.error(`[nativeHttp] Retry request failed with status ${retryResponse.status}`)
|
console.error(`[nativeHttp] Retry request failed with status ${retryResponse.status}`)
|
||||||
throw new Error(retryResponse.data)
|
const message = typeof retryResponse.data === 'string' ? retryResponse.data : `HTTP ${retryResponse.status}`
|
||||||
|
throw new Error(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
return retryResponse.data
|
return retryResponse.data
|
||||||
@@ -221,7 +223,7 @@ export default function ({ store, $db, $socket }, inject) {
|
|||||||
|
|
||||||
// Redirect to login page
|
// Redirect to login page
|
||||||
if (window.location.pathname !== '/connect') {
|
if (window.location.pathname !== '/connect') {
|
||||||
window.location.href = '/connect'
|
window.location.href = '/connect?error=refreshTokenFailed&serverConnectionConfigId=' + serverConnectionConfigId
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[nativeHttp] Failed to handle refresh failure:', error)
|
console.error('[nativeHttp] Failed to handle refresh failure:', error)
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ class ServerSocket extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onAuthFailed(data) {
|
onAuthFailed(data) {
|
||||||
console.log('[SOCKET] Auth failed', data)
|
console.log('[SOCKET] Auth failed: ' + (data?.message || 'Unknown reason'))
|
||||||
this.isAuthenticated = false
|
this.isAuthenticated = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -144,6 +144,12 @@ npx cap open android
|
|||||||
|
|
||||||
Start coding!
|
Start coding!
|
||||||
|
|
||||||
|
After making changes to the JS layer you need to rebuild the nuxt pages and sync them to the native shells:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
npm run sync
|
||||||
|
```
|
||||||
|
|
||||||
### Mac Environment Setup for iOS
|
### Mac Environment Setup for iOS
|
||||||
|
|
||||||
Required Software:
|
Required Software:
|
||||||
@@ -213,3 +219,9 @@ npx cap open ios
|
|||||||
<br>
|
<br>
|
||||||
|
|
||||||
Start coding!
|
Start coding!
|
||||||
|
|
||||||
|
After making changes to the JS layer you need to rebuild the nuxt pages and sync them to the native shells:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
npm run sync
|
||||||
|
```
|
||||||
|
|||||||
@@ -5,34 +5,8 @@ export const state = () => ({
|
|||||||
series: null,
|
series: null,
|
||||||
localMediaProgress: [],
|
localMediaProgress: [],
|
||||||
lastSearch: null,
|
lastSearch: null,
|
||||||
jumpForwardItems: [
|
jumpForwardSecondsOptions: [5, 10, 15, 30, 60, 120, 300],
|
||||||
{
|
jumpBackwardsSecondsOptions: [5, 10, 15, 30, 60, 120, 300],
|
||||||
icon: 'forward_5',
|
|
||||||
value: 5
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: 'forward_10',
|
|
||||||
value: 10
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: 'forward_30',
|
|
||||||
value: 30
|
|
||||||
}
|
|
||||||
],
|
|
||||||
jumpBackwardsItems: [
|
|
||||||
{
|
|
||||||
icon: 'replay_5',
|
|
||||||
value: 5
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: 'replay_10',
|
|
||||||
value: 10
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: 'replay_30',
|
|
||||||
value: 30
|
|
||||||
}
|
|
||||||
],
|
|
||||||
libraryIcons: ['database', 'audiobookshelf', 'books-1', 'books-2', 'book-1', 'microphone-1', 'microphone-3', 'radio', 'podcast', 'rss', 'headphones', 'music', 'file-picture', 'rocket', 'power', 'star', 'heart'],
|
libraryIcons: ['database', 'audiobookshelf', 'books-1', 'books-2', 'book-1', 'microphone-1', 'microphone-3', 'radio', 'podcast', 'rss', 'headphones', 'music', 'file-picture', 'rocket', 'power', 'star', 'heart'],
|
||||||
selectedPlaylistItems: [],
|
selectedPlaylistItems: [],
|
||||||
showPlaylistsAddCreateModal: false,
|
showPlaylistsAddCreateModal: false,
|
||||||
@@ -111,14 +85,6 @@ export const getters = {
|
|||||||
if (episodeId != null && lmp.episodeId != episodeId) return false
|
if (episodeId != null && lmp.episodeId != episodeId) return false
|
||||||
return lmp.libraryItemId == libraryItemId
|
return lmp.libraryItemId == libraryItemId
|
||||||
})
|
})
|
||||||
},
|
|
||||||
getJumpForwardIcon: (state) => (jumpForwardTime) => {
|
|
||||||
const item = state.jumpForwardItems.find((i) => i.value == jumpForwardTime)
|
|
||||||
return item ? item.icon : 'forward_10'
|
|
||||||
},
|
|
||||||
getJumpBackwardsIcon: (state) => (jumpBackwardsTime) => {
|
|
||||||
const item = state.jumpBackwardsItems.find((i) => i.value == jumpBackwardsTime)
|
|
||||||
return item ? item.icon : 'replay_10'
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -231,7 +231,6 @@ export const mutations = {
|
|||||||
state.user = user
|
state.user = user
|
||||||
},
|
},
|
||||||
setAccessToken(state, accessToken) {
|
setAccessToken(state, accessToken) {
|
||||||
console.log('[user] setAccessToken', accessToken)
|
|
||||||
state.accessToken = accessToken
|
state.accessToken = accessToken
|
||||||
},
|
},
|
||||||
removeMediaProgress(state, id) {
|
removeMediaProgress(state, id) {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"ButtonCancelTimer": "إلغاء المؤقت",
|
"ButtonCancelTimer": "إلغاء المؤقت",
|
||||||
"ButtonClearFilter": "تصفية الفرز",
|
"ButtonClearFilter": "تصفية الفرز",
|
||||||
"ButtonClearLogs": "مسح السجل",
|
"ButtonClearLogs": "مسح السجل",
|
||||||
"ButtonCloseFeed": "إغلاق",
|
"ButtonCloseFeed": "إغلاق الموجز",
|
||||||
"ButtonCollections": "المجموعات",
|
"ButtonCollections": "المجموعات",
|
||||||
"ButtonConnect": "اتصل",
|
"ButtonConnect": "اتصل",
|
||||||
"ButtonConnectToServer": "اتصال بالخادم",
|
"ButtonConnectToServer": "اتصال بالخادم",
|
||||||
@@ -63,6 +63,7 @@
|
|||||||
"HeaderChapters": "الفصول",
|
"HeaderChapters": "الفصول",
|
||||||
"HeaderCollection": "مجموعة",
|
"HeaderCollection": "مجموعة",
|
||||||
"HeaderCollectionItems": "عناصر المجموعة",
|
"HeaderCollectionItems": "عناصر المجموعة",
|
||||||
|
"HeaderConfirm": "تأكيد",
|
||||||
"HeaderConnectionStatus": "حالة الاتصال",
|
"HeaderConnectionStatus": "حالة الاتصال",
|
||||||
"HeaderDataSettings": "إعدادات البيانات",
|
"HeaderDataSettings": "إعدادات البيانات",
|
||||||
"HeaderDetails": "التفاصيل",
|
"HeaderDetails": "التفاصيل",
|
||||||
@@ -91,6 +92,7 @@
|
|||||||
"HeaderStatsRecentSessions": "الجلسات الأخيرة",
|
"HeaderStatsRecentSessions": "الجلسات الأخيرة",
|
||||||
"HeaderTableOfContents": "جدول المحتويات",
|
"HeaderTableOfContents": "جدول المحتويات",
|
||||||
"HeaderUserInterfaceSettings": "إعدادات واجهة المستخدم",
|
"HeaderUserInterfaceSettings": "إعدادات واجهة المستخدم",
|
||||||
|
"HeaderWelcome": "مرحبا, <strong>{0}</strong>",
|
||||||
"HeaderYourStats": "إحصائياتك",
|
"HeaderYourStats": "إحصائياتك",
|
||||||
"LabelAddToPlaylist": "أضف إلى قائمة التشغيل",
|
"LabelAddToPlaylist": "أضف إلى قائمة التشغيل",
|
||||||
"LabelAddedAt": "أضيفت على",
|
"LabelAddedAt": "أضيفت على",
|
||||||
@@ -146,6 +148,7 @@
|
|||||||
"LabelEndOfChapter": "نهاية الفصل",
|
"LabelEndOfChapter": "نهاية الفصل",
|
||||||
"LabelEndTime": "وقت النهاية",
|
"LabelEndTime": "وقت النهاية",
|
||||||
"LabelEpisode": "الحلقة",
|
"LabelEpisode": "الحلقة",
|
||||||
|
"LabelExplicit": "محتوى صريح",
|
||||||
"LabelFeedURL": "عنوان التغذية",
|
"LabelFeedURL": "عنوان التغذية",
|
||||||
"LabelFile": "الملف",
|
"LabelFile": "الملف",
|
||||||
"LabelFileBirthtime": "وقت انشاء الملف",
|
"LabelFileBirthtime": "وقت انشاء الملف",
|
||||||
@@ -154,6 +157,9 @@
|
|||||||
"LabelFinished": "المنجزة",
|
"LabelFinished": "المنجزة",
|
||||||
"LabelFolder": "المجلد",
|
"LabelFolder": "المجلد",
|
||||||
"LabelFontBoldness": "تعريض الخط",
|
"LabelFontBoldness": "تعريض الخط",
|
||||||
|
"LabelFontFamily": "عائلة الخطوط",
|
||||||
|
"LabelFontFamilySans": "سانس",
|
||||||
|
"LabelFontFamilySerif": "سارف",
|
||||||
"LabelFontScale": "نطاق الخط",
|
"LabelFontScale": "نطاق الخط",
|
||||||
"LabelGenre": "التصنيف",
|
"LabelGenre": "التصنيف",
|
||||||
"LabelGenres": "التصانيف",
|
"LabelGenres": "التصانيف",
|
||||||
@@ -297,6 +303,7 @@
|
|||||||
"MessageDownloading": "جاري التنزيل...",
|
"MessageDownloading": "جاري التنزيل...",
|
||||||
"MessageDownloadingEpisode": "جاري تنزيل الحلقة",
|
"MessageDownloadingEpisode": "جاري تنزيل الحلقة",
|
||||||
"MessageEpisodesQueuedForDownload": "تمت إضافة {0} حلقة (حلقات) إلى قائمة انتظار التنزيل",
|
"MessageEpisodesQueuedForDownload": "تمت إضافة {0} حلقة (حلقات) إلى قائمة انتظار التنزيل",
|
||||||
|
"MessageFailedToRefreshToken": "فشل في تحديث الرمز، إعادة تسجيل الدخول مطلوب",
|
||||||
"MessageFeedURLWillBe": "سيكون رابط التغذية هو {0}",
|
"MessageFeedURLWillBe": "سيكون رابط التغذية هو {0}",
|
||||||
"MessageFetching": "جاري الجلب...",
|
"MessageFetching": "جاري الجلب...",
|
||||||
"MessageFollowTheProjectOnGithub": "تابع المشروع على Github",
|
"MessageFollowTheProjectOnGithub": "تابع المشروع على Github",
|
||||||
@@ -324,6 +331,8 @@
|
|||||||
"MessageNoSeries": "لا توجد سلاسل",
|
"MessageNoSeries": "لا توجد سلاسل",
|
||||||
"MessageNoUpdatesWereNecessary": "لا حاجة لأي تحديثات",
|
"MessageNoUpdatesWereNecessary": "لا حاجة لأي تحديثات",
|
||||||
"MessageNoUserPlaylists": "ليس لديك أي قوائم تشغيل",
|
"MessageNoUserPlaylists": "ليس لديك أي قوائم تشغيل",
|
||||||
|
"MessageOldServerAuthReLoginRequired": "تم تحسين المصادقة في الخادم v2.26.0 لضمان الأمان. يُطلب من جميع المستخدمين إعادة تسجيل الدخول.",
|
||||||
|
"MessageOldServerAuthWarning": "الخادم يستخدم مصادقة قديمة",
|
||||||
"MessageOldServerConnectionWarning": "تكوين اتصال الخادم يستخدم معرّف مستخدم قديم. يرجى حذف اتصال الخادم هذا وإعادة إضافته.",
|
"MessageOldServerConnectionWarning": "تكوين اتصال الخادم يستخدم معرّف مستخدم قديم. يرجى حذف اتصال الخادم هذا وإعادة إضافته.",
|
||||||
"MessageOldServerConnectionWarningHelp": "لقد قمتَ بإعداد الاتصال بهذا الخادم قبل ترحيل قاعدة البيانات في الإصدار 2.3.0، الصادر في يونيو 2023. سيؤدي تحديث الخادم المستقبلي إلى إلغاء إمكانية تسجيل الدخول باستخدام هذا الاتصال القديم. يُرجى حذف اتصال الخادم الحالي والاتصال مرة أخرى (باستخدام نفس عنوان الخادم وبيانات الاعتماد). إذا كانت لديك أي وسائط مُنزّلة على هذا الجهاز، فيجب تنزيلها مرة أخرى لمزامنتها مع الخادم.",
|
"MessageOldServerConnectionWarningHelp": "لقد قمتَ بإعداد الاتصال بهذا الخادم قبل ترحيل قاعدة البيانات في الإصدار 2.3.0، الصادر في يونيو 2023. سيؤدي تحديث الخادم المستقبلي إلى إلغاء إمكانية تسجيل الدخول باستخدام هذا الاتصال القديم. يُرجى حذف اتصال الخادم الحالي والاتصال مرة أخرى (باستخدام نفس عنوان الخادم وبيانات الاعتماد). إذا كانت لديك أي وسائط مُنزّلة على هذا الجهاز، فيجب تنزيلها مرة أخرى لمزامنتها مع الخادم.",
|
||||||
"MessagePodcastSearchField": "أدخل مصطلح البحث أو عنوان URL الخاص بتغذية RSS",
|
"MessagePodcastSearchField": "أدخل مصطلح البحث أو عنوان URL الخاص بتغذية RSS",
|
||||||
|
|||||||
@@ -6,15 +6,16 @@
|
|||||||
"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": "Выдаліць лакальны выпуск",
|
||||||
"ButtonDeleteLocalFile": "Выдаліць лакальны файл",
|
"ButtonDeleteLocalFile": "Выдаліць лакальны файл",
|
||||||
"ButtonDeleteLocalItem": "Выдаліць лакальны элемент",
|
"ButtonDeleteLocalItem": "Выдаліць лакальны элемент",
|
||||||
"ButtonDisableAutoTimer": "Адключыць аўтаматычны таймер",
|
"ButtonDisableAutoTimer": "Адключыць аўтаматычны таймер",
|
||||||
@@ -26,16 +27,18 @@
|
|||||||
"ButtonLatest": "Апошняе",
|
"ButtonLatest": "Апошняе",
|
||||||
"ButtonLibrary": "Бібліятэка",
|
"ButtonLibrary": "Бібліятэка",
|
||||||
"ButtonLocalMedia": "Лакальныя медыя",
|
"ButtonLocalMedia": "Лакальныя медыя",
|
||||||
|
"ButtonLogs": "Журналы",
|
||||||
"ButtonManageLocalFiles": "Кіраванне лакальнымі файламі",
|
"ButtonManageLocalFiles": "Кіраванне лакальнымі файламі",
|
||||||
"ButtonNewFolder": "Новая тэчка",
|
"ButtonMaskServerAddress": "Схаваць адрас сервера",
|
||||||
"ButtonNextEpisode": "Наступны эпізод",
|
"ButtonNewFolder": "Новая папка",
|
||||||
"ButtonOk": "Добра",
|
"ButtonNextEpisode": "Наступны выпуск",
|
||||||
|
"ButtonOk": "ОК",
|
||||||
"ButtonOpenFeed": "Адкрыць стужку",
|
"ButtonOpenFeed": "Адкрыць стужку",
|
||||||
"ButtonOverride": "Пераазначыць",
|
"ButtonOverride": "Пераазначыць",
|
||||||
"ButtonPause": "Паўза",
|
"ButtonPause": "Прыпыніць",
|
||||||
"ButtonPlay": "Прайграць",
|
"ButtonPlay": "Прайграць",
|
||||||
"ButtonPlayEpisode": "Прайграць эпізод",
|
"ButtonPlayEpisode": "Прайграць выпуск",
|
||||||
"ButtonPlaylists": "Спісы прайгравання",
|
"ButtonPlaylists": "Плэй-лісты",
|
||||||
"ButtonRead": "Чытаць",
|
"ButtonRead": "Чытаць",
|
||||||
"ButtonReadLess": "Чытаць менш",
|
"ButtonReadLess": "Чытаць менш",
|
||||||
"ButtonReadMore": "Чытаць больш",
|
"ButtonReadMore": "Чытаць больш",
|
||||||
@@ -50,36 +53,38 @@
|
|||||||
"ButtonStream": "Трансляцыя",
|
"ButtonStream": "Трансляцыя",
|
||||||
"ButtonSubmit": "Адправіць",
|
"ButtonSubmit": "Адправіць",
|
||||||
"ButtonSwitchServerUser": "Змяніць Сервер/Карыстальніка",
|
"ButtonSwitchServerUser": "Змяніць Сервер/Карыстальніка",
|
||||||
|
"ButtonUnmaskServerAddress": "Паказаць адрас сервера",
|
||||||
"ButtonUserStats": "Статыстыка карыстальніка",
|
"ButtonUserStats": "Статыстыка карыстальніка",
|
||||||
"ButtonYes": "Так",
|
"ButtonYes": "Так",
|
||||||
"HeaderAccount": "Уліковы запіс",
|
"HeaderAccount": "Уліковы запіс",
|
||||||
"HeaderAdvanced": "Дадаткова",
|
"HeaderAdvanced": "Дадаткова",
|
||||||
"HeaderAndroidAutoSettings": "Налады Android Auto",
|
"HeaderAndroidAutoSettings": "Налады Android Auto",
|
||||||
"HeaderAudioTracks": "Аўдыядарожкі",
|
"HeaderAudioTracks": "Аўдыятрэкі",
|
||||||
"HeaderChapters": "Раздзелы",
|
"HeaderChapters": "Раздзелы",
|
||||||
"HeaderCollection": "Калекцыя",
|
"HeaderCollection": "Калекцыя",
|
||||||
"HeaderCollectionItems": "Элементы калекцыі",
|
"HeaderCollectionItems": "Элементы калекцыі",
|
||||||
|
"HeaderConfirm": "Пацвердзіць",
|
||||||
"HeaderConnectionStatus": "Стан злучэння",
|
"HeaderConnectionStatus": "Стан злучэння",
|
||||||
"HeaderDataSettings": "Налады дадзеных",
|
"HeaderDataSettings": "Налады даных",
|
||||||
"HeaderDetails": "Падрабязнасці",
|
"HeaderDetails": "Падрабязнасці",
|
||||||
"HeaderDownloads": "Спампоўкі",
|
"HeaderDownloads": "Спампоўкі",
|
||||||
"HeaderEbookFiles": "Файлы электронных кніг",
|
"HeaderEbookFiles": "Файлы электронных кніг",
|
||||||
"HeaderEpisodes": "Эпізоды",
|
"HeaderEpisodes": "Выпускі",
|
||||||
"HeaderEreaderSettings": "Налады прылады для чытання",
|
"HeaderEreaderSettings": "Налады прылады для чытання",
|
||||||
"HeaderLatestEpisodes": "Апошнія эпізоды",
|
"HeaderLatestEpisodes": "Апошнія выпускі",
|
||||||
"HeaderLibraries": "Бібліятэкі",
|
"HeaderLibraries": "Бібліятэкі",
|
||||||
"HeaderLocalFolders": "Лакальныя тэчкі",
|
"HeaderLocalFolders": "Лакальныя папкі",
|
||||||
"HeaderLocalLibraryItems": "Элементы лакальнай бібліятэкі",
|
"HeaderLocalLibraryItems": "Элементы лакальнай бібліятэкі",
|
||||||
"HeaderNewPlaylist": "Новы спіс прайгравання",
|
"HeaderNewPlaylist": "Новы плэй-ліст",
|
||||||
"HeaderOpenRSSFeed": "Адкрыць RSS-стужку",
|
"HeaderOpenRSSFeed": "Адкрыць RSS-стужку",
|
||||||
"HeaderPlaybackSettings": "Налады прайгравання",
|
"HeaderPlaybackSettings": "Налады прайгравання",
|
||||||
"HeaderPlaylist": "Спіс прайгравання",
|
"HeaderPlaylist": "Плэй-ліст",
|
||||||
"HeaderPlaylistItems": "Элементы спіса прайгравання",
|
"HeaderPlaylistItems": "Элементы плэй-ліста",
|
||||||
"HeaderProgressSyncFailed": "Не ўдалося сінхранізаваць прагрэс",
|
"HeaderProgressSyncFailed": "Не ўдалося сінхранізаваць прагрэс",
|
||||||
"HeaderRSSFeed": "RSS-стужка",
|
"HeaderRSSFeed": "RSS-стужка",
|
||||||
"HeaderRSSFeedGeneral": "Падрабязнасці RSS",
|
"HeaderRSSFeedGeneral": "Падрабязнасці RSS",
|
||||||
"HeaderRSSFeedIsOpen": "RSS-стужка адкрыта",
|
"HeaderRSSFeedIsOpen": "RSS-стужка адкрытая",
|
||||||
"HeaderSelectDownloadLocation": "Абраць месцазнаходжання для спампоўкі",
|
"HeaderSelectDownloadLocation": "Выберыце размяшчэнне для спампоўкі",
|
||||||
"HeaderSettings": "Налады",
|
"HeaderSettings": "Налады",
|
||||||
"HeaderSleepTimer": "Таймер сну",
|
"HeaderSleepTimer": "Таймер сну",
|
||||||
"HeaderSleepTimerSettings": "Налады таймера сну",
|
"HeaderSleepTimerSettings": "Налады таймера сну",
|
||||||
@@ -87,8 +92,9 @@
|
|||||||
"HeaderStatsRecentSessions": "Апошнія сеансы",
|
"HeaderStatsRecentSessions": "Апошнія сеансы",
|
||||||
"HeaderTableOfContents": "Змест",
|
"HeaderTableOfContents": "Змест",
|
||||||
"HeaderUserInterfaceSettings": "Налады інтэрфейсу карыстальніка",
|
"HeaderUserInterfaceSettings": "Налады інтэрфейсу карыстальніка",
|
||||||
|
"HeaderWelcome": "Вітаем, <strong>{0}</strong>",
|
||||||
"HeaderYourStats": "Ваша статыстыка",
|
"HeaderYourStats": "Ваша статыстыка",
|
||||||
"LabelAddToPlaylist": "Дадаць у спіс прайгравання",
|
"LabelAddToPlaylist": "Дадаць у плэй-ліст",
|
||||||
"LabelAddedAt": "Дата дабаўлення",
|
"LabelAddedAt": "Дата дабаўлення",
|
||||||
"LabelAddedDate": "Дададзена {0}",
|
"LabelAddedDate": "Дададзена {0}",
|
||||||
"LabelAll": "Усе",
|
"LabelAll": "Усе",
|
||||||
@@ -99,57 +105,62 @@
|
|||||||
"LabelAndroidAutoBrowseSeriesSequenceOrder": "Парадак кніг у серыі",
|
"LabelAndroidAutoBrowseSeriesSequenceOrder": "Парадак кніг у серыі",
|
||||||
"LabelAskConfirmation": "Запытваць пацверджанне",
|
"LabelAskConfirmation": "Запытваць пацверджанне",
|
||||||
"LabelAuthor": "Аўтар",
|
"LabelAuthor": "Аўтар",
|
||||||
"LabelAuthorFirstLast": "Аўтар (Імя Прозвішча)",
|
"LabelAuthorFirstLast": "Аўтар (імя, прозвішча)",
|
||||||
"LabelAuthorLastFirst": "Аўтар (Прозвішча, Імя)",
|
"LabelAuthorLastFirst": "Аўтар (прозвішча, імя)",
|
||||||
"LabelAuthors": "Аўтары",
|
"LabelAuthors": "Аўтары",
|
||||||
"LabelAutoDownloadEpisodes": "Аўтаматычнае спампаванне эпізодаў",
|
"LabelAutoDownloadEpisodes": "Аўтаматычна спампоўваць выпускі",
|
||||||
"LabelAutoRewindTime": "Час аўтаматычнай перамоткі назад",
|
"LabelAutoRewindTime": "Час аўтаматычнай перамоткі назад",
|
||||||
"LabelAutoSleepTimer": "Аўтаматычны таймер сну",
|
"LabelAutoSleepTimer": "Аўтаматычны таймер сну",
|
||||||
"LabelAutoSleepTimerAutoRewind": "Аўтаматычная перамотка таймера сну",
|
"LabelAutoSleepTimerAutoRewind": "Аўтаматычная перамотка таймера сну",
|
||||||
"LabelAutoSleepTimerAutoRewindHelp": "Калі таймер аўтаматычнага сну скончыцца, паўторнае прайграванне элемента аўтаматычна перамотвае вашу пазіцыю.",
|
"LabelAutoSleepTimerAutoRewindHelp": "Пасля заканчэння аўтаматычнага таймера сну паўторнае прайграванне элемента аўтаматычна перамотвае вашу пазіцыю.",
|
||||||
"LabelAutoSleepTimerHelp": "Калі медыя прайграецца ў вызначаны час паміж пачаткам і канцом, таймер сну будзе запушчаны аўтаматычна.",
|
"LabelAutoSleepTimerHelp": "Калі медыя прайграецца ў вызначаны час паміж пачаткам і канцом, таймер сну будзе запушчаны аўтаматычна.",
|
||||||
"LabelBooks": "Кнігі",
|
"LabelBooks": "Кнігі",
|
||||||
"LabelChapterTrack": "Дарожка раздзела",
|
"LabelByAuthor": "– {0}",
|
||||||
|
"LabelChapterTrack": "Трэк раздзела",
|
||||||
"LabelChapters": "Раздзелы",
|
"LabelChapters": "Раздзелы",
|
||||||
"LabelClosePlayer": "Зачыніць прайгравальнік",
|
"LabelClosePlayer": "Зачыніць прайгравальнік",
|
||||||
"LabelCollapseSeries": "Згарнуць серыі",
|
"LabelCollapseSeries": "Згарнуць серыі",
|
||||||
"LabelComplete": "Завершана",
|
"LabelComplete": "Завяршыць",
|
||||||
"LabelContinueBooks": "Працягнуць кнігі",
|
"LabelContinueBooks": "Працягнуць кнігі",
|
||||||
"LabelContinueEpisodes": "Працягнуць эпізоды",
|
"LabelContinueEpisodes": "Працягнуць выпускі",
|
||||||
"LabelContinueListening": "Працягваць слухаць",
|
"LabelContinueListening": "Працяг праслухоўвання",
|
||||||
"LabelContinueReading": "Працягнуць чытанне",
|
"LabelContinueReading": "Працягнуць чытанне",
|
||||||
"LabelContinueSeries": "Працягнуць серыі",
|
"LabelContinueSeries": "Працягнуць серыі",
|
||||||
"LabelCustomTime": "Свой час",
|
"LabelCustomTime": "Свой час",
|
||||||
"LabelDescription": "Апісанне",
|
"LabelDescription": "Апісанне",
|
||||||
"LabelDisableAudioFadeOut": "Адключыць змяншэнне гучнасці",
|
"LabelDisableAudioFadeOut": "Адключыць змяншэнне гучнасці",
|
||||||
"LabelDisableAudioFadeOutHelp": "Гучнасць аўдыё пачне змяншацца, калі на таймеры сну застанецца менш за 1 хвіліну. Уключыце гэтую наладу, каб пазбегнуць змяншэння гучнасці.",
|
"LabelDisableAudioFadeOutHelp": "Гучнасць гуку пачне змяншацца, калі на таймеры сну застанецца менш за 1 хвіліну. Уключыце гэтую наладу, каб пазбегнуць змяншэння гучнасці.",
|
||||||
"LabelDisableAutoRewind": "Адключыць аўтаматычную перамотку назад",
|
"LabelDisableAutoRewind": "Адключыць аўтаматычную перамотку назад",
|
||||||
"LabelDisableShakeToReset": "Адключыць скід страсеннем",
|
"LabelDisableShakeToReset": "Адключыць скід страсеннем",
|
||||||
"LabelDisableShakeToResetHelp": "Калі патрасці прыладу падчас працы таймера АБО на працягу 2 хвілін пасля заканчэння таймера, таймер сну скідваецца. Уключыце гэтую наладу, каб адключыць скід страсеннем.",
|
"LabelDisableShakeToResetHelp": "Калі патрасці прыладу падчас працы таймера АБО на працягу 2 хвілін пасля заканчэння таймера, таймер сну скідваецца. Уключыце гэтую наладу, каб адключыць скід страсеннем.",
|
||||||
"LabelDisableVibrateOnReset": "Адключыць вібрацыю пры скідзе",
|
"LabelDisableVibrateOnReset": "Адключыць вібрацыю пры скідзе",
|
||||||
"LabelDisableVibrateOnResetHelp": "Калі таймер сну скідваецца, ваша прылада будзе вібраваць. Уключыце гэтую наладу, каб адключыць вібрацыю пры скідзе таймера сну.",
|
"LabelDisableVibrateOnResetHelp": "Калі таймер сну скідваецца, ваша прылада будзе вібраваць. Уключыце гэтую наладу, каб адключыць вібрацыю пры скідзе таймера сну.",
|
||||||
"LabelDiscover": "Знайсці",
|
"LabelDiscover": "Знаходкі",
|
||||||
"LabelDownload": "Спампаваць",
|
"LabelDownload": "Спампаваць",
|
||||||
"LabelDownloadUsingCellular": "Спампоўка праз мабільны інтэрнэт",
|
"LabelDownloadUsingCellular": "Спампоўваць праз мабільны інтэрнэт",
|
||||||
"LabelDownloaded": "Спампавана",
|
"LabelDownloaded": "Спампавана",
|
||||||
"LabelDuration": "Працягласць",
|
"LabelDuration": "Працягласць",
|
||||||
"LabelEbook": "Электронная кніга",
|
"LabelEbook": "Электронная кніга",
|
||||||
"LabelEbooks": "Электронныя кнігі",
|
"LabelEbooks": "Электронныя кнігі",
|
||||||
"LabelEnable": "Уключыць",
|
"LabelEnable": "Уключыць",
|
||||||
"LabelEnableMp3IndexSeeking": "Уключыць індэксаванне для перамоткі MP3",
|
"LabelEnableMp3IndexSeeking": "Уключыць пошук па індэксе mp3",
|
||||||
"LabelEnableMp3IndexSeekingHelp": "Гэтую наладу варта ўключаць толькі, калі ў вас ёсць MP3-файлы, якія няправільна перамотваюцца. Хутчэй за ўсё, гэта выклікана файламі з зменным бітрэйтам (VBR). Гэтая налада прымусова ўключае індэксаванне для перамоткі, пры якім падчас чытання файла ствараецца адпаведнасць паміж часам і байтамі. У некаторых выпадках з вялікімі MP3-файламі можа быць затрымка пры перемотцы да канца файла.",
|
"LabelEnableMp3IndexSeekingHelp": "Гэтую наладу варта ўключаць толькі, калі ў вас ёсць MP3-файлы, якія няправільна перамотваюцца. Хутчэй за ўсё, гэта выклікана файламі з зменным бітрэйтам (VBR). Гэтая налада прымусова ўключае індэксаванне для перамоткі, пры якім падчас чытання файла ствараецца адпаведнасць паміж часам і байтамі. У некаторых выпадках з вялікімі MP3-файламі можа быць затрымка пры перемотцы да канца файла.",
|
||||||
"LabelEnd": "Канец",
|
"LabelEnd": "Канец",
|
||||||
"LabelEndOfChapter": "Канец раздзела",
|
"LabelEndOfChapter": "Канец раздзела",
|
||||||
"LabelEndTime": "Час заканчэння",
|
"LabelEndTime": "Час заканчэння",
|
||||||
"LabelEpisode": "Эпізод",
|
"LabelEpisode": "Выпуск",
|
||||||
|
"LabelExplicit": "Непрыстойнае",
|
||||||
"LabelFeedURL": "URL стужкі",
|
"LabelFeedURL": "URL стужкі",
|
||||||
"LabelFile": "Файл",
|
"LabelFile": "Файл",
|
||||||
"LabelFileBirthtime": "Час стварэння файла",
|
"LabelFileBirthtime": "Час стварэння файла",
|
||||||
"LabelFileModified": "Час змянення файла",
|
"LabelFileModified": "Час змянення файла",
|
||||||
"LabelFilename": "Імя файла",
|
"LabelFilename": "Назва файла",
|
||||||
"LabelFinished": "Скончана",
|
"LabelFinished": "Завершана",
|
||||||
"LabelFolder": "Тэчка",
|
"LabelFolder": "Папка",
|
||||||
"LabelFontBoldness": "Таўшчыня шрыфта",
|
"LabelFontBoldness": "Таўшчыня шрыфта",
|
||||||
|
"LabelFontFamily": "Сямейства шрыфтоў",
|
||||||
|
"LabelFontFamilySans": "Sans",
|
||||||
|
"LabelFontFamilySerif": "Serif",
|
||||||
"LabelFontScale": "Памер шрыфту",
|
"LabelFontScale": "Памер шрыфту",
|
||||||
"LabelGenre": "Жанр",
|
"LabelGenre": "Жанр",
|
||||||
"LabelGenres": "Жанры",
|
"LabelGenres": "Жанры",
|
||||||
@@ -161,7 +172,7 @@
|
|||||||
"LabelHost": "Хост",
|
"LabelHost": "Хост",
|
||||||
"LabelInProgress": "У працэсе",
|
"LabelInProgress": "У працэсе",
|
||||||
"LabelIncomplete": "Незавершана",
|
"LabelIncomplete": "Незавершана",
|
||||||
"LabelInternalAppStorage": "Унутраная памяць прыкладання",
|
"LabelInternalAppStorage": "Унутраная памяць праграмы",
|
||||||
"LabelJumpBackwardsTime": "Час пераходу назад",
|
"LabelJumpBackwardsTime": "Час пераходу назад",
|
||||||
"LabelJumpForwardsTime": "Час пераходу наперад",
|
"LabelJumpForwardsTime": "Час пераходу наперад",
|
||||||
"LabelKeepScreenAwake": "Трымаць экран уключаным",
|
"LabelKeepScreenAwake": "Трымаць экран уключаным",
|
||||||
@@ -169,6 +180,9 @@
|
|||||||
"LabelLayout": "Знешні выгляд",
|
"LabelLayout": "Знешні выгляд",
|
||||||
"LabelLayoutAuto": "Аўто",
|
"LabelLayoutAuto": "Аўто",
|
||||||
"LabelLayoutSinglePage": "Аднабаковы",
|
"LabelLayoutSinglePage": "Аднабаковы",
|
||||||
|
"LabelLibrarySortByProgress": "Прагрэс: апошняе абнаўленне",
|
||||||
|
"LabelLibrarySortByProgressFinished": "Прагрэс: завершана",
|
||||||
|
"LabelLibrarySortByProgressStarted": "Прагрэс: пачата",
|
||||||
"LabelLight": "Святло",
|
"LabelLight": "Святло",
|
||||||
"LabelLineSpacing": "Міжрадковы інтэрвал",
|
"LabelLineSpacing": "Міжрадковы інтэрвал",
|
||||||
"LabelListenAgain": "Паслухаць зноў",
|
"LabelListenAgain": "Паслухаць зноў",
|
||||||
@@ -182,9 +196,9 @@
|
|||||||
"LabelMissing": "Адсутнічае",
|
"LabelMissing": "Адсутнічае",
|
||||||
"LabelMore": "Больш",
|
"LabelMore": "Больш",
|
||||||
"LabelMoreInfo": "Больш інфармацыі",
|
"LabelMoreInfo": "Больш інфармацыі",
|
||||||
"LabelName": "Імя",
|
"LabelName": "Назва",
|
||||||
"LabelNarrator": "Чытальнік",
|
"LabelNarrator": "Дыктар",
|
||||||
"LabelNarrators": "Чытальнікі",
|
"LabelNarrators": "Дыктары",
|
||||||
"LabelNavigateWithVolume": "Навігацыя з дапамогай клавіш гучнасці",
|
"LabelNavigateWithVolume": "Навігацыя з дапамогай клавіш гучнасці",
|
||||||
"LabelNavigateWithVolumeMirrored": "Люстраваны",
|
"LabelNavigateWithVolumeMirrored": "Люстраваны",
|
||||||
"LabelNavigateWithVolumeWhilePlaying": "Дазволіць клавішам гучнасці кіраваць навігацыяй падчас прайгравання",
|
"LabelNavigateWithVolumeWhilePlaying": "Дазволіць клавішам гучнасці кіраваць навігацыяй падчас прайгравання",
|
||||||
@@ -192,13 +206,13 @@
|
|||||||
"LabelNavigateWithVolumeWhilePlayingEnabled": "Уключана",
|
"LabelNavigateWithVolumeWhilePlayingEnabled": "Уключана",
|
||||||
"LabelNever": "Ніколі",
|
"LabelNever": "Ніколі",
|
||||||
"LabelNewestAuthors": "Новыя аўтары",
|
"LabelNewestAuthors": "Новыя аўтары",
|
||||||
"LabelNewestEpisodes": "Новыя эпізоды",
|
"LabelNewestEpisodes": "Найноўшыя выпускі",
|
||||||
"LabelNo": "Не",
|
"LabelNo": "Не",
|
||||||
"LabelNotFinished": "Не скончана",
|
"LabelNotFinished": "Незавершана",
|
||||||
"LabelNotStarted": "Не пачата",
|
"LabelNotStarted": "Не пачата",
|
||||||
"LabelNumEpisodes": "{0} эпізодаў",
|
"LabelNumEpisodes": "{0} выпускаў",
|
||||||
"LabelNumEpisodesIncomplete": "{0} эпізодаў, {1} незавершаных",
|
"LabelNumEpisodesIncomplete": "{0} выпускаў, {1} незавершаных",
|
||||||
"LabelNumberOfEpisodes": "# з эпізодаў",
|
"LabelNumberOfEpisodes": "Колькасць выпускаў",
|
||||||
"LabelOff": "Выключына",
|
"LabelOff": "Выключына",
|
||||||
"LabelOn": "Уключана",
|
"LabelOn": "Уключана",
|
||||||
"LabelPassword": "Пароль",
|
"LabelPassword": "Пароль",
|
||||||
@@ -216,6 +230,7 @@
|
|||||||
"LabelPublishedDate": "Апублікавана {0}",
|
"LabelPublishedDate": "Апублікавана {0}",
|
||||||
"LabelRSSFeedCustomOwnerEmail": "Карыстальніцкая электронная пошта ўладальніка",
|
"LabelRSSFeedCustomOwnerEmail": "Карыстальніцкая электронная пошта ўладальніка",
|
||||||
"LabelRSSFeedCustomOwnerName": "Карыстальніцкае імя ўладальніка",
|
"LabelRSSFeedCustomOwnerName": "Карыстальніцкае імя ўладальніка",
|
||||||
|
"LabelRSSFeedOpen": "RSS-стужка адкрыта",
|
||||||
"LabelRSSFeedPreventIndexing": "Прадухіліць індэксацыю",
|
"LabelRSSFeedPreventIndexing": "Прадухіліць індэксацыю",
|
||||||
"LabelRSSFeedSlug": "Ідэнтыфікатар RSS-стужкі",
|
"LabelRSSFeedSlug": "Ідэнтыфікатар RSS-стужкі",
|
||||||
"LabelRandomly": "Выпадкова",
|
"LabelRandomly": "Выпадкова",
|
||||||
@@ -223,10 +238,11 @@
|
|||||||
"LabelReadAgain": "Чытаць зноў",
|
"LabelReadAgain": "Чытаць зноў",
|
||||||
"LabelRecentSeries": "Апошнія серыі",
|
"LabelRecentSeries": "Апошнія серыі",
|
||||||
"LabelRecentlyAdded": "Нядаўна дададзеныя",
|
"LabelRecentlyAdded": "Нядаўна дададзеныя",
|
||||||
"LabelRemoveFromPlaylist": "Выдаліць з спіса прайгравання",
|
"LabelRemoveFromPlaylist": "Выдаліць з плэй-ліста",
|
||||||
"LabelScaleElapsedTimeBySpeed": "Прыстасаваць мінулы час да хуткасці",
|
"LabelScaleElapsedTimeBySpeed": "Прыстасаваць мінулы час да хуткасці",
|
||||||
"LabelSeason": "Сезон",
|
"LabelSeason": "Сезон",
|
||||||
"LabelSelectADevice": "Выбраць прыладу",
|
"LabelSelectADevice": "Выбраць прыладу",
|
||||||
|
"LabelSelectMediaType": "Выберыце тып медыя",
|
||||||
"LabelSequenceAscending": "Паслядоўнасць па ўзрастанні",
|
"LabelSequenceAscending": "Паслядоўнасць па ўзрастанні",
|
||||||
"LabelSequenceDescending": "Паслядоўнасць па змяншэнні",
|
"LabelSequenceDescending": "Паслядоўнасць па змяншэнні",
|
||||||
"LabelSeries": "Серыі",
|
"LabelSeries": "Серыі",
|
||||||
@@ -237,14 +253,16 @@
|
|||||||
"LabelShowAll": "Паказаць усё",
|
"LabelShowAll": "Паказаць усё",
|
||||||
"LabelSize": "Памер",
|
"LabelSize": "Памер",
|
||||||
"LabelSleepTimer": "Таймер сну",
|
"LabelSleepTimer": "Таймер сну",
|
||||||
"LabelStart": "Пачаць",
|
"LabelSleepTimerAlmostDoneChime": "Гукавы сігнал, калі амаль звершаны",
|
||||||
|
"LabelSleepTimerAlmostDoneChimeHelp": "Памякчыць звонак, калі на таймеры сну застанецца 30 секунд",
|
||||||
|
"LabelStart": "Пачатак",
|
||||||
"LabelStartTime": "Час пачатку",
|
"LabelStartTime": "Час пачатку",
|
||||||
"LabelStatsBestDay": "Лепшы дзень",
|
"LabelStatsBestDay": "Найлепшы дзень",
|
||||||
"LabelStatsDailyAverage": "У сярэднім за дзень",
|
"LabelStatsDailyAverage": "У сярэднім за дзень",
|
||||||
"LabelStatsDays": "Дзён",
|
"LabelStatsDays": "Дзён",
|
||||||
"LabelStatsDaysListened": "Дзён праслухана",
|
"LabelStatsDaysListened": "Дзён праслухана",
|
||||||
"LabelStatsInARow": "без перапынку",
|
"LabelStatsInARow": "без перапынку",
|
||||||
"LabelStatsItemsFinished": "Скончаныя элементы",
|
"LabelStatsItemsFinished": "Элементаў завершана",
|
||||||
"LabelStatsMinutes": "хвілін",
|
"LabelStatsMinutes": "хвілін",
|
||||||
"LabelStatsMinutesListening": "Хвілін праслухоўвання",
|
"LabelStatsMinutesListening": "Хвілін праслухоўвання",
|
||||||
"LabelStatsWeekListening": "Праслухана за тыдзень",
|
"LabelStatsWeekListening": "Праслухана за тыдзень",
|
||||||
@@ -252,17 +270,18 @@
|
|||||||
"LabelTag": "Метка",
|
"LabelTag": "Метка",
|
||||||
"LabelTags": "Меткі",
|
"LabelTags": "Меткі",
|
||||||
"LabelTheme": "Тэма",
|
"LabelTheme": "Тэма",
|
||||||
|
"LabelThemeBlack": "Чорны",
|
||||||
"LabelThemeDark": "Цёмная",
|
"LabelThemeDark": "Цёмная",
|
||||||
"LabelThemeLight": "Светлая",
|
"LabelThemeLight": "Светлая",
|
||||||
"LabelTimeRemaining": "Засталося {0}",
|
"LabelTimeRemaining": "Засталося {0}",
|
||||||
"LabelTitle": "Назва",
|
"LabelTitle": "Загаловак",
|
||||||
"LabelTotalSize": "Агульны памер",
|
"LabelTotalSize": "Агульны памер",
|
||||||
"LabelTotalTrack": "Агульная колькасць дарожак",
|
"LabelTotalTrack": "Усяго трэкаў",
|
||||||
"LabelTracks": "Дарожкі",
|
"LabelTracks": "Трэкі",
|
||||||
"LabelType": "Тып",
|
"LabelType": "Тып",
|
||||||
"LabelUnknown": "Невядома",
|
"LabelUnknown": "Невядома",
|
||||||
"LabelUnlockPlayer": "Разблакаваць прайгравальнік",
|
"LabelUnlockPlayer": "Разблакаваць прайгравальнік",
|
||||||
"LabelUseBookshelfView": "Выкарыстоўваць выгляд кніжнай паліцы",
|
"LabelUseBookshelfView": "Паказваць кніжныя паліцы",
|
||||||
"LabelUser": "Карыстальнік",
|
"LabelUser": "Карыстальнік",
|
||||||
"LabelUsername": "Імя карыстальніка",
|
"LabelUsername": "Імя карыстальніка",
|
||||||
"LabelVeryHigh": "Вельмі высокі",
|
"LabelVeryHigh": "Вельмі высокі",
|
||||||
@@ -271,24 +290,30 @@
|
|||||||
"LabelYearReviewShow": "Азнаёміцца з вынікамі года",
|
"LabelYearReviewShow": "Азнаёміцца з вынікамі года",
|
||||||
"LabelYourBookmarks": "Вашы закладкі",
|
"LabelYourBookmarks": "Вашы закладкі",
|
||||||
"LabelYourProgress": "Ваш прагрэс",
|
"LabelYourProgress": "Ваш прагрэс",
|
||||||
"MessageAndroid10Downloads": "На Android 10 і ніжэй для спампоўкі будзе выкарыстоўвацца ўнутраная памяць прыкладання.",
|
"MessageAndroid10Downloads": "На Android 10 і ніжэй для спамповак будзе выкарыстоўвацца ўнутраная памяць праграмы.",
|
||||||
"MessageAttemptingServerConnection": "Спроба злучэння з серверам...",
|
"MessageAttemptingServerConnection": "Спроба злучэння з серверам...",
|
||||||
"MessageAudiobookshelfServerNotConnected": "Сервер Audiobookshelf не падлучаны",
|
"MessageAudiobookshelfServerNotConnected": "Сервер Audiobookshelf не падлучаны",
|
||||||
"MessageAudiobookshelfServerRequired": "<strong>Важна!</strong> Гэта праграма прызначана для працы з серверам Audiobookshelf, які вы ці хтосьці іншы абслугоўваеце. Праграма не змяшчае ўласнага кантэнту.",
|
"MessageAudiobookshelfServerRequired": "<strong>Важна!</strong> Гэта праграма прызначана для працы з серверам Audiobookshelf, які вы ці хтосьці іншы абслугоўваеце. Праграма не змяшчае ўласнага кантэнту.",
|
||||||
"MessageBookshelfEmpty": "Кніжная паліца пустая",
|
"MessageBookshelfEmpty": "Кніжная паліца пустая",
|
||||||
"MessageConfirmDeleteLocalEpisode": "Выдаліць лакальны эпізод \"{0}\" з вашай прылады? Файл на серверы застанецца нязменным.",
|
"MessageConfirmAppExit": "Вы хацелі выйсці з праграмы?",
|
||||||
"MessageConfirmDeleteLocalFiles": "Выдаліць лакальныя файлы гэтага элемента з вашай прылады? Файлы на серверы і ваш прагрэс застануцца нязменнымі.",
|
"MessageConfirmDeleteEpisodeDownloadQueue": "Вы ўпэўнены, што хочаце ачысціць чаргу спампоўвання выпускаў?",
|
||||||
"MessageConfirmDisableAutoTimer": "Вы ўпэўнены, што жадаеце адключыць аўтаматычны таймер на сённяшні дзень? Таймер будзе зноў уключаны па заканчэнні гэтага перыяду аўтаматычнага таймера сну або пры перазапуску праграмы.",
|
"MessageConfirmDeleteLocalEpisode": "Выдаліць лакальны выпуск \"{0}\" з вашай прылады? Файл на серверы застанецца нязменным.",
|
||||||
"MessageConfirmDiscardProgress": "Вы ўпэўнены, што жадаеце скінуць свой прагрэс?",
|
"MessageConfirmDeleteLocalFiles": "Выдаліць лакальныя файлы гэтага элемента з вашай прылады? Файлы на серверы і прагрэс застануцца нязменнымі.",
|
||||||
"MessageConfirmDownloadUsingCellular": "Вы збіраецеся спампаваць, выкарыстоўваючы мабільны інтэрнэт. Гэта можа прывесці да дадатковых плат ад аператара. Вы жадаеце працягнуць?",
|
"MessageConfirmDeleteServerConfig": "Выдаліць гэту канфігурацыю сервера?",
|
||||||
"MessageConfirmMarkAsFinished": "Вы ўпэўнены, што жадаеце пазначыць гэты элемент як скончаны?",
|
"MessageConfirmDeleteServerEpisode": "Вы ўпэўнены, што хочаце выдаліць выпуск \"{0}\" з сервера?\nУвага: будзе выдалены аўдыяфайл.",
|
||||||
"MessageConfirmRemoveBookmark": "Вы ўпэўнены, што жадаеце выдаліць закладку?",
|
"MessageConfirmDisableAutoTimer": "Вы ўпэўнены, што хочаце адключыць аўтаматычны таймер на сённяшні дзень? Таймер будзе зноў уключаны па заканчэнні перыяду гэтага аўтаматычнага таймера сну або пры перазапуску праграмы.",
|
||||||
"MessageConfirmStreamingUsingCellular": "Вы збіраецеся трансляваць кантэнт праз мабільны інтэрнэт. Гэта можа прывесці да дадатковых платаў ад вашага аператара. Вы жадаеце працягнуць?",
|
"MessageConfirmDiscardProgress": "Вы ўпэўнены, што хочаце скінуць прагрэс?",
|
||||||
|
"MessageConfirmDownloadUsingCellular": "Вы збіраецеся спампаваць праз мабільны інтэрнэт. Гэта можа прывесці да дадатковых плат ад аператара. Хочаце працягнуць?",
|
||||||
|
"MessageConfirmMarkAsFinished": "Вы ўпэўнены, што хочаце пазначыць гэты элемент як завершаны?",
|
||||||
|
"MessageConfirmPlaybackTime": "Пачаць прайграванне \"{0}\" у {1}?",
|
||||||
|
"MessageConfirmRemoveBookmark": "Вы ўпэўнены, што хочаце выдаліць закладку?",
|
||||||
|
"MessageConfirmStreamingUsingCellular": "Вы збіраецеся трансляваць кантэнт праз мабільны інтэрнэт. Гэта можа прывесці да дадатковых плат ад аператара. Хочаце працягнуць?",
|
||||||
"MessageDiscardProgress": "Скінуць прагрэс",
|
"MessageDiscardProgress": "Скінуць прагрэс",
|
||||||
"MessageDownloadCompleteProcessing": "Спампоўка завершана. Апрацоўка...",
|
"MessageDownloadCompleteProcessing": "Спампоўванне завершана. Апрацоўка...",
|
||||||
"MessageDownloading": "Спампоўванне...",
|
"MessageDownloading": "Спампоўванне...",
|
||||||
"MessageDownloadingEpisode": "Спампоўка эпізоду",
|
"MessageDownloadingEpisode": "Спампоўванне выпуску",
|
||||||
"MessageEpisodesQueuedForDownload": "{0} эпізод(аў) у чарзе для спампоўкі",
|
"MessageEpisodesQueuedForDownload": "{0} выпуск(-аў) у чарзе спампоўвання",
|
||||||
|
"MessageFailedToRefreshToken": "Не ўдалося абнавіць токен, патрабуецца паўторны ўваход",
|
||||||
"MessageFeedURLWillBe": "URL стужкі будзе {0}",
|
"MessageFeedURLWillBe": "URL стужкі будзе {0}",
|
||||||
"MessageFetching": "Атрыманне...",
|
"MessageFetching": "Атрыманне...",
|
||||||
"MessageFollowTheProjectOnGithub": "Сачыце за праектам на Github",
|
"MessageFollowTheProjectOnGithub": "Сачыце за праектам на Github",
|
||||||
@@ -296,8 +321,8 @@
|
|||||||
"MessageItemMissing": "Элемент адсутнічае і патрабуе выпраўлення на серверы. Звычайна элемент пазначаецца як адсутны, таму што файлавыя шляхі недаступныя.",
|
"MessageItemMissing": "Элемент адсутнічае і патрабуе выпраўлення на серверы. Звычайна элемент пазначаецца як адсутны, таму што файлавыя шляхі недаступныя.",
|
||||||
"MessageLoading": "Загрузка...",
|
"MessageLoading": "Загрузка...",
|
||||||
"MessageLoadingServerData": "Загрузка даных з сервера...",
|
"MessageLoadingServerData": "Загрузка даных з сервера...",
|
||||||
"MessageLocalFolderDescription": "\"Унутраная памяць прыкладання\" даступная толькі для гэтага прыкладання. Гэта прыкладанне працуе толькі з медыя, якія былі спампаваныя непасрэдна праз яго. Каб іншыя прыкладанні маглі атрымаць доступ да медыя, спампаванага гэтым прыкладаннем, можна выкарыстоўваць агульныя тэчкі.",
|
"MessageLocalFolderDescription": "\"Унутраная памяць праграмы\" даступная толькі для гэтай праграмы. Гэта праграма працуе толькі з медыя, якія былі спампаваныя непасрэдна праз яго. Каб іншыя праграмы маглі атрымаць доступ да медыя, спампаванага гэтай праграмай, можна выкарыстоўваць абагуленыя папкі.",
|
||||||
"MessageMarkAsFinished": "Пазначыць як скончана",
|
"MessageMarkAsFinished": "Пазначыць як завершаную",
|
||||||
"MessageMediaLinkedToADifferentServer": "Медыя звязаны з серверам Audiobookshelf па іншым адрасе ({0}). Сінхранізацыя прагрэсу адбудзецца пры падключэнні да гэтага адрасу сервера.",
|
"MessageMediaLinkedToADifferentServer": "Медыя звязаны з серверам Audiobookshelf па іншым адрасе ({0}). Сінхранізацыя прагрэсу адбудзецца пры падключэнні да гэтага адрасу сервера.",
|
||||||
"MessageMediaLinkedToADifferentUser": "Медыя звязаны з гэтым серверам, але быў спампаваны іншым карыстальнікам. Прагрэс будзе сінхранізавацца толькі для карыстальніка, які яго спампаваў.",
|
"MessageMediaLinkedToADifferentUser": "Медыя звязаны з гэтым серверам, але быў спампаваны іншым карыстальнікам. Прагрэс будзе сінхранізавацца толькі для карыстальніка, які яго спампаваў.",
|
||||||
"MessageMediaLinkedToServer": "Звязана з серверам {0}",
|
"MessageMediaLinkedToServer": "Звязана з серверам {0}",
|
||||||
@@ -309,19 +334,23 @@
|
|||||||
"MessageNoItems": "Няма элементаў",
|
"MessageNoItems": "Няма элементаў",
|
||||||
"MessageNoItemsFound": "Элементы не знойдзены",
|
"MessageNoItemsFound": "Элементы не знойдзены",
|
||||||
"MessageNoListeningSessions": "Няма сеансаў праслухоўвання",
|
"MessageNoListeningSessions": "Няма сеансаў праслухоўвання",
|
||||||
"MessageNoMediaFolders": "Няма тэчак з медыя",
|
"MessageNoLogs": "Няма журналаў",
|
||||||
|
"MessageNoMediaFolders": "Няма папак з медыя",
|
||||||
"MessageNoNetworkConnection": "Няма падлучэння да сеткі",
|
"MessageNoNetworkConnection": "Няма падлучэння да сеткі",
|
||||||
"MessageNoPodcastsFound": "Падкасты не знойдзены",
|
"MessageNoPodcastsFound": "Падкасты не знойдзены",
|
||||||
"MessageNoSeries": "Няма серый",
|
"MessageNoSeries": "Няма серый",
|
||||||
"MessageNoUpdatesWereNecessary": "Абнаўленні не патрабаваліся",
|
"MessageNoUpdatesWereNecessary": "Абнаўленні не патрабаваліся",
|
||||||
"MessageNoUserPlaylists": "У вас няма спісаў прайгравання",
|
"MessageNoUserPlaylists": "У вас няма плэй-лістоў",
|
||||||
|
"MessageOldServerAuthReLoginRequired": "Аўтэнтыфікацыя была палепшана для бяспекі ў серверы v2.26.0. Усім карыстальнікам патрабуецца паўторна ўвайсці ў сістэму.",
|
||||||
|
"MessageOldServerAuthWarning": "Сервер выкарыстоўвае састарэлую аўтэнтыфікацыю",
|
||||||
|
"MessageOldServerAuthWarningHelp": "Гэты сервер працуе пад версіяй, старэйшай за 2.26.0. У версіі 2.26.0 была дададзена больш бяспечная сістэма аўтэнтыфікацыі. Настойліва рэкамендуецца абнавіць сервер да апошняй версіі. Калі вы ўжо абнавілі сервер, увайдзіце зноў, каб выкарыстоўваць новую аўтэнтыфікацыю.",
|
||||||
"MessageOldServerConnectionWarning": "Канфігурацыя падлучэння да сервера выкарыстоўвае стары ідэнтыфікатар карыстальніка. Калі ласка, выдаліце і зноў дадайце гэта падлучэнне да сервера.",
|
"MessageOldServerConnectionWarning": "Канфігурацыя падлучэння да сервера выкарыстоўвае стары ідэнтыфікатар карыстальніка. Калі ласка, выдаліце і зноў дадайце гэта падлучэнне да сервера.",
|
||||||
"MessageOldServerConnectionWarningHelp": "Вы першапачаткова наладзілі падключэнне да гэтага сервера да міграцыі базы даных у версіі 2.3.0, якая выйшла ў чэрвені 2023 года. У будучым абнаўленні сервера магчымасць уваходу праз гэтае старое падключэнне будзе выдалена. Калі ласка, выдаліце існуючае падключэнне да сервера і падключыцеся зноў (выкарыстоўваючы той жа адрас сервера і ўліковыя даныя). Калі на гэтай прыладзе ёсць спампаваныя медыяфайлы, іх трэба будзе спампаваць зноў для сінхранізацыі з серверам.",
|
"MessageOldServerConnectionWarningHelp": "Вы першапачаткова наладзілі падключэнне да гэтага сервера да міграцыі базы даных у версіі 2.3.0, якая выйшла ў чэрвені 2023 года. У будучым абнаўленні сервера магчымасць уваходу праз гэтае старое падключэнне будзе выдалена. Калі ласка, выдаліце існуючае падключэнне да сервера і падключыцеся зноў (выкарыстоўваючы той жа адрас сервера і ўліковыя даныя). Калі на гэтай прыладзе ёсць спампаваныя медыяфайлы, іх трэба будзе спампаваць зноў для сінхранізацыі з серверам.",
|
||||||
"MessagePodcastSearchField": "Увядзіце пошукавы запыт або URL RSS-стужкі",
|
"MessagePodcastSearchField": "Увядзіце пошукавы запыт або URL RSS-стужкі",
|
||||||
"MessageProgressSyncFailed": "Апошняя спроба адправіць ваш прагрэс праслухоўвання на сервер не ўдалася. Спробы сінхранізаваць прагрэс будуць працягвацца кожныя 15 секунд на працягу 1 хвіліны падчас прайгравання медыяфайла.",
|
"MessageProgressSyncFailed": "Апошняя спроба адправіць ваш прагрэс праслухоўвання на сервер не ўдалася. Спробы сінхранізаваць прагрэс будуць працягвацца кожныя 15 секунд на працягу 1 хвіліны падчас прайгравання медыяфайла.",
|
||||||
"MessageReportBugsAndContribute": "Паведамляйце пра памылкі, прапануйце новыя функцыі і ўдзельнічайце на",
|
"MessageReportBugsAndContribute": "Паведамляйце пра памылкі, прапануйце функцыі і ўносьце свой уклад на",
|
||||||
"MessageSeriesAlreadyDownloaded": "Вы ўжо спампавалі ўсе кнігі з гэтай серыі.",
|
"MessageSeriesAlreadyDownloaded": "Вы ўжо спампавалі ўсе кнігі з гэтай серыі.",
|
||||||
"MessageSeriesDownloadConfirm": "Спампаваць адсутныя {0} кніг(і) з {1} файл(амі), агульным памерам {2}, у тэчку {3}?",
|
"MessageSeriesDownloadConfirm": "Спампаваць адсутныя {0} кніг(і) з {1} файлам(-і), агульным памерам {2}, у папку {3}?",
|
||||||
"MessageSeriesDownloadConfirmIos": "Спампаваць адсутныя {0} кніг(і) з {1} файл(амі), агульным памерам {2}?",
|
"MessageSeriesDownloadConfirmIos": "Спампаваць адсутныя {0} кніг(і) з {1} файл(амі), агульным памерам {2}?",
|
||||||
"MessageSocketConnectedOverMeteredCellular": "Сокет падлучаны праз мабільны інтэрнэт з абмежаваным трафікам",
|
"MessageSocketConnectedOverMeteredCellular": "Сокет падлучаны праз мабільны інтэрнэт з абмежаваным трафікам",
|
||||||
"MessageSocketConnectedOverMeteredWifi": "Сокет падлучаны праз Wi-Fi з абмежаваным трафікам",
|
"MessageSocketConnectedOverMeteredWifi": "Сокет падлучаны праз Wi-Fi з абмежаваным трафікам",
|
||||||
@@ -329,17 +358,19 @@
|
|||||||
"MessageSocketConnectedOverUnmeteredWifi": "Сокет падлучаны праз Wi-Fi без абмежавання трафіку",
|
"MessageSocketConnectedOverUnmeteredWifi": "Сокет падлучаны праз Wi-Fi без абмежавання трафіку",
|
||||||
"MessageSocketNotConnected": "Сокет не падлучаны",
|
"MessageSocketNotConnected": "Сокет не падлучаны",
|
||||||
"NoteRSSFeedPodcastAppsHttps": "Папярэджанне: большасць праграм для падкастаў патрабуюць, каб URL RSS-стужкі выкарыстоўваў HTTPS",
|
"NoteRSSFeedPodcastAppsHttps": "Папярэджанне: большасць праграм для падкастаў патрабуюць, каб URL RSS-стужкі выкарыстоўваў HTTPS",
|
||||||
"NoteRSSFeedPodcastAppsPubDate": "Папярэджанне: адзін ці больш вашых эпізодаў не маюць даты публікацыі. Некаторыя праграмы для падкастаў патрабуюць гэтага.",
|
"NoteRSSFeedPodcastAppsPubDate": "Папярэджанне: адзін ці больш выпускаў не маюць даты публікацыі. Некаторыя праграмы для падкастаў патрабуюць гэтага.",
|
||||||
"ToastBookmarkCreateFailed": "Не ўдалося стварыць закладку",
|
"ToastBookmarkCreateFailed": "Не ўдалося стварыць закладку",
|
||||||
"ToastBookmarkRemoveFailed": "Не ўдалося выдаліць закладку",
|
"ToastBookmarkRemoveFailed": "Не ўдалося выдаліць закладку",
|
||||||
"ToastBookmarkUpdateFailed": "Не ўдалося абнавіць закладку",
|
"ToastBookmarkUpdateFailed": "Не ўдалося абнавіць закладку",
|
||||||
"ToastDownloadNotAllowedOnCellular": "Спампоўванне забаронена праз мабільны інтэрнэт",
|
"ToastDownloadNotAllowedOnCellular": "Спампоўванне забаронена праз мабільны інтэрнэт",
|
||||||
"ToastItemMarkedAsFinishedFailed": "Не ўдалося пазначыць як Скончана",
|
"ToastItemMarkedAsFinishedFailed": "Не ўдалося пазначыць як завершаны",
|
||||||
"ToastItemMarkedAsNotFinishedFailed": "Не ўдалося пазначыць як Незавершанае",
|
"ToastItemMarkedAsNotFinishedFailed": "Не ўдалося пазначыць як незавершаны",
|
||||||
"ToastPlaylistCreateFailed": "Не ўдалося стварыць спіс прайгравання",
|
"ToastPlaylistCreateFailed": "Не ўдалося стварыць плэй-ліст",
|
||||||
"ToastPodcastCreateFailed": "Не ўдалося стварыць падкаст",
|
"ToastPodcastCreateFailed": "Не ўдалося стварыць падкаст",
|
||||||
"ToastPodcastCreateSuccess": "Падкаст паспяхова створаны",
|
"ToastPodcastCreateSuccess": "Падкаст паспяхова створаны",
|
||||||
"ToastRSSFeedCloseFailed": "Не ўдалося закрыць RSS-стужку",
|
"ToastRSSFeedCloseFailed": "Не ўдалося закрыць RSS-стужку",
|
||||||
"ToastRSSFeedCloseSuccess": "RSS-стужка закрыта",
|
"ToastRSSFeedCloseSuccess": "RSS-стужка закрыта",
|
||||||
"ToastStreamingNotAllowedOnCellular": "Патокавая перадача праз мабільную сувязь забаронена"
|
"ToastStreamingNotAllowedOnCellular": "Патокавая перадача праз мабільную сувязь забаронена",
|
||||||
|
"UnitMinutesShort": "{0} хв",
|
||||||
|
"UnitSecondsShort": "{0} с"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"ButtonCancel": "Отказ",
|
"ButtonCancel": "Отказ",
|
||||||
"ButtonCancelTimer": "Отмени Таймер",
|
"ButtonCancelTimer": "Отмени Таймер",
|
||||||
"ButtonClearFilter": "Изчисти филтър",
|
"ButtonClearFilter": "Изчисти филтър",
|
||||||
|
"ButtonClearLogs": "Изчисти логове",
|
||||||
"ButtonCloseFeed": "Затвори стената",
|
"ButtonCloseFeed": "Затвори стената",
|
||||||
"ButtonCollections": "Колекции",
|
"ButtonCollections": "Колекции",
|
||||||
"ButtonConnect": "Свържи се",
|
"ButtonConnect": "Свържи се",
|
||||||
@@ -26,7 +27,9 @@
|
|||||||
"ButtonLatest": "Последни",
|
"ButtonLatest": "Последни",
|
||||||
"ButtonLibrary": "Библиотека",
|
"ButtonLibrary": "Библиотека",
|
||||||
"ButtonLocalMedia": "Локална медия",
|
"ButtonLocalMedia": "Локална медия",
|
||||||
|
"ButtonLogs": "Логове",
|
||||||
"ButtonManageLocalFiles": "Управление на локални файлове",
|
"ButtonManageLocalFiles": "Управление на локални файлове",
|
||||||
|
"ButtonMaskServerAddress": "Маскирай адреса на сървърът",
|
||||||
"ButtonNewFolder": "Създай нова папка",
|
"ButtonNewFolder": "Създай нова папка",
|
||||||
"ButtonNextEpisode": "Следващ епизод",
|
"ButtonNextEpisode": "Следващ епизод",
|
||||||
"ButtonOk": "Приемам",
|
"ButtonOk": "Приемам",
|
||||||
@@ -50,6 +53,7 @@
|
|||||||
"ButtonStream": "Стрийм",
|
"ButtonStream": "Стрийм",
|
||||||
"ButtonSubmit": "Изпрати",
|
"ButtonSubmit": "Изпрати",
|
||||||
"ButtonSwitchServerUser": "Смени сървър/потребител",
|
"ButtonSwitchServerUser": "Смени сървър/потребител",
|
||||||
|
"ButtonUnmaskServerAddress": "Демаскирай адреса на съвърът",
|
||||||
"ButtonUserStats": "Потребителски статистики",
|
"ButtonUserStats": "Потребителски статистики",
|
||||||
"ButtonYes": "Да",
|
"ButtonYes": "Да",
|
||||||
"HeaderAccount": "Профил",
|
"HeaderAccount": "Профил",
|
||||||
@@ -59,6 +63,7 @@
|
|||||||
"HeaderChapters": "Глави",
|
"HeaderChapters": "Глави",
|
||||||
"HeaderCollection": "Колекция",
|
"HeaderCollection": "Колекция",
|
||||||
"HeaderCollectionItems": "Елемент в колекция",
|
"HeaderCollectionItems": "Елемент в колекция",
|
||||||
|
"HeaderConfirm": "Потвърди",
|
||||||
"HeaderConnectionStatus": "Състояние на връзката",
|
"HeaderConnectionStatus": "Състояние на връзката",
|
||||||
"HeaderDataSettings": "Настройки на данните",
|
"HeaderDataSettings": "Настройки на данните",
|
||||||
"HeaderDetails": "Детайли",
|
"HeaderDetails": "Детайли",
|
||||||
@@ -87,6 +92,7 @@
|
|||||||
"HeaderStatsRecentSessions": "Последни сесии",
|
"HeaderStatsRecentSessions": "Последни сесии",
|
||||||
"HeaderTableOfContents": "Съдържание",
|
"HeaderTableOfContents": "Съдържание",
|
||||||
"HeaderUserInterfaceSettings": "Настройки на потребителския интерфейс",
|
"HeaderUserInterfaceSettings": "Настройки на потребителския интерфейс",
|
||||||
|
"HeaderWelcome": "Добре дошли, <strong>{0}</strong>",
|
||||||
"HeaderYourStats": "Вашата статистика",
|
"HeaderYourStats": "Вашата статистика",
|
||||||
"LabelAddToPlaylist": "Добави в плейлист",
|
"LabelAddToPlaylist": "Добави в плейлист",
|
||||||
"LabelAddedAt": "Добавено в",
|
"LabelAddedAt": "Добавено в",
|
||||||
@@ -109,9 +115,10 @@
|
|||||||
"LabelAutoSleepTimerAutoRewindHelp": "Когато автоматичният таймер на изключване изтече, повторното пускане на елемента автоматично ще върне позицията ви назад.",
|
"LabelAutoSleepTimerAutoRewindHelp": "Когато автоматичният таймер на изключване изтече, повторното пускане на елемента автоматично ще върне позицията ви назад.",
|
||||||
"LabelAutoSleepTimerHelp": "Когато се възпроизвеждате медия между посочените начален и краен час, автоматично ще стартира таймер за изключване.",
|
"LabelAutoSleepTimerHelp": "Когато се възпроизвеждате медия между посочените начален и краен час, автоматично ще стартира таймер за изключване.",
|
||||||
"LabelBooks": "Книги",
|
"LabelBooks": "Книги",
|
||||||
|
"LabelByAuthor": "от {0}",
|
||||||
"LabelChapterTrack": "Трак на глава",
|
"LabelChapterTrack": "Трак на глава",
|
||||||
"LabelChapters": "Глави",
|
"LabelChapters": "Глави",
|
||||||
"LabelClosePlayer": "Затвори",
|
"LabelClosePlayer": "Затвори плейъра",
|
||||||
"LabelCollapseSeries": "Скрий сериите",
|
"LabelCollapseSeries": "Скрий сериите",
|
||||||
"LabelComplete": "Приключено",
|
"LabelComplete": "Приключено",
|
||||||
"LabelContinueBooks": "Продължи книги",
|
"LabelContinueBooks": "Продължи книги",
|
||||||
@@ -142,6 +149,7 @@
|
|||||||
"LabelEndOfChapter": "Край на глава",
|
"LabelEndOfChapter": "Край на глава",
|
||||||
"LabelEndTime": "Оставащо време",
|
"LabelEndTime": "Оставащо време",
|
||||||
"LabelEpisode": "Епизод",
|
"LabelEpisode": "Епизод",
|
||||||
|
"LabelExplicit": "Експлицитно",
|
||||||
"LabelFeedURL": "URL на емисия",
|
"LabelFeedURL": "URL на емисия",
|
||||||
"LabelFile": "Файл",
|
"LabelFile": "Файл",
|
||||||
"LabelFileBirthtime": "Дата на създаване на файла",
|
"LabelFileBirthtime": "Дата на създаване на файла",
|
||||||
@@ -150,6 +158,9 @@
|
|||||||
"LabelFinished": "Дата на приключване",
|
"LabelFinished": "Дата на приключване",
|
||||||
"LabelFolder": "Папка",
|
"LabelFolder": "Папка",
|
||||||
"LabelFontBoldness": "Дебелина на шрифта",
|
"LabelFontBoldness": "Дебелина на шрифта",
|
||||||
|
"LabelFontFamily": "Шрифт",
|
||||||
|
"LabelFontFamilySans": "Sans",
|
||||||
|
"LabelFontFamilySerif": "Serif",
|
||||||
"LabelFontScale": "Мащаб на шрифта",
|
"LabelFontScale": "Мащаб на шрифта",
|
||||||
"LabelGenre": "Жанр",
|
"LabelGenre": "Жанр",
|
||||||
"LabelGenres": "Жанрове",
|
"LabelGenres": "Жанрове",
|
||||||
@@ -169,6 +180,9 @@
|
|||||||
"LabelLayout": "Оформление",
|
"LabelLayout": "Оформление",
|
||||||
"LabelLayoutAuto": "Авто",
|
"LabelLayoutAuto": "Авто",
|
||||||
"LabelLayoutSinglePage": "Единична страница",
|
"LabelLayoutSinglePage": "Единична страница",
|
||||||
|
"LabelLibrarySortByProgress": "Прогрес: Последно обновление",
|
||||||
|
"LabelLibrarySortByProgressFinished": "Прогрес: Приключено",
|
||||||
|
"LabelLibrarySortByProgressStarted": "Прогрес: Започнато",
|
||||||
"LabelLight": "Светло",
|
"LabelLight": "Светло",
|
||||||
"LabelLineSpacing": "Междуредие",
|
"LabelLineSpacing": "Междуредие",
|
||||||
"LabelListenAgain": "Слушай отново",
|
"LabelListenAgain": "Слушай отново",
|
||||||
@@ -216,6 +230,7 @@
|
|||||||
"LabelPublishedDate": "Публикувани {0}",
|
"LabelPublishedDate": "Публикувани {0}",
|
||||||
"LabelRSSFeedCustomOwnerEmail": "Персонализиран имейл на собственика",
|
"LabelRSSFeedCustomOwnerEmail": "Персонализиран имейл на собственика",
|
||||||
"LabelRSSFeedCustomOwnerName": "Персонализирано име на собственика",
|
"LabelRSSFeedCustomOwnerName": "Персонализирано име на собственика",
|
||||||
|
"LabelRSSFeedOpen": "RSS Feed е отворен",
|
||||||
"LabelRSSFeedPreventIndexing": "Предотвратете индексиране",
|
"LabelRSSFeedPreventIndexing": "Предотвратете индексиране",
|
||||||
"LabelRSSFeedSlug": "идентификатор на RSS емисия",
|
"LabelRSSFeedSlug": "идентификатор на RSS емисия",
|
||||||
"LabelRandomly": "Случайно",
|
"LabelRandomly": "Случайно",
|
||||||
@@ -227,6 +242,7 @@
|
|||||||
"LabelScaleElapsedTimeBySpeed": "Скалируй изминалото време спрямо скоростта",
|
"LabelScaleElapsedTimeBySpeed": "Скалируй изминалото време спрямо скоростта",
|
||||||
"LabelSeason": "Сезон",
|
"LabelSeason": "Сезон",
|
||||||
"LabelSelectADevice": "Избери устройство",
|
"LabelSelectADevice": "Избери устройство",
|
||||||
|
"LabelSelectMediaType": "Изберете тип на медията",
|
||||||
"LabelSequenceAscending": "Възходяща последователност",
|
"LabelSequenceAscending": "Възходяща последователност",
|
||||||
"LabelSequenceDescending": "Низходяща последователност",
|
"LabelSequenceDescending": "Низходяща последователност",
|
||||||
"LabelSeries": "От сериите",
|
"LabelSeries": "От сериите",
|
||||||
@@ -237,6 +253,8 @@
|
|||||||
"LabelShowAll": "Покажи всички",
|
"LabelShowAll": "Покажи всички",
|
||||||
"LabelSize": "Размер",
|
"LabelSize": "Размер",
|
||||||
"LabelSleepTimer": "Таймер за изключване",
|
"LabelSleepTimer": "Таймер за изключване",
|
||||||
|
"LabelSleepTimerAlmostDoneChime": "Възпроизведи звуков сигнал малко преди да приключи",
|
||||||
|
"LabelSleepTimerAlmostDoneChimeHelp": "Възпроизведи звуков сигнал, 30 секунди преди изтичане на таймера за заспиване",
|
||||||
"LabelStart": "Старт",
|
"LabelStart": "Старт",
|
||||||
"LabelStartTime": "Време на стартиране",
|
"LabelStartTime": "Време на стартиране",
|
||||||
"LabelStatsBestDay": "Най-добър ден",
|
"LabelStatsBestDay": "Най-добър ден",
|
||||||
@@ -252,6 +270,7 @@
|
|||||||
"LabelTag": "Таг",
|
"LabelTag": "Таг",
|
||||||
"LabelTags": "Тагове",
|
"LabelTags": "Тагове",
|
||||||
"LabelTheme": "Тема",
|
"LabelTheme": "Тема",
|
||||||
|
"LabelThemeBlack": "Черен",
|
||||||
"LabelThemeDark": "Тъмна",
|
"LabelThemeDark": "Тъмна",
|
||||||
"LabelThemeLight": "Светла",
|
"LabelThemeLight": "Светла",
|
||||||
"LabelTimeRemaining": "{0} оставащи",
|
"LabelTimeRemaining": "{0} оставащи",
|
||||||
@@ -276,12 +295,17 @@
|
|||||||
"MessageAudiobookshelfServerNotConnected": "Audiobookshelf сървър не е включен",
|
"MessageAudiobookshelfServerNotConnected": "Audiobookshelf сървър не е включен",
|
||||||
"MessageAudiobookshelfServerRequired": "<strong>ВАЖНО!</strong> Това приложение е създадено да работи с Audiobookshelf сървър, който вие или някой, когото познавате, хоства. Това приложение не предоставя никакво съдържание.",
|
"MessageAudiobookshelfServerRequired": "<strong>ВАЖНО!</strong> Това приложение е създадено да работи с Audiobookshelf сървър, който вие или някой, когото познавате, хоства. Това приложение не предоставя никакво съдържание.",
|
||||||
"MessageBookshelfEmpty": "Библиотеката е празна",
|
"MessageBookshelfEmpty": "Библиотеката е празна",
|
||||||
|
"MessageConfirmAppExit": "Искате ли да излезете от апликацията?",
|
||||||
|
"MessageConfirmDeleteEpisodeDownloadQueue": "Сигурни ли сте, че искате да изчистите опашката за сваляне на епизоди?",
|
||||||
"MessageConfirmDeleteLocalEpisode": "Премахни \"{0}\" епизода от устройството? Файловете на сървъра няма да бъдат премахнати.",
|
"MessageConfirmDeleteLocalEpisode": "Премахни \"{0}\" епизода от устройството? Файловете на сървъра няма да бъдат премахнати.",
|
||||||
"MessageConfirmDeleteLocalFiles": "Да премахна ли локалните файлове на този елемент от вашето устройство? Файловете на сървъра и вашият напредък няма да бъдат засегнати.",
|
"MessageConfirmDeleteLocalFiles": "Да премахна ли локалните файлове на този елемент от вашето устройство? Файловете на сървъра и вашият напредък няма да бъдат засегнати.",
|
||||||
|
"MessageConfirmDeleteServerConfig": "Премахнете тази сървърна настройка?",
|
||||||
|
"MessageConfirmDeleteServerEpisode": "Сигурни ли сте че искате да изтрийете епизод \"{0}\" от сървъра?\nПредупреждение: Това ще изтрийе и аудио файла.",
|
||||||
"MessageConfirmDisableAutoTimer": "Сигурни ли сте, че искате да деактивирате автоматичния таймер за остатъка от днешния ден? Таймерът ще бъде активиран отново в края на този период на автоматичния таймер за изключване или ако рестартирате приложението.",
|
"MessageConfirmDisableAutoTimer": "Сигурни ли сте, че искате да деактивирате автоматичния таймер за остатъка от днешния ден? Таймерът ще бъде активиран отново в края на този период на автоматичния таймер за изключване или ако рестартирате приложението.",
|
||||||
"MessageConfirmDiscardProgress": "Сигурни ли сте, че искате да нулирате напредъка си?",
|
"MessageConfirmDiscardProgress": "Сигурни ли сте, че искате да нулирате напредъка си?",
|
||||||
"MessageConfirmDownloadUsingCellular": "Ще изтегляте, използвайки мобилни данни. Това може да включва такси за данни от оператора. Искате ли да продължите?",
|
"MessageConfirmDownloadUsingCellular": "Ще изтегляте, използвайки мобилни данни. Това може да включва такси за данни от оператора. Искате ли да продължите?",
|
||||||
"MessageConfirmMarkAsFinished": "Сигурни ли сте, че искате да маркирате този елемент като завършен?",
|
"MessageConfirmMarkAsFinished": "Сигурни ли сте, че искате да маркирате този елемент като завършен?",
|
||||||
|
"MessageConfirmPlaybackTime": "Започни възпроизвеждане на \"{0}\" в {1}?",
|
||||||
"MessageConfirmRemoveBookmark": "Сигурни ли сте, че искате да премахнете отметката?",
|
"MessageConfirmRemoveBookmark": "Сигурни ли сте, че искате да премахнете отметката?",
|
||||||
"MessageConfirmStreamingUsingCellular": "Ще стриймвате, използвайки мобилни данни. Това може да включва такси за данни от оператора. Искате ли да продължите?",
|
"MessageConfirmStreamingUsingCellular": "Ще стриймвате, използвайки мобилни данни. Това може да включва такси за данни от оператора. Искате ли да продължите?",
|
||||||
"MessageDiscardProgress": "Нулирай прогреса",
|
"MessageDiscardProgress": "Нулирай прогреса",
|
||||||
@@ -289,6 +313,7 @@
|
|||||||
"MessageDownloading": "Сваляне...",
|
"MessageDownloading": "Сваляне...",
|
||||||
"MessageDownloadingEpisode": "Сваля епизод",
|
"MessageDownloadingEpisode": "Сваля епизод",
|
||||||
"MessageEpisodesQueuedForDownload": "{0} Епизод(и) са сложени за сваляне",
|
"MessageEpisodesQueuedForDownload": "{0} Епизод(и) са сложени за сваляне",
|
||||||
|
"MessageFailedToRefreshToken": "Неуспешно опресняване на токена, налага се да се впишете отново",
|
||||||
"MessageFeedURLWillBe": "Адресът на емисията ще бъде {0}",
|
"MessageFeedURLWillBe": "Адресът на емисията ще бъде {0}",
|
||||||
"MessageFetching": "Извличане...",
|
"MessageFetching": "Извличане...",
|
||||||
"MessageFollowTheProjectOnGithub": "Последвайте проекта в Github",
|
"MessageFollowTheProjectOnGithub": "Последвайте проекта в Github",
|
||||||
@@ -296,6 +321,7 @@
|
|||||||
"MessageItemMissing": "Елементът липсва и трябва да бъде поправен на сървъра. Обикновено един елемент се маркира като липсващ, защото пътищата към файловете не са достъпни.",
|
"MessageItemMissing": "Елементът липсва и трябва да бъде поправен на сървъра. Обикновено един елемент се маркира като липсващ, защото пътищата към файловете не са достъпни.",
|
||||||
"MessageLoading": "Зарежда...",
|
"MessageLoading": "Зарежда...",
|
||||||
"MessageLoadingServerData": "Зарежда данни от сървъра...",
|
"MessageLoadingServerData": "Зарежда данни от сървъра...",
|
||||||
|
"MessageLocalFolderDescription": "'Вътрешното хранилище на приложението' е достъпно само през това приложение. Това приложение поддържа само медийни файлове, изтеглени директно през него. Споделените папки за съхранение могат да се използват, за да се позволи на други приложения да имат достъп до медийните файлове, изтеглени чрез това приложение.",
|
||||||
"MessageMarkAsFinished": "Маркирай като завършено",
|
"MessageMarkAsFinished": "Маркирай като завършено",
|
||||||
"MessageMediaLinkedToADifferentServer": "Медията е свързана със сървър на Audiobookshelf на различен адрес ({0}). Напредъкът ще бъде синхронизиран, когато се свържете с този сървърен адрес.",
|
"MessageMediaLinkedToADifferentServer": "Медията е свързана със сървър на Audiobookshelf на различен адрес ({0}). Напредъкът ще бъде синхронизиран, когато се свържете с този сървърен адрес.",
|
||||||
"MessageMediaLinkedToADifferentUser": "Медията е свързана с този сървър, но е изтеглена от друг потребител. Напредъкът ще бъде синхронизиран само с потребителя, който я е изтеглил.",
|
"MessageMediaLinkedToADifferentUser": "Медията е свързана с този сървър, но е изтеглена от друг потребител. Напредъкът ще бъде синхронизиран само с потребителя, който я е изтеглил.",
|
||||||
@@ -308,12 +334,16 @@
|
|||||||
"MessageNoItems": "Няма елементи",
|
"MessageNoItems": "Няма елементи",
|
||||||
"MessageNoItemsFound": "Няма намерени елементи",
|
"MessageNoItemsFound": "Няма намерени елементи",
|
||||||
"MessageNoListeningSessions": "Няма сесии за слушане",
|
"MessageNoListeningSessions": "Няма сесии за слушане",
|
||||||
|
"MessageNoLogs": "Няма логове",
|
||||||
"MessageNoMediaFolders": "Няма медии папки",
|
"MessageNoMediaFolders": "Няма медии папки",
|
||||||
"MessageNoNetworkConnection": "Няма връзка с интернет",
|
"MessageNoNetworkConnection": "Няма връзка с интернет",
|
||||||
"MessageNoPodcastsFound": "Няма намерени подкасти",
|
"MessageNoPodcastsFound": "Няма намерени подкасти",
|
||||||
"MessageNoSeries": "Няма серии",
|
"MessageNoSeries": "Няма серии",
|
||||||
"MessageNoUpdatesWereNecessary": "Няма нужда от обновяване",
|
"MessageNoUpdatesWereNecessary": "Няма нужда от обновяване",
|
||||||
"MessageNoUserPlaylists": "Нямате създадени плейлисти",
|
"MessageNoUserPlaylists": "Нямате създадени плейлисти",
|
||||||
|
"MessageOldServerAuthReLoginRequired": "За осигуряването на по-добра сигурност, автентикация беше подобрена във v.2.26.0. Всеки потребител ще трябва да се автентикира наново.",
|
||||||
|
"MessageOldServerAuthWarning": "Сървърът използва остаряла форма на автентикация",
|
||||||
|
"MessageOldServerAuthWarningHelp": "Този сървър е на версия, която е по-стара от v2.26.0. Във версия v2.26.0 беше добавена по-сигурна автентикация. Силно е препоръчително да обновите сървъра до последната версия. Аковече сте обновили сървъра, влезде наново, за да ползвате новата автентикация.",
|
||||||
"MessageOldServerConnectionWarning": "Конфигурацията за връзка със сървъра използва старо потребителско ID. Моля, изтрийте и добавете отново тази връзка със сървъра.",
|
"MessageOldServerConnectionWarning": "Конфигурацията за връзка със сървъра използва старо потребителско ID. Моля, изтрийте и добавете отново тази връзка със сървъра.",
|
||||||
"MessageOldServerConnectionWarningHelp": "Първоначално сте настроили връзката към този сървър преди миграцията на базата данни във версия 2.3.0, пусната през юни 2023 г. Бъдеща актуализация на сървъра ще премахне възможността за влизане с тази стара връзка. Моля, изтрийте съществуващата връзка със сървъра и се свържете отново (използвайки същия сървърен адрес и идентификационни данни). Ако имате изтеглена медия на това устройство, тя ще трябва да бъде изтеглена отново, за да се синхронизира със сървъра.",
|
"MessageOldServerConnectionWarningHelp": "Първоначално сте настроили връзката към този сървър преди миграцията на базата данни във версия 2.3.0, пусната през юни 2023 г. Бъдеща актуализация на сървъра ще премахне възможността за влизане с тази стара връзка. Моля, изтрийте съществуващата връзка със сървъра и се свържете отново (използвайки същия сървърен адрес и идентификационни данни). Ако имате изтеглена медия на това устройство, тя ще трябва да бъде изтеглена отново, за да се синхронизира със сървъра.",
|
||||||
"MessagePodcastSearchField": "Въведи какво да търся или RSS емисия адрес",
|
"MessagePodcastSearchField": "Въведи какво да търся или RSS емисия адрес",
|
||||||
@@ -340,5 +370,7 @@
|
|||||||
"ToastPodcastCreateSuccess": "Подкаст успешно създаден",
|
"ToastPodcastCreateSuccess": "Подкаст успешно създаден",
|
||||||
"ToastRSSFeedCloseFailed": "Неуспешно затваряне на RSS емисията",
|
"ToastRSSFeedCloseFailed": "Неуспешно затваряне на RSS емисията",
|
||||||
"ToastRSSFeedCloseSuccess": "RSS емисията е затворена",
|
"ToastRSSFeedCloseSuccess": "RSS емисията е затворена",
|
||||||
"ToastStreamingNotAllowedOnCellular": "Стриймването не е разрешено чрез мобилни данни"
|
"ToastStreamingNotAllowedOnCellular": "Стриймването не е разрешено чрез мобилни данни",
|
||||||
|
"UnitMinutesShort": "{0}мин",
|
||||||
|
"UnitSecondsShort": "{0}сек"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,7 @@
|
|||||||
"HeaderChapters": "Capítols",
|
"HeaderChapters": "Capítols",
|
||||||
"HeaderCollection": "Col·lecció",
|
"HeaderCollection": "Col·lecció",
|
||||||
"HeaderCollectionItems": "Elements de la col·lecció",
|
"HeaderCollectionItems": "Elements de la col·lecció",
|
||||||
|
"HeaderConfirm": "Confirma",
|
||||||
"HeaderConnectionStatus": "Estat de la connexió",
|
"HeaderConnectionStatus": "Estat de la connexió",
|
||||||
"HeaderDataSettings": "Paràmetres de dades",
|
"HeaderDataSettings": "Paràmetres de dades",
|
||||||
"HeaderDetails": "Detalls",
|
"HeaderDetails": "Detalls",
|
||||||
@@ -91,6 +92,7 @@
|
|||||||
"HeaderStatsRecentSessions": "Sessions recents",
|
"HeaderStatsRecentSessions": "Sessions recents",
|
||||||
"HeaderTableOfContents": "Sumari",
|
"HeaderTableOfContents": "Sumari",
|
||||||
"HeaderUserInterfaceSettings": "Paràmetres de la interfície d'usuari",
|
"HeaderUserInterfaceSettings": "Paràmetres de la interfície d'usuari",
|
||||||
|
"HeaderWelcome": "Benvingut, <strong>{0}</strong>",
|
||||||
"HeaderYourStats": "Les vostres estadístiques",
|
"HeaderYourStats": "Les vostres estadístiques",
|
||||||
"LabelAddToPlaylist": "Afegeix a la llista de reproducció",
|
"LabelAddToPlaylist": "Afegeix a la llista de reproducció",
|
||||||
"LabelAddedAt": "Afegit a",
|
"LabelAddedAt": "Afegit a",
|
||||||
@@ -113,6 +115,7 @@
|
|||||||
"LabelAutoSleepTimerAutoRewindHelp": "Quan el temporitzador d'auto sleep acaba, reproduir l'element altra vegada farà que es rebobine automàticament a la teua posició.",
|
"LabelAutoSleepTimerAutoRewindHelp": "Quan el temporitzador d'auto sleep acaba, reproduir l'element altra vegada farà que es rebobine automàticament a la teua posició.",
|
||||||
"LabelAutoSleepTimerHelp": "Quan es reprodueix entre hores especificades, el temporitzador s'activarà automàticament.",
|
"LabelAutoSleepTimerHelp": "Quan es reprodueix entre hores especificades, el temporitzador s'activarà automàticament.",
|
||||||
"LabelBooks": "Llibres",
|
"LabelBooks": "Llibres",
|
||||||
|
"LabelByAuthor": "per {0}",
|
||||||
"LabelChapterTrack": "Seguiment de capítol",
|
"LabelChapterTrack": "Seguiment de capítol",
|
||||||
"LabelChapters": "Capítols",
|
"LabelChapters": "Capítols",
|
||||||
"LabelClosePlayer": "Tanca el reproductor",
|
"LabelClosePlayer": "Tanca el reproductor",
|
||||||
@@ -126,11 +129,12 @@
|
|||||||
"LabelCustomTime": "Temps personalitzat",
|
"LabelCustomTime": "Temps personalitzat",
|
||||||
"LabelDescription": "Descripció",
|
"LabelDescription": "Descripció",
|
||||||
"LabelDisableAudioFadeOut": "Desactivar desaparició d'àudio",
|
"LabelDisableAudioFadeOut": "Desactivar desaparició d'àudio",
|
||||||
"LabelDisableAudioFadeOutHelp": "El volum de l'audio començara a reduïr-se quan quede menys d'1 minut del temporitzador d'anar a dormir. Habilita este ajustament per a no reduïr el volum.",
|
"LabelDisableAudioFadeOutHelp": "El volum de l'audio començara a reduïr-se quan quedi menys d'1 minut del temporitzador d'anar a dormir. Habilita aquest ajustament per a no reduïr el volum.",
|
||||||
"LabelDisableAutoRewind": "Desactivar rebobinatge automàtic",
|
"LabelDisableAutoRewind": "Desactivar rebobinatge automàtic",
|
||||||
"LabelDisableShakeToReset": "Desactivar sacsejar per reiniciar",
|
"LabelDisableShakeToReset": "Desactivar sacsejar per reiniciar",
|
||||||
"LabelDisableShakeToResetHelp": "Si sacseges el teu dispositiu quan el temporitzador és actiu o durant els 2 minuts després que hagi expirat, el temporitzador es reinicia. Activa aquesta opció per evitar-ho.",
|
"LabelDisableShakeToResetHelp": "Si sacseges el teu dispositiu quan el temporitzador és actiu o durant els 2 minuts després que hagi expirat, el temporitzador es reinicia. Activa aquesta opció per evitar-ho.",
|
||||||
"LabelDisableVibrateOnReset": "Desactivar la vibració al reiniciar",
|
"LabelDisableVibrateOnReset": "Desactivar la vibració al reiniciar",
|
||||||
|
"LabelDisableVibrateOnResetHelp": "Quan el temporitzador d'apagat es reinicia, el dispositiu vibra. Activa aquesta opció perquè no vibri quan es reiniciï el temporitzador.",
|
||||||
"LabelDiscover": "Descobrir",
|
"LabelDiscover": "Descobrir",
|
||||||
"LabelDownload": "Descarregar",
|
"LabelDownload": "Descarregar",
|
||||||
"LabelDownloadUsingCellular": "Descarregar utilitzant dades mòbils",
|
"LabelDownloadUsingCellular": "Descarregar utilitzant dades mòbils",
|
||||||
@@ -144,6 +148,7 @@
|
|||||||
"LabelEndOfChapter": "Final del capítol",
|
"LabelEndOfChapter": "Final del capítol",
|
||||||
"LabelEndTime": "Hora de finalització",
|
"LabelEndTime": "Hora de finalització",
|
||||||
"LabelEpisode": "Episodi",
|
"LabelEpisode": "Episodi",
|
||||||
|
"LabelExplicit": "Explícit",
|
||||||
"LabelFeedURL": "Font URL",
|
"LabelFeedURL": "Font URL",
|
||||||
"LabelFile": "Fitxer",
|
"LabelFile": "Fitxer",
|
||||||
"LabelFileBirthtime": "Fitxer creat a",
|
"LabelFileBirthtime": "Fitxer creat a",
|
||||||
@@ -152,22 +157,31 @@
|
|||||||
"LabelFinished": "Acabat",
|
"LabelFinished": "Acabat",
|
||||||
"LabelFolder": "Carpeta",
|
"LabelFolder": "Carpeta",
|
||||||
"LabelFontBoldness": "Gruix de la lletra",
|
"LabelFontBoldness": "Gruix de la lletra",
|
||||||
|
"LabelFontFamily": "Família tipogràfica",
|
||||||
|
"LabelFontFamilySans": "Sans",
|
||||||
|
"LabelFontFamilySerif": "Serif",
|
||||||
"LabelFontScale": "Escala de la lletra",
|
"LabelFontScale": "Escala de la lletra",
|
||||||
"LabelGenre": "Gènere",
|
"LabelGenre": "Gènere",
|
||||||
"LabelGenres": "Gèneres",
|
"LabelGenres": "Gèneres",
|
||||||
"LabelHapticFeedback": "Resposta hàptica",
|
"LabelHapticFeedback": "Resposta hàptica",
|
||||||
"LabelHasEbook": "Té llibre electrònic",
|
"LabelHasEbook": "Té llibre electrònic",
|
||||||
"LabelHasSupplementaryEbook": "Té llibre electrònic suplementari",
|
"LabelHasSupplementaryEbook": "Té llibre electrònic suplementari",
|
||||||
|
"LabelHeavy": "Pesat",
|
||||||
"LabelHigh": "Alt",
|
"LabelHigh": "Alt",
|
||||||
"LabelHost": "Amfitrió",
|
"LabelHost": "Amfitrió",
|
||||||
"LabelInProgress": "En procés",
|
"LabelInProgress": "En procés",
|
||||||
"LabelIncomplete": "Incomplet",
|
"LabelIncomplete": "Incomplet",
|
||||||
"LabelInternalAppStorage": "Emmagatzematge intern de l'aplicació",
|
"LabelInternalAppStorage": "Emmagatzematge intern de l'aplicació",
|
||||||
"LabelJumpBackwardsTime": "Saltar enrere",
|
"LabelJumpBackwardsTime": "Saltar enrere",
|
||||||
|
"LabelJumpForwardsTime": "Salt endavant en el temps",
|
||||||
|
"LabelKeepScreenAwake": "Manté la pantalla encesa",
|
||||||
"LabelLanguage": "Llengua",
|
"LabelLanguage": "Llengua",
|
||||||
"LabelLayout": "Disposició",
|
"LabelLayout": "Disposició",
|
||||||
"LabelLayoutAuto": "Automàtic",
|
"LabelLayoutAuto": "Automàtic",
|
||||||
"LabelLayoutSinglePage": "Pàgina única",
|
"LabelLayoutSinglePage": "Pàgina única",
|
||||||
|
"LabelLibrarySortByProgress": "Progrés: Última actualització",
|
||||||
|
"LabelLibrarySortByProgressFinished": "Progrés: Finalitzat",
|
||||||
|
"LabelLibrarySortByProgressStarted": "Progrés: Començat",
|
||||||
"LabelLight": "Clar",
|
"LabelLight": "Clar",
|
||||||
"LabelLineSpacing": "Interlineat",
|
"LabelLineSpacing": "Interlineat",
|
||||||
"LabelListenAgain": "Escoltar de nou",
|
"LabelListenAgain": "Escoltar de nou",
|
||||||
@@ -175,14 +189,17 @@
|
|||||||
"LabelLocalPodcasts": "Pòdcasts locals",
|
"LabelLocalPodcasts": "Pòdcasts locals",
|
||||||
"LabelLockOrientation": "Bloca l'orientació",
|
"LabelLockOrientation": "Bloca l'orientació",
|
||||||
"LabelLockPlayer": "Bloca el reproductor",
|
"LabelLockPlayer": "Bloca el reproductor",
|
||||||
|
"LabelLow": "Baix",
|
||||||
"LabelMediaType": "Tipus de mitjà",
|
"LabelMediaType": "Tipus de mitjà",
|
||||||
"LabelMedium": "Mitjà",
|
"LabelMedium": "Mitjà",
|
||||||
|
"LabelMissing": "Falta",
|
||||||
"LabelMore": "Més",
|
"LabelMore": "Més",
|
||||||
"LabelMoreInfo": "Més informació",
|
"LabelMoreInfo": "Més informació",
|
||||||
"LabelName": "Nom",
|
"LabelName": "Nom",
|
||||||
"LabelNarrator": "Narrador",
|
"LabelNarrator": "Narrador",
|
||||||
"LabelNarrators": "Narradors",
|
"LabelNarrators": "Narradors",
|
||||||
"LabelNavigateWithVolume": "Navega amb les tecles de volum",
|
"LabelNavigateWithVolume": "Navega amb les tecles de volum",
|
||||||
|
"LabelNavigateWithVolumeMirrored": "Emmirallat",
|
||||||
"LabelNavigateWithVolumeWhilePlayingDisabled": "⭘",
|
"LabelNavigateWithVolumeWhilePlayingDisabled": "⭘",
|
||||||
"LabelNavigateWithVolumeWhilePlayingEnabled": "⏽",
|
"LabelNavigateWithVolumeWhilePlayingEnabled": "⏽",
|
||||||
"LabelNever": "Mai",
|
"LabelNever": "Mai",
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
"ButtonPlaylists": "Seznamy skladeb",
|
"ButtonPlaylists": "Seznamy skladeb",
|
||||||
"ButtonRead": "Číst",
|
"ButtonRead": "Číst",
|
||||||
"ButtonReadLess": "Číst méně",
|
"ButtonReadLess": "Číst méně",
|
||||||
"ButtonReadMore": "Číst další",
|
"ButtonReadMore": "Číst více",
|
||||||
"ButtonRemove": "Odstranit",
|
"ButtonRemove": "Odstranit",
|
||||||
"ButtonRemoveFromServer": "Odstranit ze serveru",
|
"ButtonRemoveFromServer": "Odstranit ze serveru",
|
||||||
"ButtonSave": "Uložit",
|
"ButtonSave": "Uložit",
|
||||||
@@ -63,6 +63,7 @@
|
|||||||
"HeaderChapters": "Kapitoly",
|
"HeaderChapters": "Kapitoly",
|
||||||
"HeaderCollection": "Kolekce",
|
"HeaderCollection": "Kolekce",
|
||||||
"HeaderCollectionItems": "Položky kolekce",
|
"HeaderCollectionItems": "Položky kolekce",
|
||||||
|
"HeaderConfirm": "Potvrzení",
|
||||||
"HeaderConnectionStatus": "Stav připojení",
|
"HeaderConnectionStatus": "Stav připojení",
|
||||||
"HeaderDataSettings": "Nastavení dat",
|
"HeaderDataSettings": "Nastavení dat",
|
||||||
"HeaderDetails": "Podrobnosti",
|
"HeaderDetails": "Podrobnosti",
|
||||||
@@ -82,7 +83,7 @@
|
|||||||
"HeaderProgressSyncFailed": "Chyba při synchronizaci pokroku",
|
"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": "Informační kanál RSS je otevřený",
|
||||||
"HeaderSelectDownloadLocation": "Vyberte umístění pro stažení",
|
"HeaderSelectDownloadLocation": "Vyberte umístění pro stažení",
|
||||||
"HeaderSettings": "Nastavení",
|
"HeaderSettings": "Nastavení",
|
||||||
"HeaderSleepTimer": "Časovač spánku",
|
"HeaderSleepTimer": "Časovač spánku",
|
||||||
@@ -91,6 +92,7 @@
|
|||||||
"HeaderStatsRecentSessions": "Poslední sezení",
|
"HeaderStatsRecentSessions": "Poslední sezení",
|
||||||
"HeaderTableOfContents": "Obsah",
|
"HeaderTableOfContents": "Obsah",
|
||||||
"HeaderUserInterfaceSettings": "Nastavení uživatelského rozhraní",
|
"HeaderUserInterfaceSettings": "Nastavení uživatelského rozhraní",
|
||||||
|
"HeaderWelcome": "Vítejte, <strong>{0}</strong>",
|
||||||
"HeaderYourStats": "Vaše statistiky",
|
"HeaderYourStats": "Vaše statistiky",
|
||||||
"LabelAddToPlaylist": "Přidat do seznamu skladeb",
|
"LabelAddToPlaylist": "Přidat do seznamu skladeb",
|
||||||
"LabelAddedAt": "Přidáno v",
|
"LabelAddedAt": "Přidáno v",
|
||||||
@@ -113,6 +115,7 @@
|
|||||||
"LabelAutoSleepTimerAutoRewindHelp": "Když dojde k uspání automatickým časovačem spánku, pozice přehrávání je posunuta zpět o vybraný čas.",
|
"LabelAutoSleepTimerAutoRewindHelp": "Když dojde k uspání automatickým časovačem spánku, pozice přehrávání je posunuta zpět o vybraný čas.",
|
||||||
"LabelAutoSleepTimerHelp": "Během přehrávání média v časovém rozmezí \"Od\" a \"Do\" se automaticky spustí časovač spánku.",
|
"LabelAutoSleepTimerHelp": "Během přehrávání média v časovém rozmezí \"Od\" a \"Do\" se automaticky spustí časovač spánku.",
|
||||||
"LabelBooks": "Knihy",
|
"LabelBooks": "Knihy",
|
||||||
|
"LabelByAuthor": "od {0}",
|
||||||
"LabelChapterTrack": "Stopa kapitoly",
|
"LabelChapterTrack": "Stopa kapitoly",
|
||||||
"LabelChapters": "Kapitoly",
|
"LabelChapters": "Kapitoly",
|
||||||
"LabelClosePlayer": "Zavřít přehrávač",
|
"LabelClosePlayer": "Zavřít přehrávač",
|
||||||
@@ -146,7 +149,7 @@
|
|||||||
"LabelEndOfChapter": "Konec kapitoly",
|
"LabelEndOfChapter": "Konec kapitoly",
|
||||||
"LabelEndTime": "Do",
|
"LabelEndTime": "Do",
|
||||||
"LabelEpisode": "Epizoda",
|
"LabelEpisode": "Epizoda",
|
||||||
"LabelExplicit": "Explicitně",
|
"LabelExplicit": "Explicitní",
|
||||||
"LabelFeedURL": "URL kanálu",
|
"LabelFeedURL": "URL kanálu",
|
||||||
"LabelFile": "Soubor",
|
"LabelFile": "Soubor",
|
||||||
"LabelFileBirthtime": "Čas vytvoření souboru",
|
"LabelFileBirthtime": "Čas vytvoření souboru",
|
||||||
@@ -155,13 +158,16 @@
|
|||||||
"LabelFinished": "Dokončeno",
|
"LabelFinished": "Dokončeno",
|
||||||
"LabelFolder": "Složka",
|
"LabelFolder": "Složka",
|
||||||
"LabelFontBoldness": "Výraznost písma",
|
"LabelFontBoldness": "Výraznost písma",
|
||||||
"LabelFontScale": "Měřítko písma",
|
"LabelFontFamily": "Rodina písem",
|
||||||
|
"LabelFontFamilySans": "Bezpatkové",
|
||||||
|
"LabelFontFamilySerif": "Patkové",
|
||||||
|
"LabelFontScale": "Velikost písma",
|
||||||
"LabelGenre": "Žánr",
|
"LabelGenre": "Žánr",
|
||||||
"LabelGenres": "Žánry",
|
"LabelGenres": "Žánry",
|
||||||
"LabelHapticFeedback": "Haptická odezva",
|
"LabelHapticFeedback": "Haptická odezva",
|
||||||
"LabelHasEbook": "Má e-knihu",
|
"LabelHasEbook": "Má e-knihu",
|
||||||
"LabelHasSupplementaryEbook": "Má doplňkovou e-knihu",
|
"LabelHasSupplementaryEbook": "Obsahuje doplňkovou e-knihu",
|
||||||
"LabelHeavy": "Těžké",
|
"LabelHeavy": "Silná",
|
||||||
"LabelHigh": "Vysoké",
|
"LabelHigh": "Vysoké",
|
||||||
"LabelHost": "Hostitel",
|
"LabelHost": "Hostitel",
|
||||||
"LabelInProgress": "Probíhá",
|
"LabelInProgress": "Probíhá",
|
||||||
@@ -174,7 +180,10 @@
|
|||||||
"LabelLayout": "Rozvržení",
|
"LabelLayout": "Rozvržení",
|
||||||
"LabelLayoutAuto": "Automatické",
|
"LabelLayoutAuto": "Automatické",
|
||||||
"LabelLayoutSinglePage": "Jedna stránka",
|
"LabelLayoutSinglePage": "Jedna stránka",
|
||||||
"LabelLight": "Světlé",
|
"LabelLibrarySortByProgress": "Pokrok: naposledy aktualizováno",
|
||||||
|
"LabelLibrarySortByProgressFinished": "Pokrok: dokončeno",
|
||||||
|
"LabelLibrarySortByProgressStarted": "Pokrok: začato",
|
||||||
|
"LabelLight": "Slabá",
|
||||||
"LabelLineSpacing": "Řádkování",
|
"LabelLineSpacing": "Řádkování",
|
||||||
"LabelListenAgain": "Poslouchat znovu",
|
"LabelListenAgain": "Poslouchat znovu",
|
||||||
"LabelLocalBooks": "Místní knihy",
|
"LabelLocalBooks": "Místní knihy",
|
||||||
@@ -204,7 +213,7 @@
|
|||||||
"LabelNumEpisodes": "{0} epizod",
|
"LabelNumEpisodes": "{0} epizod",
|
||||||
"LabelNumEpisodesIncomplete": "{0} epizod, {1} nekompletní",
|
"LabelNumEpisodesIncomplete": "{0} epizod, {1} nekompletní",
|
||||||
"LabelNumberOfEpisodes": "Počet epizod",
|
"LabelNumberOfEpisodes": "Počet epizod",
|
||||||
"LabelOff": "Vypnout",
|
"LabelOff": "Vypnutá",
|
||||||
"LabelOn": "Zapnuto",
|
"LabelOn": "Zapnuto",
|
||||||
"LabelPassword": "Heslo",
|
"LabelPassword": "Heslo",
|
||||||
"LabelPath": "Cesta",
|
"LabelPath": "Cesta",
|
||||||
@@ -233,6 +242,7 @@
|
|||||||
"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í",
|
||||||
|
"LabelSelectMediaType": "Vyberte typ média",
|
||||||
"LabelSequenceAscending": "Řadit vzestupně",
|
"LabelSequenceAscending": "Řadit vzestupně",
|
||||||
"LabelSequenceDescending": "Řadit sestupně",
|
"LabelSequenceDescending": "Řadit sestupně",
|
||||||
"LabelSeries": "Série",
|
"LabelSeries": "Série",
|
||||||
@@ -285,12 +295,17 @@
|
|||||||
"MessageAudiobookshelfServerNotConnected": "Server Audiobookshelf není připojen",
|
"MessageAudiobookshelfServerNotConnected": "Server Audiobookshelf není připojen",
|
||||||
"MessageAudiobookshelfServerRequired": "<strong>Důležité!</strong> Tato aplikace je navržena, aby pracovala se serverem Audiobookshelf, který hostuje buď vy, nebo někdo, koho znáte. Tato aplikace neposkytuje žádný obsah.",
|
"MessageAudiobookshelfServerRequired": "<strong>Důležité!</strong> Tato aplikace je navržena, aby pracovala se serverem Audiobookshelf, který hostuje buď vy, nebo někdo, koho znáte. Tato aplikace neposkytuje žádný obsah.",
|
||||||
"MessageBookshelfEmpty": "Knihovna je prázdná",
|
"MessageBookshelfEmpty": "Knihovna je prázdná",
|
||||||
|
"MessageConfirmAppExit": "Opravdu chcete opustit aplikaci?",
|
||||||
|
"MessageConfirmDeleteEpisodeDownloadQueue": "Přejete si skutečně vymazat frontu stahování epizod?",
|
||||||
"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.",
|
||||||
|
"MessageConfirmDeleteServerConfig": "Odebrat tuto konfiguraci serveru?",
|
||||||
|
"MessageConfirmDeleteServerEpisode": "Přejete si skutečně odstranit epizodu \"{0}\" z serveru?\nUpozornění: Toto odstraní zvukový soubor.",
|
||||||
"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.",
|
"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?",
|
||||||
|
"MessageConfirmPlaybackTime": "Spustit přehrávání pro \"{0}\" v {1}?",
|
||||||
"MessageConfirmRemoveBookmark": "Opravdu chcete odebrat záložku?",
|
"MessageConfirmRemoveBookmark": "Opravdu chcete odebrat záložku?",
|
||||||
"MessageConfirmStreamingUsingCellular": "Chystáte se přehrávat přes mobilní data. Toto může zahrnovat poplatky za mobilní data. Chcete pokračovat?",
|
"MessageConfirmStreamingUsingCellular": "Chystáte se přehrávat přes mobilní data. Toto může zahrnovat poplatky za mobilní data. Chcete pokračovat?",
|
||||||
"MessageDiscardProgress": "Zahodit pokrok",
|
"MessageDiscardProgress": "Zahodit pokrok",
|
||||||
@@ -298,6 +313,7 @@
|
|||||||
"MessageDownloading": "Stahuje se...",
|
"MessageDownloading": "Stahuje se...",
|
||||||
"MessageDownloadingEpisode": "Stahuji epizodu",
|
"MessageDownloadingEpisode": "Stahuji epizodu",
|
||||||
"MessageEpisodesQueuedForDownload": "{0} Epizody zařazené do fronty ke stažení",
|
"MessageEpisodesQueuedForDownload": "{0} Epizody zařazené do fronty ke stažení",
|
||||||
|
"MessageFailedToRefreshToken": "Nepovedlo se obnovit token, je nutné se znovu přihlásit",
|
||||||
"MessageFeedURLWillBe": "URL zdroje bude {0}",
|
"MessageFeedURLWillBe": "URL zdroje bude {0}",
|
||||||
"MessageFetching": "Načítání...",
|
"MessageFetching": "Načítání...",
|
||||||
"MessageFollowTheProjectOnGithub": "Sledujte projekt na GitHubu",
|
"MessageFollowTheProjectOnGithub": "Sledujte projekt na GitHubu",
|
||||||
@@ -325,13 +341,16 @@
|
|||||||
"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",
|
||||||
|
"MessageOldServerAuthReLoginRequired": "V rámci aktualizace serveru v2.26.0 bylo vylepšeno zabezpečení autentizace. Všichni uživatelé se musí znovu přihlásit.",
|
||||||
|
"MessageOldServerAuthWarning": "Server používá zastaralou autentizaci",
|
||||||
|
"MessageOldServerAuthWarningHelp": "Server běží na verzi starší než v2.26.0. Bezpečnější způsob autentizace byl přidán ve verzi v2.26.0. Důrazně se doporučuje aktualizovat server na nejnovější verzi. Pokud jste již server aktualizovali, znovu se přihlašte pro použití nové autentizace.",
|
||||||
"MessageOldServerConnectionWarning": "Konfigurace připojení k serveru používá staré ID uživatele. Odstraňte a znovu přidejte toto připojení k serveru.",
|
"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.",
|
"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.",
|
"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 {0} chybějících knih s {1} souborem/soubory, celkem {2}, do složky {3}?",
|
||||||
"MessageSeriesDownloadConfirmIos": "Stáhnout chybějící {0} knihu(y) s {1} souborem(y), celkem {2}?",
|
"MessageSeriesDownloadConfirmIos": "Stáhnout chybějící {0} knihu(y) s {1} souborem(y), celkem {2}?",
|
||||||
"MessageSocketConnectedOverMeteredCellular": "Socket připojen přes měřenou mobilní síť",
|
"MessageSocketConnectedOverMeteredCellular": "Socket připojen přes měřenou mobilní síť",
|
||||||
"MessageSocketConnectedOverMeteredWifi": "Socket připojen přes měřenou Wi-Fi",
|
"MessageSocketConnectedOverMeteredWifi": "Socket připojen přes měřenou Wi-Fi",
|
||||||
@@ -351,5 +370,7 @@
|
|||||||
"ToastPodcastCreateSuccess": "Podcast byl úspěšně vytvořen",
|
"ToastPodcastCreateSuccess": "Podcast byl úspěšně vytvořen",
|
||||||
"ToastRSSFeedCloseFailed": "Nepodařilo se zavřít RSS kanál",
|
"ToastRSSFeedCloseFailed": "Nepodařilo se zavřít RSS kanál",
|
||||||
"ToastRSSFeedCloseSuccess": "RSS kanál uzavřen",
|
"ToastRSSFeedCloseSuccess": "RSS kanál uzavřen",
|
||||||
"ToastStreamingNotAllowedOnCellular": "Přehrávání přes mobilní data není povoleno"
|
"ToastStreamingNotAllowedOnCellular": "Přehrávání přes mobilní data není povoleno",
|
||||||
|
"UnitMinutesShort": "{0}m",
|
||||||
|
"UnitSecondsShort": "{0}s"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,7 @@
|
|||||||
"HeaderChapters": "Kapitler",
|
"HeaderChapters": "Kapitler",
|
||||||
"HeaderCollection": "Samling",
|
"HeaderCollection": "Samling",
|
||||||
"HeaderCollectionItems": "Samlingselementer",
|
"HeaderCollectionItems": "Samlingselementer",
|
||||||
|
"HeaderConfirm": "Bekræft",
|
||||||
"HeaderConnectionStatus": "Forbindelsesstatus",
|
"HeaderConnectionStatus": "Forbindelsesstatus",
|
||||||
"HeaderDataSettings": "Data indstillinger",
|
"HeaderDataSettings": "Data indstillinger",
|
||||||
"HeaderDetails": "Detaljer",
|
"HeaderDetails": "Detaljer",
|
||||||
@@ -91,6 +92,7 @@
|
|||||||
"HeaderStatsRecentSessions": "Seneste Sessions",
|
"HeaderStatsRecentSessions": "Seneste Sessions",
|
||||||
"HeaderTableOfContents": "Indholdsfortegnelse",
|
"HeaderTableOfContents": "Indholdsfortegnelse",
|
||||||
"HeaderUserInterfaceSettings": "Indstillinger for brugergrænseflade",
|
"HeaderUserInterfaceSettings": "Indstillinger for brugergrænseflade",
|
||||||
|
"HeaderWelcome": "Velkommen, <strong>{0}</strong>",
|
||||||
"HeaderYourStats": "Dine Statistikker",
|
"HeaderYourStats": "Dine Statistikker",
|
||||||
"LabelAddToPlaylist": "Tilføj til Afspilningsliste",
|
"LabelAddToPlaylist": "Tilføj til Afspilningsliste",
|
||||||
"LabelAddedAt": "Tilføjet",
|
"LabelAddedAt": "Tilføjet",
|
||||||
@@ -108,11 +110,12 @@
|
|||||||
"LabelAuthors": "Forfattere",
|
"LabelAuthors": "Forfattere",
|
||||||
"LabelAutoDownloadEpisodes": "Auto Download Episoder",
|
"LabelAutoDownloadEpisodes": "Auto Download Episoder",
|
||||||
"LabelAutoRewindTime": "Automatisk tilbagespolingstid",
|
"LabelAutoRewindTime": "Automatisk tilbagespolingstid",
|
||||||
"LabelAutoSleepTimer": "Auto sleep timer",
|
"LabelAutoSleepTimer": "Auto søvn-timer",
|
||||||
"LabelAutoSleepTimerAutoRewind": "Automatisk sleep timer automatisk tilbagespoling",
|
"LabelAutoSleepTimerAutoRewind": "Automatisk sleep timer automatisk tilbagespoling",
|
||||||
"LabelAutoSleepTimerAutoRewindHelp": "Når den automatiske sleep-timer er færdig, vil genafspilning af elementet automatisk spole din position tilbage.",
|
"LabelAutoSleepTimerAutoRewindHelp": "Når den automatiske sleep-timer er færdig, vil genafspilning af elementet automatisk spole din position tilbage.",
|
||||||
"LabelAutoSleepTimerHelp": "Når der afspilles medie mellem de angivne start- og sluttidspunkter, starter en sleep-timer automatisk.",
|
"LabelAutoSleepTimerHelp": "Når der afspilles medie mellem de angivne start- og sluttidspunkter, starter en sleep-timer automatisk.",
|
||||||
"LabelBooks": "Bøger",
|
"LabelBooks": "Bøger",
|
||||||
|
"LabelByAuthor": "Efter Forfatter",
|
||||||
"LabelChapterTrack": "Kapitel spor",
|
"LabelChapterTrack": "Kapitel spor",
|
||||||
"LabelChapters": "Kapitler",
|
"LabelChapters": "Kapitler",
|
||||||
"LabelClosePlayer": "Luk afspiller",
|
"LabelClosePlayer": "Luk afspiller",
|
||||||
@@ -146,6 +149,7 @@
|
|||||||
"LabelEndOfChapter": "Slutningen af kapitel",
|
"LabelEndOfChapter": "Slutningen af kapitel",
|
||||||
"LabelEndTime": "Sluttidspunkt",
|
"LabelEndTime": "Sluttidspunkt",
|
||||||
"LabelEpisode": "Afsnit",
|
"LabelEpisode": "Afsnit",
|
||||||
|
"LabelExplicit": "Eksplisit",
|
||||||
"LabelFeedURL": "Feed URL",
|
"LabelFeedURL": "Feed URL",
|
||||||
"LabelFile": "Fil",
|
"LabelFile": "Fil",
|
||||||
"LabelFileBirthtime": "Oprettelsestidspunkt for fil",
|
"LabelFileBirthtime": "Oprettelsestidspunkt for fil",
|
||||||
@@ -154,6 +158,9 @@
|
|||||||
"LabelFinished": "Færdig",
|
"LabelFinished": "Færdig",
|
||||||
"LabelFolder": "Mappe",
|
"LabelFolder": "Mappe",
|
||||||
"LabelFontBoldness": "Skrift tykkelse",
|
"LabelFontBoldness": "Skrift tykkelse",
|
||||||
|
"LabelFontFamily": "Skrifttypefamilie",
|
||||||
|
"LabelFontFamilySans": "Font Sans",
|
||||||
|
"LabelFontFamilySerif": "Font Serif",
|
||||||
"LabelFontScale": "Skriftstørrelse",
|
"LabelFontScale": "Skriftstørrelse",
|
||||||
"LabelGenre": "Genre",
|
"LabelGenre": "Genre",
|
||||||
"LabelGenres": "Genrer",
|
"LabelGenres": "Genrer",
|
||||||
@@ -173,6 +180,9 @@
|
|||||||
"LabelLayout": "Layout",
|
"LabelLayout": "Layout",
|
||||||
"LabelLayoutAuto": "Automatisk",
|
"LabelLayoutAuto": "Automatisk",
|
||||||
"LabelLayoutSinglePage": "Enkel Side",
|
"LabelLayoutSinglePage": "Enkel Side",
|
||||||
|
"LabelLibrarySortByProgress": "Fremgang: Sidst opdateret",
|
||||||
|
"LabelLibrarySortByProgressFinished": "Fremgang: Afsluttet",
|
||||||
|
"LabelLibrarySortByProgressStarted": "Fremgang: Startet",
|
||||||
"LabelLight": "Lys",
|
"LabelLight": "Lys",
|
||||||
"LabelLineSpacing": "Linjeafstand",
|
"LabelLineSpacing": "Linjeafstand",
|
||||||
"LabelListenAgain": "Lyt Igen",
|
"LabelListenAgain": "Lyt Igen",
|
||||||
@@ -232,6 +242,7 @@
|
|||||||
"LabelScaleElapsedTimeBySpeed": "Skalér Forløbet Tid med Hastighed",
|
"LabelScaleElapsedTimeBySpeed": "Skalér Forløbet Tid med Hastighed",
|
||||||
"LabelSeason": "Sæson",
|
"LabelSeason": "Sæson",
|
||||||
"LabelSelectADevice": "Vælg en Enhed",
|
"LabelSelectADevice": "Vælg en Enhed",
|
||||||
|
"LabelSelectMediaType": "Vælg medietype",
|
||||||
"LabelSequenceAscending": "Sekvens Stigende",
|
"LabelSequenceAscending": "Sekvens Stigende",
|
||||||
"LabelSequenceDescending": "Sekvens Faldende",
|
"LabelSequenceDescending": "Sekvens Faldende",
|
||||||
"LabelSeries": "Serie",
|
"LabelSeries": "Serie",
|
||||||
@@ -282,10 +293,13 @@
|
|||||||
"MessageAndroid10Downloads": "Android 10 og mindre vil bruge intern lagerplads til downloads.",
|
"MessageAndroid10Downloads": "Android 10 og mindre vil bruge intern lagerplads til downloads.",
|
||||||
"MessageAttemptingServerConnection": "Prøver at tilgå server...",
|
"MessageAttemptingServerConnection": "Prøver at tilgå server...",
|
||||||
"MessageAudiobookshelfServerNotConnected": "Audiobookshel server er ikke tilsluttet",
|
"MessageAudiobookshelfServerNotConnected": "Audiobookshel server er ikke tilsluttet",
|
||||||
"MessageAudiobookshelfServerRequired": "<strong>Vigtig</strong> Denne app er designet til at fungere med en Audiobookshelf server, som du eller en du kender hoster. Denne app levere ikke noget indhold.",
|
"MessageAudiobookshelfServerRequired": "<strong>Vigtigt!</strong> Denne app er designet til at fungere med en Audiobookshelf-server, som du eller en du kender hoster. Denne app leverer ikke noget indhold.",
|
||||||
"MessageBookshelfEmpty": "Bogreol Tom",
|
"MessageBookshelfEmpty": "Bogreol Tom",
|
||||||
|
"MessageConfirmAppExit": "Ville du lukke appen?",
|
||||||
|
"MessageConfirmDeleteEpisodeDownloadQueue": "Er du sikker på, at du vil rydde downloadkøen for episoder?",
|
||||||
"MessageConfirmDeleteLocalEpisode": "Slet lokal episode \"{0}\" fra din enhed? Filen på serveren vil ikke blive påvirket.",
|
"MessageConfirmDeleteLocalEpisode": "Slet lokal episode \"{0}\" fra din enhed? Filen på serveren vil ikke blive påvirket.",
|
||||||
"MessageConfirmDeleteLocalFiles": "Slet lokale filer af denne type fra din enhed? Filerne på serveren og din fremgang vil ikke blive påvirket.",
|
"MessageConfirmDeleteLocalFiles": "Slet lokale filer af denne type fra din enhed? Filerne på serveren og din fremgang vil ikke blive påvirket.",
|
||||||
|
"MessageConfirmDeleteServerConfig": "Fjern denne serverkonfiguration?",
|
||||||
"MessageConfirmDisableAutoTimer": "Er du sikker på at du vil slå auto-timer fra resten af dagen? Timeren vil blive slået til ved slutningen af auto-sleep timer perioden eller appen genstartes.",
|
"MessageConfirmDisableAutoTimer": "Er du sikker på at du vil slå auto-timer fra resten af dagen? Timeren vil blive slået til ved slutningen af auto-sleep timer perioden eller appen genstartes.",
|
||||||
"MessageConfirmDiscardProgress": "Er du sikker på at du vil nulstille din fremgang?",
|
"MessageConfirmDiscardProgress": "Er du sikker på at du vil nulstille din fremgang?",
|
||||||
"MessageConfirmDownloadUsingCellular": "Du er ved at downloade via mobildata. Dette kan medføre ekstra omkostninger fra din teleoperatør. Vil du fortsætte?",
|
"MessageConfirmDownloadUsingCellular": "Du er ved at downloade via mobildata. Dette kan medføre ekstra omkostninger fra din teleoperatør. Vil du fortsætte?",
|
||||||
@@ -297,6 +311,7 @@
|
|||||||
"MessageDownloading": "Downloader...",
|
"MessageDownloading": "Downloader...",
|
||||||
"MessageDownloadingEpisode": "Downloader episode",
|
"MessageDownloadingEpisode": "Downloader episode",
|
||||||
"MessageEpisodesQueuedForDownload": "{0} episoder er sat i kø til download",
|
"MessageEpisodesQueuedForDownload": "{0} episoder er sat i kø til download",
|
||||||
|
"MessageFailedToRefreshToken": "Mislykkedes med at opdatere token, nyt loginforsøg nødvendigt",
|
||||||
"MessageFeedURLWillBe": "Feed-URL vil være {0}",
|
"MessageFeedURLWillBe": "Feed-URL vil være {0}",
|
||||||
"MessageFetching": "Henter...",
|
"MessageFetching": "Henter...",
|
||||||
"MessageFollowTheProjectOnGithub": "Følg projektet på GitHub",
|
"MessageFollowTheProjectOnGithub": "Følg projektet på GitHub",
|
||||||
@@ -324,6 +339,9 @@
|
|||||||
"MessageNoSeries": "Ingen serier",
|
"MessageNoSeries": "Ingen serier",
|
||||||
"MessageNoUpdatesWereNecessary": "Ingen opdateringer var nødvendige",
|
"MessageNoUpdatesWereNecessary": "Ingen opdateringer var nødvendige",
|
||||||
"MessageNoUserPlaylists": "Du har ingen afspilningslister",
|
"MessageNoUserPlaylists": "Du har ingen afspilningslister",
|
||||||
|
"MessageOldServerAuthReLoginRequired": "Autentificering er blevet forbedret af sikkerhedsmæssige årsager i server v2.26.0. Alle brugere skal logge ind igen.",
|
||||||
|
"MessageOldServerAuthWarning": "Serveren bruger forældet autentificering",
|
||||||
|
"MessageOldServerAuthWarningHelp": "Denne server kører en version ældre end v2.26.0. Et mere sikkert autentificeringssystem blev tilføjet i v2.26.0. Det anbefales kraftigt at opdatere serveren til den nyeste version. Hvis du allerede har opdateret serveren, skal du logge ind igen for at bruge den nye autentificering.",
|
||||||
"MessageOldServerConnectionWarning": "Server forbindelseskonfiguration anvender et gammelt bruger ID. Fjern venligst og gentilføj denne server forbindelse.",
|
"MessageOldServerConnectionWarning": "Server forbindelseskonfiguration anvender et gammelt bruger ID. Fjern venligst og gentilføj denne server forbindelse.",
|
||||||
"MessageOldServerConnectionWarningHelp": "Du har oprindeligt sat forbindelsen til denne server op før databasemigreringen i 2.3.0, udgivet i juni 2023. En senere serveropdatering vil fjerne muligheden for at logge ind med genne gamle metode. Sørg venligst for at slette den eksisterende serverforbindelse og opret forbindelse igen (ved anvendelse af den samme server adresse og login oplysninger). Hvis du har downloaded noget medie på denne enhed vil dette være nødsaget til at bive downloaded igen efter serversynkronisering.",
|
"MessageOldServerConnectionWarningHelp": "Du har oprindeligt sat forbindelsen til denne server op før databasemigreringen i 2.3.0, udgivet i juni 2023. En senere serveropdatering vil fjerne muligheden for at logge ind med genne gamle metode. Sørg venligst for at slette den eksisterende serverforbindelse og opret forbindelse igen (ved anvendelse af den samme server adresse og login oplysninger). Hvis du har downloaded noget medie på denne enhed vil dette være nødsaget til at bive downloaded igen efter serversynkronisering.",
|
||||||
"MessagePodcastSearchField": "Indtast søgeterm eller RSS URL",
|
"MessagePodcastSearchField": "Indtast søgeterm eller RSS URL",
|
||||||
@@ -343,12 +361,14 @@
|
|||||||
"ToastBookmarkRemoveFailed": "Mislykkedes fjernelse af bogmærke",
|
"ToastBookmarkRemoveFailed": "Mislykkedes fjernelse af bogmærke",
|
||||||
"ToastBookmarkUpdateFailed": "Mislykkedes opdatering af bogmærke",
|
"ToastBookmarkUpdateFailed": "Mislykkedes opdatering af bogmærke",
|
||||||
"ToastDownloadNotAllowedOnCellular": "Det er ikke tillad at downloade over mobildata",
|
"ToastDownloadNotAllowedOnCellular": "Det er ikke tillad at downloade over mobildata",
|
||||||
"ToastItemMarkedAsFinishedFailed": "Mislykkedes markering som afsluttet",
|
"ToastItemMarkedAsFinishedFailed": "Markering som afsluttet mislykkedes",
|
||||||
"ToastItemMarkedAsNotFinishedFailed": "Mislykkedes markering som ikke afsluttet",
|
"ToastItemMarkedAsNotFinishedFailed": "Markering som ikke afsluttet mislykkedes",
|
||||||
"ToastPlaylistCreateFailed": "Mislykkedes oprettelse af afspilningsliste",
|
"ToastPlaylistCreateFailed": "Mislykkedes oprettelse af afspilningsliste",
|
||||||
"ToastPodcastCreateFailed": "Mislykkedes oprettelse af podcast",
|
"ToastPodcastCreateFailed": "Mislykkedes oprettelse af podcast",
|
||||||
"ToastPodcastCreateSuccess": "Podcast oprettet med succes",
|
"ToastPodcastCreateSuccess": "Podcast oprettet med succes",
|
||||||
"ToastRSSFeedCloseFailed": "Mislykkedes lukning af RSS-feed",
|
"ToastRSSFeedCloseFailed": "Mislykkedes lukning af RSS-feed",
|
||||||
"ToastRSSFeedCloseSuccess": "RSS-feed lukket",
|
"ToastRSSFeedCloseSuccess": "RSS-feed lukket",
|
||||||
"ToastStreamingNotAllowedOnCellular": "Det er ikke tillad at streame over mobildata"
|
"ToastStreamingNotAllowedOnCellular": "Det er ikke tillad at streame over mobildata",
|
||||||
|
"UnitMinutesShort": "{0}m",
|
||||||
|
"UnitSecondsShort": "{0}s"
|
||||||
}
|
}
|
||||||
|
|||||||