Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
941e823374 | ||
|
|
f74a1ceb47 |
@@ -6,3 +6,7 @@ indent_size = 2
|
||||
charset = utf-8
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.swift]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
|
||||
@@ -72,10 +72,10 @@ body:
|
||||
description: Please ensure your app is up to date. *If you are using a 3rd-party app, please reach out to them directly.*
|
||||
multiple: true
|
||||
options:
|
||||
- 'Android App - 0.10.0'
|
||||
- 'iOS App - 0.10.0'
|
||||
- 'Android App - 0.9.81'
|
||||
- 'iOS App - 0.9.81'
|
||||
- 'Android App - 0.12.0'
|
||||
- 'iOS App - 0.12.0'
|
||||
- 'Android App - 0.11.0'
|
||||
- 'iOS App - 0.11.0'
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
@@ -43,10 +43,10 @@ body:
|
||||
description: Please ensure your app is up to date. *If you are using a 3rd-party app, please reach out to them directly.*
|
||||
multiple: true
|
||||
options:
|
||||
- 'Android App - 0.10.0'
|
||||
- 'iOS App - 0.10.0'
|
||||
- 'Android App - 0.9.81'
|
||||
- 'iOS App - 0.9.81'
|
||||
- 'Android App - 0.12.0'
|
||||
- 'iOS App - 0.12.0'
|
||||
- 'Android App - 0.11.0'
|
||||
- 'iOS App - 0.11.0'
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
|
||||
@@ -36,8 +36,8 @@ android {
|
||||
applicationId "com.audiobookshelf.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 114
|
||||
versionName "0.10.1-beta"
|
||||
versionCode 117
|
||||
versionName "0.13.0-beta"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
manifestPlaceholders = [
|
||||
"appAuthRedirectScheme": "com.audiobookshelf.app"
|
||||
|
||||
@@ -36,6 +36,10 @@
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/media_player_widget_info" />
|
||||
|
||||
<meta-data
|
||||
android:name="com.samsung.android.appwidget.provider"
|
||||
android:resource="@xml/samsung_cover_widget_info" />
|
||||
</receiver>
|
||||
|
||||
<!-- Used by Android Auto -->
|
||||
|
||||
@@ -527,4 +527,18 @@ constructor(private val playerNotificationService: PlayerNotificationService) {
|
||||
|
||||
checkAutoSleepTimer()
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the current sleep timer state to the client.
|
||||
* Called when app resumes from background to sync UI state.
|
||||
*/
|
||||
fun sendCurrentSleepTimerState() {
|
||||
if (sleepTimerRunning) {
|
||||
val timeRemaining = getSleepTimerTimeRemainingSeconds(getPlaybackSpeed())
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(timeRemaining, isAutoSleepTimer)
|
||||
} else {
|
||||
// No timer running - send 0 to clear any stale UI state
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(0, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ data class DownloadItemPart(
|
||||
}
|
||||
|
||||
val downloadUri = Uri.parse(downloadUrl)
|
||||
Log.d("DownloadItemPart", "Audio File Destination Uri: $destinationUri | Final Destination Uri: $finalDestinationUri | Download URI $downloadUri")
|
||||
Log.d("DownloadItemPart", "Audio File Destination Uri: $destinationUri | Final Destination Uri: $finalDestinationUri | Server Path $serverPath")
|
||||
return DownloadItemPart(
|
||||
id = DeviceManager.getBase64Id(finalDestinationFile.absolutePath),
|
||||
downloadItemId,
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.audiobookshelf.app.player
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import kotlin.math.roundToInt
|
||||
import android.view.Surface
|
||||
import android.view.SurfaceHolder
|
||||
import android.view.SurfaceView
|
||||
@@ -53,6 +54,10 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
||||
private var myCurrentTrackGroups: TrackGroupArray
|
||||
private var myCurrentTrackSelections: TrackSelectionArray
|
||||
|
||||
// Cache for pending volume to handle async setVolume
|
||||
private var pendingVolume: Int? = null
|
||||
private var pendingVolumeSetAt: Long = 0L
|
||||
|
||||
var currentMediaItems:List<MediaItem> = mutableListOf()
|
||||
var remoteMediaClient:RemoteMediaClient? = null
|
||||
var sessionAvailabilityListener:SessionAvailabilityListener? = null
|
||||
@@ -76,7 +81,10 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
||||
COMMAND_GET_MEDIA_ITEMS_METADATA,
|
||||
COMMAND_SET_MEDIA_ITEMS_METADATA,
|
||||
COMMAND_CHANGE_MEDIA_ITEMS,
|
||||
COMMAND_GET_TRACKS)
|
||||
COMMAND_GET_TRACKS,
|
||||
COMMAND_GET_DEVICE_VOLUME,
|
||||
COMMAND_SET_DEVICE_VOLUME,
|
||||
COMMAND_ADJUST_DEVICE_VOLUME)
|
||||
.build()
|
||||
|
||||
var currentPlaybackState = Player.STATE_IDLE
|
||||
@@ -861,27 +869,66 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
||||
}
|
||||
|
||||
override fun getDeviceVolume(): Int {
|
||||
return 0
|
||||
// Short-lived cache without even-value quantization
|
||||
val now = System.currentTimeMillis()
|
||||
if (pendingVolume != null && (now - pendingVolumeSetAt) < 100) {
|
||||
return pendingVolume!!
|
||||
}
|
||||
|
||||
val session = castContext.sessionManager.currentCastSession
|
||||
val vol = if (session != null && !session.isMute) {
|
||||
(session.volume * 100).roundToInt().coerceIn(0, 100)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
|
||||
if (pendingVolume != null && vol == pendingVolume) {
|
||||
pendingVolume = null
|
||||
}
|
||||
|
||||
return vol
|
||||
}
|
||||
|
||||
override fun isDeviceMuted(): Boolean {
|
||||
return false
|
||||
val session = castContext.sessionManager.currentCastSession
|
||||
return session?.isMute ?: false
|
||||
}
|
||||
|
||||
override fun setDeviceVolume(volume: Int) {
|
||||
val session = castContext.sessionManager.currentCastSession
|
||||
try {
|
||||
val clamped = volume.coerceIn(0, 100)
|
||||
val target = (clamped / 100.0).let { kotlin.math.round(it * 100.0) / 100.0 }
|
||||
|
||||
// Cache for 100ms to return in getDeviceVolume during async period
|
||||
pendingVolume = clamped
|
||||
pendingVolumeSetAt = System.currentTimeMillis()
|
||||
|
||||
session?.setVolume(target)
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "Failed to set cast device volume", e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun increaseDeviceVolume() {
|
||||
|
||||
val current = getDeviceVolume()
|
||||
val next = current + 1
|
||||
setDeviceVolume(next.coerceAtMost(100))
|
||||
}
|
||||
|
||||
override fun decreaseDeviceVolume() {
|
||||
|
||||
val current = getDeviceVolume()
|
||||
val next = current - 1
|
||||
setDeviceVolume(next.coerceAtLeast(0))
|
||||
}
|
||||
|
||||
override fun setDeviceMuted(muted: Boolean) {
|
||||
|
||||
val session = castContext.sessionManager.currentCastSession
|
||||
try {
|
||||
session?.isMute = muted
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "Failed to set cast device mute state", e)
|
||||
}
|
||||
}
|
||||
|
||||
inner class StatusListener() : RemoteMediaClient.Callback(), SessionManagerListener<CastSession>, RemoteMediaClient.ProgressListener {
|
||||
|
||||
@@ -18,6 +18,8 @@ import android.support.v4.media.MediaDescriptionCompat
|
||||
import android.support.v4.media.MediaMetadataCompat
|
||||
import android.support.v4.media.session.MediaControllerCompat
|
||||
import android.support.v4.media.session.MediaSessionCompat
|
||||
import androidx.media.VolumeProviderCompat
|
||||
import android.media.AudioManager
|
||||
import android.support.v4.media.session.PlaybackStateCompat
|
||||
import android.util.Log
|
||||
import androidx.annotation.RequiresApi
|
||||
@@ -94,6 +96,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
private lateinit var mediaSessionConnector: MediaSessionConnector
|
||||
private lateinit var playerNotificationManager: PlayerNotificationManager
|
||||
lateinit var mediaSession: MediaSessionCompat
|
||||
private var remoteVolumeProvider: VolumeProviderCompat? = null
|
||||
private lateinit var transportControls: MediaControllerCompat.TransportControls
|
||||
|
||||
lateinit var mediaManager: MediaManager
|
||||
@@ -477,6 +480,8 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
val dataSourceFactory = DefaultDataSource.Factory(ctx)
|
||||
|
||||
val extractorsFactory = DefaultExtractorsFactory()
|
||||
extractorsFactory.setConstantBitrateSeekingEnabled(true)
|
||||
|
||||
if (DeviceManager.deviceData.deviceSettings?.enableMp3IndexSeeking == true) {
|
||||
// @see
|
||||
// https://exoplayer.dev/troubleshooting.html#why-is-seeking-inaccurate-in-some-mp3-files
|
||||
@@ -491,6 +496,8 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
val dataSourceFactory = DefaultHttpDataSource.Factory()
|
||||
|
||||
val extractorsFactory = DefaultExtractorsFactory()
|
||||
extractorsFactory.setConstantBitrateSeekingEnabled(true)
|
||||
|
||||
if (DeviceManager.deviceData.deviceSettings?.enableMp3IndexSeeking == true) {
|
||||
// @see
|
||||
// https://exoplayer.dev/troubleshooting.html#why-is-seeking-inaccurate-in-some-mp3-files
|
||||
@@ -705,11 +712,13 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
Log.d(tag, "switchToPlayer: Using Cast Player " + castPlayer?.deviceInfo)
|
||||
mediaSessionConnector.setPlayer(castPlayer)
|
||||
playerNotificationManager.setPlayer(castPlayer)
|
||||
setMediaSessionToCastVolume()
|
||||
castPlayer as CastPlayer
|
||||
} else {
|
||||
Log.d(tag, "switchToPlayer: Using ExoPlayer")
|
||||
mediaSessionConnector.setPlayer(mPlayer)
|
||||
playerNotificationManager.setPlayer(mPlayer)
|
||||
setMediaSessionToLocalVolume()
|
||||
mPlayer
|
||||
}
|
||||
|
||||
@@ -726,6 +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 {
|
||||
return if (currentPlayer.mediaItemCount > 1) {
|
||||
val windowIndex = currentPlayer.currentMediaItemIndex
|
||||
|
||||
@@ -33,6 +33,9 @@ class AbsAudioPlayer : Plugin() {
|
||||
|
||||
private var isCastAvailable:Boolean = false
|
||||
|
||||
// Track foreground state to avoid flooding WebView with events while backgrounded
|
||||
private var isInForeground: Boolean = true
|
||||
|
||||
override fun load() {
|
||||
mainActivity = (activity as MainActivity)
|
||||
apiHandler = ApiHandler(mainActivity)
|
||||
@@ -60,6 +63,8 @@ class AbsAudioPlayer : Plugin() {
|
||||
}
|
||||
|
||||
override fun onMetadata(metadata: PlaybackMetadata) {
|
||||
// Skip frequent metadata updates when app is backgrounded to prevent event queue buildup
|
||||
if (!isInForeground) return
|
||||
notifyListeners("onMetadata", JSObject(jacksonMapper.writeValueAsString(metadata)))
|
||||
}
|
||||
|
||||
@@ -68,6 +73,8 @@ class AbsAudioPlayer : Plugin() {
|
||||
}
|
||||
|
||||
override fun onSleepTimerSet(sleepTimeRemaining: Int, isAutoSleepTimer:Boolean) {
|
||||
// Skip sleep timer updates when app is backgrounded to prevent event queue buildup
|
||||
if (!isInForeground) return
|
||||
val ret = JSObject()
|
||||
ret.put("value", sleepTimeRemaining)
|
||||
ret.put("isAuto", isAutoSleepTimer)
|
||||
@@ -75,6 +82,8 @@ class AbsAudioPlayer : Plugin() {
|
||||
}
|
||||
|
||||
override fun onLocalMediaProgressUpdate(localMediaProgress: LocalMediaProgress) {
|
||||
// Skip progress updates when app is backgrounded to prevent event queue buildup
|
||||
if (!isInForeground) return
|
||||
notifyListeners("onLocalMediaProgressUpdate", JSObject(jacksonMapper.writeValueAsString(localMediaProgress)))
|
||||
}
|
||||
|
||||
@@ -118,6 +127,27 @@ class AbsAudioPlayer : Plugin() {
|
||||
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() {
|
||||
val googleApi = GoogleApiAvailability.getInstance()
|
||||
val statusCode = googleApi.isGooglePlayServicesAvailable(mainActivity)
|
||||
|
||||
@@ -119,7 +119,7 @@ class AbsDatabase : Plugin() {
|
||||
|
||||
@PluginMethod
|
||||
fun setCurrentServerConnectionConfig(call:PluginCall) {
|
||||
Log.d(tag, "setCurrentServerConnectionConfig ${call.data}")
|
||||
Log.d(tag, "setCurrentServerConnectionConfig called")
|
||||
val serverConfigPayload = jacksonMapper.readValue<ServerConnConfigPayload>(call.data.toString())
|
||||
var serverConnectionConfig = DeviceManager.deviceData.serverConnectionConfigs.find { it.id == serverConfigPayload.id }
|
||||
|
||||
@@ -558,7 +558,7 @@ class AbsDatabase : Plugin() {
|
||||
|
||||
@PluginMethod
|
||||
fun updateDeviceSettings(call:PluginCall) { // Returns device data
|
||||
Log.d(tag, "updateDeviceSettings ${call.data}")
|
||||
Log.d(tag, "updateDeviceSettings called")
|
||||
val newDeviceSettings = jacksonMapper.readValue<DeviceSettings>(call.data.toString())
|
||||
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
@@ -576,7 +576,7 @@ class AbsDatabase : Plugin() {
|
||||
|
||||
@PluginMethod
|
||||
fun getMediaItemHistory(call:PluginCall) { // Returns device data
|
||||
Log.d(tag, "getMediaItemHistory ${call.data}")
|
||||
Log.d(tag, "getMediaItemHistory called")
|
||||
val mediaId = call.getString("mediaId") ?: ""
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
|
||||
@@ -11,4 +11,4 @@
|
||||
android:targetCellWidth="4"
|
||||
android:targetCellHeight="1"
|
||||
android:updatePeriodMillis="86400000"
|
||||
android:widgetCategory="home_screen" />
|
||||
android:widgetCategory="home_screen|keyguard" />
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<samsung-appwidget-provider
|
||||
display="sub_screen"
|
||||
privacyWidget="false" />
|
||||
@@ -3,7 +3,7 @@ class WrappingMarquee {
|
||||
#scrollSpeed = 30
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} el
|
||||
* @param {HTMLElement} el
|
||||
*/
|
||||
constructor(el) {
|
||||
this.el = el
|
||||
@@ -21,8 +21,8 @@ class WrappingMarquee {
|
||||
|
||||
/**
|
||||
* Transparent gradient mask shown when text is scrolling left and overflowing right
|
||||
*
|
||||
* @param {boolean} showLeft
|
||||
*
|
||||
* @param {boolean} showLeft
|
||||
*/
|
||||
setMask(showLeft) {
|
||||
if (!this.el) return
|
||||
@@ -39,11 +39,11 @@ class WrappingMarquee {
|
||||
this.setMask(true)
|
||||
|
||||
let textScrollAmount = this.el.scrollWidth
|
||||
this.pEl.innerHTML = this.innerText + ' '.repeat(15)
|
||||
this.pEl.textContent = this.innerText + '\u00A0'.repeat(15) // \u00A0 = non-breaking space
|
||||
let totalScrollAmount = this.el.scrollWidth
|
||||
let scrollDuration = totalScrollAmount * this.#scrollSpeed
|
||||
|
||||
this.pEl.innerHTML = this.pEl.innerHTML + this.innerText
|
||||
this.pEl.textContent = this.pEl.textContent + this.innerText
|
||||
|
||||
let done = false
|
||||
let start, previousTimeStamp
|
||||
@@ -55,13 +55,14 @@ class WrappingMarquee {
|
||||
const elapsed = timeStamp - start
|
||||
|
||||
if (this.isScrolling && previousTimeStamp !== timeStamp) {
|
||||
const amountToMove = Math.min(elapsed / scrollDuration * totalScrollAmount, totalScrollAmount)
|
||||
const amountToMove = Math.min((elapsed / scrollDuration) * totalScrollAmount, totalScrollAmount)
|
||||
this.pEl.style.transform = `translateX(-${amountToMove}px)`
|
||||
if (amountToMove === totalScrollAmount) done = true
|
||||
if (amountToMove > textScrollAmount) this.setMask(false)
|
||||
}
|
||||
|
||||
if (!this.isScrolling || done) { // canceled or done
|
||||
if (!this.isScrolling || done) {
|
||||
// canceled or done
|
||||
this.isScrolling = false
|
||||
this.pEl.style.transform = 'translateX(0px)'
|
||||
this.pEl.innerText = this.innerText
|
||||
@@ -69,7 +70,8 @@ class WrappingMarquee {
|
||||
if (done) {
|
||||
this.startTimer()
|
||||
}
|
||||
} else if (elapsed < scrollDuration) { // step
|
||||
} else if (elapsed < scrollDuration) {
|
||||
// step
|
||||
previousTimeStamp = timeStamp
|
||||
this.animationId = window.requestAnimationFrame(step)
|
||||
}
|
||||
@@ -94,8 +96,8 @@ class WrappingMarquee {
|
||||
/**
|
||||
* Initialize and start marquee if text overflows container
|
||||
* resets the marquee if already active
|
||||
*
|
||||
* @param {string} innerText
|
||||
*
|
||||
* @param {string} innerText
|
||||
*/
|
||||
init(innerText) {
|
||||
if (!this.el || !this.pEl) return
|
||||
@@ -114,4 +116,4 @@ class WrappingMarquee {
|
||||
}
|
||||
}
|
||||
}
|
||||
export default WrappingMarquee
|
||||
export default WrappingMarquee
|
||||
|
||||
@@ -69,16 +69,22 @@
|
||||
|
||||
<div id="playerControls" class="absolute right-0 bottom-0 mx-auto" style="max-width: 414px">
|
||||
<div class="flex items-center max-w-full" :class="playerSettings.lockUi ? 'justify-center' : 'justify-between'">
|
||||
<span v-show="showFullscreen && !playerSettings.lockUi" class="material-symbols next-icon text-fg cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpChapterStart">first_page</span>
|
||||
<span v-show="!playerSettings.lockUi" class="material-symbols jump-icon text-fg cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpBackwards">{{ jumpBackwardsIcon }}</span>
|
||||
<span v-show="showFullscreen && !playerSettings.lockUi" class="material-symbols next-icon text-fg cursor-pointer" :class="showLoadingState ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpChapterStart">first_page</span>
|
||||
<div v-show="!playerSettings.lockUi" class="jump-icon text-fg cursor-pointer flex flex-col items-center" :class="showLoadingState ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpBackwards">
|
||||
<span class="material-symbols text-3xl leading-none">replay</span>
|
||||
<span v-if="showFullscreen" class="jump-label text-[10px] font-semibold leading-tight">{{ jumpBackwardsLabel }}</span>
|
||||
</div>
|
||||
<div class="play-btn cursor-pointer shadow-sm flex items-center justify-center rounded-full text-primary mx-4 relative overflow-hidden" :style="{ backgroundColor: coverRgb }" :class="{ 'animate-spin': seekLoading }" @mousedown.prevent @mouseup.prevent @click.stop="playPauseClick">
|
||||
<div v-if="!coverBgIsLight" class="absolute top-0 left-0 w-full h-full bg-white bg-opacity-20 pointer-events-none" />
|
||||
|
||||
<span v-if="!isLoading" class="material-symbols fill" :class="{ 'text-white': coverRgb && !coverBgIsLight }">{{ seekLoading ? 'autorenew' : !isPlaying ? 'play_arrow' : 'pause' }}</span>
|
||||
<span v-if="!showLoadingState" class="material-symbols fill" :class="{ 'text-white': coverRgb && !coverBgIsLight }">{{ seekLoading ? 'autorenew' : !isPlaying ? 'play_arrow' : 'pause' }}</span>
|
||||
<widgets-spinner-icon v-else class="h-8 w-8" />
|
||||
</div>
|
||||
<span v-show="!playerSettings.lockUi" class="material-symbols jump-icon text-fg cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpForward">{{ jumpForwardIcon }}</span>
|
||||
<span v-show="showFullscreen && !playerSettings.lockUi" class="material-symbols next-icon text-fg cursor-pointer" :class="nextChapter && !isLoading ? 'text-opacity-75' : 'text-opacity-10'" @click.stop="jumpNextChapter">last_page</span>
|
||||
<div v-show="!playerSettings.lockUi" class="jump-icon text-fg cursor-pointer flex flex-col items-center" :class="showLoadingState ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpForward">
|
||||
<span class="material-symbols text-3xl leading-none">forward_media</span>
|
||||
<span v-if="showFullscreen" class="jump-label text-[10px] font-semibold leading-tight">{{ jumpForwardLabel }}</span>
|
||||
</div>
|
||||
<span v-show="showFullscreen && !playerSettings.lockUi" class="material-symbols next-icon text-fg cursor-pointer" :class="nextChapter && !showLoadingState ? 'text-opacity-75' : 'text-opacity-10'" @click.stop="jumpNextChapter">last_page</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -88,7 +94,7 @@
|
||||
<div class="flex-grow" />
|
||||
<p class="font-mono text-fg" style="font-size: 0.8rem">{{ timeRemainingPretty }}</p>
|
||||
</div>
|
||||
<div ref="track" class="h-1.5 w-full bg-track/50 relative rounded-full" :class="{ 'animate-pulse': isLoading }" @click.stop>
|
||||
<div ref="track" class="h-1.5 w-full bg-track/50 relative rounded-full" :class="{ 'animate-pulse': showLoadingState }" @click.stop>
|
||||
<div ref="readyTrack" class="h-full bg-track-buffered absolute top-0 left-0 rounded-full pointer-events-none" />
|
||||
<div ref="bufferedTrack" class="h-full bg-track absolute top-0 left-0 rounded-full pointer-events-none" />
|
||||
<div ref="playedTrack" class="h-full bg-track-cursor absolute top-0 left-0 rounded-full pointer-events-none" />
|
||||
@@ -108,8 +114,9 @@
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { AbsAudioPlayer } from '@/plugins/capacitor'
|
||||
import { Dialog } from '@capacitor/dialog'
|
||||
import { FastAverageColor } from 'fast-average-color'
|
||||
import { getAverageColorFromCoverUrl } from '@/utils/coverAverageColor'
|
||||
import WrappingMarquee from '@/assets/WrappingMarquee.js'
|
||||
import jumpLabelMixin from '@/mixins/jumpLabel'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
@@ -121,6 +128,7 @@ export default {
|
||||
sleepTimeRemaining: Number,
|
||||
serverLibraryItemId: String
|
||||
},
|
||||
mixins: [jumpLabelMixin],
|
||||
data() {
|
||||
return {
|
||||
windowHeight: 0,
|
||||
@@ -149,6 +157,7 @@ export default {
|
||||
lockUi: false
|
||||
},
|
||||
isLoading: false,
|
||||
isCheckingServerProgress: false,
|
||||
isDraggingCursor: false,
|
||||
draggingTouchStartX: 0,
|
||||
draggingTouchStartTime: 0,
|
||||
@@ -221,11 +230,11 @@ export default {
|
||||
|
||||
return items
|
||||
},
|
||||
jumpForwardIcon() {
|
||||
return this.$store.getters['globals/getJumpForwardIcon'](this.jumpForwardTime)
|
||||
jumpForwardLabel() {
|
||||
return this.getJumpLabel(this.jumpForwardTime)
|
||||
},
|
||||
jumpBackwardsIcon() {
|
||||
return this.$store.getters['globals/getJumpBackwardsIcon'](this.jumpBackwardsTime)
|
||||
jumpBackwardsLabel() {
|
||||
return this.getJumpLabel(this.jumpBackwardsTime)
|
||||
},
|
||||
jumpForwardTime() {
|
||||
return this.$store.getters['getJumpForwardTime']
|
||||
@@ -262,6 +271,9 @@ export default {
|
||||
return 190 * heightScale
|
||||
}
|
||||
},
|
||||
showLoadingState() {
|
||||
return this.isLoading || this.isCheckingServerProgress
|
||||
},
|
||||
showCastBtn() {
|
||||
return this.$store.state.isCastAvailable
|
||||
},
|
||||
@@ -400,17 +412,14 @@ export default {
|
||||
},
|
||||
async coverImageLoaded(fullCoverUrl) {
|
||||
if (!fullCoverUrl) return
|
||||
|
||||
const fac = new FastAverageColor()
|
||||
fac
|
||||
.getColorAsync(fullCoverUrl)
|
||||
.then((color) => {
|
||||
this.coverRgb = color.rgba
|
||||
this.coverBgIsLight = color.isLight
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log(e)
|
||||
})
|
||||
const avg = await getAverageColorFromCoverUrl(this, fullCoverUrl)
|
||||
if (!avg) {
|
||||
this.coverRgb = 'rgb(55, 56, 56)'
|
||||
this.coverBgIsLight = false
|
||||
} else {
|
||||
this.coverRgb = avg.rgba
|
||||
this.coverBgIsLight = avg.isLight
|
||||
}
|
||||
},
|
||||
clickTitleAndAuthor() {
|
||||
if (!this.showFullscreen) return
|
||||
@@ -453,13 +462,13 @@ export default {
|
||||
},
|
||||
async jumpNextChapter() {
|
||||
await this.$hapticsImpact()
|
||||
if (this.isLoading) return
|
||||
if (this.showLoadingState) return
|
||||
if (!this.nextChapter) return
|
||||
this.seek(this.nextChapter.start)
|
||||
},
|
||||
async jumpChapterStart() {
|
||||
await this.$hapticsImpact()
|
||||
if (this.isLoading) return
|
||||
if (this.showLoadingState) return
|
||||
if (!this.currentChapter) {
|
||||
return this.restart()
|
||||
}
|
||||
@@ -489,12 +498,12 @@ export default {
|
||||
},
|
||||
async jumpBackwards() {
|
||||
await this.$hapticsImpact()
|
||||
if (this.isLoading) return
|
||||
if (this.showLoadingState) return
|
||||
AbsAudioPlayer.seekBackward({ value: this.jumpBackwardsTime })
|
||||
},
|
||||
async jumpForward() {
|
||||
await this.$hapticsImpact()
|
||||
if (this.isLoading) return
|
||||
if (this.showLoadingState) return
|
||||
AbsAudioPlayer.seekForward({ value: this.jumpForwardTime })
|
||||
},
|
||||
setStreamReady() {
|
||||
@@ -581,8 +590,12 @@ export default {
|
||||
}
|
||||
|
||||
const ptWidth = Math.round(percentDone * this.trackWidth)
|
||||
this.$refs.playedTrack.style.width = ptWidth + 'px'
|
||||
this.$refs.bufferedTrack.style.width = Math.round(bufferedPercent * this.trackWidth) + 'px'
|
||||
if (this.$refs.playedTrack) {
|
||||
this.$refs.playedTrack.style.width = ptWidth + 'px'
|
||||
}
|
||||
if (this.$refs.bufferedTrack) {
|
||||
this.$refs.bufferedTrack.style.width = Math.round(bufferedPercent * this.trackWidth) + 'px'
|
||||
}
|
||||
|
||||
if (this.$refs.trackCursor) {
|
||||
this.$refs.trackCursor.style.left = ptWidth - 14 + 'px'
|
||||
@@ -594,7 +607,7 @@ export default {
|
||||
}
|
||||
},
|
||||
seek(time) {
|
||||
if (this.isLoading) return
|
||||
if (this.showLoadingState) return
|
||||
if (this.seekLoading) {
|
||||
console.error('Already seek loading', this.seekedTime)
|
||||
return
|
||||
@@ -626,11 +639,14 @@ export default {
|
||||
},
|
||||
async playPauseClick() {
|
||||
await this.$hapticsImpact()
|
||||
if (this.isLoading) return
|
||||
if (this.showLoadingState) return
|
||||
|
||||
this.isPlaying = !!((await AbsAudioPlayer.playPause()) || {}).playing
|
||||
this.isEnded = false
|
||||
},
|
||||
setIsCheckingServerProgress(value) {
|
||||
this.isCheckingServerProgress = !!value
|
||||
},
|
||||
play() {
|
||||
AbsAudioPlayer.playPlayer()
|
||||
this.startPlayInterval()
|
||||
@@ -1087,6 +1103,9 @@ export default {
|
||||
margin: 0px 0px;
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
#playerControls .jump-label {
|
||||
margin-top: 2px;
|
||||
}
|
||||
#playerControls .play-btn {
|
||||
transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1);
|
||||
transition-property: padding, margin, height, width, min-width, min-height;
|
||||
|
||||
@@ -48,6 +48,9 @@ export default {
|
||||
},
|
||||
isIos() {
|
||||
return this.$platform === 'ios'
|
||||
},
|
||||
currentPlaybackSession() {
|
||||
return this.$store.state.currentPlaybackSession
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -304,48 +307,129 @@ export default {
|
||||
this.$refs.audioPlayer?.seek(currentTime)
|
||||
},
|
||||
/**
|
||||
* When device gains focus then refresh the timestamps in the audio player
|
||||
* Fetch the current user's media progress from the server for a given library item / episode.
|
||||
* Returns the server media progress object, or null if the request fails, times out, or the
|
||||
* response doesn't match the requested library item.
|
||||
*
|
||||
* The audio player's loading state is shown while the request is in flight so the user
|
||||
* doesn't tap play before we have a chance to update the timestamps. The request timeout
|
||||
* is 7 seconds so a slow/unresponsive server doesn't block the user for long.
|
||||
*/
|
||||
deviceFocused(hasFocus) {
|
||||
if (!this.$store.state.currentPlaybackSession) return
|
||||
async getServerMediaProgressForCurrentSession() {
|
||||
if (!this.$store.state.user.user || !this.$store.state.networkConnected) return null
|
||||
const libraryItemId = this.currentPlaybackSession?.libraryItemId
|
||||
const episodeId = this.currentPlaybackSession?.episodeId
|
||||
if (!libraryItemId) return null
|
||||
|
||||
if (hasFocus) {
|
||||
if (this.$refs.audioPlayer?.isCheckingServerProgress) {
|
||||
console.log('[AudioPlayerContainer] getServerMediaProgressForCurrentSession: already checking server progress')
|
||||
return null
|
||||
}
|
||||
|
||||
const url = episodeId ? `/api/me/progress/${libraryItemId}/${episodeId}` : `/api/me/progress/${libraryItemId}`
|
||||
|
||||
this.$refs.audioPlayer?.setIsCheckingServerProgress(true)
|
||||
try {
|
||||
const data = await this.$nativeHttp.get(url, { connectTimeout: 7000, readTimeout: 7000 })
|
||||
if (!data || data.libraryItemId !== libraryItemId) return null
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error('[AudioPlayerContainer] Failed to get server media progress', error)
|
||||
return null
|
||||
} finally {
|
||||
this.$refs.audioPlayer?.setIsCheckingServerProgress(false)
|
||||
}
|
||||
},
|
||||
getLocalMediaProgressForCurrentSession() {
|
||||
if (!this.currentPlaybackSession) return null
|
||||
return this.$store.getters['globals/getLocalMediaProgressById'](this.currentPlaybackSession.localLibraryItem?.id, this.currentPlaybackSession.localEpisodeId)
|
||||
},
|
||||
/**
|
||||
* Sync the server media progress with the local media progress
|
||||
*/
|
||||
async syncServerMediaProgressWithLocalMediaProgress(localMediaProgressId, serverMediaProgress) {
|
||||
try {
|
||||
const newLocalMediaProgress = await this.$db.syncServerMediaProgressWithLocalMediaProgress({
|
||||
localMediaProgressId,
|
||||
mediaProgress: serverMediaProgress
|
||||
})
|
||||
if (newLocalMediaProgress?.id) {
|
||||
this.$store.commit('globals/updateLocalMediaProgress', newLocalMediaProgress)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[AudioPlayerContainer] Failed to sync server progress with local media progress', error)
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Check if the server media progress is more recent than the local media progress and sync if so
|
||||
*/
|
||||
async checkSyncServerProgressWithLocalProgress(localMediaProgress) {
|
||||
if (!localMediaProgress) return
|
||||
console.log('[AudioPlayerContainer] checkSyncServerProgressWithLocalProgress: checking server media progress for local media item open in player')
|
||||
const serverMediaProgress = await this.getServerMediaProgressForCurrentSession()
|
||||
if (!serverMediaProgress?.lastUpdate || serverMediaProgress.lastUpdate <= localMediaProgress.lastUpdate) return
|
||||
|
||||
console.log('[AudioPlayerContainer] checkSyncServerProgressWithLocalProgress: server progress is more recent than local progress. Server current time:', serverMediaProgress.currentTime, 'vs local', localMediaProgress.currentTime, `(server lastUpdate=${serverMediaProgress.lastUpdate} > local lastUpdate=${localMediaProgress.lastUpdate})`)
|
||||
if (!this.$refs.audioPlayer?.isPlaying && serverMediaProgress.currentTime !== localMediaProgress.currentTime) {
|
||||
// Use seek() so the native audio player's current session is updated
|
||||
this.$refs.audioPlayer.seek(serverMediaProgress.currentTime)
|
||||
}
|
||||
|
||||
await this.syncServerMediaProgressWithLocalMediaProgress(localMediaProgress.id, serverMediaProgress)
|
||||
},
|
||||
/**
|
||||
* When socket is reconnected after a delay, if a local media item is open in the player (paused)
|
||||
* we fetch the server media progress and sync it if it is more recent than the local progress
|
||||
*
|
||||
* If there is no socket connection we may have missed external progress updates
|
||||
*/
|
||||
async socketReconnected() {
|
||||
if (!this.currentPlaybackSession) return
|
||||
// dont update timestamps if player is playing
|
||||
if (this.$refs.audioPlayer?.isPlaying) return
|
||||
|
||||
if (this.$refs.audioPlayer.isLocalPlayMethod) {
|
||||
const localMediaProgress = this.getLocalMediaProgressForCurrentSession()
|
||||
if (!localMediaProgress) {
|
||||
console.error('[AudioPlayerContainer] socket reconnected: Local media progress not found')
|
||||
return
|
||||
}
|
||||
|
||||
await this.checkSyncServerProgressWithLocalProgress(localMediaProgress)
|
||||
}
|
||||
},
|
||||
/**
|
||||
* When device re-gains focus then refresh the timestamps in the audio player
|
||||
* if local item is open then fetch the server media progress and update if more recent
|
||||
*/
|
||||
async deviceFocused(hasFocus) {
|
||||
if (!this.currentPlaybackSession || !hasFocus) return
|
||||
// dont update timestamps if player is playing
|
||||
if (this.$refs.audioPlayer?.isPlaying) return
|
||||
|
||||
if (this.$refs.audioPlayer.isLocalPlayMethod) {
|
||||
const localMediaProgress = this.getLocalMediaProgressForCurrentSession()
|
||||
if (!localMediaProgress) {
|
||||
console.error('[AudioPlayerContainer] device visibility: Local media progress not found')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[AudioPlayerContainer] device visibility: found local media progress', localMediaProgress.currentTime, 'last time in player is', this.currentTime)
|
||||
this.$refs.audioPlayer.currentTime = localMediaProgress.currentTime
|
||||
this.$refs.audioPlayer.timeupdate()
|
||||
|
||||
await this.checkSyncServerProgressWithLocalProgress(localMediaProgress)
|
||||
} else {
|
||||
// server item so fetch server media progress and update player time
|
||||
console.log('[AudioPlayerContainer] device visibility: checking server media progress for server media item open in player')
|
||||
const data = await this.getServerMediaProgressForCurrentSession()
|
||||
if (!data) return
|
||||
if (!this.$refs.audioPlayer?.isPlaying) {
|
||||
const playbackSession = this.$store.state.currentPlaybackSession
|
||||
if (this.$refs.audioPlayer.isLocalPlayMethod) {
|
||||
const localLibraryItemId = playbackSession.localLibraryItem?.id
|
||||
const localEpisodeId = playbackSession.localEpisodeId
|
||||
if (!localLibraryItemId) {
|
||||
console.error('[AudioPlayerContainer] device visibility: no local library item for session', JSON.stringify(playbackSession))
|
||||
return
|
||||
}
|
||||
const localMediaProgress = this.$store.state.globals.localMediaProgress.find((mp) => {
|
||||
if (localEpisodeId) return mp.localEpisodeId === localEpisodeId
|
||||
return mp.localLibraryItemId === localLibraryItemId
|
||||
})
|
||||
if (localMediaProgress) {
|
||||
console.log('[AudioPlayerContainer] device visibility: found local media progress', localMediaProgress.currentTime, 'last time in player is', this.currentTime)
|
||||
this.$refs.audioPlayer.currentTime = localMediaProgress.currentTime
|
||||
this.$refs.audioPlayer.timeupdate()
|
||||
} else {
|
||||
console.error('[AudioPlayerContainer] device visibility: Local media progress not found')
|
||||
}
|
||||
} else {
|
||||
const libraryItemId = playbackSession.libraryItemId
|
||||
const episodeId = playbackSession.episodeId
|
||||
const url = episodeId ? `/api/me/progress/${libraryItemId}/${episodeId}` : `/api/me/progress/${libraryItemId}`
|
||||
this.$nativeHttp
|
||||
.get(url)
|
||||
.then((data) => {
|
||||
if (!this.$refs.audioPlayer?.isPlaying && data.libraryItemId === libraryItemId) {
|
||||
console.log('[AudioPlayerContainer] device visibility: got server media progress', data.currentTime, 'last time in player is', this.currentTime)
|
||||
this.$refs.audioPlayer.currentTime = data.currentTime
|
||||
this.$refs.audioPlayer.timeupdate()
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('[AudioPlayerContainer] device visibility: Failed to get progress', error)
|
||||
})
|
||||
console.log('[AudioPlayerContainer] device visibility: got server media progress', data.currentTime, 'last time in player is', this.currentTime)
|
||||
// Only seek if the difference is greater than 1 second
|
||||
if (Math.abs(data.currentTime - this.currentTime) > 1) {
|
||||
// Use seek() so the native audio player's current session is updated
|
||||
this.$refs.audioPlayer.seek(data.currentTime)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -368,6 +452,7 @@ export default {
|
||||
this.$eventBus.$on('user-settings', this.settingsUpdated)
|
||||
this.$eventBus.$on('playback-time-update', this.playbackTimeUpdate)
|
||||
this.$eventBus.$on('device-focus-update', this.deviceFocused)
|
||||
this.$eventBus.$on('socket-reconnected', this.socketReconnected)
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.onLocalMediaProgressUpdateListener?.remove()
|
||||
@@ -383,6 +468,7 @@ export default {
|
||||
this.$eventBus.$off('user-settings', this.settingsUpdated)
|
||||
this.$eventBus.$off('playback-time-update', this.playbackTimeUpdate)
|
||||
this.$eventBus.$off('device-focus-update', this.deviceFocused)
|
||||
this.$eventBus.$off('socket-reconnected', this.socketReconnected)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -3,10 +3,7 @@
|
||||
<div class="absolute top-0 left-0 w-full h-full bg-black transition-opacity duration-200" :class="show ? 'bg-opacity-60 pointer-events-auto' : 'bg-opacity-0'" @click="clickBackground" />
|
||||
<div class="absolute top-0 right-0 w-64 h-full bg-bg transform transition-transform py-6 pointer-events-auto" :class="show ? '' : 'translate-x-64'" @click.stop>
|
||||
<div class="px-6 mb-4">
|
||||
<p v-if="user" class="text-base">
|
||||
Welcome,
|
||||
<strong>{{ username }}</strong>
|
||||
</p>
|
||||
<p v-if="user" class="text-base" v-html="$getString('HeaderWelcome', [username])" />
|
||||
</div>
|
||||
|
||||
<div class="w-full overflow-y-auto">
|
||||
@@ -134,14 +131,12 @@ export default {
|
||||
to: '/settings'
|
||||
})
|
||||
|
||||
if (this.$platform !== 'ios') {
|
||||
items.push({
|
||||
icon: 'bug_report',
|
||||
iconOutlined: true,
|
||||
text: this.$strings.ButtonLogs,
|
||||
to: '/logs'
|
||||
})
|
||||
}
|
||||
items.push({
|
||||
icon: 'bug_report',
|
||||
iconOutlined: true,
|
||||
text: this.$strings.ButtonLogs,
|
||||
to: '/logs'
|
||||
})
|
||||
|
||||
if (this.serverConnectionConfig) {
|
||||
items.push({
|
||||
@@ -185,11 +180,18 @@ export default {
|
||||
await this.$hapticsImpact()
|
||||
await this.logout()
|
||||
|
||||
// Redirect to home page
|
||||
if (this.$route.name !== 'bookshelf') {
|
||||
this.$router.replace('/bookshelf')
|
||||
} else {
|
||||
location.reload()
|
||||
}
|
||||
|
||||
// If player is open and not playing locally, then close the player
|
||||
if (this.$store.getters['getIsPlayerOpen']) {
|
||||
this.$eventBus.$emit('close-stream')
|
||||
}
|
||||
|
||||
// Close side drawer
|
||||
this.show = false
|
||||
},
|
||||
touchstart(e) {
|
||||
this.touchEvent = new TouchEvent(e)
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<div class="flex h-full px-1 overflow-hidden">
|
||||
<covers-book-cover :library-item="libraryItem" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
<div class="grow px-2 audiobookSearchCardContent">
|
||||
<p class="truncate text-sm">{{ title }}</p>
|
||||
<p v-if="subtitle" class="truncate text-xs text-gray-300">{{ subtitle }}</p>
|
||||
<p class="text-xs text-gray-200 truncate">{{ $getString('LabelByAuthor', [authorName]) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
episode: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
libraryItem: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
coverWidth() {
|
||||
if (this.bookCoverAspectRatio === 1) return 50 * 1.2
|
||||
return 50
|
||||
},
|
||||
media() {
|
||||
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||
},
|
||||
mediaMetadata() {
|
||||
return this.media.metadata || {}
|
||||
},
|
||||
mediaType() {
|
||||
return this.libraryItem ? this.libraryItem.mediaType : null
|
||||
},
|
||||
isPodcast() {
|
||||
return this.mediaType === 'podcast'
|
||||
},
|
||||
title() {
|
||||
return this.episode?.title || 'No Title'
|
||||
},
|
||||
subtitle() {
|
||||
return this.mediaMetadata.title
|
||||
},
|
||||
authorName() {
|
||||
return this.mediaMetadata.author
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.audiobookSearchCardContent {
|
||||
width: calc(100% - 80px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
@@ -1,16 +1,10 @@
|
||||
<template>
|
||||
<div class="flex h-full px-1 overflow-hidden">
|
||||
<covers-book-cover :library-item="libraryItem" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
<div class="flex-grow px-2 audiobookSearchCardContent">
|
||||
<p v-if="matchKey !== 'title'" class="truncate text-sm">{{ title }}</p>
|
||||
<p v-else class="truncate text-sm" v-html="matchHtml" />
|
||||
|
||||
<p v-if="matchKey === 'subtitle'" class="truncate text-xs text-fg-muted">{{ matchHtml }}</p>
|
||||
|
||||
<p v-if="matchKey !== 'authors'" class="text-xs text-fg truncate">by {{ authorName }}</p>
|
||||
<p v-else class="truncate text-xs text-fg" v-html="matchHtml" />
|
||||
|
||||
<div v-if="matchKey === 'series' || matchKey === 'tags' || matchKey === 'isbn' || matchKey === 'asin' || matchKey === 'episode' || matchKey === 'narrators'" class="m-0 p-0 truncate text-xs" v-html="matchHtml" />
|
||||
<div class="grow px-2 audiobookSearchCardContent">
|
||||
<p class="truncate text-sm">{{ title }}</p>
|
||||
<p v-if="subtitle" class="truncate text-xs text-gray-300">{{ subtitle }}</p>
|
||||
<p class="text-xs text-gray-200 truncate">{{ $getString('LabelByAuthor', [authorName]) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -22,9 +16,7 @@ export default {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
search: String,
|
||||
matchKey: String,
|
||||
matchText: String
|
||||
search: String
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
@@ -58,35 +50,6 @@ export default {
|
||||
authorName() {
|
||||
if (this.isPodcast) return this.mediaMetadata.author
|
||||
return this.mediaMetadata.authorName
|
||||
},
|
||||
matchHtml() {
|
||||
if (!this.matchText || !this.search) return ''
|
||||
if (this.matchKey === 'subtitle') return ''
|
||||
var matchSplit = this.matchText.toLowerCase().split(this.search.toLowerCase().trim())
|
||||
if (matchSplit.length < 2) return ''
|
||||
|
||||
var html = ''
|
||||
var totalLenSoFar = 0
|
||||
for (let i = 0; i < matchSplit.length - 1; i++) {
|
||||
var indexOf = matchSplit[i].length
|
||||
var firstPart = this.matchText.substr(totalLenSoFar, indexOf)
|
||||
var actualWasThere = this.matchText.substr(totalLenSoFar + indexOf, this.search.length)
|
||||
totalLenSoFar += indexOf + this.search.length
|
||||
|
||||
html += `${firstPart}<strong class="text-warning">${actualWasThere}</strong>`
|
||||
}
|
||||
var lastPart = this.matchText.substr(totalLenSoFar)
|
||||
html += lastPart
|
||||
|
||||
if (this.matchKey === 'episode') return `<p class="truncate">Episode: ${html}</p>`
|
||||
if (this.matchKey === 'tags') return `<p class="truncate">Tags: ${html}</p>`
|
||||
if (this.matchKey === 'subtitle') return `<p class="truncate">${html}</p>`
|
||||
if (this.matchKey === 'authors') return `by ${html}`
|
||||
if (this.matchKey === 'isbn') return `<p class="truncate">ISBN: ${html}</p>`
|
||||
if (this.matchKey === 'asin') return `<p class="truncate">ASIN: ${html}</p>`
|
||||
if (this.matchKey === 'series') return `<p class="truncate">Series: ${html}</p>`
|
||||
if (this.matchKey === 'narrators') return `<p class="truncate">Narrator: ${html}</p>`
|
||||
return `${html}`
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
@@ -101,4 +64,4 @@ export default {
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -4,13 +4,16 @@
|
||||
<!-- list of server connection configs -->
|
||||
<template v-if="!showForm">
|
||||
<div v-for="config in serverConnectionConfigs" :key="config.id" class="border-b border-fg/10 py-4">
|
||||
<div class="flex items-center my-1 relative" @click="connectToServer(config)">
|
||||
<span class="material-symbols text-xl text-fg-muted">dns</span>
|
||||
<p class="pl-3 pr-6 text-base text-fg">{{ config.name }}</p>
|
||||
|
||||
<div class="absolute top-0 right-0 h-full px-4 flex items-center" @click.stop="editServerConfig(config)">
|
||||
<div class="flex items-center my-1 relative space-x-2" @click="connectToServer(config)">
|
||||
<div class="grow inline-flex items-center overflow-hidden">
|
||||
<p class="text-base text-fg truncate">{{ config.name }}</p>
|
||||
</div>
|
||||
<div class="h-full w-6 flex items-center" @click.stop="editServerConfig(config)">
|
||||
<span class="material-symbols text-2xl text-fg-muted">more_vert</span>
|
||||
</div>
|
||||
<div class="h-full w-6 flex items-center" @click.stop="removeServerConfigClick(config)">
|
||||
<span class="material-symbols fill text-1.5xl text-fg-muted">delete</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- warning message if server connection config is using an old user id -->
|
||||
<div v-if="!checkIdUuid(config.userId)" class="flex flex-nowrap justify-between items-center space-x-4 pt-4">
|
||||
@@ -57,7 +60,7 @@
|
||||
<ui-text-input v-model="password" type="password" :disabled="processing" :placeholder="$strings.LabelPassword" class="w-full mb-2 text-lg" />
|
||||
|
||||
<div class="flex items-center pt-2">
|
||||
<ui-icon-btn v-if="serverConfig.id" small bg-color="error" icon="delete" type="button" @click="removeServerConfigClick" />
|
||||
<ui-icon-btn v-if="serverConfig.id" bg-color="error" icon="delete" type="button" @click="removeServerConfigClick(serverConfig)" />
|
||||
<div class="flex-grow" />
|
||||
<ui-btn :disabled="processing || !networkConnected" type="submit" class="mt-1 h-10">{{ networkConnected ? $strings.ButtonSubmit : $strings.MessageNoNetworkConnection }}</ui-btn>
|
||||
</div>
|
||||
@@ -76,9 +79,6 @@
|
||||
|
||||
<div :class="processing ? 'opacity-100' : 'opacity-0 pointer-events-none'" class="fixed w-full h-full top-0 left-0 bg-black/75 flex items-center justify-center z-30 transition-opacity duration-500">
|
||||
<div>
|
||||
<div class="absolute top-0 left-0 w-full p-6 flex items-center flex-col justify-center z-0 short:hidden">
|
||||
<img src="/Logo.png" class="h-20 w-20 mb-2" />
|
||||
</div>
|
||||
<svg class="animate-spin w-16 h-16" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
|
||||
</svg>
|
||||
@@ -478,19 +478,19 @@ export default {
|
||||
}
|
||||
}
|
||||
},
|
||||
async removeServerConfigClick() {
|
||||
if (!this.serverConfig.id) return
|
||||
async removeServerConfigClick(serverConfig) {
|
||||
if (!serverConfig.id) return
|
||||
await this.$hapticsImpact()
|
||||
|
||||
const { value } = await Dialog.confirm({
|
||||
title: 'Confirm',
|
||||
message: `Remove this server config?`
|
||||
title: this.$strings.HeaderConfirm,
|
||||
message: this.$strings.MessageConfirmDeleteServerConfig
|
||||
})
|
||||
if (value) {
|
||||
this.processing = true
|
||||
await this.$db.removeServerConnectionConfig(this.serverConfig.id)
|
||||
await this.$db.removeServerConnectionConfig(serverConfig.id)
|
||||
const updatedDeviceData = { ...this.deviceData }
|
||||
updatedDeviceData.serverConnectionConfigs = this.deviceData.serverConnectionConfigs.filter((scc) => scc.id != this.serverConfig.id)
|
||||
updatedDeviceData.serverConnectionConfigs = this.deviceData.serverConnectionConfigs.filter((scc) => scc.id != serverConfig.id)
|
||||
this.$store.commit('setDeviceData', updatedDeviceData)
|
||||
|
||||
this.serverConfig = {
|
||||
@@ -502,6 +502,7 @@ export default {
|
||||
this.processing = false
|
||||
this.showAuth = false
|
||||
this.showForm = !this.serverConnectionConfigs.length
|
||||
this.error = null
|
||||
}
|
||||
},
|
||||
async editServerConfig(serverConfig) {
|
||||
@@ -842,7 +843,7 @@ export default {
|
||||
async setUserAndConnection({ user, userDefaultLibraryId, serverSettings, ereaderDevices }) {
|
||||
if (!user) return
|
||||
|
||||
console.log('Successfully logged in', JSON.stringify(user))
|
||||
console.log('Successfully logged in: ' + user.username)
|
||||
|
||||
this.$store.commit('setServerSettings', serverSettings)
|
||||
this.$store.commit('libraries/setEReaderDevices', ereaderDevices)
|
||||
@@ -950,8 +951,9 @@ export default {
|
||||
init() {
|
||||
if (this.$route.query.serverConnectionConfigId) {
|
||||
// Handle force re-login for servers using new JWT auth but still using an old token OR refresh token failed
|
||||
this.serverConfig = this.serverConnectionConfigs.find((scc) => scc.id === this.$route.query.serverConnectionConfigId)
|
||||
if (this.serverConfig) {
|
||||
const _serverConfig = this.serverConnectionConfigs.find((scc) => scc.id === this.$route.query.serverConnectionConfigId)
|
||||
if (_serverConfig) {
|
||||
this.serverConfig = { ..._serverConfig }
|
||||
this.setForceRelogin(this.$route.query.error)
|
||||
return
|
||||
} else {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" :width="400" height="100%">
|
||||
<modals-modal v-model="show" :width="400" max-width="95%" height="100%">
|
||||
<template #outer>
|
||||
<div v-if="currentChapter" class="absolute top-10 left-4 z-40 pt-1" style="max-width: 80%">
|
||||
<p class="text-white text-lg truncate">{{ chapters.length }} {{ $strings.LabelChapters }}</p>
|
||||
@@ -8,19 +8,31 @@
|
||||
|
||||
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
|
||||
<div ref="container" class="w-full overflow-x-hidden overflow-y-auto bg-secondary rounded-lg border border-fg/20" style="max-height: 75%" @click.stop>
|
||||
<div class="sticky top-0 z-10 bg-secondary grid grid-cols-[1fr_auto_auto] gap-2 px-3 py-2 border-b border-fg/10 transition-shadow" :class="{ 'shadow-md': isScrolled }">
|
||||
<div>
|
||||
<p class="text-fg-muted text-sm">{{ $strings.LabelChapters }}</p>
|
||||
</div>
|
||||
<div class="text-right" style="min-width: 60px">
|
||||
<p class="text-fg-muted text-sm" style="letter-spacing: -0.5px">{{ $strings.LabelStart }}</p>
|
||||
</div>
|
||||
<div class="text-right" style="min-width: 60px">
|
||||
<p class="text-fg-muted text-sm" style="letter-spacing: -0.5px">{{ $strings.LabelDuration }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||
<template v-for="chapter in chapters">
|
||||
<li :key="chapter.id" :id="`chapter-row-${chapter.id}`" class="text-fg select-none relative py-4 cursor-pointer" :class="currentChapterId === chapter.id ? 'bg-primary bg-opacity-80' : ''" role="option" @click="clickedOption(chapter)">
|
||||
<div class="relative flex items-center pl-3 pr-20">
|
||||
<p class="font-normal block truncate text-sm text-fg/80">{{ chapter.title }}</p>
|
||||
<div class="absolute top-0 right-3 -mt-0.5">
|
||||
<span class="font-mono text-fg-muted leading-3 text-sm" style="letter-spacing: -0.5px">{{ $secondsToTimestamp(chapter.start / _playbackRate) }}</span>
|
||||
</div>
|
||||
<li v-for="chapter in chapters" :key="chapter.id" :id="`chapter-row-${chapter.id}`" class="text-fg select-none relative cursor-pointer" :class="currentChapterId === chapter.id ? 'bg-primary bg-opacity-80' : ''" role="option" @click="clickedOption(chapter)">
|
||||
<div class="grid grid-cols-[1fr_auto_auto] gap-2 px-3 py-3 items-start">
|
||||
<p class="font-normal line-clamp-2 text-sm text-fg/80">{{ chapter.title }}</p>
|
||||
<div class="text-right" style="min-width: 60px">
|
||||
<span class="font-mono text-fg-muted text-sm" style="letter-spacing: -0.5px">{{ $secondsToTimestamp(chapter.start / _playbackRate) }}</span>
|
||||
</div>
|
||||
<div class="text-right" style="min-width: 60px">
|
||||
<span class="font-mono text-fg-muted text-sm" style="letter-spacing: -0.5px">{{ $secondsToTimestamp(Math.max(0, chapter.end - chapter.start) / _playbackRate) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="chapter.id === currentChapterId" class="w-0.5 h-full absolute top-0 left-0 bg-yellow-400" />
|
||||
</li>
|
||||
</template>
|
||||
<div v-show="chapter.id === currentChapterId" class="w-0.5 h-full absolute top-0 left-0 bg-yellow-400" />
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -42,7 +54,9 @@ export default {
|
||||
playbackRate: Number
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
return {
|
||||
isScrolled: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
value(newVal) {
|
||||
@@ -87,8 +101,25 @@ export default {
|
||||
container.scrollTo({ top: offsetTop - containerHeight / 2 })
|
||||
}
|
||||
}
|
||||
},
|
||||
handleScroll() {
|
||||
const container = this.$refs.container
|
||||
if (container) {
|
||||
this.isScrolled = container.scrollTop > 0
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
mounted() {
|
||||
const container = this.$refs.container
|
||||
if (container) {
|
||||
container.addEventListener('scroll', this.handleScroll)
|
||||
}
|
||||
},
|
||||
beforeDestroy() {
|
||||
const container = this.$refs.container
|
||||
if (container) {
|
||||
container.removeEventListener('scroll', this.handleScroll)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div ref="wrapper" class="modal modal-bg w-screen fixed bottom-0 left-0 flex items-center justify-center z-50" :class="threeQuartersScreen ? 'h-[75vh] min-h-[400px]' : 'h-screen'" @click.stop @touchstart.stop @touchend.stop>
|
||||
<div ref="wrapper" class="modal modal-bg w-screen fixed bottom-0 left-0 flex items-center justify-center z-50" :class="threeQuartersScreen ? 'h-[75vh] min-h-[400px] short:min-h-0 short:h-screen' : 'h-screen'" @click.stop @touchstart.stop @touchend.stop>
|
||||
<div ref="content" class="relative text-fg h-full w-full bg-bg">
|
||||
<slot />
|
||||
</div>
|
||||
@@ -74,4 +74,4 @@ export default {
|
||||
this.$eventBus.$off('close-modal', this.closeModalEvt)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -303,7 +303,7 @@ export default {
|
||||
// Show confirm if item has progress since it will reset
|
||||
if (this.userItemProgress && this.userItemProgress.progress > 0 && !this.userIsFinished) {
|
||||
const { value } = await Dialog.confirm({
|
||||
title: 'Confirm',
|
||||
title: this.$strings.HeaderConfirm,
|
||||
message: this.$strings.MessageConfirmMarkAsFinished
|
||||
})
|
||||
if (!value) return
|
||||
@@ -369,7 +369,7 @@ export default {
|
||||
await this.$hapticsImpact()
|
||||
|
||||
const { value } = await Dialog.confirm({
|
||||
title: 'Confirm',
|
||||
title: this.$strings.HeaderConfirm,
|
||||
message: this.$strings.MessageConfirmDiscardProgress
|
||||
})
|
||||
if (value) {
|
||||
@@ -407,7 +407,7 @@ export default {
|
||||
}
|
||||
|
||||
const { value } = await Dialog.confirm({
|
||||
title: 'Confirm',
|
||||
title: this.$strings.HeaderConfirm,
|
||||
message: this.$getString('MessageConfirmDeleteLocalEpisode', [localFile.basePath])
|
||||
})
|
||||
if (value) {
|
||||
@@ -432,7 +432,7 @@ export default {
|
||||
await this.$hapticsImpact()
|
||||
|
||||
const { value } = await Dialog.confirm({
|
||||
title: 'Confirm',
|
||||
title: this.$strings.HeaderConfirm,
|
||||
message: this.$strings.MessageConfirmDeleteLocalFiles
|
||||
})
|
||||
if (value) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" width="90%">
|
||||
<div class="w-full h-full bg-primary rounded-lg border border-fg/20">
|
||||
<ul class="w-full rounded-lg text-base" role="listbox" aria-labelledby="listbox-label">
|
||||
<ul class="w-full rounded-lg text-base max-h-[70vh] overflow-y-auto overscroll-contain" role="listbox" aria-labelledby="listbox-label">
|
||||
<template v-for="item in items">
|
||||
<li :key="item.value" class="text-fg select-none relative py-4 pr-9 cursor-pointer" :class="item.value === selected ? 'bg-bg bg-opacity-50' : ''" role="option" @click="clickedOption(item.value)">
|
||||
<div class="flex items-center">
|
||||
@@ -64,6 +64,18 @@ export default {
|
||||
text: this.$strings.LabelFileModified,
|
||||
value: 'mtimeMs'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelLibrarySortByProgress,
|
||||
value: 'progress'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelLibrarySortByProgressStarted,
|
||||
value: 'progress.createdAt'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelLibrarySortByProgressFinished,
|
||||
value: 'progress.finishedAt'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelRandomly,
|
||||
value: 'random'
|
||||
|
||||
@@ -108,7 +108,7 @@ export default {
|
||||
async cancelSleepTimer() {
|
||||
if (this.isAuto) {
|
||||
const { value } = await Dialog.confirm({
|
||||
title: 'Confirm',
|
||||
title: this.$strings.HeaderConfirm,
|
||||
message: this.$strings.MessageConfirmDisableAutoTimer
|
||||
})
|
||||
if (!value) return
|
||||
|
||||
@@ -37,6 +37,7 @@ export default {
|
||||
isRefreshingUI: false,
|
||||
ereaderSettings: {
|
||||
theme: 'dark',
|
||||
font: 'serif',
|
||||
fontScale: 100,
|
||||
lineSpacing: 115,
|
||||
textStroke: 0
|
||||
@@ -134,6 +135,7 @@ export default {
|
||||
|
||||
const fontScale = settings.fontScale || 100
|
||||
this.rendition.themes.fontSize(`${fontScale}%`)
|
||||
this.rendition.themes.font(settings.font)
|
||||
this.rendition.spread(settings.spread || 'auto')
|
||||
},
|
||||
goToChapter(href) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div v-if="show" :data-theme="ereaderTheme" class="group fixed top-0 left-0 right-0 layout-wrapper w-full z-40 pt-8 data-[theme=black]:bg-black data-[theme=black]:text-white data-[theme=dark]:bg-[#232323] data-[theme=dark]:text-white data-[theme=light]:bg-white data-[theme=light]:text-black" :class="{ 'reader-player-open': isPlayerOpen }">
|
||||
<!-- toolbar -->
|
||||
<div class="h-32 pt-10 w-full px-2 fixed top-0 left-0 z-30 transition-transform bg-bg text-fg" :class="showingToolbar ? 'translate-y-0' : '-translate-y-32'" :style="{ boxShadow: showingToolbar ? '0px 8px 8px #11111155' : '' }" @touchstart.stop @mousedown.stop @touchend.stop @mouseup.stop>
|
||||
<div class="w-full px-2 fixed top-0 left-0 z-30 transition-transform bg-bg text-fg" :class="`${showingToolbar ? 'translate-y-0' : '-translate-y-36'} ${isIos ? 'pt-14 h-36' : 'pt-10 h-32'}`" :style="{ boxShadow: showingToolbar ? '0px 8px 8px #11111155' : '' }" @touchstart.stop @mousedown.stop @touchend.stop @mouseup.stop>
|
||||
<div class="flex items-center mb-2">
|
||||
<button type="button" class="inline-flex mx-2" @click.stop="show = false">
|
||||
<span class="material-symbols text-3xl text-fg">chevron_left</span>
|
||||
@@ -62,7 +62,7 @@
|
||||
<span class="material-symbols">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="w-full overflow-y-auto overflow-x-hidden h-full max-h-[calc(75vh-85px)]">
|
||||
<div class="w-full overflow-y-auto overflow-x-hidden h-[calc(75vh-85px)] min-h-[320px] short:min-h-0 short:h-[calc(100vh-85px)]">
|
||||
<div class="w-full h-full px-4">
|
||||
<div class="flex items-center mb-6">
|
||||
<div class="w-32">
|
||||
@@ -70,6 +70,12 @@
|
||||
</div>
|
||||
<ui-toggle-btns v-model="ereaderSettings.theme" name="theme" :items="themeItems" @input="settingsUpdated" />
|
||||
</div>
|
||||
<div class="flex items-center mb-6">
|
||||
<div class="w-32">
|
||||
<p class="text-sm">{{ $strings.LabelFontFamily }}</p>
|
||||
</div>
|
||||
<ui-toggle-btns v-model="ereaderSettings.font" name="font" :items="fontItems" @input="settingsUpdated" />
|
||||
</div>
|
||||
<div class="flex items-center mb-6">
|
||||
<div class="w-32">
|
||||
<p class="text-sm">{{ $strings.LabelFontScale }}</p>
|
||||
@@ -141,6 +147,7 @@ export default {
|
||||
isInittingWatchVolume: false,
|
||||
ereaderSettings: {
|
||||
theme: 'dark',
|
||||
font: 'serif',
|
||||
fontScale: 100,
|
||||
lineSpacing: 115,
|
||||
spread: 'auto',
|
||||
@@ -180,6 +187,9 @@ export default {
|
||||
this.$store.commit('setShowReader', val)
|
||||
}
|
||||
},
|
||||
isIos() {
|
||||
return this.$platform === 'ios'
|
||||
},
|
||||
title() {
|
||||
return this.mediaMetadata.title || 'No Title'
|
||||
},
|
||||
@@ -252,6 +262,18 @@ export default {
|
||||
}
|
||||
]
|
||||
},
|
||||
fontItems() {
|
||||
return [
|
||||
{
|
||||
text: this.$strings.LabelFontFamilySans,
|
||||
value: 'sans-serif'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelFontFamilySerif,
|
||||
value: 'serif'
|
||||
}
|
||||
]
|
||||
},
|
||||
readerComponentName() {
|
||||
if (this.ebookType === 'epub') return 'readers-epub-reader'
|
||||
else if (this.ebookType === 'mobi') return 'readers-mobi-reader'
|
||||
|
||||
@@ -183,10 +183,16 @@ export default {
|
||||
daysInARow() {
|
||||
var count = 0
|
||||
while (true) {
|
||||
var _date = this.$addDaysToToday(count * -1)
|
||||
var datestr = this.$formatJsDate(_date, 'yyyy-MM-dd')
|
||||
const _date = this.$addDaysToToday(count * -1 - 1)
|
||||
const datestr = this.$formatJsDate(_date, 'yyyy-MM-dd')
|
||||
|
||||
if (!this.listeningStatsDays[datestr] || this.listeningStatsDays[datestr] === 0) {
|
||||
// don't require listening today to count towards days in a row, but do count it if already listened today
|
||||
const today = this.$formatJsDate(new Date(), 'yyyy-MM-dd')
|
||||
if (this.listeningStatsDays[today]) {
|
||||
count++
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
count++
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
<tr>
|
||||
<th class="text-left">{{ $strings.LabelTitle }}</th>
|
||||
<th class="text-center w-16">{{ $strings.LabelStart }}</th>
|
||||
<th class="text-center w-16">{{ $strings.LabelDuration }}</th>
|
||||
</tr>
|
||||
<tr v-for="chapter in chapters" :key="chapter.id">
|
||||
<td>
|
||||
@@ -23,6 +24,9 @@
|
||||
<td class="font-mono text-center underline w-16" @click.stop="goToTimestamp(chapter.start)">
|
||||
{{ $secondsToTimestamp(chapter.start) }}
|
||||
</td>
|
||||
<td class="font-mono text-center">
|
||||
{{ $secondsToTimestamp(Math.max(0, chapter.end - chapter.start)) }}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</transition>
|
||||
|
||||
@@ -228,8 +228,8 @@ export default {
|
||||
methods: {
|
||||
async clearDownloadQueue() {
|
||||
const { value } = await Dialog.confirm({
|
||||
title: 'Confirm',
|
||||
message: `Are you sure you want to clear episode download queue?`
|
||||
title: this.$strings.HeaderConfirm,
|
||||
message: this.$strings.MessageConfirmDeleteEpisodeDownloadQueue
|
||||
})
|
||||
|
||||
if (value) {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<p>Zur Verwendung mit Ihrem Audiobookshelf-Server. Audiobookshelf ist ein kostenloser Open-Source-Hörbuch- und Podcast-Manager.</p><p>Verbinden Sie sich mit Ihrem selbst gehosteten Audiobookshelf-Server und streamen Sie Ihre Hörbücher und Podcasts direkt auf Ihr Gerät.</p>
|
||||
@@ -0,0 +1 @@
|
||||
Hörbuch- und Podcast-Player für Ihren selbst gehosteten Audiobookshelf-Server
|
||||
@@ -0,0 +1 @@
|
||||
<p>For use with your audiobookshelf server. Audiobookshelf is a free and open source audiobook and podcast manager.</p><p>Connect to your self-hosted audiobookshelf server and stream your audiobooks and podcasts directly to your device.</p>
|
||||
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 45 KiB |
@@ -0,0 +1 @@
|
||||
Audiobook and podcast player for your self-hosted Audiobookshelf server
|
||||
@@ -36,6 +36,7 @@
|
||||
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
|
||||
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
|
||||
65643F4DAA661FB0B247247E /* Pods_Audiobookshelf.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8F9A7CCC479333E44DC314BE /* Pods_Audiobookshelf.framework */; };
|
||||
6F2516272E68DA3000F40541 /* LogEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2516262E68DA3000F40541 /* LogEntry.swift */; };
|
||||
E9D5504628AC1A3900C746DD /* LibraryItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5504528AC1A3900C746DD /* LibraryItem.swift */; };
|
||||
E9D5504828AC1A7A00C746DD /* MediaType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5504728AC1A7A00C746DD /* MediaType.swift */; };
|
||||
E9D5504A28AC1AA600C746DD /* Metadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5504928AC1AA600C746DD /* Metadata.swift */; };
|
||||
@@ -111,6 +112,7 @@
|
||||
504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
|
||||
6F2516262E68DA3000F40541 /* LogEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogEntry.swift; sourceTree = "<group>"; };
|
||||
8F9A7CCC479333E44DC314BE /* Pods_Audiobookshelf.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Audiobookshelf.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
BE96D57E131924D520D57057 /* Pods-Audiobookshelf.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Audiobookshelf.debug.xcconfig"; path = "Target Support Files/Pods-Audiobookshelf/Pods-Audiobookshelf.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
D2F7F575384A63F1C47DE984 /* Pods-Audiobookshelf.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Audiobookshelf.release.xcconfig"; path = "Target Support Files/Pods-Audiobookshelf/Pods-Audiobookshelf.release.xcconfig"; sourceTree = "<group>"; };
|
||||
@@ -231,6 +233,7 @@
|
||||
E9D5506128AC1CC900C746DD /* PlayerState.swift */,
|
||||
4DF74911287105C600AC7814 /* DeviceSettings.swift */,
|
||||
E9D5507428AEF93100C746DD /* PlayerSettings.swift */,
|
||||
6F2516262E68DA3000F40541 /* LogEntry.swift */,
|
||||
E9D5506328AC1D3F00C746DD /* server */,
|
||||
E9D5506428AC1D5800C746DD /* local */,
|
||||
E9D5506D28AC1E7400C746DD /* download */,
|
||||
@@ -533,6 +536,7 @@
|
||||
3ABF580928059BAE005DFBE5 /* PlaybackSession.swift in Sources */,
|
||||
E9D5506628AC1D7300C746DD /* LocalLibraryItem.swift in Sources */,
|
||||
E9D5504628AC1A3900C746DD /* LibraryItem.swift in Sources */,
|
||||
6F2516272E68DA3000F40541 /* LogEntry.swift in Sources */,
|
||||
3ABF618F2804325C0070250E /* PlayerHandler.swift in Sources */,
|
||||
3AD4FCED28044E6C006DB301 /* Store.swift in Sources */,
|
||||
4D66B958282EEA14008272D4 /* AbsFileSystem.swift in Sources */,
|
||||
@@ -736,12 +740,12 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 40;
|
||||
CURRENT_PROJECT_VERSION = 43;
|
||||
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 0.10.1;
|
||||
MARKETING_VERSION = 0.13.0;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app.dev;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -760,12 +764,12 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 40;
|
||||
CURRENT_PROJECT_VERSION = 43;
|
||||
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 0.10.1;
|
||||
MARKETING_VERSION = 0.13.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
|
||||
@@ -5,8 +5,6 @@ import RealmSwift
|
||||
@UIApplicationMain
|
||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
|
||||
private let logger = AppLogger(category: "AppDelegate")
|
||||
|
||||
lazy var window: UIWindow? = UIWindow(frame: UIScreen.main.bounds)
|
||||
var backgroundCompletionHandler: (() -> Void)?
|
||||
|
||||
@@ -17,13 +15,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
schemaVersion: 20,
|
||||
migrationBlock: { [weak self] migration, oldSchemaVersion in
|
||||
if (oldSchemaVersion < 1) {
|
||||
self?.logger.log("Realm schema version was \(oldSchemaVersion)")
|
||||
AbsLogger.info(message: "Realm schema version was \(oldSchemaVersion)")
|
||||
migration.enumerateObjects(ofType: DeviceSettings.className()) { oldObject, newObject in
|
||||
newObject?["enableAltView"] = false
|
||||
}
|
||||
}
|
||||
if (oldSchemaVersion < 4) {
|
||||
self?.logger.log("Realm schema version was \(oldSchemaVersion)... Reindexing server configs")
|
||||
AbsLogger.info(message: "Realm schema version was \(oldSchemaVersion)... Reindexing server configs")
|
||||
var indexCounter = 1
|
||||
migration.enumerateObjects(ofType: ServerConnectionConfig.className()) { oldObject, newObject in
|
||||
newObject?["index"] = indexCounter
|
||||
@@ -31,44 +29,44 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
}
|
||||
}
|
||||
if (oldSchemaVersion < 5) {
|
||||
self?.logger.log("Realm schema version was \(oldSchemaVersion)... Adding lockOrientation setting")
|
||||
AbsLogger.info(message: "Realm schema version was \(oldSchemaVersion)... Adding lockOrientation setting")
|
||||
migration.enumerateObjects(ofType: DeviceSettings.className()) { oldObject, newObject in
|
||||
newObject?["lockOrientation"] = "NONE"
|
||||
}
|
||||
}
|
||||
if (oldSchemaVersion < 6) {
|
||||
self?.logger.log("Realm schema version was \(oldSchemaVersion)... Adding hapticFeedback setting")
|
||||
AbsLogger.info(message: "Realm schema version was \(oldSchemaVersion)... Adding hapticFeedback setting")
|
||||
migration.enumerateObjects(ofType: DeviceSettings.className()) { oldObject, newObject in
|
||||
newObject?["hapticFeedback"] = "LIGHT"
|
||||
}
|
||||
}
|
||||
if (oldSchemaVersion < 15) {
|
||||
self?.logger.log("Realm schema version was \(oldSchemaVersion)... Adding languageCode setting")
|
||||
AbsLogger.info(message: "Realm schema version was \(oldSchemaVersion)... Adding languageCode setting")
|
||||
migration.enumerateObjects(ofType: DeviceSettings.className()) { oldObject, newObject in
|
||||
newObject?["languageCode"] = "en-us"
|
||||
}
|
||||
}
|
||||
if (oldSchemaVersion < 16) {
|
||||
self?.logger.log("Realm schema version was \(oldSchemaVersion)... Adding chapterTrack setting")
|
||||
AbsLogger.info(message: "Realm schema version was \(oldSchemaVersion)... Adding chapterTrack setting")
|
||||
migration.enumerateObjects(ofType: PlayerSettings.className()) { oldObject, newObject in
|
||||
newObject?["chapterTrack"] = false
|
||||
}
|
||||
}
|
||||
if (oldSchemaVersion < 17) {
|
||||
self?.logger.log("Realm schema version was \(oldSchemaVersion)... Adding downloadUsingCellular and streamingUsingCellular settings")
|
||||
AbsLogger.info(message: "Realm schema version was \(oldSchemaVersion)... Adding downloadUsingCellular and streamingUsingCellular settings")
|
||||
migration.enumerateObjects(ofType: PlayerSettings.className()) { oldObject, newObject in
|
||||
newObject?["downloadUsingCellular"] = "ALWAYS"
|
||||
newObject?["streamingUsingCellular"] = "ALWAYS"
|
||||
}
|
||||
}
|
||||
if (oldSchemaVersion < 18) {
|
||||
self?.logger.log("Realm schema version was \(oldSchemaVersion)... Adding disableSleepTimerFadeOut settings")
|
||||
AbsLogger.info(message: "Realm schema version was \(oldSchemaVersion)... Adding disableSleepTimerFadeOut settings")
|
||||
migration.enumerateObjects(ofType: PlayerSettings.className()) { oldObject, newObject in
|
||||
newObject?["disableSleepTimerFadeOut"] = false
|
||||
}
|
||||
}
|
||||
if (oldSchemaVersion < 20) {
|
||||
self?.logger.log("Realm schema version was \(oldSchemaVersion)... Adding version to ServerConnectionConfigs")
|
||||
AbsLogger.info(message: "Realm schema version was \(oldSchemaVersion)... Adding version to ServerConnectionConfigs")
|
||||
migration.enumerateObjects(ofType: ServerConnectionConfig.className()) { oldObject, newObject in
|
||||
newObject?["version"] = ""
|
||||
}
|
||||
@@ -88,22 +86,22 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
func applicationDidEnterBackground(_ application: UIApplication) {
|
||||
// Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later.
|
||||
// If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits.
|
||||
logger.log("Audiobookself is now in the background")
|
||||
AbsLogger.info(message: "Audiobookself is now in the background")
|
||||
}
|
||||
|
||||
func applicationWillEnterForeground(_ application: UIApplication) {
|
||||
// Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background.
|
||||
logger.log("Audiobookself is now in the foreground")
|
||||
AbsLogger.info(message: "Audiobookself is now in the foreground")
|
||||
}
|
||||
|
||||
func applicationDidBecomeActive(_ application: UIApplication) {
|
||||
// Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
|
||||
logger.log("Audiobookself is now active")
|
||||
AbsLogger.info(message: "Audiobookself is now active")
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ application: UIApplication) {
|
||||
// Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
|
||||
logger.log("Audiobookself is terminating")
|
||||
AbsLogger.info(message: "Audiobookself is terminating")
|
||||
}
|
||||
|
||||
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
|
||||
|
||||
@@ -33,8 +33,6 @@ public class AbsAudioPlayer: CAPPlugin, CAPBridgedPlugin {
|
||||
CAPPluginMethod(name: "getSleepTimerTime", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "setSleepTimer", returnType: CAPPluginReturnPromise)
|
||||
]
|
||||
|
||||
private let logger = AppLogger(category: "AbsAudioPlayer")
|
||||
|
||||
private var initialPlayWhenReady = false
|
||||
private var monitor: NWPathMonitor?
|
||||
@@ -82,7 +80,7 @@ public class AbsAudioPlayer: CAPPlugin, CAPBridgedPlugin {
|
||||
let startTimeOverride = call.getDouble("startTime")
|
||||
|
||||
if libraryItemId == nil {
|
||||
logger.error("provide library item id")
|
||||
AbsLogger.error(message: "No id provided")
|
||||
return call.resolve()
|
||||
}
|
||||
|
||||
@@ -93,7 +91,7 @@ public class AbsAudioPlayer: CAPPlugin, CAPBridgedPlugin {
|
||||
let item = Database.shared.getLocalLibraryItem(localLibraryItemId: libraryItemId!)
|
||||
let episode = item?.getPodcastEpisode(episodeId: episodeId)
|
||||
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([:])
|
||||
}
|
||||
|
||||
@@ -105,7 +103,7 @@ public class AbsAudioPlayer: CAPPlugin, CAPBridgedPlugin {
|
||||
try self.startPlaybackSession(playbackSession, playWhenReady: playWhenReady, playbackRate: playbackRate)
|
||||
call.resolve(try playbackSession.asDictionary())
|
||||
} catch(let exception) {
|
||||
logger.error("Failed to start session")
|
||||
AbsLogger.error(message: "Failed to start session for local item: \(exception)")
|
||||
debugPrint(exception)
|
||||
call.resolve([:])
|
||||
}
|
||||
@@ -119,7 +117,7 @@ public class AbsAudioPlayer: CAPPlugin, CAPBridgedPlugin {
|
||||
try self?.startPlaybackSession(session, playWhenReady: playWhenReady, playbackRate: playbackRate)
|
||||
call.resolve(try session.asDictionary())
|
||||
} catch(let exception) {
|
||||
self?.logger.error("Failed to start session")
|
||||
AbsLogger.error(message: "Failed to start streaming session")
|
||||
debugPrint(exception)
|
||||
call.resolve([:])
|
||||
}
|
||||
@@ -127,19 +125,22 @@ public class AbsAudioPlayer: CAPPlugin, CAPBridgedPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/// Stops playback and closes session.
|
||||
@objc func closePlayback(_ call: CAPPluginCall) {
|
||||
logger.log("Close playback")
|
||||
AbsLogger.info(message: "Close playback")
|
||||
|
||||
PlayerHandler.stopPlayback()
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
|
||||
@objc func getCurrentTime(_ call: CAPPluginCall) {
|
||||
call.resolve([
|
||||
"value": PlayerHandler.getCurrentTime() ?? 0,
|
||||
"bufferedTime": PlayerHandler.getCurrentTime() ?? 0,
|
||||
])
|
||||
}
|
||||
|
||||
@objc func setPlaybackSpeed(_ call: CAPPluginCall) {
|
||||
let playbackRate = call.getFloat("value", 1.0)
|
||||
let settings = PlayerSettings.main()
|
||||
@@ -152,7 +153,7 @@ public class AbsAudioPlayer: CAPPlugin, CAPBridgedPlugin {
|
||||
|
||||
@objc func setChapterTrack(_ call: CAPPluginCall) {
|
||||
let chapterTrack = call.getBool("enabled", true)
|
||||
logger.log(String(chapterTrack))
|
||||
AbsLogger.info(message: String(chapterTrack))
|
||||
let settings = PlayerSettings.main()
|
||||
try? settings.update {
|
||||
settings.chapterTrack = chapterTrack
|
||||
@@ -225,7 +226,7 @@ public class AbsAudioPlayer: CAPPlugin, CAPBridgedPlugin {
|
||||
|
||||
let seconds = time / 1000
|
||||
|
||||
logger.log("chapter time: \(isChapterTime)")
|
||||
AbsLogger.info(message: "chapter time: \(isChapterTime)")
|
||||
if isChapterTime {
|
||||
PlayerHandler.setChapterSleepTime(stopAt: seconds)
|
||||
return call.resolve([ "success": true ])
|
||||
@@ -262,7 +263,7 @@ public class AbsAudioPlayer: CAPPlugin, CAPBridgedPlugin {
|
||||
guard let localMediaProgressId = PlayerHandler.getPlaybackSession()?.localMediaProgressId else { return }
|
||||
guard let localMediaProgress = Database.shared.getLocalMediaProgress(localMediaProgressId: localMediaProgressId) 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)
|
||||
}
|
||||
|
||||
@@ -272,7 +273,7 @@ public class AbsAudioPlayer: CAPPlugin, CAPBridgedPlugin {
|
||||
let playWhenReady = PlayerHandler.getPlayWhenReady()
|
||||
let libraryItemId = session?.libraryItemId ?? ""
|
||||
let episodeId = session?.episodeId ?? nil
|
||||
logger.log("Forcing Transcode")
|
||||
AbsLogger.info(message: "Forcing Transcode")
|
||||
|
||||
// If direct playing then fallback to transcode
|
||||
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.sendMetadata()
|
||||
} catch(let exception) {
|
||||
self?.logger.error("Failed to start transcoded session")
|
||||
AbsLogger.error(message: "Failed to start transcoded session")
|
||||
debugPrint(exception)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,8 +49,7 @@ public class AbsDatabase: CAPPlugin, CAPBridgedPlugin {
|
||||
CAPPluginMethod(name: "updateDeviceSettings", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "updateLocalEbookProgress", returnType: CAPPluginReturnPromise)
|
||||
]
|
||||
|
||||
private let logger = AppLogger(category: "AbsDatabase")
|
||||
|
||||
private let secureStorage = SecureStorage()
|
||||
|
||||
// Used to notify the webview frontend that the token has been refreshed
|
||||
@@ -80,7 +79,7 @@ public class AbsDatabase: CAPPlugin, CAPBridgedPlugin {
|
||||
if (refreshToken != "") {
|
||||
// Store refresh token securely if provided
|
||||
let hasRefreshToken = secureStorage.storeRefreshToken(serverConnectionConfigId: id ?? "", refreshToken: refreshToken)
|
||||
logger.log("Refresh token secured = \(hasRefreshToken)")
|
||||
AbsLogger.info(message: "Refresh token secured = \(hasRefreshToken)")
|
||||
}
|
||||
|
||||
let config = ServerConnectionConfig()
|
||||
@@ -148,15 +147,16 @@ public class AbsDatabase: CAPPlugin, CAPBridgedPlugin {
|
||||
let items = Database.shared.getLocalLibraryItems()
|
||||
call.resolve([ "value": try items.asDictionaryArray()])
|
||||
} catch(let exception) {
|
||||
logger.error("error while readling local library items")
|
||||
AbsLogger.error(message: "error reading local library items \(exception)")
|
||||
debugPrint(exception)
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@objc func getLocalLibraryItem(_ call: CAPPluginCall) {
|
||||
let id = call.getString("id") ?? ""
|
||||
do {
|
||||
let item = Database.shared.getLocalLibraryItem(localLibraryItemId: call.getString("id") ?? "")
|
||||
let item = Database.shared.getLocalLibraryItem(localLibraryItemId: id)
|
||||
switch item {
|
||||
case .some(let foundItem):
|
||||
call.resolve(try foundItem.asDictionary())
|
||||
@@ -164,7 +164,7 @@ public class AbsDatabase: CAPPlugin, CAPBridgedPlugin {
|
||||
call.resolve()
|
||||
}
|
||||
} catch(let exception) {
|
||||
logger.error("error while readling local library items")
|
||||
AbsLogger.error(message: "error reading local library item[\(id)] \(exception)")
|
||||
debugPrint(exception)
|
||||
call.resolve()
|
||||
}
|
||||
@@ -180,8 +180,7 @@ public class AbsDatabase: CAPPlugin, CAPBridgedPlugin {
|
||||
call.resolve()
|
||||
}
|
||||
} catch(let exception) {
|
||||
logger.error("error while readling local library items")
|
||||
debugPrint(exception)
|
||||
AbsLogger.error(message: "error while readling local library items: \(exception)", error: exception)
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
@@ -194,8 +193,7 @@ public class AbsDatabase: CAPPlugin, CAPBridgedPlugin {
|
||||
do {
|
||||
call.resolve([ "value": try Database.shared.getAllLocalMediaProgress().asDictionaryArray() ])
|
||||
} catch {
|
||||
logger.error("Error while loading local media progress")
|
||||
debugPrint(error)
|
||||
AbsLogger.error(message: "Error while loading local media progress", error: error)
|
||||
call.resolve(["value": []])
|
||||
}
|
||||
}
|
||||
@@ -212,7 +210,7 @@ public class AbsDatabase: CAPPlugin, CAPBridgedPlugin {
|
||||
|
||||
@objc func syncLocalSessionsWithServer(_ call: CAPPluginCall) {
|
||||
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 {
|
||||
call.reject("syncLocalSessionsWithServer not connected to server")
|
||||
return call.resolve()
|
||||
@@ -244,12 +242,13 @@ public class AbsDatabase: CAPPlugin, CAPBridgedPlugin {
|
||||
return
|
||||
}
|
||||
|
||||
logger.log("syncServerMediaProgressWithLocalMediaProgress: Saving local media progress")
|
||||
AbsLogger.info(message: "Saving local media progress \(serverMediaProgress)")
|
||||
try localMediaProgress.updateFromServerMediaProgress(serverMediaProgress)
|
||||
|
||||
call.resolve(try localMediaProgress.asDictionary())
|
||||
} catch {
|
||||
call.reject("Failed to sync media progress")
|
||||
AbsLogger.error(message: "Failed to sync: \(error)")
|
||||
debugPrint(error)
|
||||
}
|
||||
}
|
||||
@@ -264,7 +263,7 @@ public class AbsDatabase: CAPPlugin, CAPBridgedPlugin {
|
||||
localMediaProgressId += "-\(localEpisodeId ?? "")"
|
||||
}
|
||||
|
||||
logger.log("updateLocalMediaProgressFinished \(localMediaProgressId) | Is Finished: \(isFinished)")
|
||||
AbsLogger.info(message: "\(localMediaProgressId): isFinished=\(isFinished)")
|
||||
|
||||
do {
|
||||
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 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 {
|
||||
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]
|
||||
|
||||
private let logger = AppLogger(category: "AbsDownloader")
|
||||
|
||||
private lazy var session: URLSession = {
|
||||
let config = URLSessionConfiguration.background(withIdentifier: "AbsDownloader")
|
||||
let queue = OperationQueue()
|
||||
@@ -30,32 +28,70 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
|
||||
private let progressStatusQueue = DispatchQueue(label: "progress-status-queue", attributes: .concurrent)
|
||||
private var downloadItemProgress = [String: DownloadItem]()
|
||||
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
|
||||
|
||||
|
||||
public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
|
||||
handleDownloadTaskUpdate(downloadTask: downloadTask) { downloadItem, downloadItemPart in
|
||||
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 {
|
||||
downloadItemPart.bytesDownloaded = downloadItemPart.fileSize
|
||||
downloadItemPart.progress = 100
|
||||
downloadItemPart.completed = true
|
||||
liveDownloadItemPart.bytesDownloaded = liveDownloadItemPart.fileSize
|
||||
liveDownloadItemPart.progress = 100
|
||||
liveDownloadItemPart.completed = true
|
||||
}
|
||||
|
||||
do {
|
||||
// Move the downloaded file into place
|
||||
guard let destinationUrl = downloadItemPart.destinationURL else {
|
||||
guard let destinationUrl = liveDownloadItemPart.destinationURL else {
|
||||
throw LibraryItemDownloadError.downloadItemPartDestinationUrlNotDefined
|
||||
}
|
||||
try? FileManager.default.removeItem(at: destinationUrl)
|
||||
try FileManager.default.moveItem(at: location, to: destinationUrl)
|
||||
try realm.write {
|
||||
downloadItemPart.moved = true
|
||||
liveDownloadItemPart.moved = true
|
||||
}
|
||||
} catch {
|
||||
try realm.write {
|
||||
downloadItemPart.failed = true
|
||||
liveDownloadItemPart.failed = true
|
||||
}
|
||||
throw error
|
||||
}
|
||||
@@ -65,13 +101,26 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
|
||||
public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
||||
handleDownloadTaskUpdate(downloadTask: task) { downloadItem, downloadItemPart in
|
||||
if let error = error {
|
||||
try Realm().write {
|
||||
downloadItemPart.completed = true
|
||||
downloadItemPart.failed = true
|
||||
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 {
|
||||
liveDownloadItemPart.completed = true
|
||||
liveDownloadItemPart.failed = true
|
||||
}
|
||||
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) {
|
||||
@@ -81,9 +130,17 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
|
||||
|
||||
// Only update the progress if we received accurate progress data
|
||||
if percentDownloaded >= 0.0 && percentDownloaded <= 100.0 {
|
||||
try Realm().write {
|
||||
downloadItemPart.bytesDownloaded = Double(totalBytesWritten)
|
||||
downloadItemPart.progress = percentDownloaded
|
||||
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 {
|
||||
liveDownloadItemPart.bytesDownloaded = Double(totalBytesWritten)
|
||||
liveDownloadItemPart.progress = percentDownloaded
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -104,7 +161,7 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
|
||||
private func handleDownloadTaskUpdate(downloadTask: URLSessionTask, progressHandler: DownloadProgressHandler) {
|
||||
do {
|
||||
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
|
||||
let downloadItem = Database.shared.getDownloadItem(downloadItemPartId: downloadItemPartId)
|
||||
@@ -119,7 +176,7 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
|
||||
try progressHandler(downloadItem, part)
|
||||
try? self.notifyListeners("onDownloadItemPartUpdate", data: part.asDictionary())
|
||||
} catch {
|
||||
logger.error("Error while processing progress")
|
||||
AbsLogger.error(message: "Error while processing progress")
|
||||
debugPrint(error)
|
||||
}
|
||||
|
||||
@@ -130,7 +187,7 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
|
||||
}
|
||||
self.notifyDownloadProgress()
|
||||
} catch {
|
||||
logger.error("DownloadItemError")
|
||||
AbsLogger.error(message: "DownloadItemError")
|
||||
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
|
||||
private func notifyDownloadProgress() {
|
||||
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 {
|
||||
DispatchQueue.runOnMainQueue {
|
||||
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
|
||||
func fetchActiveDownloads() -> [String: DownloadItem]? {
|
||||
self.progressStatusQueue.sync {
|
||||
let activeDownloads = self.downloadItemProgress
|
||||
if activeDownloads.isEmpty {
|
||||
logger.log("Finishing monitoring download progress...")
|
||||
AbsLogger.info(message: "Finishing monitoring download progress...")
|
||||
t.invalidate()
|
||||
}
|
||||
return activeDownloads
|
||||
@@ -184,7 +241,7 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
|
||||
|
||||
if ( downloadItem.didDownloadSuccessfully() ) {
|
||||
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
|
||||
var coverFile: String?
|
||||
|
||||
@@ -231,12 +288,12 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
|
||||
var episodeId = call.getString("episodeId")
|
||||
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"]) }
|
||||
|
||||
ApiClient.getLibraryItemWithProgress(libraryItemId: libraryItemId, episodeId: episodeId) { [weak self] libraryItem in
|
||||
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 {
|
||||
if let episodeId = episodeId {
|
||||
// Download a podcast episode
|
||||
@@ -309,32 +366,37 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
|
||||
|
||||
// Persist in the database before status start coming in
|
||||
try Database.shared.saveDownloadItem(downloadItem)
|
||||
|
||||
// Start all the downloads
|
||||
for task in tasks {
|
||||
task.task.resume()
|
||||
}
|
||||
|
||||
// Add all tasks to the download queue
|
||||
downloadQueueLock.lock()
|
||||
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 {
|
||||
logger.log("TRACK \(track.contentUrl!)")
|
||||
|
||||
AbsLogger.info(message: "TRACK \(track.contentUrl!)")
|
||||
|
||||
// If we don't name metadata, then we can't proceed
|
||||
guard let filename = track.metadata?.filename else {
|
||||
throw LibraryItemDownloadError.noMetadata
|
||||
}
|
||||
|
||||
|
||||
let serverUrl = urlForTrack(item: item, track: track)
|
||||
let itemDirectory = try createLibraryItemFileDirectory(item: item)
|
||||
let localUrl = "\(itemDirectory)/\(filename)"
|
||||
|
||||
|
||||
let task = session.downloadTask(with: serverUrl)
|
||||
let part = DownloadItemPart(downloadItemId: downloadItemId, filename: filename, destination: localUrl, itemTitle: track.title ?? "Unknown", serverPath: Store.serverConfig!.address, audioTrack: track, episode: episode, ebookFile: nil, size: track.metadata?.size ?? 0)
|
||||
|
||||
|
||||
// Store the id on the task so the download item can be pulled from the database later
|
||||
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 {
|
||||
@@ -342,14 +404,14 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
|
||||
let serverPath = "/api/items/\(item.id)/file/\(ebookFile.ino)/download"
|
||||
let itemDirectory = try createLibraryItemFileDirectory(item: item)
|
||||
let localUrl = "\(itemDirectory)/\(filename)"
|
||||
|
||||
|
||||
let part = DownloadItemPart(downloadItemId: downloadItemId, filename: filename, destination: localUrl, itemTitle: filename, serverPath: serverPath, audioTrack: nil, episode: nil, ebookFile: ebookFile, size: ebookFile.metadata?.size ?? 0)
|
||||
let task = session.downloadTask(with: part.downloadURL!)
|
||||
|
||||
|
||||
// Store the id on the task so the download item can be pulled from the database later
|
||||
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 {
|
||||
@@ -357,19 +419,19 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
|
||||
let serverPath = "/api/items/\(item.id)/cover"
|
||||
let itemDirectory = try createLibraryItemFileDirectory(item: item)
|
||||
let localUrl = "\(itemDirectory)/\(filename)"
|
||||
|
||||
|
||||
// Find library file to get cover size
|
||||
let coverLibraryFile = item.libraryFiles.first(where: {
|
||||
$0.metadata?.path == item.media?.coverPath
|
||||
})
|
||||
|
||||
|
||||
let part = DownloadItemPart(downloadItemId: downloadItemId, filename: filename, destination: localUrl, itemTitle: "cover", serverPath: serverPath, audioTrack: nil, episode: nil, ebookFile: nil, size: coverLibraryFile?.metadata?.size ?? 0)
|
||||
let task = session.downloadTask(with: part.downloadURL!)
|
||||
|
||||
|
||||
// Store the id on the task so the download item can be pulled from the database later
|
||||
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 {
|
||||
@@ -393,10 +455,10 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
|
||||
|
||||
private func createLibraryItemFileDirectory(item: LibraryItem) throws -> String {
|
||||
let itemDirectory = item.id
|
||||
logger.log("ITEM DIR \(itemDirectory)")
|
||||
AbsLogger.info(message: "ITEM DIR \(itemDirectory)")
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -418,7 +480,7 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
|
||||
|
||||
return itemFolder
|
||||
} catch {
|
||||
AppLogger().error("Failed to CREATE LI DIRECTORY \(error)")
|
||||
AbsLogger.error(message: "Failed to CREATE LI DIRECTORY \(error)", error: error)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -433,6 +495,8 @@ typealias DownloadProgressHandler = (_ downloadItem: DownloadItem, _ downloadIte
|
||||
struct DownloadItemPartTask {
|
||||
let part: DownloadItemPart
|
||||
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 {
|
||||
|
||||
@@ -23,12 +23,10 @@ public class AbsFileSystem: CAPPlugin, CAPBridgedPlugin {
|
||||
CAPPluginMethod(name: "deleteTrackFromItem", returnType: CAPPluginReturnPromise)
|
||||
]
|
||||
|
||||
private let logger = AppLogger(category: "AbsFileSystem")
|
||||
|
||||
@objc func selectFolder(_ call: CAPPluginCall) {
|
||||
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")
|
||||
}
|
||||
@@ -36,7 +34,7 @@ public class AbsFileSystem: CAPPlugin, CAPBridgedPlugin {
|
||||
@objc func checkFolderPermission(_ call: CAPPluginCall) {
|
||||
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")
|
||||
}
|
||||
@@ -45,7 +43,7 @@ public class AbsFileSystem: CAPPlugin, CAPBridgedPlugin {
|
||||
let folderId = call.getString("folderId")
|
||||
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")
|
||||
}
|
||||
@@ -53,7 +51,7 @@ public class AbsFileSystem: CAPPlugin, CAPBridgedPlugin {
|
||||
@objc func removeFolder(_ call: CAPPluginCall) {
|
||||
let folderId = call.getString("folderId")
|
||||
|
||||
logger.log("removeFolder \(folderId ?? "UNSET")")
|
||||
AbsLogger.info(message: "removeFolder \(folderId ?? "UNSET")")
|
||||
|
||||
call.unavailable("Not available on iOS")
|
||||
}
|
||||
@@ -61,7 +59,7 @@ public class AbsFileSystem: CAPPlugin, CAPBridgedPlugin {
|
||||
@objc func removeLocalLibraryItem(_ call: CAPPluginCall) {
|
||||
let localLibraryItemId = call.getString("localLibraryItemId")
|
||||
|
||||
logger.log("removeLocalLibraryItem \(localLibraryItemId ?? "UNSET")")
|
||||
AbsLogger.info(message: "removeLocalLibraryItem \(localLibraryItemId ?? "UNSET")")
|
||||
|
||||
call.unavailable("Not available on iOS")
|
||||
}
|
||||
@@ -70,7 +68,7 @@ public class AbsFileSystem: CAPPlugin, CAPBridgedPlugin {
|
||||
let localLibraryItemId = call.getString("localLibraryItemId")
|
||||
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")
|
||||
}
|
||||
@@ -79,7 +77,7 @@ public class AbsFileSystem: CAPPlugin, CAPBridgedPlugin {
|
||||
let localLibraryItemId = call.getString("id")
|
||||
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
|
||||
do {
|
||||
@@ -89,7 +87,7 @@ public class AbsFileSystem: CAPPlugin, CAPBridgedPlugin {
|
||||
success = true
|
||||
}
|
||||
} catch {
|
||||
logger.error("Failed to delete \(error)")
|
||||
AbsLogger.error(message: "Failed to delete \(error)")
|
||||
success = false
|
||||
}
|
||||
|
||||
@@ -100,7 +98,7 @@ public class AbsFileSystem: CAPPlugin, CAPBridgedPlugin {
|
||||
let localLibraryItemId = call.getString("id")
|
||||
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
|
||||
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
|
||||
}
|
||||
} catch {
|
||||
logger.error("Failed to delete \(error)")
|
||||
AbsLogger.error(message: "Failed to delete \(error)")
|
||||
success = false
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
logger.error("Failed to delete \(error)")
|
||||
AbsLogger.error(message: "Failed to delete \(error)")
|
||||
success = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,16 @@
|
||||
import Foundation
|
||||
import Capacitor
|
||||
|
||||
enum AbsLogLevel {
|
||||
case info
|
||||
case warn
|
||||
case error
|
||||
}
|
||||
|
||||
func fileName(file: String = #file) -> String {
|
||||
return (file as NSString).lastPathComponent
|
||||
}
|
||||
|
||||
@objc(AbsLogger)
|
||||
public class AbsLogger: CAPPlugin, CAPBridgedPlugin {
|
||||
public var identifier = "AbsLoggerPlugin"
|
||||
@@ -19,29 +29,96 @@ public class AbsLogger: CAPPlugin, CAPBridgedPlugin {
|
||||
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) {
|
||||
let message = call.getString("message") ?? ""
|
||||
let tag = call.getString("tag") ?? ""
|
||||
|
||||
logger.log("[\(tag)] \(message)")
|
||||
call.resolve()
|
||||
|
||||
do {
|
||||
try info(tag: tag, message: message)
|
||||
call.resolve()
|
||||
} catch {
|
||||
call.reject("Failed to log \(message)", "101", error)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@objc func error(_ call: CAPPluginCall) {
|
||||
let message = call.getString("message") ?? ""
|
||||
let tag = call.getString("tag") ?? ""
|
||||
|
||||
logger.error("[\(tag)] \(message)")
|
||||
call.resolve()
|
||||
|
||||
do {
|
||||
try error(tag: tag, message: message)
|
||||
call.resolve()
|
||||
} catch {
|
||||
call.reject("Failed to log \(message)", "101", error)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@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) {
|
||||
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
|
||||
- CordovaPlugins (6.2.1):
|
||||
- CapacitorCordova
|
||||
- Realm (10.54.4):
|
||||
- Realm/Headers (= 10.54.4)
|
||||
- Realm/Headers (10.54.4)
|
||||
- RealmSwift (10.54.4):
|
||||
- Realm (= 10.54.4)
|
||||
- Realm (10.54.6):
|
||||
- Realm/Headers (= 10.54.6)
|
||||
- Realm/Headers (10.54.6)
|
||||
- RealmSwift (10.54.6):
|
||||
- Realm (= 10.54.6)
|
||||
- WebnativellcCapacitorFilesharer (7.0.4):
|
||||
- Capacitor
|
||||
|
||||
@@ -89,22 +89,22 @@ EXTERNAL SOURCES:
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
Alamofire: 7193b3b92c74a07f85569e1a6c4f4237291e7496
|
||||
Capacitor: 106e7a4205f4618d582b886a975657c61179138d
|
||||
CapacitorApp: d63334c052278caf5d81585d80b21905c6f93f39
|
||||
CapacitorBrowser: 081852cf532acf77b9d2953f3a88fe5b9711fb06
|
||||
CapacitorClipboard: b98aead5dc7ec595547fc2c5d75bacd2ae3338bc
|
||||
CapacitorCommunityKeepAwake: 00dfd8fa3cca0df003c9a3e2cd7bee678aeec68b
|
||||
CapacitorCommunityVolumeButtons: 8a0443a202ed659688d85f4d44d66f42f62f2b56
|
||||
Capacitor: 03bc7cbdde6a629a8b910a9d7d78c3cc7ed09ea7
|
||||
CapacitorApp: febecbb9582cb353aed037e18ec765141f880fe9
|
||||
CapacitorBrowser: 6299776d496e968505464884d565992faa20444a
|
||||
CapacitorClipboard: 70bfdb42b877b320a6e511ab94fa7a6a55d57ecb
|
||||
CapacitorCommunityKeepAwake: ae762ce29b53147d28cfcaae5273cd1db0c38fc4
|
||||
CapacitorCommunityVolumeButtons: 1b84f7abf29cd9476cef9e8979b2854a64d2eed5
|
||||
CapacitorCordova: 5967b9ba03915ef1d585469d6e31f31dc49be96f
|
||||
CapacitorDialog: 9b934329026b2b0ffa56939bb06df3c67541a2ab
|
||||
CapacitorHaptics: 70e47470fa1a6bd6338cd102552e3846b7f9a1b3
|
||||
CapacitorNetwork: 07ec4c69c1bb696f41c23e00d31bda1bbb221bba
|
||||
CapacitorPreferences: cbf154e5e5519b7f5ab33817a334dda1e98387f9
|
||||
CapacitorStatusBar: 275cbf2f4dfc00388f519ef80c7ec22edda342c9
|
||||
CordovaPlugins: 5a72a85b45469e68556bb172409f1b6d57b27236
|
||||
Realm: 8b5cda39a41f17a1734da2f39c6004eb8745587a
|
||||
RealmSwift: 0b4f808fed6898f1f6c26f501f740efd80dff0b4
|
||||
WebnativellcCapacitorFilesharer: 10b111373d4dc49608935600dcbcc14605258c73
|
||||
CapacitorDialog: 0e09f242f6c3f5e82e4dc76b20f2a056be57a579
|
||||
CapacitorHaptics: 1f1e17041f435d8ead9ff2a34edd592c6aa6a8d6
|
||||
CapacitorNetwork: 15cb4385f0913a8ceb5e9a4d7af1ec554bdb8de8
|
||||
CapacitorPreferences: 6c98117d4d7508034a4af9db64d6b26fc75d7b94
|
||||
CapacitorStatusBar: 6e7af040d8fc4dd655999819625cae9c2d74c36f
|
||||
CordovaPlugins: 2ecbba09775516c41764dbf78ade612427311b7e
|
||||
Realm: b1b3bc68162fa242132eb7eefbf91d7c40f36a85
|
||||
RealmSwift: 456cfd82a4f23dff8e3456980999331ab69bbf3e
|
||||
WebnativellcCapacitorFilesharer: e3a5930240633db3335040251d66aac6762ff111
|
||||
|
||||
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 {
|
||||
internal let queue = DispatchQueue(label: "ABSAudioPlayerQueue")
|
||||
internal let logger = AppLogger(category: "AudioPlayer")
|
||||
|
||||
private var status: PlayerStatus
|
||||
internal var rateManager: AudioPlayerRateManager
|
||||
@@ -81,7 +80,7 @@ class AudioPlayer: NSObject {
|
||||
|
||||
let playbackSession = self.getPlaybackSession()
|
||||
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)
|
||||
return
|
||||
}
|
||||
@@ -115,10 +114,10 @@ class AudioPlayer: NSObject {
|
||||
}
|
||||
|
||||
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]
|
||||
logger.log("Setting player items \(playerItems.count)")
|
||||
AbsLogger.info(message:"Setting player items \(playerItems.count)")
|
||||
|
||||
for item in Array(playerItems) {
|
||||
self.audioPlayer.insert(item, after:self.audioPlayer.items().last)
|
||||
@@ -128,7 +127,7 @@ class AudioPlayer: NSObject {
|
||||
setupQueueObserver()
|
||||
setupQueueItemStatusObserver()
|
||||
|
||||
logger.log("Audioplayer ready")
|
||||
AbsLogger.info(message:"Audioplayer ready")
|
||||
}
|
||||
|
||||
deinit {
|
||||
@@ -146,8 +145,7 @@ class AudioPlayer: NSObject {
|
||||
do {
|
||||
try AVAudioSession.sharedInstance().setActive(false)
|
||||
} catch {
|
||||
logger.error("Failed to set AVAudioSession inactive")
|
||||
logger.error(error)
|
||||
AbsLogger.error(message: "Failed to set AVAudioSession inactive", error: error)
|
||||
}
|
||||
|
||||
self.removeAudioSessionNotifications()
|
||||
@@ -253,14 +251,14 @@ class AudioPlayer: NSObject {
|
||||
self.audioPlayer.currentItem.map { item in
|
||||
self.currentTrackIndex = self.allPlayerItems.firstIndex(of:item) ?? 0
|
||||
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() {
|
||||
logger.log("queueStatusObserver: Setting up")
|
||||
AbsLogger.info(message:"queueStatusObserver: Setting up")
|
||||
|
||||
// Listen for player item updates
|
||||
self.queueItemStatusObserver?.invalidate()
|
||||
@@ -275,13 +273,13 @@ class AudioPlayer: NSObject {
|
||||
}
|
||||
|
||||
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 {
|
||||
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.failed.rawValue), object: nil)
|
||||
return
|
||||
}
|
||||
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
|
||||
let firstReady = self.status == .uninitialized
|
||||
@@ -300,7 +298,7 @@ class AudioPlayer: NSObject {
|
||||
self.status = .paused
|
||||
}
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
@@ -332,7 +330,7 @@ class AudioPlayer: NSObject {
|
||||
}
|
||||
|
||||
private func resumePlayback() {
|
||||
logger.log("PLAY: Resuming playback")
|
||||
AbsLogger.info(message:"PLAY: Resuming playback")
|
||||
|
||||
self.markAudioSessionAs(active: true)
|
||||
DispatchQueue.runOnMainQueue {
|
||||
@@ -351,7 +349,7 @@ class AudioPlayer: NSObject {
|
||||
public func pause() {
|
||||
guard self.isInitialized() else { return }
|
||||
|
||||
logger.log("PAUSE: Pausing playback")
|
||||
AbsLogger.info(message:"PAUSE: Pausing playback")
|
||||
DispatchQueue.runOnMainQueue {
|
||||
self.audioPlayer.pause()
|
||||
}
|
||||
@@ -371,7 +369,7 @@ class AudioPlayer: NSObject {
|
||||
public func startFadeOut() {
|
||||
guard self.isInitialized() 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.
|
||||
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.
|
||||
self.audioPlayer.volume = targetVolume
|
||||
t.invalidate()
|
||||
self.logger.log("Fadeout: Fade complete, pausing playback")
|
||||
AbsLogger.info(message:"Fadeout: Fade complete, pausing playback")
|
||||
self.pause()
|
||||
self.audioPlayer.volume = initialVolume
|
||||
self.seek(currentTime, from: "fadeOut")
|
||||
@@ -419,12 +417,12 @@ class AudioPlayer: NSObject {
|
||||
}
|
||||
|
||||
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 }
|
||||
|
||||
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 {
|
||||
self.currentTrackIndex = indexOfSeek
|
||||
@@ -488,16 +486,16 @@ class AudioPlayer: NSObject {
|
||||
let currentTrack = playbackSession.audioTracks[self.currentTrackIndex]
|
||||
let ctso = currentTrack.startOffset ?? 0.0
|
||||
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 seekTime = boundedTime - ctso
|
||||
|
||||
DispatchQueue.runOnMainQueue {
|
||||
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 {
|
||||
self?.logger.log("SEEK: WARNING: seeking not completed (to \(seekTime)")
|
||||
AbsLogger.info(message:"SEEK: WARNING: seeking not completed (to \(seekTime)")
|
||||
return
|
||||
}
|
||||
guard let self = self else { return }
|
||||
@@ -574,7 +572,7 @@ class AudioPlayer: NSObject {
|
||||
} else if (playbackSession.playMethod == PlayMethod.local.rawValue) {
|
||||
guard let localFile = track.getLocalFile() else {
|
||||
// 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 url = URL(string: urlstr)!
|
||||
return AVURLAsset(url: url)
|
||||
@@ -594,8 +592,7 @@ class AudioPlayer: NSObject {
|
||||
do {
|
||||
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio)
|
||||
} catch {
|
||||
logger.error("Failed to set AVAudioSession category")
|
||||
logger.error(error)
|
||||
AbsLogger.error(message: "Failed to set AVAudioSession category", error: error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -603,7 +600,7 @@ class AudioPlayer: NSObject {
|
||||
do {
|
||||
try AVAudioSession.sharedInstance().setActive(active)
|
||||
} 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 reason = AVAudioSession.InterruptionReason(rawValue: reasonValue)
|
||||
if (reason == .appWasSuspended) {
|
||||
logger.log("AVAudioSession was suspended")
|
||||
AbsLogger.info(message:"AVAudioSession was suspended")
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -812,14 +809,14 @@ class AudioPlayer: NSObject {
|
||||
if context == &playerContext {
|
||||
if keyPath == #keyPath(AVPlayer.currentItem) {
|
||||
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 the queue is rebuilding, we expect the current item may be nil
|
||||
if self.isRebuildingQueue {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ extension AudioPlayer {
|
||||
}
|
||||
|
||||
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.sleepTimeRemaining = secondsUntilSleep
|
||||
|
||||
@@ -53,7 +53,7 @@ extension AudioPlayer {
|
||||
guard let currentTime = self.getCurrentTime() 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
|
||||
self.sleepTimeChapterStopAt = stopAt
|
||||
@@ -138,7 +138,7 @@ extension AudioPlayer {
|
||||
}
|
||||
|
||||
private func handleSleepEnd() {
|
||||
logger.log("SLEEP TIMER: Pausing audio")
|
||||
AbsLogger.info(message: "SLEEP TIMER: Pausing audio")
|
||||
self.pause()
|
||||
self.removeSleepTimer()
|
||||
}
|
||||
|
||||
@@ -10,8 +10,7 @@ import AVFoundation
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
class DefaultedAudioPlayerRateManager: NSObject, AudioPlayerRateManager {
|
||||
internal let logger = AppLogger(category: "DefaultedAudioPlayerRateManager")
|
||||
|
||||
|
||||
internal var audioPlayer: AVPlayer
|
||||
|
||||
// MARK: - AudioPlayerRateManager
|
||||
|
||||
@@ -9,7 +9,6 @@ import Foundation
|
||||
import AVFoundation
|
||||
|
||||
class LegacyAudioPlayerRateManager: NSObject, AudioPlayerRateManager {
|
||||
internal let logger = AppLogger(category: "AudioPlayer")
|
||||
|
||||
internal var audioPlayer: AVPlayer
|
||||
|
||||
@@ -54,7 +53,7 @@ class LegacyAudioPlayerRateManager: NSObject, AudioPlayerRateManager {
|
||||
let playbackSpeedChanged = rate > 0.0 && rate != self.defaultRate && !(observed && rate == 1)
|
||||
|
||||
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 {
|
||||
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?) {
|
||||
if context == &managerContext {
|
||||
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)
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -14,8 +14,6 @@ class PlayerProgress {
|
||||
|
||||
private static var TIME_BETWEEN_SESSION_SYNC_IN_SECONDS = 15.0
|
||||
|
||||
private let logger = AppLogger(category: "PlayerProgress")
|
||||
|
||||
private init() {}
|
||||
|
||||
|
||||
@@ -30,8 +28,7 @@ class PlayerProgress {
|
||||
try await updateServerSessionFromLocalSession(session, rateLimitSync: !isStopping)
|
||||
}
|
||||
} catch {
|
||||
logger.error("Failed to syncFromPlayer")
|
||||
logger.error(error)
|
||||
AbsLogger.error(message: "Failed to syncFromPlayer \(error)", error: error)
|
||||
}
|
||||
await UIApplication.shared.endBackgroundTask(backgroundToken)
|
||||
}
|
||||
@@ -76,7 +73,7 @@ class PlayerProgress {
|
||||
|
||||
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
|
||||
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.localProgress.rawValue), object: nil)
|
||||
@@ -126,7 +123,7 @@ class PlayerProgress {
|
||||
session = session.freeze()
|
||||
|
||||
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
|
||||
if session.isLocal {
|
||||
@@ -155,25 +152,25 @@ class PlayerProgress {
|
||||
|
||||
// TODO: Unused for now
|
||||
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: {
|
||||
$0.isActiveSession == true && $0.serverConnectionConfigId == Store.serverConfig?.id
|
||||
})?.freeze() else {
|
||||
logger.log("updateLocalSessionFromServerMediaProgress: Failed to get session")
|
||||
AbsLogger.info(message:"updateLocalSessionFromServerMediaProgress: Failed to get session")
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch the current progress
|
||||
let progress = await ApiClient.getMediaProgress(libraryItemId: session.libraryItemId!, episodeId: session.episodeId)
|
||||
guard let progress = progress else {
|
||||
logger.log("updateLocalSessionFromServerMediaProgress: No progress object")
|
||||
AbsLogger.info(message:"updateLocalSessionFromServerMediaProgress: No progress object")
|
||||
return
|
||||
}
|
||||
|
||||
// Determine which session is newer
|
||||
let serverLastUpdate = progress.lastUpdate
|
||||
guard let localLastUpdate = session.updatedAt else {
|
||||
logger.log("updateLocalSessionFromServerMediaProgress: No local session updatedAt")
|
||||
AbsLogger.info(message:"updateLocalSessionFromServerMediaProgress: No local session updatedAt")
|
||||
return
|
||||
}
|
||||
let serverCurrentTime = progress.currentTime
|
||||
@@ -184,16 +181,16 @@ class PlayerProgress {
|
||||
|
||||
// Update the session, if needed
|
||||
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 }
|
||||
try session.update {
|
||||
session.currentTime = serverCurrentTime
|
||||
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)
|
||||
} 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
|
||||
|
||||
class ApiClient {
|
||||
private static let logger = AppLogger(category: "ApiClient")
|
||||
private static let secureStorage = SecureStorage()
|
||||
|
||||
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)?) {
|
||||
if (Store.serverConfig == nil) {
|
||||
logger.error("Server config not set")
|
||||
AbsLogger.error(message: "Server config not set")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -35,7 +34,7 @@ class ApiClient {
|
||||
case .success(let obj):
|
||||
callback?(obj)
|
||||
case .failure(let error):
|
||||
logger.error("api request to \(endpoint) failed")
|
||||
AbsLogger.error(message: "api request to \(endpoint) failed")
|
||||
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)?) {
|
||||
if (Store.serverConfig == nil) {
|
||||
logger.error("Server config not set")
|
||||
AbsLogger.error(message: "Server config not set")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -56,7 +55,7 @@ class ApiClient {
|
||||
case .success(let obj):
|
||||
callback?(obj)
|
||||
case .failure(let error):
|
||||
logger.error("api request to \(endpoint) failed")
|
||||
AbsLogger.error(message: "api request to \(endpoint) failed")
|
||||
print(error)
|
||||
}
|
||||
}
|
||||
@@ -72,7 +71,7 @@ class ApiClient {
|
||||
|
||||
public static func postResource<T:Encodable>(endpoint: String, parameters: T, callback: ((_ success: Bool) -> Void)?) {
|
||||
if (Store.serverConfig == nil) {
|
||||
logger.error("Server config not set")
|
||||
AbsLogger.error(message: "Server config not set")
|
||||
callback?(false)
|
||||
return
|
||||
}
|
||||
@@ -86,7 +85,7 @@ class ApiClient {
|
||||
case .success(_):
|
||||
callback?(true)
|
||||
case .failure(let error):
|
||||
logger.error("api request to \(endpoint) failed")
|
||||
AbsLogger.error(message: "api request to \(endpoint) failed")
|
||||
print(error)
|
||||
|
||||
callback?(false)
|
||||
@@ -96,7 +95,7 @@ class ApiClient {
|
||||
|
||||
public static func patchResource<T: Encodable>(endpoint: String, parameters: T, callback: ((_ success: Bool) -> Void)?) {
|
||||
if (Store.serverConfig == nil) {
|
||||
logger.error("Server config not set")
|
||||
AbsLogger.error(message: "Server config not set")
|
||||
callback?(false)
|
||||
return
|
||||
}
|
||||
@@ -110,7 +109,7 @@ class ApiClient {
|
||||
case .success(_):
|
||||
callback?(true)
|
||||
case .failure(let error):
|
||||
logger.error("api request to \(endpoint) failed")
|
||||
AbsLogger.error(message: "api request to \(endpoint) failed")
|
||||
print(error)
|
||||
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)?) {
|
||||
if (Store.serverConfig == nil) {
|
||||
logger.error("Server config not set")
|
||||
AbsLogger.error(message: "Server config not set")
|
||||
callback?(nil)
|
||||
return
|
||||
}
|
||||
@@ -141,7 +140,7 @@ class ApiClient {
|
||||
case .success(let obj):
|
||||
callback?(obj)
|
||||
case .failure(let error):
|
||||
logger.error("api request to \(endpoint) failed")
|
||||
AbsLogger.error(message: "api request to \(endpoint) failed")
|
||||
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)?) {
|
||||
guard let serverConfig = Store.serverConfig else {
|
||||
logger.error("handleTokenRefresh: No server config available")
|
||||
AbsLogger.error(message: "handleTokenRefresh: No server config available")
|
||||
callback?(nil)
|
||||
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
|
||||
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()
|
||||
callback?(nil)
|
||||
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
|
||||
let refreshHeaders: HTTPHeaders = [
|
||||
@@ -190,23 +189,23 @@ class ApiClient {
|
||||
case .success(let refreshResponse):
|
||||
guard let user = refreshResponse.user,
|
||||
!user.accessToken.isEmpty else {
|
||||
logger.error("handleTokenRefresh: No access token in refresh response for server \(serverConfig.name)")
|
||||
AbsLogger.error(message: "handleTokenRefresh: No access token in refresh response for server \(serverConfig.name)")
|
||||
handleRefreshFailure()
|
||||
callback?(nil)
|
||||
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
|
||||
updateTokens(newAccessToken: user.accessToken, newRefreshToken: user.refreshToken ?? refreshToken, serverConnectionConfigId: serverConfig.id)
|
||||
|
||||
// Retry the original request with the new access token
|
||||
logger.log("handleTokenRefresh: Retrying original request with new token")
|
||||
AbsLogger.info(message: "handleTokenRefresh: Retrying original request with new token")
|
||||
retryOriginalRequest(endpoint: endpoint, method: method, parameters: parameters, decodable: decodable, newAccessToken: user.accessToken, callback: callback)
|
||||
|
||||
case .failure(let error):
|
||||
logger.error("handleTokenRefresh: Refresh request failed for server \(serverConfig.name): \(error)")
|
||||
AbsLogger.error(message: "handleTokenRefresh: Refresh request failed for server \(serverConfig.name): \(error)")
|
||||
handleRefreshFailure()
|
||||
callback?(nil)
|
||||
}
|
||||
@@ -220,14 +219,14 @@ class ApiClient {
|
||||
// Update the refresh token in secure storage if it's new
|
||||
if newRefreshToken != secureStorage.getRefreshToken(serverConnectionConfigId: serverConnectionConfigId) {
|
||||
let hasStored = secureStorage.storeRefreshToken(serverConnectionConfigId: serverConnectionConfigId, refreshToken: newRefreshToken)
|
||||
logger.log("updateTokens: Updated refresh token in secure storage. Stored=\(hasStored)")
|
||||
AbsLogger.info(message: "updateTokens: Updated refresh token in secure storage. Stored=\(hasStored)")
|
||||
}
|
||||
|
||||
// Update access token on server connection config
|
||||
Database.shared.updateServerConnectionConfigToken(newToken: newAccessToken)
|
||||
logger.log("updateTokens: Updated access token in server connection config")
|
||||
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
|
||||
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)?) {
|
||||
guard let serverConfig = Store.serverConfig else {
|
||||
logger.error("retryOriginalRequest: No server config available")
|
||||
AbsLogger.error(message: "retryOriginalRequest: No server config available")
|
||||
callback?(nil)
|
||||
return
|
||||
}
|
||||
@@ -270,7 +269,7 @@ class ApiClient {
|
||||
retryRequest = AF.request("\(serverConfig.address)/\(endpoint)", method: .patch, headers: headers)
|
||||
}
|
||||
default:
|
||||
logger.error("retryOriginalRequest: Unsupported method \(method)")
|
||||
AbsLogger.error(message: "retryOriginalRequest: Unsupported method \(method)")
|
||||
callback?(nil)
|
||||
return
|
||||
}
|
||||
@@ -282,7 +281,7 @@ class ApiClient {
|
||||
if let data = response.data, !data.isEmpty {
|
||||
// If it is a string return nil (e.g. express returns OK for 200 status codes)
|
||||
if let responseString = String(data: data, encoding: .utf8) {
|
||||
logger.log("retryOriginalRequest: Got string response '\(responseString)'")
|
||||
AbsLogger.info(message: "retryOriginalRequest: Got string response '\(responseString)'")
|
||||
callback?(nil)
|
||||
return
|
||||
}
|
||||
@@ -292,16 +291,16 @@ class ApiClient {
|
||||
let decodedObject = try JSONDecoder().decode(decodable, from: data)
|
||||
callback?(decodedObject)
|
||||
} catch {
|
||||
logger.error("retryOriginalRequest: JSON decode failed: \(error)")
|
||||
AbsLogger.error(message: "retryOriginalRequest: JSON decode failed: \(error)", error: error)
|
||||
callback?(nil)
|
||||
}
|
||||
} else {
|
||||
// Empty response
|
||||
logger.log("retryOriginalRequest: Empty response with success status \(statusCode)")
|
||||
AbsLogger.info(message: "retryOriginalRequest: Empty response with success status \(statusCode)")
|
||||
callback?(nil)
|
||||
}
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
@@ -312,7 +311,7 @@ class ApiClient {
|
||||
* This will clear the current server connection and notify webview
|
||||
*/
|
||||
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
|
||||
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)?) {
|
||||
if (Store.serverConfig == nil) {
|
||||
logger.error("Server config not set")
|
||||
AbsLogger.error(message: "Server config not set")
|
||||
callback?(nil)
|
||||
return
|
||||
}
|
||||
@@ -345,14 +344,14 @@ class ApiClient {
|
||||
|
||||
request.responseDecodable(of: decodable) { response in
|
||||
if let statusCode = response.response?.statusCode, statusCode == 401 {
|
||||
logger.log("getResourceWithTokenRefresh: 401 Unauthorized for request to \(endpoint) - attempting token refresh")
|
||||
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)
|
||||
} else {
|
||||
switch response.result {
|
||||
case .success(let obj):
|
||||
callback?(obj)
|
||||
case .failure(let error):
|
||||
logger.error("api request to \(endpoint) failed")
|
||||
AbsLogger.error(message: "api request to \(endpoint) failed")
|
||||
print(error)
|
||||
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)?) {
|
||||
if (Store.serverConfig == nil) {
|
||||
logger.error("Server config not set")
|
||||
AbsLogger.error(message: "Server config not set")
|
||||
callback?(nil)
|
||||
return
|
||||
}
|
||||
@@ -375,14 +374,14 @@ class ApiClient {
|
||||
|
||||
request.responseDecodable(of: decodable) { response in
|
||||
if let statusCode = response.response?.statusCode, statusCode == 401 {
|
||||
logger.log("postResourceWithTokenRefresh: 401 Unauthorized for request to \(endpoint) - attempting token refresh")
|
||||
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)
|
||||
} else {
|
||||
switch response.result {
|
||||
case .success(let obj):
|
||||
callback?(obj)
|
||||
case .failure(let error):
|
||||
logger.error("api request to \(endpoint) failed")
|
||||
AbsLogger.error(message: "api request to \(endpoint) failed")
|
||||
print(error)
|
||||
callback?(nil)
|
||||
}
|
||||
@@ -395,7 +394,7 @@ class ApiClient {
|
||||
*/
|
||||
public static func postResourceWithTokenRefresh<T: Encodable>(endpoint: String, parameters: T, callback: ((_ success: Bool) -> Void)?) {
|
||||
if (Store.serverConfig == nil) {
|
||||
logger.error("Server config not set")
|
||||
AbsLogger.error(message: "Server config not set")
|
||||
callback?(false)
|
||||
return
|
||||
}
|
||||
@@ -408,7 +407,7 @@ class ApiClient {
|
||||
|
||||
request.response { response in
|
||||
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
|
||||
callback?(result != nil)
|
||||
}
|
||||
@@ -417,7 +416,7 @@ class ApiClient {
|
||||
case .success(_):
|
||||
callback?(true)
|
||||
case .failure(let error):
|
||||
logger.error("api request to \(endpoint) failed")
|
||||
AbsLogger.error(message: "api request to \(endpoint) failed")
|
||||
print(error)
|
||||
callback?(false)
|
||||
}
|
||||
@@ -427,7 +426,7 @@ class ApiClient {
|
||||
|
||||
public static func patchResourceWithTokenRefresh<T: Encodable>(endpoint: String, parameters: T, callback: ((_ success: Bool) -> Void)?) {
|
||||
if (Store.serverConfig == nil) {
|
||||
logger.error("Server config not set")
|
||||
AbsLogger.error(message: "Server config not set")
|
||||
callback?(false)
|
||||
return
|
||||
}
|
||||
@@ -440,7 +439,7 @@ class ApiClient {
|
||||
|
||||
request.response { response in
|
||||
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
|
||||
callback?(result != nil)
|
||||
}
|
||||
@@ -449,7 +448,7 @@ class ApiClient {
|
||||
case .success(_):
|
||||
callback?(true)
|
||||
case .failure(let error):
|
||||
logger.error("api request to \(endpoint) failed")
|
||||
AbsLogger.error(message: "api request to \(endpoint) failed")
|
||||
print(error)
|
||||
callback?(false)
|
||||
}
|
||||
@@ -489,7 +488,7 @@ class ApiClient {
|
||||
// Use the new token refresh-enabled method
|
||||
postResourceWithTokenRefresh(endpoint: endpoint, parameters: parameters, decodable: PlaybackSession.self) { session in
|
||||
guard let session = session else {
|
||||
logger.error("startPlaybackSession: Failed to create playback session")
|
||||
AbsLogger.error(message: "startPlaybackSession: Failed to create playback session")
|
||||
callback(PlaybackSession()) // Return empty session on failure
|
||||
return
|
||||
}
|
||||
@@ -533,14 +532,14 @@ class ApiClient {
|
||||
let localMediaProgressList = Database.shared.getAllLocalMediaProgress().filter {
|
||||
$0.serverConnectionConfigId == Store.serverConfig?.id
|
||||
}.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) {
|
||||
logger.log("syncLocalSessionsWithServer: No local progress to sync")
|
||||
AbsLogger.info(message: "syncLocalSessionsWithServer: No local progress to sync")
|
||||
} else {
|
||||
let currentUser = await ApiClient.getCurrentUser()
|
||||
guard let currentUser = currentUser else {
|
||||
logger.log("syncLocalSessionsWithServer: No User")
|
||||
AbsLogger.info(message: "syncLocalSessionsWithServer: No User")
|
||||
return
|
||||
}
|
||||
try currentUser.mediaProgress.forEach { mediaProgress in
|
||||
@@ -552,12 +551,12 @@ class ApiClient {
|
||||
}
|
||||
}
|
||||
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() {
|
||||
try localMediaProgress.updateFromServerMediaProgress(mediaProgress)
|
||||
}
|
||||
} 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 {
|
||||
$0.serverConnectionConfigId == Store.serverConfig?.id
|
||||
}.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) {
|
||||
let success = await ApiClient.reportAllLocalPlaybackSessions(playbackSessions)
|
||||
if (success) {
|
||||
// Remove sessions from db
|
||||
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
|
||||
if (!session.isActiveSession || isFirstSync) {
|
||||
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) {
|
||||
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 ?? "")"
|
||||
patchResourceWithTokenRefresh(endpoint: endpoint, parameters: payload) { _ in
|
||||
callback()
|
||||
@@ -597,7 +596,7 @@ class ApiClient {
|
||||
}
|
||||
|
||||
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 ?? "")"
|
||||
return await withCheckedContinuation { continuation in
|
||||
getResourceWithTokenRefresh(endpoint: endpoint, decodable: MediaProgress.self) { result in
|
||||
@@ -607,7 +606,7 @@ class ApiClient {
|
||||
}
|
||||
|
||||
public static func getCurrentUser() async -> User? {
|
||||
logger.log("getCurrentUser")
|
||||
AbsLogger.info(message: "getCurrentUser")
|
||||
return await withCheckedContinuation { continuation in
|
||||
getResourceWithTokenRefresh(endpoint: "api/me", decodable: User.self) { result in
|
||||
continuation.resume(returning: result)
|
||||
|
||||
@@ -12,10 +12,14 @@ class Database {
|
||||
public static var shared = {
|
||||
return Database()
|
||||
}()
|
||||
|
||||
private let logger = AppLogger(category: "Database")
|
||||
|
||||
private init() {}
|
||||
private init() {
|
||||
do {
|
||||
try cleanExpiredLogs()
|
||||
} catch {
|
||||
debugPrint(error)
|
||||
}
|
||||
}
|
||||
|
||||
public func setServerConnectionConfig(config: ServerConnectionConfig) {
|
||||
let config = config
|
||||
@@ -33,7 +37,7 @@ class Database {
|
||||
existing.token = config.token
|
||||
}
|
||||
} catch {
|
||||
logger.error("failed to update server config")
|
||||
AbsLogger.error("setServerConn", message: "failed to update server config")
|
||||
debugPrint(error)
|
||||
}
|
||||
|
||||
@@ -54,7 +58,7 @@ class Database {
|
||||
realm.add(config)
|
||||
}
|
||||
} catch(let exception) {
|
||||
logger.error("failed to save server config")
|
||||
AbsLogger.error(message: "failed to save server config")
|
||||
debugPrint(exception)
|
||||
}
|
||||
|
||||
@@ -86,7 +90,7 @@ class Database {
|
||||
}
|
||||
}
|
||||
} catch(let exception) {
|
||||
logger.error("failed to delete server config")
|
||||
AbsLogger.error(message: "failed to delete server config")
|
||||
debugPrint(exception)
|
||||
}
|
||||
}
|
||||
@@ -117,7 +121,7 @@ class Database {
|
||||
}
|
||||
}
|
||||
} catch(let exception) {
|
||||
logger.error("failed to save server config active index")
|
||||
AbsLogger.error(message: "failed to save server config active index")
|
||||
debugPrint(exception)
|
||||
}
|
||||
}
|
||||
@@ -137,7 +141,7 @@ class Database {
|
||||
realm.add(deviceSettings)
|
||||
}
|
||||
} catch {
|
||||
logger.error("failed to save device settings")
|
||||
AbsLogger.error(message: "failed to save device settings")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,4 +279,54 @@ class Database {
|
||||
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)
|
||||
return try JSONDecoder().decode(type, from: json)
|
||||
} catch {
|
||||
AppLogger().error("Failed to get json for \(key)")
|
||||
debugPrint(error)
|
||||
AbsLogger.error(message: "Failed to get json for \(key)", error: error)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -106,3 +105,80 @@ extension URL {
|
||||
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,
|
||||
hasMounted: false,
|
||||
disconnectTime: 0,
|
||||
socketDisconnectedTime: 0,
|
||||
timeLostFocus: 0,
|
||||
currentLang: null
|
||||
}
|
||||
@@ -44,7 +45,7 @@ export default {
|
||||
} else {
|
||||
var timeSinceDisconnect = Date.now() - this.disconnectTime
|
||||
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(() => {
|
||||
this.syncLocalSessions(false)
|
||||
}, 4000)
|
||||
@@ -55,6 +56,28 @@ export default {
|
||||
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: {
|
||||
@@ -67,6 +90,9 @@ export default {
|
||||
networkConnected() {
|
||||
return this.$store.state.networkConnected
|
||||
},
|
||||
socketConnected() {
|
||||
return this.$store.state.socketConnected
|
||||
},
|
||||
user() {
|
||||
return this.$store.state.user.user
|
||||
},
|
||||
|
||||
@@ -33,7 +33,7 @@ export default {
|
||||
this.$strings.MessageConfirmStreamingUsingCellular
|
||||
|
||||
const { value } = await Dialog.confirm({
|
||||
title: 'Confirm',
|
||||
title: this.$strings.HeaderConfirm,
|
||||
message
|
||||
})
|
||||
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",
|
||||
"version": "0.10.1-beta",
|
||||
"version": "0.13.0-beta",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "audiobookshelf-app",
|
||||
"version": "0.10.1-beta",
|
||||
"version": "0.13.0-beta",
|
||||
"dependencies": {
|
||||
"@capacitor-community/keep-awake": "^7.0.0",
|
||||
"@capacitor-community/volume-buttons": "^7.0.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf-app",
|
||||
"version": "0.10.1-beta",
|
||||
"version": "0.13.0-beta",
|
||||
"author": "advplyr",
|
||||
"scripts": {
|
||||
"dev": "nuxt --hostname 0.0.0.0 --port 1337",
|
||||
|
||||
@@ -74,6 +74,12 @@ export default {
|
||||
console.log(`[categories] networkConnected false so fetching categories`)
|
||||
this.fetchCategories()
|
||||
}
|
||||
},
|
||||
user(newVal, oldVal) {
|
||||
if ((newVal && !oldVal) || (!newVal && oldVal)) {
|
||||
console.log(`[categories] user changed so fetching categories`)
|
||||
this.fetchCategories()
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
|
||||
@@ -148,7 +148,7 @@ export default {
|
||||
|
||||
// Show confirmation dialog and start downloading if user chooses so
|
||||
const { value } = await Dialog.confirm({
|
||||
title: 'Confirm',
|
||||
title: this.$strings.HeaderConfirm,
|
||||
message: startDownloadMessage
|
||||
})
|
||||
if (value) {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<span v-else class="font-semibold text-fg">{{ Math.round(itemPart.progress) }}%</span>
|
||||
</div>
|
||||
<div class="flex-grow px-2">
|
||||
<p class="truncate">{{ itemPart.filename }}</p>
|
||||
<p class="break-all">{{ itemPart.filename }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -346,7 +346,7 @@ export default {
|
||||
}
|
||||
|
||||
const { value } = await Dialog.confirm({
|
||||
title: 'Confirm',
|
||||
title: this.$strings.HeaderConfirm,
|
||||
message: this.$getString('MessageConfirmDeleteLocalEpisode', [localFile.basePath])
|
||||
})
|
||||
if (value) {
|
||||
@@ -514,7 +514,7 @@ export default {
|
||||
await this.$hapticsImpact()
|
||||
|
||||
const { value } = await Dialog.confirm({
|
||||
title: 'Confirm',
|
||||
title: this.$strings.HeaderConfirm,
|
||||
message: this.$strings.MessageConfirmDiscardProgress
|
||||
})
|
||||
if (value) {
|
||||
@@ -575,8 +575,8 @@ export default {
|
||||
await this.$hapticsImpact()
|
||||
|
||||
const { value } = await Dialog.confirm({
|
||||
title: 'Confirm',
|
||||
message: `Are you sure you want to delete episode "${this.title}" from the server?\nWarning: This will delete the audio file.`
|
||||
title: this.$strings.HeaderConfirm,
|
||||
message: this.$getString('MessageConfirmDeleteServerEpisode', [this.title])
|
||||
})
|
||||
|
||||
if (value) {
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
@@ -172,7 +172,7 @@
|
||||
<script>
|
||||
import { Dialog } from '@capacitor/dialog'
|
||||
import { AbsFileSystem, AbsDownloader } from '@/plugins/capacitor'
|
||||
import { FastAverageColor } from 'fast-average-color'
|
||||
import { getAverageColorFromCoverUrl } from '@/utils/coverAverageColor'
|
||||
import cellularPermissionHelpers from '@/mixins/cellularPermissionHelpers'
|
||||
|
||||
export default {
|
||||
@@ -496,17 +496,10 @@ export default {
|
||||
},
|
||||
async coverImageLoaded(fullCoverUrl) {
|
||||
if (!fullCoverUrl) return
|
||||
|
||||
const fac = new FastAverageColor()
|
||||
fac
|
||||
.getColorAsync(fullCoverUrl)
|
||||
.then((color) => {
|
||||
this.coverRgb = color.rgba
|
||||
this.coverBgIsLight = color.isLight
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log(e)
|
||||
})
|
||||
const avg = await getAverageColorFromCoverUrl(this, fullCoverUrl)
|
||||
if (!avg) return
|
||||
this.coverRgb = avg.rgba
|
||||
this.coverBgIsLight = avg.isLight
|
||||
},
|
||||
moreButtonPress() {
|
||||
this.showMoreMenu = true
|
||||
@@ -535,7 +528,11 @@ export default {
|
||||
|
||||
if (this.isPodcast) {
|
||||
this.episodes.sort((a, b) => {
|
||||
return String(b.publishedAt).localeCompare(String(a.publishedAt), undefined, { numeric: true, sensitivity: 'base' })
|
||||
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' })
|
||||
}
|
||||
})
|
||||
|
||||
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 (startTime !== null && startTime !== undefined && !this.$store.getters['getIsMediaStreaming'](libraryItemId, null)) {
|
||||
const { value } = await Dialog.confirm({
|
||||
title: 'Confirm',
|
||||
message: `Start playback for "${this.title}" at ${this.$secondsToTimestamp(startTime)}?`
|
||||
title: this.$strings.HeaderConfirm,
|
||||
message: this.$getString('MessageConfirmPlaybackTime', [this.title, this.$secondsToTimestamp(startTime)])
|
||||
})
|
||||
if (!value) return
|
||||
}
|
||||
@@ -673,7 +670,7 @@ export default {
|
||||
}
|
||||
}
|
||||
const { value } = await Dialog.confirm({
|
||||
title: 'Confirm',
|
||||
title: this.$strings.HeaderConfirm,
|
||||
message: startDownloadMessage
|
||||
})
|
||||
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)`
|
||||
}
|
||||
const { value } = await Dialog.confirm({
|
||||
title: 'Confirm',
|
||||
title: this.$strings.HeaderConfirm,
|
||||
message: deleteMessage
|
||||
})
|
||||
if (value) {
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
</div>
|
||||
<div v-if="!isAndroid10OrBelow || overrideFolderRestriction" class="flex border-t border-fg/10 my-4 py-4">
|
||||
<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>
|
||||
<ui-btn small class="w-28" color="success" @click="selectFolder">{{ $strings.ButtonNewFolder }}</ui-btn>
|
||||
</div>
|
||||
|
||||
@@ -369,7 +369,7 @@ export default {
|
||||
confirmMessage += ' The file on the server will be unaffected.'
|
||||
}
|
||||
const { value } = await Dialog.confirm({
|
||||
title: 'Confirm',
|
||||
title: this.$strings.HeaderConfirm,
|
||||
message: confirmMessage
|
||||
})
|
||||
if (value) {
|
||||
@@ -395,7 +395,7 @@ export default {
|
||||
confirmMessage += ' The file on the server will be unaffected.'
|
||||
}
|
||||
const { value } = await Dialog.confirm({
|
||||
title: 'Confirm',
|
||||
title: this.$strings.HeaderConfirm,
|
||||
message: confirmMessage
|
||||
})
|
||||
if (value) {
|
||||
@@ -412,7 +412,7 @@ export default {
|
||||
confirmMessage += ' The files on the server and your progress will be unaffected.'
|
||||
}
|
||||
const { value } = await Dialog.confirm({
|
||||
title: 'Confirm',
|
||||
title: this.$strings.HeaderConfirm,
|
||||
message: confirmMessage
|
||||
})
|
||||
if (value) {
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
<div class="flex-grow"></div>
|
||||
<div class="text-xs text-gray-400">{{ log.tag }}</div>
|
||||
</div>
|
||||
<div class="text-xs">{{ maskServerAddress ? log.maskedMessage : log.message }}</div>
|
||||
<div class="text-xs break-words">{{ maskServerAddress ? log.maskedMessage : log.message }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -14,16 +14,25 @@
|
||||
<template v-for="item in bookResults">
|
||||
<div :key="item.libraryItem.id" class="w-full h-16 py-1">
|
||||
<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="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">
|
||||
<div :key="item.libraryItem.id" class="text-fg select-none relative py-1">
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
@@ -77,6 +86,7 @@ export default {
|
||||
isFetching: false,
|
||||
bookResults: [],
|
||||
podcastResults: [],
|
||||
episodeResults: [],
|
||||
seriesResults: [],
|
||||
authorResults: [],
|
||||
narratorResults: [],
|
||||
@@ -91,7 +101,7 @@ export default {
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
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: {
|
||||
@@ -104,6 +114,7 @@ export default {
|
||||
if (!this.lastSearch) {
|
||||
this.bookResults = []
|
||||
this.podcastResults = []
|
||||
this.episodeResults = []
|
||||
this.seriesResults = []
|
||||
this.authorResults = []
|
||||
this.narratorResults = []
|
||||
@@ -125,6 +136,7 @@ export default {
|
||||
|
||||
this.bookResults = results?.book || []
|
||||
this.podcastResults = results?.podcast || []
|
||||
this.episodeResults = results?.episodes || []
|
||||
this.seriesResults = results?.series || []
|
||||
this.authorResults = results?.authors || []
|
||||
this.narratorResults = results?.narrators || []
|
||||
@@ -160,4 +172,4 @@ export default {
|
||||
height: calc(100% - 108px);
|
||||
max-height: calc(100% - 108px);
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -36,24 +36,24 @@
|
||||
|
||||
<!-- Playback settings -->
|
||||
<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="w-10 flex justify-center" @click="toggleDisableAutoRewind">
|
||||
<ui-toggle-switch v-model="settings.disableAutoRewind" @input="saveSettings" />
|
||||
</div>
|
||||
<p class="pl-4">{{ $strings.LabelDisableAutoRewind }}</p>
|
||||
</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 class="w-10 flex justify-center" @click="toggleEnableMp3IndexSeeking">
|
||||
<ui-toggle-switch v-model="settings.enableMp3IndexSeeking" @input="saveSettings" />
|
||||
@@ -179,7 +179,7 @@
|
||||
<ui-loading-indicator />
|
||||
</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-auto-sleep-timer-rewind-length-modal v-model="showAutoSleepTimerRewindLengthModal" @change="showAutoSleepTimerRewindLengthModalSelection" />
|
||||
</div>
|
||||
@@ -187,8 +187,10 @@
|
||||
|
||||
<script>
|
||||
import { Dialog } from '@capacitor/dialog'
|
||||
import jumpLabelMixin from '@/mixins/jumpLabel'
|
||||
|
||||
export default {
|
||||
mixins: [jumpLabelMixin],
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
@@ -352,15 +354,21 @@ export default {
|
||||
isiOS() {
|
||||
return this.$platform === 'ios'
|
||||
},
|
||||
jumpForwardItems() {
|
||||
return this.$store.state.globals.jumpForwardItems || []
|
||||
jumpForwardSecondsOptions() {
|
||||
return this.$store.state.globals.jumpForwardSecondsOptions || []
|
||||
},
|
||||
jumpBackwardsItems() {
|
||||
return this.$store.state.globals.jumpBackwardsItems || []
|
||||
jumpBackwardsSecondsOptions() {
|
||||
return this.$store.state.globals.jumpBackwardsSecondsOptions || []
|
||||
},
|
||||
languageOptionItems() {
|
||||
return this.$languageCodeOptions || []
|
||||
},
|
||||
jumpForwardOption() {
|
||||
return this.getJumpLabel(this.settings.jumpForwardTime)
|
||||
},
|
||||
jumpBackwardsOption() {
|
||||
return this.getJumpLabel(this.settings.jumpBackwardsTime)
|
||||
},
|
||||
themeOptionItems() {
|
||||
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() {
|
||||
const item = this.shakeSensitivityItems.find((i) => i.value === this.settings.shakeSensitivity)
|
||||
return item?.text || 'Error'
|
||||
@@ -434,7 +428,29 @@ export default {
|
||||
else if (this.moreMenuSetting === 'downloadUsingCellular') return this.downloadUsingCellularItems
|
||||
else if (this.moreMenuSetting === 'streamingUsingCellular') return this.streamingUsingCellularItems
|
||||
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 []
|
||||
},
|
||||
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: {
|
||||
@@ -468,6 +484,14 @@ export default {
|
||||
this.moreMenuSetting = 'theme'
|
||||
this.showMoreMenuDialog = true
|
||||
},
|
||||
showJumpForwardOptions() {
|
||||
this.moreMenuSetting = 'jumpForward'
|
||||
this.showMoreMenuDialog = true
|
||||
},
|
||||
showJumpBackwardsOptions() {
|
||||
this.moreMenuSetting = 'jumpBackwards'
|
||||
this.showMoreMenuDialog = true
|
||||
},
|
||||
showDownloadUsingCellularOptions() {
|
||||
this.moreMenuSetting = 'downloadUsingCellular'
|
||||
this.showMoreMenuDialog = true
|
||||
@@ -503,6 +527,12 @@ export default {
|
||||
} else if (this.moreMenuSetting === 'androidAutoBrowseSeriesSequenceOrder') {
|
||||
this.settings.androidAutoBrowseSeriesSequenceOrder = action
|
||||
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) {
|
||||
@@ -598,17 +628,6 @@ export default {
|
||||
this.$setOrientationLock(this.settings.lockOrientation)
|
||||
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() {
|
||||
await this.$hapticsImpact()
|
||||
const updatedDeviceData = await this.$db.updateDeviceSettings({ ...this.settings })
|
||||
|
||||
@@ -51,7 +51,7 @@ class AbsDatabaseWeb extends WebPlugin {
|
||||
ssc.customHeaders = serverConnectionConfig.customHeaders || {}
|
||||
|
||||
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
|
||||
localStorage.setItem(`refresh_token_${ssc.id}`, serverConnectionConfig.refreshToken)
|
||||
}
|
||||
@@ -71,7 +71,7 @@ class AbsDatabaseWeb extends WebPlugin {
|
||||
}
|
||||
|
||||
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
|
||||
localStorage.setItem(`refresh_token_${ssc.id}`, serverConnectionConfig.refreshToken)
|
||||
}
|
||||
|
||||
@@ -128,13 +128,12 @@ export default ({ app, store }, inject) => {
|
||||
|
||||
// Listen for token refresh events from native app
|
||||
AbsDatabase.addListener('onTokenRefresh', (data) => {
|
||||
console.log('[db] onTokenRefresh', data)
|
||||
store.commit('user/setAccessToken', data.accessToken)
|
||||
})
|
||||
|
||||
// Listen for token refresh failure events from native app
|
||||
AbsDatabase.addListener('onTokenRefreshFailure', async (data) => {
|
||||
console.log('[db] onTokenRefreshFailure', data)
|
||||
console.log('[db] onTokenRefreshFailure')
|
||||
// Clear store and redirect to login page
|
||||
await store.dispatch('user/logout')
|
||||
if (window.location.pathname !== '/connect') {
|
||||
|
||||
@@ -6,6 +6,7 @@ let $localStore = null
|
||||
|
||||
const languageCodeMap = {
|
||||
bn: { label: 'বাংলা', dateFnsLocale: 'bn' },
|
||||
bg: { label: 'Български', dateFnsLocale: 'bg' },
|
||||
ca: { label: 'Català', dateFnsLocale: 'ca' },
|
||||
cs: { label: 'Čeština', dateFnsLocale: 'cs' },
|
||||
da: { label: 'Dansk', dateFnsLocale: 'da' },
|
||||
@@ -25,6 +26,7 @@ const languageCodeMap = {
|
||||
ru: { label: 'Русский', dateFnsLocale: 'ru' },
|
||||
sl: { label: 'Slovenščina', dateFnsLocale: 'sl' },
|
||||
sv: { label: 'Svenska', dateFnsLocale: 'sv' },
|
||||
tr: { label: 'Türkçe', dateFnsLocale: 'tr' },
|
||||
uk: { label: 'Українська', dateFnsLocale: 'uk' },
|
||||
'vi-vn': { label: 'Tiếng Việt', dateFnsLocale: 'vi' },
|
||||
'zh-cn': { label: '简体中文 (Simplified Chinese)', dateFnsLocale: 'zhCN' }
|
||||
|
||||
@@ -314,8 +314,8 @@ export default ({ store, app }, inject) => {
|
||||
}
|
||||
if (!canGoBack) {
|
||||
const { value } = await Dialog.confirm({
|
||||
title: 'Confirm',
|
||||
message: `Did you want to exit the app?`
|
||||
title: eventBus.$strings.HeaderConfirm,
|
||||
message: eventBus.$strings.MessageConfirmAppExit
|
||||
})
|
||||
if (value) {
|
||||
App.exitApp()
|
||||
|
||||
@@ -43,7 +43,8 @@ export default function ({ store, $db, $socket }, inject) {
|
||||
}
|
||||
if (res.status >= 400) {
|
||||
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
|
||||
})
|
||||
@@ -100,7 +101,8 @@ export default function ({ store, $db, $socket }, inject) {
|
||||
|
||||
if (retryResponse.status >= 400) {
|
||||
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
|
||||
|
||||
@@ -110,7 +110,7 @@ class ServerSocket extends EventEmitter {
|
||||
}
|
||||
|
||||
onAuthFailed(data) {
|
||||
console.log('[SOCKET] Auth failed', data)
|
||||
console.log('[SOCKET] Auth failed: ' + (data?.message || 'Unknown reason'))
|
||||
this.isAuthenticated = false
|
||||
}
|
||||
|
||||
|
||||
@@ -144,6 +144,12 @@ npx cap open android
|
||||
|
||||
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
|
||||
|
||||
Required Software:
|
||||
@@ -213,3 +219,9 @@ npx cap open ios
|
||||
<br>
|
||||
|
||||
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,
|
||||
localMediaProgress: [],
|
||||
lastSearch: null,
|
||||
jumpForwardItems: [
|
||||
{
|
||||
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
|
||||
}
|
||||
],
|
||||
jumpForwardSecondsOptions: [5, 10, 15, 30, 60, 120, 300],
|
||||
jumpBackwardsSecondsOptions: [5, 10, 15, 30, 60, 120, 300],
|
||||
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: [],
|
||||
showPlaylistsAddCreateModal: false,
|
||||
@@ -111,15 +85,7 @@ export const getters = {
|
||||
if (episodeId != null && lmp.episodeId != episodeId) return false
|
||||
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'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
|
||||
@@ -231,7 +231,6 @@ export const mutations = {
|
||||
state.user = user
|
||||
},
|
||||
setAccessToken(state, accessToken) {
|
||||
console.log('[user] setAccessToken', accessToken)
|
||||
state.accessToken = accessToken
|
||||
},
|
||||
removeMediaProgress(state, id) {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"ButtonCancelTimer": "إلغاء المؤقت",
|
||||
"ButtonClearFilter": "تصفية الفرز",
|
||||
"ButtonClearLogs": "مسح السجل",
|
||||
"ButtonCloseFeed": "إغلاق",
|
||||
"ButtonCloseFeed": "إغلاق الموجز",
|
||||
"ButtonCollections": "المجموعات",
|
||||
"ButtonConnect": "اتصل",
|
||||
"ButtonConnectToServer": "اتصال بالخادم",
|
||||
@@ -63,6 +63,7 @@
|
||||
"HeaderChapters": "الفصول",
|
||||
"HeaderCollection": "مجموعة",
|
||||
"HeaderCollectionItems": "عناصر المجموعة",
|
||||
"HeaderConfirm": "تأكيد",
|
||||
"HeaderConnectionStatus": "حالة الاتصال",
|
||||
"HeaderDataSettings": "إعدادات البيانات",
|
||||
"HeaderDetails": "التفاصيل",
|
||||
@@ -91,6 +92,7 @@
|
||||
"HeaderStatsRecentSessions": "الجلسات الأخيرة",
|
||||
"HeaderTableOfContents": "جدول المحتويات",
|
||||
"HeaderUserInterfaceSettings": "إعدادات واجهة المستخدم",
|
||||
"HeaderWelcome": "مرحبا, <strong>{0}</strong>",
|
||||
"HeaderYourStats": "إحصائياتك",
|
||||
"LabelAddToPlaylist": "أضف إلى قائمة التشغيل",
|
||||
"LabelAddedAt": "أضيفت على",
|
||||
@@ -146,6 +148,7 @@
|
||||
"LabelEndOfChapter": "نهاية الفصل",
|
||||
"LabelEndTime": "وقت النهاية",
|
||||
"LabelEpisode": "الحلقة",
|
||||
"LabelExplicit": "محتوى صريح",
|
||||
"LabelFeedURL": "عنوان التغذية",
|
||||
"LabelFile": "الملف",
|
||||
"LabelFileBirthtime": "وقت انشاء الملف",
|
||||
@@ -154,6 +157,9 @@
|
||||
"LabelFinished": "المنجزة",
|
||||
"LabelFolder": "المجلد",
|
||||
"LabelFontBoldness": "تعريض الخط",
|
||||
"LabelFontFamily": "عائلة الخطوط",
|
||||
"LabelFontFamilySans": "سانس",
|
||||
"LabelFontFamilySerif": "سارف",
|
||||
"LabelFontScale": "نطاق الخط",
|
||||
"LabelGenre": "التصنيف",
|
||||
"LabelGenres": "التصانيف",
|
||||
@@ -297,6 +303,7 @@
|
||||
"MessageDownloading": "جاري التنزيل...",
|
||||
"MessageDownloadingEpisode": "جاري تنزيل الحلقة",
|
||||
"MessageEpisodesQueuedForDownload": "تمت إضافة {0} حلقة (حلقات) إلى قائمة انتظار التنزيل",
|
||||
"MessageFailedToRefreshToken": "فشل في تحديث الرمز، إعادة تسجيل الدخول مطلوب",
|
||||
"MessageFeedURLWillBe": "سيكون رابط التغذية هو {0}",
|
||||
"MessageFetching": "جاري الجلب...",
|
||||
"MessageFollowTheProjectOnGithub": "تابع المشروع على Github",
|
||||
@@ -324,6 +331,8 @@
|
||||
"MessageNoSeries": "لا توجد سلاسل",
|
||||
"MessageNoUpdatesWereNecessary": "لا حاجة لأي تحديثات",
|
||||
"MessageNoUserPlaylists": "ليس لديك أي قوائم تشغيل",
|
||||
"MessageOldServerAuthReLoginRequired": "تم تحسين المصادقة في الخادم v2.26.0 لضمان الأمان. يُطلب من جميع المستخدمين إعادة تسجيل الدخول.",
|
||||
"MessageOldServerAuthWarning": "الخادم يستخدم مصادقة قديمة",
|
||||
"MessageOldServerConnectionWarning": "تكوين اتصال الخادم يستخدم معرّف مستخدم قديم. يرجى حذف اتصال الخادم هذا وإعادة إضافته.",
|
||||
"MessageOldServerConnectionWarningHelp": "لقد قمتَ بإعداد الاتصال بهذا الخادم قبل ترحيل قاعدة البيانات في الإصدار 2.3.0، الصادر في يونيو 2023. سيؤدي تحديث الخادم المستقبلي إلى إلغاء إمكانية تسجيل الدخول باستخدام هذا الاتصال القديم. يُرجى حذف اتصال الخادم الحالي والاتصال مرة أخرى (باستخدام نفس عنوان الخادم وبيانات الاعتماد). إذا كانت لديك أي وسائط مُنزّلة على هذا الجهاز، فيجب تنزيلها مرة أخرى لمزامنتها مع الخادم.",
|
||||
"MessagePodcastSearchField": "أدخل مصطلح البحث أو عنوان URL الخاص بتغذية RSS",
|
||||
|
||||
@@ -11,11 +11,11 @@
|
||||
"ButtonCollections": "Калекцыі",
|
||||
"ButtonConnect": "Падлучыцца",
|
||||
"ButtonConnectToServer": "Падлучыцца да сервера",
|
||||
"ButtonCreate": "Ствараць",
|
||||
"ButtonCreate": "Стварыць",
|
||||
"ButtonCreateBookmark": "Стварыць закладку",
|
||||
"ButtonCreateNewPlaylist": "Стварыць новы спіс прайгравання",
|
||||
"ButtonCreateNewPlaylist": "Стварыць новы плэй-ліст",
|
||||
"ButtonDelete": "Выдаліць",
|
||||
"ButtonDeleteLocalEpisode": "Выдаліць лакальны эпізод",
|
||||
"ButtonDeleteLocalEpisode": "Выдаліць лакальны выпуск",
|
||||
"ButtonDeleteLocalFile": "Выдаліць лакальны файл",
|
||||
"ButtonDeleteLocalItem": "Выдаліць лакальны элемент",
|
||||
"ButtonDisableAutoTimer": "Адключыць аўтаматычны таймер",
|
||||
@@ -30,15 +30,15 @@
|
||||
"ButtonLogs": "Журналы",
|
||||
"ButtonManageLocalFiles": "Кіраванне лакальнымі файламі",
|
||||
"ButtonMaskServerAddress": "Схаваць адрас сервера",
|
||||
"ButtonNewFolder": "Новая тэчка",
|
||||
"ButtonNextEpisode": "Наступны эпізод",
|
||||
"ButtonOk": "Добра",
|
||||
"ButtonNewFolder": "Новая папка",
|
||||
"ButtonNextEpisode": "Наступны выпуск",
|
||||
"ButtonOk": "ОК",
|
||||
"ButtonOpenFeed": "Адкрыць стужку",
|
||||
"ButtonOverride": "Пераазначыць",
|
||||
"ButtonPause": "Паўза",
|
||||
"ButtonPause": "Прыпыніць",
|
||||
"ButtonPlay": "Прайграць",
|
||||
"ButtonPlayEpisode": "Прайграць эпізод",
|
||||
"ButtonPlaylists": "Спісы прайгравання",
|
||||
"ButtonPlayEpisode": "Прайграць выпуск",
|
||||
"ButtonPlaylists": "Плэй-лісты",
|
||||
"ButtonRead": "Чытаць",
|
||||
"ButtonReadLess": "Чытаць менш",
|
||||
"ButtonReadMore": "Чытаць больш",
|
||||
@@ -59,31 +59,32 @@
|
||||
"HeaderAccount": "Уліковы запіс",
|
||||
"HeaderAdvanced": "Дадаткова",
|
||||
"HeaderAndroidAutoSettings": "Налады Android Auto",
|
||||
"HeaderAudioTracks": "Аўдыядарожкі",
|
||||
"HeaderAudioTracks": "Аўдыятрэкі",
|
||||
"HeaderChapters": "Раздзелы",
|
||||
"HeaderCollection": "Калекцыя",
|
||||
"HeaderCollectionItems": "Элементы калекцыі",
|
||||
"HeaderConfirm": "Пацвердзіць",
|
||||
"HeaderConnectionStatus": "Стан злучэння",
|
||||
"HeaderDataSettings": "Налады дадзеных",
|
||||
"HeaderDataSettings": "Налады даных",
|
||||
"HeaderDetails": "Падрабязнасці",
|
||||
"HeaderDownloads": "Спампоўкі",
|
||||
"HeaderEbookFiles": "Файлы электронных кніг",
|
||||
"HeaderEpisodes": "Эпізоды",
|
||||
"HeaderEpisodes": "Выпускі",
|
||||
"HeaderEreaderSettings": "Налады прылады для чытання",
|
||||
"HeaderLatestEpisodes": "Апошнія эпізоды",
|
||||
"HeaderLatestEpisodes": "Апошнія выпускі",
|
||||
"HeaderLibraries": "Бібліятэкі",
|
||||
"HeaderLocalFolders": "Лакальныя тэчкі",
|
||||
"HeaderLocalFolders": "Лакальныя папкі",
|
||||
"HeaderLocalLibraryItems": "Элементы лакальнай бібліятэкі",
|
||||
"HeaderNewPlaylist": "Новы спіс прайгравання",
|
||||
"HeaderNewPlaylist": "Новы плэй-ліст",
|
||||
"HeaderOpenRSSFeed": "Адкрыць RSS-стужку",
|
||||
"HeaderPlaybackSettings": "Налады прайгравання",
|
||||
"HeaderPlaylist": "Спіс прайгравання",
|
||||
"HeaderPlaylistItems": "Элементы спіса прайгравання",
|
||||
"HeaderPlaylist": "Плэй-ліст",
|
||||
"HeaderPlaylistItems": "Элементы плэй-ліста",
|
||||
"HeaderProgressSyncFailed": "Не ўдалося сінхранізаваць прагрэс",
|
||||
"HeaderRSSFeed": "RSS-стужка",
|
||||
"HeaderRSSFeedGeneral": "Падрабязнасці RSS",
|
||||
"HeaderRSSFeedIsOpen": "RSS-стужка адкрытая",
|
||||
"HeaderSelectDownloadLocation": "Абраць месцазнаходжання для спампоўкі",
|
||||
"HeaderSelectDownloadLocation": "Выберыце размяшчэнне для спампоўкі",
|
||||
"HeaderSettings": "Налады",
|
||||
"HeaderSleepTimer": "Таймер сну",
|
||||
"HeaderSleepTimerSettings": "Налады таймера сну",
|
||||
@@ -91,8 +92,9 @@
|
||||
"HeaderStatsRecentSessions": "Апошнія сеансы",
|
||||
"HeaderTableOfContents": "Змест",
|
||||
"HeaderUserInterfaceSettings": "Налады інтэрфейсу карыстальніка",
|
||||
"HeaderWelcome": "Вітаем, <strong>{0}</strong>",
|
||||
"HeaderYourStats": "Ваша статыстыка",
|
||||
"LabelAddToPlaylist": "Дадаць у спіс прайгравання",
|
||||
"LabelAddToPlaylist": "Дадаць у плэй-ліст",
|
||||
"LabelAddedAt": "Дата дабаўлення",
|
||||
"LabelAddedDate": "Дададзена {0}",
|
||||
"LabelAll": "Усе",
|
||||
@@ -103,58 +105,62 @@
|
||||
"LabelAndroidAutoBrowseSeriesSequenceOrder": "Парадак кніг у серыі",
|
||||
"LabelAskConfirmation": "Запытваць пацверджанне",
|
||||
"LabelAuthor": "Аўтар",
|
||||
"LabelAuthorFirstLast": "Аўтар (Імя Прозвішча)",
|
||||
"LabelAuthorLastFirst": "Аўтар (Прозвішча, Імя)",
|
||||
"LabelAuthorFirstLast": "Аўтар (імя, прозвішча)",
|
||||
"LabelAuthorLastFirst": "Аўтар (прозвішча, імя)",
|
||||
"LabelAuthors": "Аўтары",
|
||||
"LabelAutoDownloadEpisodes": "Аўтаматычнае спампаванне эпізодаў",
|
||||
"LabelAutoDownloadEpisodes": "Аўтаматычна спампоўваць выпускі",
|
||||
"LabelAutoRewindTime": "Час аўтаматычнай перамоткі назад",
|
||||
"LabelAutoSleepTimer": "Аўтаматычны таймер сну",
|
||||
"LabelAutoSleepTimerAutoRewind": "Аўтаматычная перамотка таймера сну",
|
||||
"LabelAutoSleepTimerAutoRewindHelp": "Калі таймер аўтаматычнага сну скончыцца, паўторнае прайграванне элемента аўтаматычна перамотвае вашу пазіцыю.",
|
||||
"LabelAutoSleepTimerAutoRewindHelp": "Пасля заканчэння аўтаматычнага таймера сну паўторнае прайграванне элемента аўтаматычна перамотвае вашу пазіцыю.",
|
||||
"LabelAutoSleepTimerHelp": "Калі медыя прайграецца ў вызначаны час паміж пачаткам і канцом, таймер сну будзе запушчаны аўтаматычна.",
|
||||
"LabelBooks": "Кнігі",
|
||||
"LabelChapterTrack": "Дарожка раздзела",
|
||||
"LabelByAuthor": "– {0}",
|
||||
"LabelChapterTrack": "Трэк раздзела",
|
||||
"LabelChapters": "Раздзелы",
|
||||
"LabelClosePlayer": "Зачыніць прайгравальнік",
|
||||
"LabelCollapseSeries": "Згарнуць серыі",
|
||||
"LabelComplete": "Завершана",
|
||||
"LabelComplete": "Завяршыць",
|
||||
"LabelContinueBooks": "Працягнуць кнігі",
|
||||
"LabelContinueEpisodes": "Працягнуць эпізоды",
|
||||
"LabelContinueListening": "Працягваць слухаць",
|
||||
"LabelContinueEpisodes": "Працягнуць выпускі",
|
||||
"LabelContinueListening": "Працяг праслухоўвання",
|
||||
"LabelContinueReading": "Працягнуць чытанне",
|
||||
"LabelContinueSeries": "Працягнуць серыі",
|
||||
"LabelCustomTime": "Свой час",
|
||||
"LabelDescription": "Апісанне",
|
||||
"LabelDisableAudioFadeOut": "Адключыць змяншэнне гучнасці",
|
||||
"LabelDisableAudioFadeOutHelp": "Гучнасць аўдыё пачне змяншацца, калі на таймеры сну застанецца менш за 1 хвіліну. Уключыце гэтую наладу, каб пазбегнуць змяншэння гучнасці.",
|
||||
"LabelDisableAudioFadeOutHelp": "Гучнасць гуку пачне змяншацца, калі на таймеры сну застанецца менш за 1 хвіліну. Уключыце гэтую наладу, каб пазбегнуць змяншэння гучнасці.",
|
||||
"LabelDisableAutoRewind": "Адключыць аўтаматычную перамотку назад",
|
||||
"LabelDisableShakeToReset": "Адключыць скід страсеннем",
|
||||
"LabelDisableShakeToResetHelp": "Калі патрасці прыладу падчас працы таймера АБО на працягу 2 хвілін пасля заканчэння таймера, таймер сну скідваецца. Уключыце гэтую наладу, каб адключыць скід страсеннем.",
|
||||
"LabelDisableVibrateOnReset": "Адключыць вібрацыю пры скідзе",
|
||||
"LabelDisableVibrateOnResetHelp": "Калі таймер сну скідваецца, ваша прылада будзе вібраваць. Уключыце гэтую наладу, каб адключыць вібрацыю пры скідзе таймера сну.",
|
||||
"LabelDiscover": "Знайсці",
|
||||
"LabelDiscover": "Знаходкі",
|
||||
"LabelDownload": "Спампаваць",
|
||||
"LabelDownloadUsingCellular": "Спампоўка праз мабільны інтэрнэт",
|
||||
"LabelDownloadUsingCellular": "Спампоўваць праз мабільны інтэрнэт",
|
||||
"LabelDownloaded": "Спампавана",
|
||||
"LabelDuration": "Працягласць",
|
||||
"LabelEbook": "Электронная кніга",
|
||||
"LabelEbooks": "Электронныя кнігі",
|
||||
"LabelEnable": "Уключыць",
|
||||
"LabelEnableMp3IndexSeeking": "Уключыць індэксаванне для перамоткі MP3",
|
||||
"LabelEnableMp3IndexSeeking": "Уключыць пошук па індэксе mp3",
|
||||
"LabelEnableMp3IndexSeekingHelp": "Гэтую наладу варта ўключаць толькі, калі ў вас ёсць MP3-файлы, якія няправільна перамотваюцца. Хутчэй за ўсё, гэта выклікана файламі з зменным бітрэйтам (VBR). Гэтая налада прымусова ўключае індэксаванне для перамоткі, пры якім падчас чытання файла ствараецца адпаведнасць паміж часам і байтамі. У некаторых выпадках з вялікімі MP3-файламі можа быць затрымка пры перемотцы да канца файла.",
|
||||
"LabelEnd": "Канец",
|
||||
"LabelEndOfChapter": "Канец раздзела",
|
||||
"LabelEndTime": "Час заканчэння",
|
||||
"LabelEpisode": "Эпізод",
|
||||
"LabelExplicit": "Відверты",
|
||||
"LabelEpisode": "Выпуск",
|
||||
"LabelExplicit": "Непрыстойнае",
|
||||
"LabelFeedURL": "URL стужкі",
|
||||
"LabelFile": "Файл",
|
||||
"LabelFileBirthtime": "Час стварэння файла",
|
||||
"LabelFileModified": "Час змянення файла",
|
||||
"LabelFilename": "Імя файла",
|
||||
"LabelFinished": "Скончана",
|
||||
"LabelFolder": "Тэчка",
|
||||
"LabelFilename": "Назва файла",
|
||||
"LabelFinished": "Завершана",
|
||||
"LabelFolder": "Папка",
|
||||
"LabelFontBoldness": "Таўшчыня шрыфта",
|
||||
"LabelFontFamily": "Сямейства шрыфтоў",
|
||||
"LabelFontFamilySans": "Sans",
|
||||
"LabelFontFamilySerif": "Serif",
|
||||
"LabelFontScale": "Памер шрыфту",
|
||||
"LabelGenre": "Жанр",
|
||||
"LabelGenres": "Жанры",
|
||||
@@ -166,7 +172,7 @@
|
||||
"LabelHost": "Хост",
|
||||
"LabelInProgress": "У працэсе",
|
||||
"LabelIncomplete": "Незавершана",
|
||||
"LabelInternalAppStorage": "Унутраная памяць прыкладання",
|
||||
"LabelInternalAppStorage": "Унутраная памяць праграмы",
|
||||
"LabelJumpBackwardsTime": "Час пераходу назад",
|
||||
"LabelJumpForwardsTime": "Час пераходу наперад",
|
||||
"LabelKeepScreenAwake": "Трымаць экран уключаным",
|
||||
@@ -174,6 +180,9 @@
|
||||
"LabelLayout": "Знешні выгляд",
|
||||
"LabelLayoutAuto": "Аўто",
|
||||
"LabelLayoutSinglePage": "Аднабаковы",
|
||||
"LabelLibrarySortByProgress": "Прагрэс: апошняе абнаўленне",
|
||||
"LabelLibrarySortByProgressFinished": "Прагрэс: завершана",
|
||||
"LabelLibrarySortByProgressStarted": "Прагрэс: пачата",
|
||||
"LabelLight": "Святло",
|
||||
"LabelLineSpacing": "Міжрадковы інтэрвал",
|
||||
"LabelListenAgain": "Паслухаць зноў",
|
||||
@@ -187,9 +196,9 @@
|
||||
"LabelMissing": "Адсутнічае",
|
||||
"LabelMore": "Больш",
|
||||
"LabelMoreInfo": "Больш інфармацыі",
|
||||
"LabelName": "Імя",
|
||||
"LabelNarrator": "Чытальнік",
|
||||
"LabelNarrators": "Чытальнікі",
|
||||
"LabelName": "Назва",
|
||||
"LabelNarrator": "Дыктар",
|
||||
"LabelNarrators": "Дыктары",
|
||||
"LabelNavigateWithVolume": "Навігацыя з дапамогай клавіш гучнасці",
|
||||
"LabelNavigateWithVolumeMirrored": "Люстраваны",
|
||||
"LabelNavigateWithVolumeWhilePlaying": "Дазволіць клавішам гучнасці кіраваць навігацыяй падчас прайгравання",
|
||||
@@ -197,13 +206,13 @@
|
||||
"LabelNavigateWithVolumeWhilePlayingEnabled": "Уключана",
|
||||
"LabelNever": "Ніколі",
|
||||
"LabelNewestAuthors": "Новыя аўтары",
|
||||
"LabelNewestEpisodes": "Новыя эпізоды",
|
||||
"LabelNewestEpisodes": "Найноўшыя выпускі",
|
||||
"LabelNo": "Не",
|
||||
"LabelNotFinished": "Не скончана",
|
||||
"LabelNotFinished": "Незавершана",
|
||||
"LabelNotStarted": "Не пачата",
|
||||
"LabelNumEpisodes": "{0} эпізодаў",
|
||||
"LabelNumEpisodesIncomplete": "{0} эпізодаў, {1} незавершаных",
|
||||
"LabelNumberOfEpisodes": "# з эпізодаў",
|
||||
"LabelNumEpisodes": "{0} выпускаў",
|
||||
"LabelNumEpisodesIncomplete": "{0} выпускаў, {1} незавершаных",
|
||||
"LabelNumberOfEpisodes": "Колькасць выпускаў",
|
||||
"LabelOff": "Выключына",
|
||||
"LabelOn": "Уключана",
|
||||
"LabelPassword": "Пароль",
|
||||
@@ -229,10 +238,11 @@
|
||||
"LabelReadAgain": "Чытаць зноў",
|
||||
"LabelRecentSeries": "Апошнія серыі",
|
||||
"LabelRecentlyAdded": "Нядаўна дададзеныя",
|
||||
"LabelRemoveFromPlaylist": "Выдаліць з спіса прайгравання",
|
||||
"LabelRemoveFromPlaylist": "Выдаліць з плэй-ліста",
|
||||
"LabelScaleElapsedTimeBySpeed": "Прыстасаваць мінулы час да хуткасці",
|
||||
"LabelSeason": "Сезон",
|
||||
"LabelSelectADevice": "Выбраць прыладу",
|
||||
"LabelSelectMediaType": "Выберыце тып медыя",
|
||||
"LabelSequenceAscending": "Паслядоўнасць па ўзрастанні",
|
||||
"LabelSequenceDescending": "Паслядоўнасць па змяншэнні",
|
||||
"LabelSeries": "Серыі",
|
||||
@@ -243,16 +253,16 @@
|
||||
"LabelShowAll": "Паказаць усё",
|
||||
"LabelSize": "Памер",
|
||||
"LabelSleepTimer": "Таймер сну",
|
||||
"LabelSleepTimerAlmostDoneChime": "Праіграйце звонак, калі амаль скончана",
|
||||
"LabelSleepTimerAlmostDoneChime": "Гукавы сігнал, калі амаль звершаны",
|
||||
"LabelSleepTimerAlmostDoneChimeHelp": "Памякчыць звонак, калі на таймеры сну застанецца 30 секунд",
|
||||
"LabelStart": "Пачаць",
|
||||
"LabelStart": "Пачатак",
|
||||
"LabelStartTime": "Час пачатку",
|
||||
"LabelStatsBestDay": "Лепшы дзень",
|
||||
"LabelStatsBestDay": "Найлепшы дзень",
|
||||
"LabelStatsDailyAverage": "У сярэднім за дзень",
|
||||
"LabelStatsDays": "Дзён",
|
||||
"LabelStatsDaysListened": "Дзён праслухана",
|
||||
"LabelStatsInARow": "без перапынку",
|
||||
"LabelStatsItemsFinished": "Скончаныя элементы",
|
||||
"LabelStatsItemsFinished": "Элементаў завершана",
|
||||
"LabelStatsMinutes": "хвілін",
|
||||
"LabelStatsMinutesListening": "Хвілін праслухоўвання",
|
||||
"LabelStatsWeekListening": "Праслухана за тыдзень",
|
||||
@@ -264,14 +274,14 @@
|
||||
"LabelThemeDark": "Цёмная",
|
||||
"LabelThemeLight": "Светлая",
|
||||
"LabelTimeRemaining": "Засталося {0}",
|
||||
"LabelTitle": "Назва",
|
||||
"LabelTitle": "Загаловак",
|
||||
"LabelTotalSize": "Агульны памер",
|
||||
"LabelTotalTrack": "Агульная колькасць дарожак",
|
||||
"LabelTracks": "Дарожкі",
|
||||
"LabelTotalTrack": "Усяго трэкаў",
|
||||
"LabelTracks": "Трэкі",
|
||||
"LabelType": "Тып",
|
||||
"LabelUnknown": "Невядома",
|
||||
"LabelUnlockPlayer": "Разблакаваць прайгравальнік",
|
||||
"LabelUseBookshelfView": "Выкарыстоўваць выгляд кніжнай паліцы",
|
||||
"LabelUseBookshelfView": "Паказваць кніжныя паліцы",
|
||||
"LabelUser": "Карыстальнік",
|
||||
"LabelUsername": "Імя карыстальніка",
|
||||
"LabelVeryHigh": "Вельмі высокі",
|
||||
@@ -280,24 +290,30 @@
|
||||
"LabelYearReviewShow": "Азнаёміцца з вынікамі года",
|
||||
"LabelYourBookmarks": "Вашы закладкі",
|
||||
"LabelYourProgress": "Ваш прагрэс",
|
||||
"MessageAndroid10Downloads": "На Android 10 і ніжэй для спампоўкі будзе выкарыстоўвацца ўнутраная памяць прыкладання.",
|
||||
"MessageAndroid10Downloads": "На Android 10 і ніжэй для спамповак будзе выкарыстоўвацца ўнутраная памяць праграмы.",
|
||||
"MessageAttemptingServerConnection": "Спроба злучэння з серверам...",
|
||||
"MessageAudiobookshelfServerNotConnected": "Сервер Audiobookshelf не падлучаны",
|
||||
"MessageAudiobookshelfServerRequired": "<strong>Важна!</strong> Гэта праграма прызначана для працы з серверам Audiobookshelf, які вы ці хтосьці іншы абслугоўваеце. Праграма не змяшчае ўласнага кантэнту.",
|
||||
"MessageBookshelfEmpty": "Кніжная паліца пустая",
|
||||
"MessageConfirmDeleteLocalEpisode": "Выдаліць лакальны эпізод \"{0}\" з вашай прылады? Файл на серверы застанецца нязменным.",
|
||||
"MessageConfirmDeleteLocalFiles": "Выдаліць лакальныя файлы гэтага элемента з вашай прылады? Файлы на серверы і ваш прагрэс застануцца нязменнымі.",
|
||||
"MessageConfirmDisableAutoTimer": "Вы ўпэўнены, што жадаеце адключыць аўтаматычны таймер на сённяшні дзень? Таймер будзе зноў уключаны па заканчэнні гэтага перыяду аўтаматычнага таймера сну або пры перазапуску праграмы.",
|
||||
"MessageConfirmDiscardProgress": "Вы ўпэўнены, што жадаеце скінуць свой прагрэс?",
|
||||
"MessageConfirmDownloadUsingCellular": "Вы збіраецеся спампаваць, выкарыстоўваючы мабільны інтэрнэт. Гэта можа прывесці да дадатковых плат ад аператара. Вы жадаеце працягнуць?",
|
||||
"MessageConfirmMarkAsFinished": "Вы ўпэўнены, што жадаеце пазначыць гэты элемент як скончаны?",
|
||||
"MessageConfirmRemoveBookmark": "Вы ўпэўнены, што жадаеце выдаліць закладку?",
|
||||
"MessageConfirmStreamingUsingCellular": "Вы збіраецеся трансляваць кантэнт праз мабільны інтэрнэт. Гэта можа прывесці да дадатковых платаў ад вашага аператара. Вы жадаеце працягнуць?",
|
||||
"MessageConfirmAppExit": "Вы хацелі выйсці з праграмы?",
|
||||
"MessageConfirmDeleteEpisodeDownloadQueue": "Вы ўпэўнены, што хочаце ачысціць чаргу спампоўвання выпускаў?",
|
||||
"MessageConfirmDeleteLocalEpisode": "Выдаліць лакальны выпуск \"{0}\" з вашай прылады? Файл на серверы застанецца нязменным.",
|
||||
"MessageConfirmDeleteLocalFiles": "Выдаліць лакальныя файлы гэтага элемента з вашай прылады? Файлы на серверы і прагрэс застануцца нязменнымі.",
|
||||
"MessageConfirmDeleteServerConfig": "Выдаліць гэту канфігурацыю сервера?",
|
||||
"MessageConfirmDeleteServerEpisode": "Вы ўпэўнены, што хочаце выдаліць выпуск \"{0}\" з сервера?\nУвага: будзе выдалены аўдыяфайл.",
|
||||
"MessageConfirmDisableAutoTimer": "Вы ўпэўнены, што хочаце адключыць аўтаматычны таймер на сённяшні дзень? Таймер будзе зноў уключаны па заканчэнні перыяду гэтага аўтаматычнага таймера сну або пры перазапуску праграмы.",
|
||||
"MessageConfirmDiscardProgress": "Вы ўпэўнены, што хочаце скінуць прагрэс?",
|
||||
"MessageConfirmDownloadUsingCellular": "Вы збіраецеся спампаваць праз мабільны інтэрнэт. Гэта можа прывесці да дадатковых плат ад аператара. Хочаце працягнуць?",
|
||||
"MessageConfirmMarkAsFinished": "Вы ўпэўнены, што хочаце пазначыць гэты элемент як завершаны?",
|
||||
"MessageConfirmPlaybackTime": "Пачаць прайграванне \"{0}\" у {1}?",
|
||||
"MessageConfirmRemoveBookmark": "Вы ўпэўнены, што хочаце выдаліць закладку?",
|
||||
"MessageConfirmStreamingUsingCellular": "Вы збіраецеся трансляваць кантэнт праз мабільны інтэрнэт. Гэта можа прывесці да дадатковых плат ад аператара. Хочаце працягнуць?",
|
||||
"MessageDiscardProgress": "Скінуць прагрэс",
|
||||
"MessageDownloadCompleteProcessing": "Спампоўка завершана. Апрацоўка...",
|
||||
"MessageDownloadCompleteProcessing": "Спампоўванне завершана. Апрацоўка...",
|
||||
"MessageDownloading": "Спампоўванне...",
|
||||
"MessageDownloadingEpisode": "Спампоўка эпізоду",
|
||||
"MessageEpisodesQueuedForDownload": "{0} эпізод(аў) у чарзе для спампоўкі",
|
||||
"MessageDownloadingEpisode": "Спампоўванне выпуску",
|
||||
"MessageEpisodesQueuedForDownload": "{0} выпуск(-аў) у чарзе спампоўвання",
|
||||
"MessageFailedToRefreshToken": "Не ўдалося абнавіць токен, патрабуецца паўторны ўваход",
|
||||
"MessageFeedURLWillBe": "URL стужкі будзе {0}",
|
||||
"MessageFetching": "Атрыманне...",
|
||||
"MessageFollowTheProjectOnGithub": "Сачыце за праектам на Github",
|
||||
@@ -305,8 +321,8 @@
|
||||
"MessageItemMissing": "Элемент адсутнічае і патрабуе выпраўлення на серверы. Звычайна элемент пазначаецца як адсутны, таму што файлавыя шляхі недаступныя.",
|
||||
"MessageLoading": "Загрузка...",
|
||||
"MessageLoadingServerData": "Загрузка даных з сервера...",
|
||||
"MessageLocalFolderDescription": "\"Унутраная памяць прыкладання\" даступная толькі для гэтага прыкладання. Гэта прыкладанне працуе толькі з медыя, якія былі спампаваныя непасрэдна праз яго. Каб іншыя прыкладанні маглі атрымаць доступ да медыя, спампаванага гэтым прыкладаннем, можна выкарыстоўваць агульныя тэчкі.",
|
||||
"MessageMarkAsFinished": "Пазначыць як скончана",
|
||||
"MessageLocalFolderDescription": "\"Унутраная памяць праграмы\" даступная толькі для гэтай праграмы. Гэта праграма працуе толькі з медыя, якія былі спампаваныя непасрэдна праз яго. Каб іншыя праграмы маглі атрымаць доступ да медыя, спампаванага гэтай праграмай, можна выкарыстоўваць абагуленыя папкі.",
|
||||
"MessageMarkAsFinished": "Пазначыць як завершаную",
|
||||
"MessageMediaLinkedToADifferentServer": "Медыя звязаны з серверам Audiobookshelf па іншым адрасе ({0}). Сінхранізацыя прагрэсу адбудзецца пры падключэнні да гэтага адрасу сервера.",
|
||||
"MessageMediaLinkedToADifferentUser": "Медыя звязаны з гэтым серверам, але быў спампаваны іншым карыстальнікам. Прагрэс будзе сінхранізавацца толькі для карыстальніка, які яго спампаваў.",
|
||||
"MessageMediaLinkedToServer": "Звязана з серверам {0}",
|
||||
@@ -319,12 +335,12 @@
|
||||
"MessageNoItemsFound": "Элементы не знойдзены",
|
||||
"MessageNoListeningSessions": "Няма сеансаў праслухоўвання",
|
||||
"MessageNoLogs": "Няма журналаў",
|
||||
"MessageNoMediaFolders": "Няма тэчак з медыя",
|
||||
"MessageNoMediaFolders": "Няма папак з медыя",
|
||||
"MessageNoNetworkConnection": "Няма падлучэння да сеткі",
|
||||
"MessageNoPodcastsFound": "Падкасты не знойдзены",
|
||||
"MessageNoSeries": "Няма серый",
|
||||
"MessageNoUpdatesWereNecessary": "Абнаўленні не патрабаваліся",
|
||||
"MessageNoUserPlaylists": "У вас няма спісаў прайгравання",
|
||||
"MessageNoUserPlaylists": "У вас няма плэй-лістоў",
|
||||
"MessageOldServerAuthReLoginRequired": "Аўтэнтыфікацыя была палепшана для бяспекі ў серверы v2.26.0. Усім карыстальнікам патрабуецца паўторна ўвайсці ў сістэму.",
|
||||
"MessageOldServerAuthWarning": "Сервер выкарыстоўвае састарэлую аўтэнтыфікацыю",
|
||||
"MessageOldServerAuthWarningHelp": "Гэты сервер працуе пад версіяй, старэйшай за 2.26.0. У версіі 2.26.0 была дададзена больш бяспечная сістэма аўтэнтыфікацыі. Настойліва рэкамендуецца абнавіць сервер да апошняй версіі. Калі вы ўжо абнавілі сервер, увайдзіце зноў, каб выкарыстоўваць новую аўтэнтыфікацыю.",
|
||||
@@ -332,9 +348,9 @@
|
||||
"MessageOldServerConnectionWarningHelp": "Вы першапачаткова наладзілі падключэнне да гэтага сервера да міграцыі базы даных у версіі 2.3.0, якая выйшла ў чэрвені 2023 года. У будучым абнаўленні сервера магчымасць уваходу праз гэтае старое падключэнне будзе выдалена. Калі ласка, выдаліце існуючае падключэнне да сервера і падключыцеся зноў (выкарыстоўваючы той жа адрас сервера і ўліковыя даныя). Калі на гэтай прыладзе ёсць спампаваныя медыяфайлы, іх трэба будзе спампаваць зноў для сінхранізацыі з серверам.",
|
||||
"MessagePodcastSearchField": "Увядзіце пошукавы запыт або URL RSS-стужкі",
|
||||
"MessageProgressSyncFailed": "Апошняя спроба адправіць ваш прагрэс праслухоўвання на сервер не ўдалася. Спробы сінхранізаваць прагрэс будуць працягвацца кожныя 15 секунд на працягу 1 хвіліны падчас прайгравання медыяфайла.",
|
||||
"MessageReportBugsAndContribute": "Паведамляйце пра памылкі, прапануйце новыя функцыі і ўдзельнічайце на",
|
||||
"MessageReportBugsAndContribute": "Паведамляйце пра памылкі, прапануйце функцыі і ўносьце свой уклад на",
|
||||
"MessageSeriesAlreadyDownloaded": "Вы ўжо спампавалі ўсе кнігі з гэтай серыі.",
|
||||
"MessageSeriesDownloadConfirm": "Спампаваць адсутныя {0} кніг(і) з {1} файл(амі), агульным памерам {2}, у тэчку {3}?",
|
||||
"MessageSeriesDownloadConfirm": "Спампаваць адсутныя {0} кніг(і) з {1} файлам(-і), агульным памерам {2}, у папку {3}?",
|
||||
"MessageSeriesDownloadConfirmIos": "Спампаваць адсутныя {0} кніг(і) з {1} файл(амі), агульным памерам {2}?",
|
||||
"MessageSocketConnectedOverMeteredCellular": "Сокет падлучаны праз мабільны інтэрнэт з абмежаваным трафікам",
|
||||
"MessageSocketConnectedOverMeteredWifi": "Сокет падлучаны праз Wi-Fi з абмежаваным трафікам",
|
||||
@@ -342,17 +358,19 @@
|
||||
"MessageSocketConnectedOverUnmeteredWifi": "Сокет падлучаны праз Wi-Fi без абмежавання трафіку",
|
||||
"MessageSocketNotConnected": "Сокет не падлучаны",
|
||||
"NoteRSSFeedPodcastAppsHttps": "Папярэджанне: большасць праграм для падкастаў патрабуюць, каб URL RSS-стужкі выкарыстоўваў HTTPS",
|
||||
"NoteRSSFeedPodcastAppsPubDate": "Папярэджанне: адзін ці больш вашых эпізодаў не маюць даты публікацыі. Некаторыя праграмы для падкастаў патрабуюць гэтага.",
|
||||
"NoteRSSFeedPodcastAppsPubDate": "Папярэджанне: адзін ці больш выпускаў не маюць даты публікацыі. Некаторыя праграмы для падкастаў патрабуюць гэтага.",
|
||||
"ToastBookmarkCreateFailed": "Не ўдалося стварыць закладку",
|
||||
"ToastBookmarkRemoveFailed": "Не ўдалося выдаліць закладку",
|
||||
"ToastBookmarkUpdateFailed": "Не ўдалося абнавіць закладку",
|
||||
"ToastDownloadNotAllowedOnCellular": "Спампоўванне забаронена праз мабільны інтэрнэт",
|
||||
"ToastItemMarkedAsFinishedFailed": "Не ўдалося пазначыць як Скончана",
|
||||
"ToastItemMarkedAsNotFinishedFailed": "Не ўдалося пазначыць як Незавершанае",
|
||||
"ToastPlaylistCreateFailed": "Не ўдалося стварыць спіс прайгравання",
|
||||
"ToastItemMarkedAsFinishedFailed": "Не ўдалося пазначыць як завершаны",
|
||||
"ToastItemMarkedAsNotFinishedFailed": "Не ўдалося пазначыць як незавершаны",
|
||||
"ToastPlaylistCreateFailed": "Не ўдалося стварыць плэй-ліст",
|
||||
"ToastPodcastCreateFailed": "Не ўдалося стварыць падкаст",
|
||||
"ToastPodcastCreateSuccess": "Падкаст паспяхова створаны",
|
||||
"ToastRSSFeedCloseFailed": "Не ўдалося закрыць RSS-стужку",
|
||||
"ToastRSSFeedCloseSuccess": "RSS-стужка закрыта",
|
||||
"ToastStreamingNotAllowedOnCellular": "Патокавая перадача праз мабільную сувязь забаронена"
|
||||
"ToastStreamingNotAllowedOnCellular": "Патокавая перадача праз мабільную сувязь забаронена",
|
||||
"UnitMinutesShort": "{0} хв",
|
||||
"UnitSecondsShort": "{0} с"
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
"HeaderChapters": "Глави",
|
||||
"HeaderCollection": "Колекция",
|
||||
"HeaderCollectionItems": "Елемент в колекция",
|
||||
"HeaderConfirm": "Потвърди",
|
||||
"HeaderConnectionStatus": "Състояние на връзката",
|
||||
"HeaderDataSettings": "Настройки на данните",
|
||||
"HeaderDetails": "Детайли",
|
||||
@@ -91,6 +92,7 @@
|
||||
"HeaderStatsRecentSessions": "Последни сесии",
|
||||
"HeaderTableOfContents": "Съдържание",
|
||||
"HeaderUserInterfaceSettings": "Настройки на потребителския интерфейс",
|
||||
"HeaderWelcome": "Добре дошли, <strong>{0}</strong>",
|
||||
"HeaderYourStats": "Вашата статистика",
|
||||
"LabelAddToPlaylist": "Добави в плейлист",
|
||||
"LabelAddedAt": "Добавено в",
|
||||
@@ -113,6 +115,7 @@
|
||||
"LabelAutoSleepTimerAutoRewindHelp": "Когато автоматичният таймер на изключване изтече, повторното пускане на елемента автоматично ще върне позицията ви назад.",
|
||||
"LabelAutoSleepTimerHelp": "Когато се възпроизвеждате медия между посочените начален и краен час, автоматично ще стартира таймер за изключване.",
|
||||
"LabelBooks": "Книги",
|
||||
"LabelByAuthor": "от {0}",
|
||||
"LabelChapterTrack": "Трак на глава",
|
||||
"LabelChapters": "Глави",
|
||||
"LabelClosePlayer": "Затвори плейъра",
|
||||
@@ -155,6 +158,9 @@
|
||||
"LabelFinished": "Дата на приключване",
|
||||
"LabelFolder": "Папка",
|
||||
"LabelFontBoldness": "Дебелина на шрифта",
|
||||
"LabelFontFamily": "Шрифт",
|
||||
"LabelFontFamilySans": "Sans",
|
||||
"LabelFontFamilySerif": "Serif",
|
||||
"LabelFontScale": "Мащаб на шрифта",
|
||||
"LabelGenre": "Жанр",
|
||||
"LabelGenres": "Жанрове",
|
||||
@@ -174,6 +180,9 @@
|
||||
"LabelLayout": "Оформление",
|
||||
"LabelLayoutAuto": "Авто",
|
||||
"LabelLayoutSinglePage": "Единична страница",
|
||||
"LabelLibrarySortByProgress": "Прогрес: Последно обновление",
|
||||
"LabelLibrarySortByProgressFinished": "Прогрес: Приключено",
|
||||
"LabelLibrarySortByProgressStarted": "Прогрес: Започнато",
|
||||
"LabelLight": "Светло",
|
||||
"LabelLineSpacing": "Междуредие",
|
||||
"LabelListenAgain": "Слушай отново",
|
||||
@@ -233,6 +242,7 @@
|
||||
"LabelScaleElapsedTimeBySpeed": "Скалируй изминалото време спрямо скоростта",
|
||||
"LabelSeason": "Сезон",
|
||||
"LabelSelectADevice": "Избери устройство",
|
||||
"LabelSelectMediaType": "Изберете тип на медията",
|
||||
"LabelSequenceAscending": "Възходяща последователност",
|
||||
"LabelSequenceDescending": "Низходяща последователност",
|
||||
"LabelSeries": "От сериите",
|
||||
@@ -285,12 +295,17 @@
|
||||
"MessageAudiobookshelfServerNotConnected": "Audiobookshelf сървър не е включен",
|
||||
"MessageAudiobookshelfServerRequired": "<strong>ВАЖНО!</strong> Това приложение е създадено да работи с Audiobookshelf сървър, който вие или някой, когото познавате, хоства. Това приложение не предоставя никакво съдържание.",
|
||||
"MessageBookshelfEmpty": "Библиотеката е празна",
|
||||
"MessageConfirmAppExit": "Искате ли да излезете от апликацията?",
|
||||
"MessageConfirmDeleteEpisodeDownloadQueue": "Сигурни ли сте, че искате да изчистите опашката за сваляне на епизоди?",
|
||||
"MessageConfirmDeleteLocalEpisode": "Премахни \"{0}\" епизода от устройството? Файловете на сървъра няма да бъдат премахнати.",
|
||||
"MessageConfirmDeleteLocalFiles": "Да премахна ли локалните файлове на този елемент от вашето устройство? Файловете на сървъра и вашият напредък няма да бъдат засегнати.",
|
||||
"MessageConfirmDeleteServerConfig": "Премахнете тази сървърна настройка?",
|
||||
"MessageConfirmDeleteServerEpisode": "Сигурни ли сте че искате да изтрийете епизод \"{0}\" от сървъра?\nПредупреждение: Това ще изтрийе и аудио файла.",
|
||||
"MessageConfirmDisableAutoTimer": "Сигурни ли сте, че искате да деактивирате автоматичния таймер за остатъка от днешния ден? Таймерът ще бъде активиран отново в края на този период на автоматичния таймер за изключване или ако рестартирате приложението.",
|
||||
"MessageConfirmDiscardProgress": "Сигурни ли сте, че искате да нулирате напредъка си?",
|
||||
"MessageConfirmDownloadUsingCellular": "Ще изтегляте, използвайки мобилни данни. Това може да включва такси за данни от оператора. Искате ли да продължите?",
|
||||
"MessageConfirmMarkAsFinished": "Сигурни ли сте, че искате да маркирате този елемент като завършен?",
|
||||
"MessageConfirmPlaybackTime": "Започни възпроизвеждане на \"{0}\" в {1}?",
|
||||
"MessageConfirmRemoveBookmark": "Сигурни ли сте, че искате да премахнете отметката?",
|
||||
"MessageConfirmStreamingUsingCellular": "Ще стриймвате, използвайки мобилни данни. Това може да включва такси за данни от оператора. Искате ли да продължите?",
|
||||
"MessageDiscardProgress": "Нулирай прогреса",
|
||||
@@ -355,5 +370,7 @@
|
||||
"ToastPodcastCreateSuccess": "Подкаст успешно създаден",
|
||||
"ToastRSSFeedCloseFailed": "Неуспешно затваряне на RSS емисията",
|
||||
"ToastRSSFeedCloseSuccess": "RSS емисията е затворена",
|
||||
"ToastStreamingNotAllowedOnCellular": "Стриймването не е разрешено чрез мобилни данни"
|
||||
"ToastStreamingNotAllowedOnCellular": "Стриймването не е разрешено чрез мобилни данни",
|
||||
"UnitMinutesShort": "{0}мин",
|
||||
"UnitSecondsShort": "{0}сек"
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
"HeaderChapters": "Capítols",
|
||||
"HeaderCollection": "Col·lecció",
|
||||
"HeaderCollectionItems": "Elements de la col·lecció",
|
||||
"HeaderConfirm": "Confirma",
|
||||
"HeaderConnectionStatus": "Estat de la connexió",
|
||||
"HeaderDataSettings": "Paràmetres de dades",
|
||||
"HeaderDetails": "Detalls",
|
||||
@@ -91,6 +92,7 @@
|
||||
"HeaderStatsRecentSessions": "Sessions recents",
|
||||
"HeaderTableOfContents": "Sumari",
|
||||
"HeaderUserInterfaceSettings": "Paràmetres de la interfície d'usuari",
|
||||
"HeaderWelcome": "Benvingut, <strong>{0}</strong>",
|
||||
"HeaderYourStats": "Les vostres estadístiques",
|
||||
"LabelAddToPlaylist": "Afegeix a la llista de reproducció",
|
||||
"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ó.",
|
||||
"LabelAutoSleepTimerHelp": "Quan es reprodueix entre hores especificades, el temporitzador s'activarà automàticament.",
|
||||
"LabelBooks": "Llibres",
|
||||
"LabelByAuthor": "per {0}",
|
||||
"LabelChapterTrack": "Seguiment de capítol",
|
||||
"LabelChapters": "Capítols",
|
||||
"LabelClosePlayer": "Tanca el reproductor",
|
||||
@@ -126,11 +129,12 @@
|
||||
"LabelCustomTime": "Temps personalitzat",
|
||||
"LabelDescription": "Descripció",
|
||||
"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",
|
||||
"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.",
|
||||
"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",
|
||||
"LabelDownload": "Descarregar",
|
||||
"LabelDownloadUsingCellular": "Descarregar utilitzant dades mòbils",
|
||||
@@ -144,6 +148,7 @@
|
||||
"LabelEndOfChapter": "Final del capítol",
|
||||
"LabelEndTime": "Hora de finalització",
|
||||
"LabelEpisode": "Episodi",
|
||||
"LabelExplicit": "Explícit",
|
||||
"LabelFeedURL": "Font URL",
|
||||
"LabelFile": "Fitxer",
|
||||
"LabelFileBirthtime": "Fitxer creat a",
|
||||
@@ -152,22 +157,31 @@
|
||||
"LabelFinished": "Acabat",
|
||||
"LabelFolder": "Carpeta",
|
||||
"LabelFontBoldness": "Gruix de la lletra",
|
||||
"LabelFontFamily": "Família tipogràfica",
|
||||
"LabelFontFamilySans": "Sans",
|
||||
"LabelFontFamilySerif": "Serif",
|
||||
"LabelFontScale": "Escala de la lletra",
|
||||
"LabelGenre": "Gènere",
|
||||
"LabelGenres": "Gèneres",
|
||||
"LabelHapticFeedback": "Resposta hàptica",
|
||||
"LabelHasEbook": "Té llibre electrònic",
|
||||
"LabelHasSupplementaryEbook": "Té llibre electrònic suplementari",
|
||||
"LabelHeavy": "Pesat",
|
||||
"LabelHigh": "Alt",
|
||||
"LabelHost": "Amfitrió",
|
||||
"LabelInProgress": "En procés",
|
||||
"LabelIncomplete": "Incomplet",
|
||||
"LabelInternalAppStorage": "Emmagatzematge intern de l'aplicació",
|
||||
"LabelJumpBackwardsTime": "Saltar enrere",
|
||||
"LabelJumpForwardsTime": "Salt endavant en el temps",
|
||||
"LabelKeepScreenAwake": "Manté la pantalla encesa",
|
||||
"LabelLanguage": "Llengua",
|
||||
"LabelLayout": "Disposició",
|
||||
"LabelLayoutAuto": "Automàtic",
|
||||
"LabelLayoutSinglePage": "Pàgina única",
|
||||
"LabelLibrarySortByProgress": "Progrés: Última actualització",
|
||||
"LabelLibrarySortByProgressFinished": "Progrés: Finalitzat",
|
||||
"LabelLibrarySortByProgressStarted": "Progrés: Començat",
|
||||
"LabelLight": "Clar",
|
||||
"LabelLineSpacing": "Interlineat",
|
||||
"LabelListenAgain": "Escoltar de nou",
|
||||
@@ -175,14 +189,17 @@
|
||||
"LabelLocalPodcasts": "Pòdcasts locals",
|
||||
"LabelLockOrientation": "Bloca l'orientació",
|
||||
"LabelLockPlayer": "Bloca el reproductor",
|
||||
"LabelLow": "Baix",
|
||||
"LabelMediaType": "Tipus de mitjà",
|
||||
"LabelMedium": "Mitjà",
|
||||
"LabelMissing": "Falta",
|
||||
"LabelMore": "Més",
|
||||
"LabelMoreInfo": "Més informació",
|
||||
"LabelName": "Nom",
|
||||
"LabelNarrator": "Narrador",
|
||||
"LabelNarrators": "Narradors",
|
||||
"LabelNavigateWithVolume": "Navega amb les tecles de volum",
|
||||
"LabelNavigateWithVolumeMirrored": "Emmirallat",
|
||||
"LabelNavigateWithVolumeWhilePlayingDisabled": "⭘",
|
||||
"LabelNavigateWithVolumeWhilePlayingEnabled": "⏽",
|
||||
"LabelNever": "Mai",
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
"ButtonPlaylists": "Seznamy skladeb",
|
||||
"ButtonRead": "Číst",
|
||||
"ButtonReadLess": "Číst méně",
|
||||
"ButtonReadMore": "Číst další",
|
||||
"ButtonReadMore": "Číst více",
|
||||
"ButtonRemove": "Odstranit",
|
||||
"ButtonRemoveFromServer": "Odstranit ze serveru",
|
||||
"ButtonSave": "Uložit",
|
||||
@@ -63,6 +63,7 @@
|
||||
"HeaderChapters": "Kapitoly",
|
||||
"HeaderCollection": "Kolekce",
|
||||
"HeaderCollectionItems": "Položky kolekce",
|
||||
"HeaderConfirm": "Potvrzení",
|
||||
"HeaderConnectionStatus": "Stav připojení",
|
||||
"HeaderDataSettings": "Nastavení dat",
|
||||
"HeaderDetails": "Podrobnosti",
|
||||
@@ -82,7 +83,7 @@
|
||||
"HeaderProgressSyncFailed": "Chyba při synchronizaci pokroku",
|
||||
"HeaderRSSFeed": "RSS kanál",
|
||||
"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í",
|
||||
"HeaderSettings": "Nastavení",
|
||||
"HeaderSleepTimer": "Časovač spánku",
|
||||
@@ -91,6 +92,7 @@
|
||||
"HeaderStatsRecentSessions": "Poslední sezení",
|
||||
"HeaderTableOfContents": "Obsah",
|
||||
"HeaderUserInterfaceSettings": "Nastavení uživatelského rozhraní",
|
||||
"HeaderWelcome": "Vítejte, <strong>{0}</strong>",
|
||||
"HeaderYourStats": "Vaše statistiky",
|
||||
"LabelAddToPlaylist": "Přidat do seznamu skladeb",
|
||||
"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.",
|
||||
"LabelAutoSleepTimerHelp": "Během přehrávání média v časovém rozmezí \"Od\" a \"Do\" se automaticky spustí časovač spánku.",
|
||||
"LabelBooks": "Knihy",
|
||||
"LabelByAuthor": "od {0}",
|
||||
"LabelChapterTrack": "Stopa kapitoly",
|
||||
"LabelChapters": "Kapitoly",
|
||||
"LabelClosePlayer": "Zavřít přehrávač",
|
||||
@@ -146,7 +149,7 @@
|
||||
"LabelEndOfChapter": "Konec kapitoly",
|
||||
"LabelEndTime": "Do",
|
||||
"LabelEpisode": "Epizoda",
|
||||
"LabelExplicit": "Explicitně",
|
||||
"LabelExplicit": "Explicitní",
|
||||
"LabelFeedURL": "URL kanálu",
|
||||
"LabelFile": "Soubor",
|
||||
"LabelFileBirthtime": "Čas vytvoření souboru",
|
||||
@@ -155,13 +158,16 @@
|
||||
"LabelFinished": "Dokončeno",
|
||||
"LabelFolder": "Složka",
|
||||
"LabelFontBoldness": "Výraznost písma",
|
||||
"LabelFontScale": "Měřítko písma",
|
||||
"LabelFontFamily": "Rodina písem",
|
||||
"LabelFontFamilySans": "Bezpatkové",
|
||||
"LabelFontFamilySerif": "Patkové",
|
||||
"LabelFontScale": "Velikost písma",
|
||||
"LabelGenre": "Žánr",
|
||||
"LabelGenres": "Žánry",
|
||||
"LabelHapticFeedback": "Haptická odezva",
|
||||
"LabelHasEbook": "Má e-knihu",
|
||||
"LabelHasSupplementaryEbook": "Má doplňkovou e-knihu",
|
||||
"LabelHeavy": "Těžké",
|
||||
"LabelHasSupplementaryEbook": "Obsahuje doplňkovou e-knihu",
|
||||
"LabelHeavy": "Silná",
|
||||
"LabelHigh": "Vysoké",
|
||||
"LabelHost": "Hostitel",
|
||||
"LabelInProgress": "Probíhá",
|
||||
@@ -174,7 +180,10 @@
|
||||
"LabelLayout": "Rozvržení",
|
||||
"LabelLayoutAuto": "Automatické",
|
||||
"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í",
|
||||
"LabelListenAgain": "Poslouchat znovu",
|
||||
"LabelLocalBooks": "Místní knihy",
|
||||
@@ -204,7 +213,7 @@
|
||||
"LabelNumEpisodes": "{0} epizod",
|
||||
"LabelNumEpisodesIncomplete": "{0} epizod, {1} nekompletní",
|
||||
"LabelNumberOfEpisodes": "Počet epizod",
|
||||
"LabelOff": "Vypnout",
|
||||
"LabelOff": "Vypnutá",
|
||||
"LabelOn": "Zapnuto",
|
||||
"LabelPassword": "Heslo",
|
||||
"LabelPath": "Cesta",
|
||||
@@ -233,6 +242,7 @@
|
||||
"LabelScaleElapsedTimeBySpeed": "Škálovat uplynulý čas podle rychlosti",
|
||||
"LabelSeason": "Sezóna",
|
||||
"LabelSelectADevice": "Vyberte zařízení",
|
||||
"LabelSelectMediaType": "Vyberte typ média",
|
||||
"LabelSequenceAscending": "Řadit vzestupně",
|
||||
"LabelSequenceDescending": "Řadit sestupně",
|
||||
"LabelSeries": "Série",
|
||||
@@ -285,12 +295,17 @@
|
||||
"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.",
|
||||
"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ý.",
|
||||
"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.",
|
||||
"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?",
|
||||
"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?",
|
||||
"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",
|
||||
@@ -355,5 +370,7 @@
|
||||
"ToastPodcastCreateSuccess": "Podcast byl úspěšně vytvořen",
|
||||
"ToastRSSFeedCloseFailed": "Nepodařilo se zavřít RSS kanál",
|
||||
"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",
|
||||
"HeaderCollection": "Samling",
|
||||
"HeaderCollectionItems": "Samlingselementer",
|
||||
"HeaderConfirm": "Bekræft",
|
||||
"HeaderConnectionStatus": "Forbindelsesstatus",
|
||||
"HeaderDataSettings": "Data indstillinger",
|
||||
"HeaderDetails": "Detaljer",
|
||||
@@ -91,6 +92,7 @@
|
||||
"HeaderStatsRecentSessions": "Seneste Sessions",
|
||||
"HeaderTableOfContents": "Indholdsfortegnelse",
|
||||
"HeaderUserInterfaceSettings": "Indstillinger for brugergrænseflade",
|
||||
"HeaderWelcome": "Velkommen, <strong>{0}</strong>",
|
||||
"HeaderYourStats": "Dine Statistikker",
|
||||
"LabelAddToPlaylist": "Tilføj til Afspilningsliste",
|
||||
"LabelAddedAt": "Tilføjet",
|
||||
@@ -108,11 +110,12 @@
|
||||
"LabelAuthors": "Forfattere",
|
||||
"LabelAutoDownloadEpisodes": "Auto Download Episoder",
|
||||
"LabelAutoRewindTime": "Automatisk tilbagespolingstid",
|
||||
"LabelAutoSleepTimer": "Auto sleep timer",
|
||||
"LabelAutoSleepTimer": "Auto søvn-timer",
|
||||
"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.",
|
||||
"LabelAutoSleepTimerHelp": "Når der afspilles medie mellem de angivne start- og sluttidspunkter, starter en sleep-timer automatisk.",
|
||||
"LabelBooks": "Bøger",
|
||||
"LabelByAuthor": "Efter Forfatter",
|
||||
"LabelChapterTrack": "Kapitel spor",
|
||||
"LabelChapters": "Kapitler",
|
||||
"LabelClosePlayer": "Luk afspiller",
|
||||
@@ -155,6 +158,9 @@
|
||||
"LabelFinished": "Færdig",
|
||||
"LabelFolder": "Mappe",
|
||||
"LabelFontBoldness": "Skrift tykkelse",
|
||||
"LabelFontFamily": "Skrifttypefamilie",
|
||||
"LabelFontFamilySans": "Font Sans",
|
||||
"LabelFontFamilySerif": "Font Serif",
|
||||
"LabelFontScale": "Skriftstørrelse",
|
||||
"LabelGenre": "Genre",
|
||||
"LabelGenres": "Genrer",
|
||||
@@ -174,6 +180,9 @@
|
||||
"LabelLayout": "Layout",
|
||||
"LabelLayoutAuto": "Automatisk",
|
||||
"LabelLayoutSinglePage": "Enkel Side",
|
||||
"LabelLibrarySortByProgress": "Fremgang: Sidst opdateret",
|
||||
"LabelLibrarySortByProgressFinished": "Fremgang: Afsluttet",
|
||||
"LabelLibrarySortByProgressStarted": "Fremgang: Startet",
|
||||
"LabelLight": "Lys",
|
||||
"LabelLineSpacing": "Linjeafstand",
|
||||
"LabelListenAgain": "Lyt Igen",
|
||||
@@ -233,6 +242,7 @@
|
||||
"LabelScaleElapsedTimeBySpeed": "Skalér Forløbet Tid med Hastighed",
|
||||
"LabelSeason": "Sæson",
|
||||
"LabelSelectADevice": "Vælg en Enhed",
|
||||
"LabelSelectMediaType": "Vælg medietype",
|
||||
"LabelSequenceAscending": "Sekvens Stigende",
|
||||
"LabelSequenceDescending": "Sekvens Faldende",
|
||||
"LabelSeries": "Serie",
|
||||
@@ -283,10 +293,13 @@
|
||||
"MessageAndroid10Downloads": "Android 10 og mindre vil bruge intern lagerplads til downloads.",
|
||||
"MessageAttemptingServerConnection": "Prøver at tilgå server...",
|
||||
"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",
|
||||
"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.",
|
||||
"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.",
|
||||
"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?",
|
||||
@@ -298,6 +311,7 @@
|
||||
"MessageDownloading": "Downloader...",
|
||||
"MessageDownloadingEpisode": "Downloader episode",
|
||||
"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}",
|
||||
"MessageFetching": "Henter...",
|
||||
"MessageFollowTheProjectOnGithub": "Følg projektet på GitHub",
|
||||
@@ -347,12 +361,14 @@
|
||||
"ToastBookmarkRemoveFailed": "Mislykkedes fjernelse af bogmærke",
|
||||
"ToastBookmarkUpdateFailed": "Mislykkedes opdatering af bogmærke",
|
||||
"ToastDownloadNotAllowedOnCellular": "Det er ikke tillad at downloade over mobildata",
|
||||
"ToastItemMarkedAsFinishedFailed": "Mislykkedes markering som afsluttet",
|
||||
"ToastItemMarkedAsNotFinishedFailed": "Mislykkedes markering som ikke afsluttet",
|
||||
"ToastItemMarkedAsFinishedFailed": "Markering som afsluttet mislykkedes",
|
||||
"ToastItemMarkedAsNotFinishedFailed": "Markering som ikke afsluttet mislykkedes",
|
||||
"ToastPlaylistCreateFailed": "Mislykkedes oprettelse af afspilningsliste",
|
||||
"ToastPodcastCreateFailed": "Mislykkedes oprettelse af podcast",
|
||||
"ToastPodcastCreateSuccess": "Podcast oprettet med succes",
|
||||
"ToastRSSFeedCloseFailed": "Mislykkedes lukning af RSS-feed",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"ButtonMaskServerAddress": "Server Adresse verstecken",
|
||||
"ButtonNewFolder": "Neuer Ordner",
|
||||
"ButtonNextEpisode": "Nächste Episode",
|
||||
"ButtonOk": "Ok",
|
||||
"ButtonOk": "OK",
|
||||
"ButtonOpenFeed": "Feed öffnen",
|
||||
"ButtonOverride": "Überschreiben",
|
||||
"ButtonPause": "Pausieren",
|
||||
@@ -40,18 +40,18 @@
|
||||
"ButtonPlayEpisode": "Episode abspielen",
|
||||
"ButtonPlaylists": "Wiedergabelisten",
|
||||
"ButtonRead": "Lesen",
|
||||
"ButtonReadLess": "weniger Anzeigen",
|
||||
"ButtonReadLess": "Weniger Anzeigen",
|
||||
"ButtonReadMore": "Mehr Anzeigen",
|
||||
"ButtonRemove": "Entfernen",
|
||||
"ButtonRemoveFromServer": "Vom Server entfernen",
|
||||
"ButtonSave": "Speichern",
|
||||
"ButtonSaveOrder": "Reihenfolge speichern",
|
||||
"ButtonSearch": "Suchen",
|
||||
"ButtonSendEbookToDevice": "E-Buch an Gerät senden",
|
||||
"ButtonSendEbookToDevice": "E-Book an Gerät senden",
|
||||
"ButtonSeries": "Serien",
|
||||
"ButtonSetTimer": "Setze Timer",
|
||||
"ButtonStream": "Streamen",
|
||||
"ButtonSubmit": "Ok",
|
||||
"ButtonSubmit": "Absenden",
|
||||
"ButtonSwitchServerUser": "Server/Benutzer wechseln",
|
||||
"ButtonUnmaskServerAddress": "Server Adresse anzeigen",
|
||||
"ButtonUserStats": "Nutzerstatistiken",
|
||||
@@ -63,17 +63,18 @@
|
||||
"HeaderChapters": "Kapitel",
|
||||
"HeaderCollection": "Sammlung",
|
||||
"HeaderCollectionItems": "Sammlungseinträge",
|
||||
"HeaderConfirm": "Bestätigen",
|
||||
"HeaderConnectionStatus": "Verbindungsstatus",
|
||||
"HeaderDataSettings": "Daten-Einstellungen",
|
||||
"HeaderDetails": "Details",
|
||||
"HeaderDownloads": "Downloads",
|
||||
"HeaderEbookFiles": "E-Buch-Dateien",
|
||||
"HeaderEbookFiles": "E-Book-Dateien",
|
||||
"HeaderEpisodes": "Episoden",
|
||||
"HeaderEreaderSettings": "Einstellungen zum Lesen",
|
||||
"HeaderEreaderSettings": "E-Reader-Einstellungen",
|
||||
"HeaderLatestEpisodes": "Neueste Episoden",
|
||||
"HeaderLibraries": "Bibliotheken",
|
||||
"HeaderLocalFolders": "Lokale Ordner",
|
||||
"HeaderLocalLibraryItems": "Elemente der lokalen Bibliothek",
|
||||
"HeaderLocalLibraryItems": "Objekte in der lokalen Bibliothek",
|
||||
"HeaderNewPlaylist": "Neue Wiedergabeliste",
|
||||
"HeaderOpenRSSFeed": "RSS-Feed öffnen",
|
||||
"HeaderPlaybackSettings": "Wiedergabe-Einstellungen",
|
||||
@@ -91,6 +92,7 @@
|
||||
"HeaderStatsRecentSessions": "Neueste Ereignisse",
|
||||
"HeaderTableOfContents": "Inhaltsverzeichnis",
|
||||
"HeaderUserInterfaceSettings": "Einstellungen der Benutzeroberfläche",
|
||||
"HeaderWelcome": "Willkommen, <strong>{0}</strong>",
|
||||
"HeaderYourStats": "Eigene Statistiken",
|
||||
"LabelAddToPlaylist": "Zur Wiedergabeliste hinzufügen",
|
||||
"LabelAddedAt": "Hinzugefügt am",
|
||||
@@ -113,13 +115,14 @@
|
||||
"LabelAutoSleepTimerAutoRewindHelp": "Wenn die Schlummerfunktion abgelaufen ist, wird bei der erneuten Wiedergabe des Titels die Position automatisch zurückgespult.",
|
||||
"LabelAutoSleepTimerHelp": "Bei der Wiedergabe von Medien zwischen der angegebenen Start- und Endzeit wird automatisch eine Schlummerfunktion gestartet.",
|
||||
"LabelBooks": "Bücher",
|
||||
"LabelByAuthor": "von {0}",
|
||||
"LabelChapterTrack": "Kapitelspur",
|
||||
"LabelChapters": "Kapitel",
|
||||
"LabelClosePlayer": "Player schließen",
|
||||
"LabelCollapseSeries": "Serien einklappen",
|
||||
"LabelComplete": "Vollständig",
|
||||
"LabelContinueBooks": "Bücher fortfahren",
|
||||
"LabelContinueEpisodes": "Episoden fortfahren",
|
||||
"LabelContinueBooks": "Bücher fortsetzen",
|
||||
"LabelContinueEpisodes": "Episoden fortsetzen",
|
||||
"LabelContinueListening": "Weiterhören",
|
||||
"LabelContinueReading": "Weiterlesen",
|
||||
"LabelContinueSeries": "Serie fortsetzen",
|
||||
@@ -155,6 +158,9 @@
|
||||
"LabelFinished": "Beendet",
|
||||
"LabelFolder": "Ordner",
|
||||
"LabelFontBoldness": "Schriftstärke",
|
||||
"LabelFontFamily": "Schriftfamilie",
|
||||
"LabelFontFamilySans": "Sans",
|
||||
"LabelFontFamilySerif": "Serifen",
|
||||
"LabelFontScale": "Schriftgröße",
|
||||
"LabelGenre": "Kategorie",
|
||||
"LabelGenres": "Kategorien",
|
||||
@@ -174,6 +180,9 @@
|
||||
"LabelLayout": "Ansicht",
|
||||
"LabelLayoutAuto": "Auto",
|
||||
"LabelLayoutSinglePage": "Eine Seite",
|
||||
"LabelLibrarySortByProgress": "Fortschritt: Letzte Aktualisierung",
|
||||
"LabelLibrarySortByProgressFinished": "Fortschritt: Beendet",
|
||||
"LabelLibrarySortByProgressStarted": "Fortschritt: Gestartet",
|
||||
"LabelLight": "Leicht",
|
||||
"LabelLineSpacing": "Zeilenabstand",
|
||||
"LabelListenAgain": "Erneut anhören",
|
||||
@@ -233,6 +242,7 @@
|
||||
"LabelScaleElapsedTimeBySpeed": "Vergangene Zeit anhand der Geschwindigkeit skalieren",
|
||||
"LabelSeason": "Staffel",
|
||||
"LabelSelectADevice": "Wähle ein Gerät",
|
||||
"LabelSelectMediaType": "Wähle die Art des Mediums",
|
||||
"LabelSequenceAscending": "Reihenfolge aufsteigend",
|
||||
"LabelSequenceDescending": "Reihenfolge absteigend",
|
||||
"LabelSeries": "Serien",
|
||||
@@ -285,12 +295,17 @@
|
||||
"MessageAudiobookshelfServerNotConnected": "Audiobookshelf Server nicht verbunden",
|
||||
"MessageAudiobookshelfServerRequired": "<strong>Wichtig!</strong> Diese App wurde entwickelt, um mit einem Audiobookshelf-Server zu arbeiten, den du oder jemand, den du kennst, hostet. Diese App stellt keine Inhalte zur Verfügung.",
|
||||
"MessageBookshelfEmpty": "Bücherregal leer",
|
||||
"MessageConfirmAppExit": "Möchten Sie die App schließen?",
|
||||
"MessageConfirmDeleteEpisodeDownloadQueue": "Sind Sie sicher, dass Sie die Warteschlange für Epsioden-Downloads leeren wollen?",
|
||||
"MessageConfirmDeleteLocalEpisode": "Soll die lokale Episode \"{0}\" von deinem Gerät entfernt werden? Die Datei auf dem Server bleibt davon unberührt.",
|
||||
"MessageConfirmDeleteLocalFiles": "Sollen lokale Dateien dieses Elements von deinem Gerät entfernt werden? Die Dateien auf dem Server und Ihr Fortschritt bleiben davon unberührt.",
|
||||
"MessageConfirmDeleteServerConfig": "Diese Server-Konfiguration entfernen?",
|
||||
"MessageConfirmDeleteServerEpisode": "Sind Sie sicher, dass Sie die Episode \"{0}\" vom Server löschen wollen?\nWarnung: Damit wird die Audio-Datei gelöscht.",
|
||||
"MessageConfirmDisableAutoTimer": "Bist du sicher, dass du den automatischen Timer für den Rest des Tages deaktivieren möchtest? Der Timer wird am Ende dieser automatischen Schlaf-Timer-Periode oder beim Neustart der App wieder aktiviert.",
|
||||
"MessageConfirmDiscardProgress": "Bist du sicher, dass du deinen Fortschritt zurücksetzen willst?",
|
||||
"MessageConfirmDownloadUsingCellular": "Du bist dabei, über mobile Daten herunterzuladen. Dies kann zu Gebühren deines Mobilfunkanbieters führen. Möchtest du fortfahren?",
|
||||
"MessageConfirmMarkAsFinished": "Bist du sicher, dass du diesen Artikel als beendet markieren willst?",
|
||||
"MessageConfirmPlaybackTime": "Wiedergabe von \"{0}\" bei {1} starten?",
|
||||
"MessageConfirmRemoveBookmark": "Bist du sicher, dass du das Lesezeichen entfernen willst?",
|
||||
"MessageConfirmStreamingUsingCellular": "Du bist dabei, über mobile Daten zu streamen. Dies kann zu Gebühren deines Mobilfunkanbieters führen. Möchtest du fortfahren?",
|
||||
"MessageDiscardProgress": "Fortschritt verwerfen",
|
||||
@@ -335,8 +350,8 @@
|
||||
"MessageProgressSyncFailed": "Der letzte Versuch den aktuellen Hörfortschritt an den Server zu melden ist fehlgeschlagen. Die Anfragen den Fortschritt zu synchronisieren wird alle 15 Sekunden bis 1 Minute versucht, während Medien abgespielt werden.",
|
||||
"MessageReportBugsAndContribute": "Fehler melden, Funktionen anfordern und mitwirken",
|
||||
"MessageSeriesAlreadyDownloaded": "Du hast bereits alle Bücher in dieser Serie heruntergeladen.",
|
||||
"MessageSeriesDownloadConfirm": "Lade {0} Bücher mit {1} Daten, zusammen {2}, in den Ordner {3} herunter?",
|
||||
"MessageSeriesDownloadConfirmIos": "Lade {0} Bücher mit {1} Daten, zusammen {2}, herunter?",
|
||||
"MessageSeriesDownloadConfirm": "Fehlende(s) {0} Buch/Bücher mit {1} Datei(en), insgesamt {2}, in den Ordner {3} herunterladen?",
|
||||
"MessageSeriesDownloadConfirmIos": "Fehlende(s) {0} Buch/Bücher mit {1} Datei(en), insgesamt {2}, herunterladen?",
|
||||
"MessageSocketConnectedOverMeteredCellular": "Socket verbunden über getaktetes Mobilfunknetz",
|
||||
"MessageSocketConnectedOverMeteredWifi": "Socket verbunden über getaktetes WLAN",
|
||||
"MessageSocketConnectedOverUnmeteredCellular": "Socket verbunden über ungetaktetes Mobilfunknetz",
|
||||
@@ -355,5 +370,7 @@
|
||||
"ToastPodcastCreateSuccess": "Podcast erstellt",
|
||||
"ToastRSSFeedCloseFailed": "RSS-Feed konnte nicht geschlossen werden",
|
||||
"ToastRSSFeedCloseSuccess": "RSS-Feed geschlossen",
|
||||
"ToastStreamingNotAllowedOnCellular": "Das Streamen über mobile Daten ist nicht erlaubt"
|
||||
"ToastStreamingNotAllowedOnCellular": "Das Streamen über mobile Daten ist nicht erlaubt",
|
||||
"UnitMinutesShort": "{0}m",
|
||||
"UnitSecondsShort": "{0}s"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
{
|
||||
"ButtonAdd": "Προσθήκη",
|
||||
"ButtonAddNewServer": "Προσθήκη Νέου Διακομιστή",
|
||||
"ButtonAuthors": "Συγγραφείς",
|
||||
"ButtonBack": "Πίσω",
|
||||
"ButtonCancel": "Ακύρωση",
|
||||
"ButtonCancelTimer": "Ακύρωση Χρονόμετρου",
|
||||
"ButtonClearFilter": "Διαγραφή Φίλτρου",
|
||||
"ButtonClearLogs": "Διαγραφή Αρχείων Καταγραφής",
|
||||
"ButtonCloseFeed": "Κλείσιμο Τροφοδοσίας",
|
||||
"ButtonCollections": "Συλλογές",
|
||||
"ButtonConnect": "Σύνδεση",
|
||||
"ButtonConnectToServer": "Σύνδεση σε Διακομιστή",
|
||||
"ButtonCreate": "Δημιουργία",
|
||||
"ButtonCreateBookmark": "Δημιουργία Σελιδοδείκτη",
|
||||
"ButtonCreateNewPlaylist": "Δημιουργία Νέας Λίστας Αναπαραγωγής",
|
||||
"ButtonDelete": "Διαγραφή",
|
||||
"ButtonDeleteLocalEpisode": "Διαγραφή τοπικού επεισοδίου",
|
||||
"ButtonDeleteLocalFile": "Διαγραφή τοπικού αρχείου",
|
||||
"ButtonDeleteLocalItem": "Διαγραφή τοπικού αντικειμένου",
|
||||
"ButtonDisableAutoTimer": "Απενεργοποίηση Αυτόματου Χρονόμετρου",
|
||||
"ButtonDisconnect": "Αποσύνδεση Σύνδσης",
|
||||
"ButtonGoToWebClient": "Μετάβαση στον Web Client",
|
||||
"ButtonHistory": "Ιστορικό",
|
||||
"ButtonHome": "Αρχική",
|
||||
"ButtonIssues": "Θέματα",
|
||||
"ButtonLatest": "Τελευταία",
|
||||
"ButtonLibrary": "Βιβλιοθήκη",
|
||||
"ButtonLocalMedia": "Τοπικά Μέσα",
|
||||
"ButtonLogs": "Αρχεία Καταγραφής",
|
||||
"ButtonManageLocalFiles": "Διαχείριση Τοπικών Αρχείων",
|
||||
"ButtonMaskServerAddress": "Απόκρυψη διέθυνσης διακομιστή",
|
||||
"ButtonNewFolder": "Νέος Φάκελος",
|
||||
"ButtonNextEpisode": "Επόμενο Επεισόδιο",
|
||||
"ButtonOk": "Εντάξει",
|
||||
"ButtonOpenFeed": "Άνοιγμα Τροφοδοσίας",
|
||||
"ButtonOverride": "Παράκαμψη",
|
||||
"ButtonPause": "Παύση",
|
||||
"ButtonPlay": "Αναπαραγωγή",
|
||||
"ButtonPlayEpisode": "Αναπαραγωγή Επεισοδίου",
|
||||
"ButtonPlaylists": "Λίστες Αναπαραγωγής",
|
||||
"ButtonRead": "Ανάγνωση",
|
||||
"ButtonReadLess": "Ανάγνωση λιγότερων",
|
||||
"ButtonReadMore": "Διάβασε περισσότερα",
|
||||
"ButtonRemove": "Αφαίρεση",
|
||||
"ButtonRemoveFromServer": "Αφαίρεση απ'τον Διακομιστή",
|
||||
"ButtonSave": "Αποθήκευση",
|
||||
"ButtonSaveOrder": "Αποθήκευση Ταξινόμησης",
|
||||
"ButtonSearch": "Αναζήτηση",
|
||||
"ButtonSendEbookToDevice": "Αποστολή Ebook σε Συσκευή",
|
||||
"ButtonSeries": "Σειρά",
|
||||
"ButtonSetTimer": "Ορισμός Χρονοδιακόπτη",
|
||||
"ButtonStream": "Μετάδοση",
|
||||
"ButtonSubmit": "Υποβολή",
|
||||
"ButtonSwitchServerUser": "Αλλαγή Διακομιστή/Χρήστη",
|
||||
"ButtonUnmaskServerAddress": "Αφαίρεση απόκρυψης διακομιστή",
|
||||
"ButtonUserStats": "Στατιστικά Χρήστη",
|
||||
"ButtonYes": "Ναι",
|
||||
"HeaderAccount": "Λογαριασμός",
|
||||
"HeaderAdvanced": "Για Προχωρημένους",
|
||||
"HeaderAndroidAutoSettings": "Αυτόματες Ρυθμίσεις Διαχειριστή",
|
||||
"HeaderAudioTracks": "Κομμάτια Ήχου",
|
||||
"HeaderChapters": "Κεφάλαια",
|
||||
"HeaderCollection": "Συλλογή",
|
||||
"HeaderCollectionItems": "Αντικείμενα Συλλογής",
|
||||
"HeaderConfirm": "Επιβεβαίωση",
|
||||
"HeaderConnectionStatus": "Στατιστικά Σύνδεσης",
|
||||
"HeaderDataSettings": "Ρυθμίσεις Δεδομένων",
|
||||
"HeaderDetails": "Λεπτομέρειες",
|
||||
"HeaderDownloads": "Λήψεις",
|
||||
"HeaderEbookFiles": "Αρχεία Ebook",
|
||||
"HeaderEpisodes": "Επεισόδια",
|
||||
"HeaderEreaderSettings": "Ρυθμίσεις Ereader",
|
||||
"HeaderLatestEpisodes": "Τελευταία Επεισόδια",
|
||||
"HeaderLibraries": "Βιβλιοθήκες",
|
||||
"HeaderLocalFolders": "Τοπικοί Φάκελοι",
|
||||
"HeaderLocalLibraryItems": "Αντικείμενα Τοπικής Βιβλιοθήκης",
|
||||
"HeaderNewPlaylist": "Νέα Λίστα Αναπαραγωγής",
|
||||
"HeaderOpenRSSFeed": "Άνοιγμα Τροφοδοσίας RSS",
|
||||
"HeaderPlaybackSettings": "Ρυθμίσεις Αναπαραγωγής",
|
||||
"HeaderPlaylist": "Λίστα Αναπαραγωγής",
|
||||
"HeaderPlaylistItems": "Αντικείμενα Λίστας Αναπαραγωγής",
|
||||
"HeaderProgressSyncFailed": "Αποτυχία Συγχρονισμού Προόδου",
|
||||
"HeaderRSSFeed": "Τροφοδοσία RSS",
|
||||
"HeaderRSSFeedGeneral": "Λεπτομέρειες RSS",
|
||||
"HeaderRSSFeedIsOpen": "Η Τροφοδοσία RSS είναι Ανοιχτή",
|
||||
"HeaderSelectDownloadLocation": "Επιλογή Τοποθεσίας Λήψης",
|
||||
"HeaderSettings": "Ρυθμίσεις",
|
||||
"HeaderSleepTimer": "Χρονοδιακόπτης Ύπνου",
|
||||
"HeaderSleepTimerSettings": "Ρυθμίσεις Χρονοδιακόπτη Ύπνου",
|
||||
"HeaderStatsMinutesListeningChart": "Λεπτά Ακρόασης (τελευταίες 7 ημέρες)",
|
||||
"HeaderStatsRecentSessions": "Πρόσφατες Συνεδρίες",
|
||||
"HeaderTableOfContents": "Πίνακας Περιεχομένων",
|
||||
"HeaderUserInterfaceSettings": "Ρυθμίσεις Διεπαφής Χρήστη",
|
||||
"HeaderWelcome": "Καλωσήρθες, <strong>{0}</strong>",
|
||||
"HeaderYourStats": "Τα Στατιστικά Σας",
|
||||
"LabelAddToPlaylist": "Προσθήκη στην Λίστα Αναπαραγωγής",
|
||||
"LabelAddedAt": "Προστέθηκε Στις",
|
||||
"LabelAddedDate": "Προστέθηκε {0}",
|
||||
"LabelAll": "Όλα",
|
||||
"LabelAllowSeekingOnMediaControls": "Να επιτρέπεται η αλλαγή θέσης από τα χειριστήρια ειδοποιήσεων πολυμέσων",
|
||||
"LabelAlways": "Πάντα",
|
||||
"LabelAndroidAutoBrowseLimitForGrouping": "Όριο λίστας κατά αλφαβητική σειρά",
|
||||
"LabelAndroidAutoBrowseLimitForGroupingHelp": "Μην χρησιμοποιείτε την αλφαβητική λίστα περιορισμού όταν υπάρχουν λιγότερα από αυτά τα στοιχεία για εμφάνιση",
|
||||
"LabelAndroidAutoBrowseSeriesSequenceOrder": "Ταξινόμηση βιβλίων σειράς",
|
||||
"LabelAskConfirmation": "Ερώτηση για επιβεβαίωση",
|
||||
"LabelAuthor": "Συγγραφέας",
|
||||
"LabelAuthorFirstLast": "Συγγραφέας (Όνομα Επώνυμο)",
|
||||
"LabelAuthorLastFirst": "Συγγραφέας (Επώνυμο, Όνομα)",
|
||||
"LabelAuthors": "Συγγραφείς",
|
||||
"LabelAutoDownloadEpisodes": "Αυτόματο Κατέβασμα Επεισοδίων",
|
||||
"LabelAutoRewindTime": "Χρόνος αυτόματης επαναφοράς",
|
||||
"LabelAutoSleepTimer": "Αυτόματος Χρονοδιακόπτης Ύπνου",
|
||||
"LabelAutoSleepTimerAutoRewind": "Αυτόματη επαναφορά κατά τον χρονοδιακόπτη ύπνου",
|
||||
"LabelAutoSleepTimerAutoRewindHelp": "Όταν ολοκληρωθεί ο αυτόματος χρονοδιακόπτης ύπνου, η επαναφορά της αναπαραγωγής θα γυρίζει αυτόματα τη θέση σας πίσω.",
|
||||
"LabelAutoSleepTimerHelp": "Όταν αναπαράγεται περιεχόμενο μεταξύ των καθορισμένων χρόνων έναρξης και λήξης, ο χρονοδιακόπτης ύπνου θα ξεκινά αυτόματα.",
|
||||
"LabelBooks": "Βιβλία",
|
||||
"LabelByAuthor": "κατά {0}",
|
||||
"LabelChapterTrack": "Κομμάτι Κεφαλαίου",
|
||||
"LabelChapters": "Κεφάλαια",
|
||||
"LabelClosePlayer": "Κλείσιμο αναπαραγωγής",
|
||||
"LabelCollapseSeries": "Σύμπτυξη Σειράς",
|
||||
"LabelComplete": "Ολοκλήρωση",
|
||||
"LabelContinueBooks": "Συνέχεια Βιβλίων",
|
||||
"LabelContinueEpisodes": "Συνέχεια Επεισοδίων",
|
||||
"LabelContinueListening": "Συνέχεια Ακρόασης",
|
||||
"LabelContinueReading": "Συνέχεια Ανάγνωσης",
|
||||
"LabelContinueSeries": "Συνέχεια Σειράς",
|
||||
"LabelCustomTime": "Προσαρμοσμένη ώρα",
|
||||
"LabelDescription": "Περιγραφή",
|
||||
"LabelDisableAudioFadeOut": "Απενεργοποίηση σταδιακής μείωσης ήχου",
|
||||
"LabelDisableAudioFadeOutHelp": "Η ένταση του ήχου θα αρχίσει να μειώνεται όταν απομένει λιγότερο από 1 λεπτό στον χρονοδιακόπτη ύπνου. Ενεργοποιήστε αυτή τη ρύθμιση για να μην γίνεται σταδιακή μείωση.",
|
||||
"LabelDisableAutoRewind": "Απενεργοποίηση αυτόματης επαναφοράς",
|
||||
"LabelDownload": "Λήψη",
|
||||
"LabelDownloadUsingCellular": "Λήψη με χρήση δεδομένων",
|
||||
"LabelDownloaded": "Ληφθέντα",
|
||||
"LabelDuration": "Διάρκεια",
|
||||
"LabelEbook": "Ebook",
|
||||
"LabelEbooks": "Ebooks",
|
||||
"LabelEnable": "Ενεργοποίηση",
|
||||
"LabelEnd": "Τέλος",
|
||||
"LabelEndOfChapter": "Τέλος Κεφαλαίου",
|
||||
"LabelEpisode": "Επεισόδιο",
|
||||
"LabelFile": "Αρχείο",
|
||||
"LabelFilename": "Όνομα Αρχείου",
|
||||
"LabelFinished": "Ολοκληρώθηκε",
|
||||
"LabelFolder": "Φάκελος",
|
||||
"LabelFontFamily": "Οικογένεια Γραμματοσειράς",
|
||||
"LabelGenre": "Είδος",
|
||||
"LabelGenres": "Είδη",
|
||||
"LabelHost": "Διακομιστής",
|
||||
"LabelInProgress": "Σε Εξέλιξη",
|
||||
"LabelLanguage": "Γλώσσα",
|
||||
"LabelLayoutAuto": "Αυτόματα",
|
||||
"LabelLayoutSinglePage": "Μονή Σελίδα",
|
||||
"LabelLight": "Ανοιχτό",
|
||||
"LabelListenAgain": "Επανάληψη Ακρόασης",
|
||||
"LabelLocalBooks": "Τοπικά Βιβλία",
|
||||
"LabelLocalPodcasts": "Τοπικά Podcasts",
|
||||
"LabelLow": "Χαμηλά",
|
||||
"LabelMediaType": "Τύπος Πολυμέσων",
|
||||
"LabelMore": "Περισσότερα",
|
||||
"LabelMoreInfo": "Περισσότερες Πληροφορίες",
|
||||
"LabelName": "Όνομα",
|
||||
"LabelNarrator": "Αφηγητής",
|
||||
"LabelNarrators": "Αφηγητές",
|
||||
"LabelNavigateWithVolumeMirrored": "Καθρεπτισμός",
|
||||
"LabelNavigateWithVolumeWhilePlayingDisabled": "Ανενεργό",
|
||||
"LabelNavigateWithVolumeWhilePlayingEnabled": "Ενεργό",
|
||||
"LabelNever": "Ποτέ",
|
||||
"LabelNewestAuthors": "Πρόσφατοι Συγγραφείς",
|
||||
"LabelNewestEpisodes": "Πρόσφατα Επεισόδια",
|
||||
"LabelNo": "Όχι",
|
||||
"LabelNotStarted": "Δεν Έχει Ξεκινήσει",
|
||||
"LabelNumEpisodes": "{0} επεισόδια",
|
||||
"LabelNumberOfEpisodes": "# Επεισοδίων",
|
||||
"LabelOff": "Ανενεργό",
|
||||
"LabelOn": "Ενεργό",
|
||||
"LabelPassword": "Κωδικός Πρόσβασης",
|
||||
"LabelPath": "Διαδρομή",
|
||||
"LabelPlaybackLocal": "Τοπικά",
|
||||
"LabelPlaybackSpeed": "Ταχύτητα Αναπαραγωγής",
|
||||
"LabelProgress": "Πρόοδος",
|
||||
"LabelPublishYear": "Χρονολογία Έκδοσης",
|
||||
"LabelPublishedDate": "Εκδόθηκε {0}",
|
||||
"LabelRandomly": "Τυχαία",
|
||||
"LabelRead": "Ανάγνωση",
|
||||
"LabelReadAgain": "Ανάγνωση Ξανά",
|
||||
"LabelRecentSeries": "Πρόσφατη Σειρά",
|
||||
"LabelRecentlyAdded": "Προστέθηκαν Πρόσφατα",
|
||||
"LabelRemoveFromPlaylist": "Αφαίρεση από την Λίστα Αναπαραγωγής",
|
||||
"LabelSelectADevice": "Επιλογή συσκευής",
|
||||
"LabelSeries": "Σειρά",
|
||||
"LabelServerAddress": "Διεύθυνση Διακομιστή",
|
||||
"LabelSetEbookAsPrimary": "Ορισμός ως πρωτεύων",
|
||||
"LabelShowAll": "Εμφάνιση Όλων",
|
||||
"LabelSize": "Μέγεθος",
|
||||
"LabelSleepTimer": "Χρονοδιακόπτης Ύπνου",
|
||||
"LabelStart": "Έναρξη",
|
||||
"LabelStatsBestDay": "Καλύτερη Ημέρα",
|
||||
"LabelStatsDailyAverage": "Ημερήσιος Μέσος Όρος",
|
||||
"LabelStatsDays": "Ημέρες",
|
||||
"LabelStatsDaysListened": "Ημέρες Ακρόασης",
|
||||
"LabelStatsInARow": "Σε σειρά",
|
||||
"LabelStatsItemsFinished": "Ολοκληρωμένα Αντικείμενα",
|
||||
"LabelStatsMinutes": "λεπτά",
|
||||
"LabelStatsMinutesListening": "Λεπτά Ακρόασης",
|
||||
"LabelStatsWeekListening": "Εβδομαδιαία Ακρόαση",
|
||||
"LabelTheme": "Θέμα",
|
||||
"LabelThemeBlack": "Μαύρο",
|
||||
"LabelThemeDark": "Σκοτεινό",
|
||||
"LabelThemeLight": "Φωτεινό",
|
||||
"LabelTimeRemaining": "{0} απομένουν",
|
||||
"LabelTitle": "Τίτλος",
|
||||
"LabelTotalSize": "Συνολικό Μέγεθος",
|
||||
"LabelTracks": "Κομμάτια",
|
||||
"LabelType": "Τύπος",
|
||||
"LabelUnknown": "Άγνωστο",
|
||||
"LabelUseBookshelfView": "Χρήση προβολής βιβλιοθήκης",
|
||||
"LabelUser": "Χρήστης",
|
||||
"LabelUsername": "Όνομα Χρήστη",
|
||||
"LabelVeryHigh": "Πολύ Ψηλά",
|
||||
"LabelVeryLow": "Πολύ Χαμηλά",
|
||||
"LabelYourProgress": "Η Πρόοδος Σας",
|
||||
"MessageDownloadCompleteProcessing": "Η λήψη ολοκληρώθηκε. Επεξεργασία...",
|
||||
"MessageDownloading": "Λήψη...",
|
||||
"MessageDownloadingEpisode": "Λήψη επεισοδίου",
|
||||
"MessageLoading": "Φόρτωση...",
|
||||
"MessageMarkAsFinished": "Σήμανση ως Ολοκληρωμένο",
|
||||
"MessageNoItemsFound": "Δεν βρέθηκαν αντικείμενα",
|
||||
"MessageNoNetworkConnection": "Δεν υπάρχει σύνδεση δικτύου",
|
||||
"MessageNoUserPlaylists": "Δεν έχετε λίστες αναπαραγωγής"
|
||||
}
|
||||
@@ -63,6 +63,7 @@
|
||||
"HeaderChapters": "Chapters",
|
||||
"HeaderCollection": "Collection",
|
||||
"HeaderCollectionItems": "Collection Items",
|
||||
"HeaderConfirm": "Confirm",
|
||||
"HeaderConnectionStatus": "Connection Status",
|
||||
"HeaderDataSettings": "Data Settings",
|
||||
"HeaderDetails": "Details",
|
||||
@@ -91,6 +92,7 @@
|
||||
"HeaderStatsRecentSessions": "Recent Sessions",
|
||||
"HeaderTableOfContents": "Table of Contents",
|
||||
"HeaderUserInterfaceSettings": "User Interface Settings",
|
||||
"HeaderWelcome": "Welcome, <strong>{0}</strong>",
|
||||
"HeaderYourStats": "Your Stats",
|
||||
"LabelAddToPlaylist": "Add to Playlist",
|
||||
"LabelAddedAt": "Added At",
|
||||
@@ -113,6 +115,7 @@
|
||||
"LabelAutoSleepTimerAutoRewindHelp": "When the auto sleep timer finishes, playing the item again will automatically rewind your position.",
|
||||
"LabelAutoSleepTimerHelp": "When playing media between the specified start and end times a sleep timer will automatically start.",
|
||||
"LabelBooks": "Books",
|
||||
"LabelByAuthor": "by {0}",
|
||||
"LabelChapterTrack": "Chapter Track",
|
||||
"LabelChapters": "Chapters",
|
||||
"LabelClosePlayer": "Close player",
|
||||
@@ -155,6 +158,9 @@
|
||||
"LabelFinished": "Finished",
|
||||
"LabelFolder": "Folder",
|
||||
"LabelFontBoldness": "Font Boldness",
|
||||
"LabelFontFamily": "Font family",
|
||||
"LabelFontFamilySans": "Sans",
|
||||
"LabelFontFamilySerif": "Serif",
|
||||
"LabelFontScale": "Font scale",
|
||||
"LabelGenre": "Genre",
|
||||
"LabelGenres": "Genres",
|
||||
@@ -174,6 +180,9 @@
|
||||
"LabelLayout": "Layout",
|
||||
"LabelLayoutAuto": "Auto",
|
||||
"LabelLayoutSinglePage": "Single page",
|
||||
"LabelLibrarySortByProgress": "Progress: Last Updated",
|
||||
"LabelLibrarySortByProgressFinished": "Progress: Finished",
|
||||
"LabelLibrarySortByProgressStarted": "Progress: Started",
|
||||
"LabelLight": "Light",
|
||||
"LabelLineSpacing": "Line spacing",
|
||||
"LabelListenAgain": "Listen Again",
|
||||
@@ -233,6 +242,7 @@
|
||||
"LabelScaleElapsedTimeBySpeed": "Scale Elapsed Time by Speed",
|
||||
"LabelSeason": "Season",
|
||||
"LabelSelectADevice": "Select a device",
|
||||
"LabelSelectMediaType": "Select media type",
|
||||
"LabelSequenceAscending": "Sequence Ascending",
|
||||
"LabelSequenceDescending": "Sequence Descending",
|
||||
"LabelSeries": "Series",
|
||||
@@ -285,12 +295,17 @@
|
||||
"MessageAudiobookshelfServerNotConnected": "Audiobookshelf server not connected",
|
||||
"MessageAudiobookshelfServerRequired": "<strong>Important!</strong> This app is designed to work with an Audiobookshelf server that you or someone you know is hosting. This app does not provide any content.",
|
||||
"MessageBookshelfEmpty": "Bookshelf empty",
|
||||
"MessageConfirmAppExit":"Did you want to exit the app?",
|
||||
"MessageConfirmDeleteEpisodeDownloadQueue": "Are you sure you want to clear episode download queue?",
|
||||
"MessageConfirmDeleteLocalEpisode": "Remove local episode \"{0}\" from your device? The file on the server will be unaffected.",
|
||||
"MessageConfirmDeleteLocalFiles": "Remove local files of this item from your device? The files on the server and your progress will be unaffected.",
|
||||
"MessageConfirmDeleteServerConfig": "Remove this server config?",
|
||||
"MessageConfirmDeleteServerEpisode": "Are you sure you want to delete episode \"{0}\" from the server?\nWarning: This will delete the audio file.",
|
||||
"MessageConfirmDisableAutoTimer": "Are you sure you want to disable the auto timer for the rest of today? The timer will be re-enabled at the end of this auto-sleep timer period, or if you restart the app.",
|
||||
"MessageConfirmDiscardProgress": "Are you sure you want to reset your progress?",
|
||||
"MessageConfirmDownloadUsingCellular": "You are about to download using cellular data. This may include carrier data charges. Do you wish to continue?",
|
||||
"MessageConfirmMarkAsFinished": "Are you sure you want to mark this item as finished?",
|
||||
"MessageConfirmPlaybackTime":"Start playback for \"{0}\" at {1}?",
|
||||
"MessageConfirmRemoveBookmark": "Are you sure you want to remove bookmark?",
|
||||
"MessageConfirmStreamingUsingCellular": "You are about to stream using cellular data. This may include carrier data charges. Do you wish to continue?",
|
||||
"MessageDiscardProgress": "Discard Progress",
|
||||
@@ -355,5 +370,7 @@
|
||||
"ToastPodcastCreateSuccess": "Podcast created successfully",
|
||||
"ToastRSSFeedCloseFailed": "Failed to close RSS feed",
|
||||
"ToastRSSFeedCloseSuccess": "RSS feed closed",
|
||||
"ToastStreamingNotAllowedOnCellular": "Streaming is not allowed on cellular data"
|
||||
"ToastStreamingNotAllowedOnCellular": "Streaming is not allowed on cellular data",
|
||||
"UnitMinutesShort": "{0}m",
|
||||
"UnitSecondsShort": "{0}s"
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"ButtonBack": "Atrás",
|
||||
"ButtonCancel": "Cancelar",
|
||||
"ButtonCancelTimer": "Cancelar temporizador",
|
||||
"ButtonClearFilter": "Quitar filtros",
|
||||
"ButtonClearFilter": "Vaciar filtro",
|
||||
"ButtonClearLogs": "Vaciar registros",
|
||||
"ButtonCloseFeed": "Cerrar suministro",
|
||||
"ButtonCollections": "Colecciones",
|
||||
@@ -18,16 +18,16 @@
|
||||
"ButtonDeleteLocalEpisode": "Eliminar episodio local",
|
||||
"ButtonDeleteLocalFile": "Eliminar archivo local",
|
||||
"ButtonDeleteLocalItem": "Eliminar elemento local",
|
||||
"ButtonDisableAutoTimer": "Desactivar temporizador automático",
|
||||
"ButtonDisableAutoTimer": "Inhabilitar temporizador automático",
|
||||
"ButtonDisconnect": "Desconectar",
|
||||
"ButtonGoToWebClient": "Ir al cliente web",
|
||||
"ButtonHistory": "Historial",
|
||||
"ButtonHome": "Inicio",
|
||||
"ButtonIssues": "Problemas",
|
||||
"ButtonIssues": "Incidencias",
|
||||
"ButtonLatest": "Más recientes",
|
||||
"ButtonLibrary": "Biblioteca",
|
||||
"ButtonLocalMedia": "Medios locales",
|
||||
"ButtonLogs": "Registros",
|
||||
"ButtonLogs": "Bitácoras",
|
||||
"ButtonManageLocalFiles": "Gestionar archivos locales",
|
||||
"ButtonMaskServerAddress": "Enmascarar dirección de servidor",
|
||||
"ButtonNewFolder": "Carpeta nueva",
|
||||
@@ -49,102 +49,105 @@
|
||||
"ButtonSearch": "Buscar",
|
||||
"ButtonSendEbookToDevice": "Enviar libro al dispositivo",
|
||||
"ButtonSeries": "Series",
|
||||
"ButtonSetTimer": "Ajustar el temporizador",
|
||||
"ButtonSetTimer": "Ajustar cronómetro",
|
||||
"ButtonStream": "En directo",
|
||||
"ButtonSubmit": "Enviar",
|
||||
"ButtonSubmit": "Entregar",
|
||||
"ButtonSwitchServerUser": "Cambiar servidor/usuario",
|
||||
"ButtonUnmaskServerAddress": "Desenmascarar dirección de servidor",
|
||||
"ButtonUserStats": "Estadísticas de usuario",
|
||||
"ButtonYes": "Sí",
|
||||
"HeaderAccount": "Cuenta",
|
||||
"HeaderAdvanced": "Avanzado",
|
||||
"HeaderAndroidAutoSettings": "Configuración de Android Auto",
|
||||
"HeaderAudioTracks": "Pistas de audio",
|
||||
"HeaderAndroidAutoSettings": "Ajustes automáticos de Android",
|
||||
"HeaderAudioTracks": "Pistas de Audio",
|
||||
"HeaderChapters": "Capítulos",
|
||||
"HeaderCollection": "Colección",
|
||||
"HeaderCollectionItems": "Elementos en la colección",
|
||||
"HeaderConnectionStatus": "Estado de la conexión",
|
||||
"HeaderDataSettings": "Configuración de datos",
|
||||
"HeaderCollectionItems": "Elementos de colección",
|
||||
"HeaderConfirm": "Confirmar",
|
||||
"HeaderConnectionStatus": "Estado de conexión",
|
||||
"HeaderDataSettings": "Ajustes de datos",
|
||||
"HeaderDetails": "Detalles",
|
||||
"HeaderDownloads": "Descargas",
|
||||
"HeaderEbookFiles": "Archivos de libros digitales",
|
||||
"HeaderEpisodes": "Episodios",
|
||||
"HeaderEreaderSettings": "Configuración del lector",
|
||||
"HeaderEreaderSettings": "Ajustes del Lector-e",
|
||||
"HeaderLatestEpisodes": "Episodios más recientes",
|
||||
"HeaderLibraries": "Bibliotecas",
|
||||
"HeaderLocalFolders": "Carpetas locales",
|
||||
"HeaderLocalLibraryItems": "Elementos de la biblioteca local",
|
||||
"HeaderLocalLibraryItems": "Elementos de Biblioteca Local",
|
||||
"HeaderNewPlaylist": "Nueva lista de reproducción",
|
||||
"HeaderOpenRSSFeed": "Abrir suministro RSS",
|
||||
"HeaderPlaybackSettings": "Configuración de reproducción",
|
||||
"HeaderPlaybackSettings": "Ajustes de Reproducción",
|
||||
"HeaderPlaylist": "Lista de reproducción",
|
||||
"HeaderPlaylistItems": "Elementos de lista de reproducción",
|
||||
"HeaderProgressSyncFailed": "Falló la sincronización del progreso",
|
||||
"HeaderProgressSyncFailed": "Progreso de sincronización incorrecto",
|
||||
"HeaderRSSFeed": "Suministro RSS",
|
||||
"HeaderRSSFeedGeneral": "Detalles de RSS",
|
||||
"HeaderRSSFeedIsOpen": "El suministro RSS está abierto",
|
||||
"HeaderSelectDownloadLocation": "Seleccionar ubicación de descarga",
|
||||
"HeaderSettings": "Configuración",
|
||||
"HeaderSleepTimer": "Temporizador de apagado",
|
||||
"HeaderSleepTimerSettings": "Ajustes del temporizador para dormir",
|
||||
"HeaderSelectDownloadLocation": "Seleccionar lugar de descarga",
|
||||
"HeaderSettings": "Ajustes",
|
||||
"HeaderSleepTimer": "Cronómetro de dormida",
|
||||
"HeaderSleepTimerSettings": "Ajustes del temporizador para dormida",
|
||||
"HeaderStatsMinutesListeningChart": "Minutos escuchando (últimos 7 días)",
|
||||
"HeaderStatsRecentSessions": "Sesiones recientes",
|
||||
"HeaderTableOfContents": "Sumario",
|
||||
"HeaderUserInterfaceSettings": "Configuración de interfaz de usuario",
|
||||
"HeaderUserInterfaceSettings": "Ajustes de interfaz de usuario",
|
||||
"HeaderWelcome": "Bienvenido/a, <strong>{0}</strong>",
|
||||
"HeaderYourStats": "Sus estadísticas",
|
||||
"LabelAddToPlaylist": "Añadir a lista de reproducción",
|
||||
"LabelAddedAt": "Añadido",
|
||||
"LabelAddedDate": "{0} Añadido",
|
||||
"LabelAddedAt": "Añadido en",
|
||||
"LabelAddedDate": "Añadido {0}",
|
||||
"LabelAll": "Todos",
|
||||
"LabelAllowSeekingOnMediaControls": "Permitir la búsqueda de posición en los controles de notificación de medios",
|
||||
"LabelAllowSeekingOnMediaControls": "Concede la búsqueda de posición en los controles de notificación de medios",
|
||||
"LabelAlways": "Siempre",
|
||||
"LabelAndroidAutoBrowseLimitForGrouping": "Limite del despliegue alfabético",
|
||||
"LabelAndroidAutoBrowseLimitForGrouping": "Límite del despliegue alfabético",
|
||||
"LabelAndroidAutoBrowseLimitForGroupingHelp": "No utilice el despliegue alfabético cuando haya menos de esta cantidad de elementos para mostrar",
|
||||
"LabelAndroidAutoBrowseSeriesSequenceOrder": "Orden de libros de Series",
|
||||
"LabelAndroidAutoBrowseSeriesSequenceOrder": "Ordenación de libros de Series",
|
||||
"LabelAskConfirmation": "Pedir confirmación",
|
||||
"LabelAuthor": "Autor",
|
||||
"LabelAuthorFirstLast": "Autor (Nombre Apellido)",
|
||||
"LabelAuthorLastFirst": "Autor (Apellido, Nombre)",
|
||||
"LabelAuthors": "Autores",
|
||||
"LabelAutoDownloadEpisodes": "Descargar episodios automáticamente",
|
||||
"LabelAutoRewindTime": "Tiempo de rebobinado automático",
|
||||
"LabelAutoSleepTimer": "Temporizador de apagado automático",
|
||||
"LabelAutoSleepTimerAutoRewind": "Temporizador de apagado automático con rebobinado automático",
|
||||
"LabelAutoSleepTimerAutoRewindHelp": "Cuando el temporizador de auto apagado finaliza, reproducir el elemento nuevamente rebobinará automáticamente tu posición.",
|
||||
"LabelAutoDownloadEpisodes": "Auto‐Descargar episodios",
|
||||
"LabelAutoRewindTime": "Tiempo de auto‐rebobinado",
|
||||
"LabelAutoSleepTimer": "Cronómetro de auto‐apagado",
|
||||
"LabelAutoSleepTimerAutoRewind": "Cronómetro de auto‐apagado con auto‐rebobinado",
|
||||
"LabelAutoSleepTimerAutoRewindHelp": "Cuando el cronómetro de auto‐apagado finaliza, reproducir el elemento nuevamente auto‐rebobinará tu posición.",
|
||||
"LabelAutoSleepTimerHelp": "Cuando se reproduce contenido multimedia entre las horas de inicio y finalización especificadas, se activará automáticamente un temporizador de apagado.",
|
||||
"LabelBooks": "Libros",
|
||||
"LabelByAuthor": "por {0}",
|
||||
"LabelChapterTrack": "Seguimiento de Capítulo",
|
||||
"LabelChapters": "Capítulos",
|
||||
"LabelClosePlayer": "Cerrar reproductor",
|
||||
"LabelCollapseSeries": "Colapsar serie",
|
||||
"LabelCollapseSeries": "Colapsar Series",
|
||||
"LabelComplete": "Completo",
|
||||
"LabelContinueBooks": "Continuar libros",
|
||||
"LabelContinueEpisodes": "Continuar episodios",
|
||||
"LabelContinueListening": "Seguir escuchando",
|
||||
"LabelContinueEpisodes": "Continuar Episodios",
|
||||
"LabelContinueListening": "Seguir Escuchando",
|
||||
"LabelContinueReading": "Continuar leyendo",
|
||||
"LabelContinueSeries": "Continuar series",
|
||||
"LabelCustomTime": "Tiempo personalizado",
|
||||
"LabelDescription": "Descripción",
|
||||
"LabelDisableAudioFadeOut": "Desactivar el fundido de audio",
|
||||
"LabelDisableAudioFadeOutHelp": "El volumen de audio comenzará a disminuir cuando quede menos de 1 minuto en el temporizador de apagado. Habilite este ajuste para que no se desvanezca.",
|
||||
"LabelDisableAutoRewind": "Desactivar rebobinado automático",
|
||||
"LabelDisableShakeToReset": "Desactivar agitar para reiniciar",
|
||||
"LabelDisableShakeToResetHelp": "Si agitas el dispositivo mientras el temporizador está en marcha o en los 2 minutos siguientes a la expiración del temporizador, éste se reiniciará. Habilita esta configuración para desactivar el restablecimiento al agitar.",
|
||||
"LabelDisableAudioFadeOutHelp": "El volumen de audio comenzará a disminuir cuando quede menos de 1 minuto en el temporizador de dormida. Habilite este ajuste para que no se desvanezca.",
|
||||
"LabelDisableAutoRewind": "Desactivar auto‐rebobinado",
|
||||
"LabelDisableShakeToReset": "Desactivar agitar para restablecer",
|
||||
"LabelDisableShakeToResetHelp": "Si agitas el dispositivo mientras el temporizador está en marcha o en los 2 minutos siguientes a la caducidad del temporizador, éste se reiniciará. Habilita estos ajustes para desactivar el restablecimiento al agitar.",
|
||||
"LabelDisableVibrateOnReset": "Desactivar vibración al reiniciar",
|
||||
"LabelDisableVibrateOnResetHelp": "Cuando el temporizador de apagado se reinicia, el dispositivo vibra. Activa esta opción para que no vibre cuando se reinicie el temporizador.",
|
||||
"LabelDisableVibrateOnResetHelp": "Cuando el temporizador de apagado se reinicia, el dispositivo vibra. Activa esta opción para que no vibre cuando se duerme el temporizador.",
|
||||
"LabelDiscover": "Descubrir",
|
||||
"LabelDownload": "Descargar",
|
||||
"LabelDownloadUsingCellular": "Descargar usando el móvil",
|
||||
"LabelDownloaded": "Descargado",
|
||||
"LabelDuration": "Duración",
|
||||
"LabelEbook": "Libro electrónico",
|
||||
"LabelEbooks": "Libros electrónicos",
|
||||
"LabelEbook": "Libro-e",
|
||||
"LabelEbooks": "Libros-e",
|
||||
"LabelEnable": "Activar",
|
||||
"LabelEnableMp3IndexSeeking": "Activar la búsqueda de índices mp3",
|
||||
"LabelEnableMp3IndexSeekingHelp": "Esta configuración solo debe activarse si tiene archivos MP3 en los que no se puede buscar correctamente. La búsqueda inexacta probablemente se deba a archivos MP3 con tasas de bits variables (VBR). Esta configuración forzará la búsqueda de índice, en la que se construye una asignación de tiempo a bytes mientras se lee el archivo. En algunos casos, con archivos MP3 grandes, puede haber un retraso al buscar hacia el final del archivo.",
|
||||
"LabelEnableMp3IndexSeekingHelp": "Estos ajustes solo estaría activado si tiene archivos mp3 en los que no se puede buscar correctamente. La búsqueda inexacta probablemente se deba a archivos mp3 con tasas de los bit variables (VBR). Este ajuste forzará la búsqueda de índice, en la que se construye una asignación de tiempo a bytes mientras se lee el archivo. En algunos casos, con archivos mp3 grandes, puede haber un retraso al buscar hacia el final del archivo.",
|
||||
"LabelEnd": "Fin",
|
||||
"LabelEndOfChapter": "Fin del capítulo",
|
||||
"LabelEndTime": "Hora de finalización",
|
||||
"LabelEndTime": "Tiempo de finalización",
|
||||
"LabelEpisode": "Episodio",
|
||||
"LabelExplicit": "Explícito",
|
||||
"LabelFeedURL": "URL del suministro",
|
||||
@@ -152,35 +155,41 @@
|
||||
"LabelFileBirthtime": "Archivo creado en",
|
||||
"LabelFileModified": "Archivo modificado",
|
||||
"LabelFilename": "Nombre del archivo",
|
||||
"LabelFinished": "Terminado",
|
||||
"LabelFinished": "Finalizado",
|
||||
"LabelFolder": "Carpeta",
|
||||
"LabelFontBoldness": "Peso tipográfico",
|
||||
"LabelFontBoldness": "Tipográfico sin Negrita",
|
||||
"LabelFontFamily": "Familia tipográfica",
|
||||
"LabelFontFamilySans": "Sans",
|
||||
"LabelFontFamilySerif": "Serif",
|
||||
"LabelFontScale": "Escala de letra",
|
||||
"LabelGenre": "Género",
|
||||
"LabelGenres": "Géneros",
|
||||
"LabelHapticFeedback": "Respuesta háptica",
|
||||
"LabelHasEbook": "Tiene un libro",
|
||||
"LabelHasSupplementaryEbook": "Tiene un libro complementario",
|
||||
"LabelHeavy": "Pesado",
|
||||
"LabelHapticFeedback": "Comentario háptico",
|
||||
"LabelHasEbook": "Tiene libro-e",
|
||||
"LabelHasSupplementaryEbook": "Tiene un libro-e suplementario",
|
||||
"LabelHeavy": "Denso",
|
||||
"LabelHigh": "Alto",
|
||||
"LabelHost": "Anfitrión",
|
||||
"LabelInProgress": "En proceso",
|
||||
"LabelIncomplete": "Incompleto",
|
||||
"LabelInternalAppStorage": "Almacenamiento interno de aplicaciones",
|
||||
"LabelJumpBackwardsTime": "Saltar atrás en el tiempo",
|
||||
"LabelJumpForwardsTime": "Salto adelante en el tiempo",
|
||||
"LabelInternalAppStorage": "Almacenamiento Interno de App",
|
||||
"LabelJumpBackwardsTime": "Volver en el tiempo",
|
||||
"LabelJumpForwardsTime": "Avanzar en el tiempo",
|
||||
"LabelKeepScreenAwake": "Mantener la pantalla encendida",
|
||||
"LabelLanguage": "Idioma",
|
||||
"LabelLayout": "Disposición",
|
||||
"LabelLayoutAuto": "Automático",
|
||||
"LabelLayoutSinglePage": "Página única",
|
||||
"LabelLibrarySortByProgress": "Progreso: Último actualizado",
|
||||
"LabelLibrarySortByProgressFinished": "Progreso: Finalizado",
|
||||
"LabelLibrarySortByProgressStarted": "Progreso: Iniciado",
|
||||
"LabelLight": "Claro",
|
||||
"LabelLineSpacing": "Interlineado",
|
||||
"LabelListenAgain": "Volver a escuchar",
|
||||
"LabelLocalBooks": "Libros Locales",
|
||||
"LabelLocalPodcasts": "Pódcast locales",
|
||||
"LabelLockOrientation": "Bloquear orientación",
|
||||
"LabelLockPlayer": "Bloquear el reproductor",
|
||||
"LabelLockPlayer": "Bloquear reproductor",
|
||||
"LabelLow": "Bajo",
|
||||
"LabelMediaType": "Tipo de multimedia",
|
||||
"LabelMedium": "Medio",
|
||||
@@ -191,7 +200,7 @@
|
||||
"LabelNarrator": "Narrador",
|
||||
"LabelNarrators": "Narradores",
|
||||
"LabelNavigateWithVolume": "Navegar con las teclas de volumen",
|
||||
"LabelNavigateWithVolumeMirrored": "Espejo",
|
||||
"LabelNavigateWithVolumeMirrored": "Espejado",
|
||||
"LabelNavigateWithVolumeWhilePlaying": "También puede utilizar los botones de volumen para desplazarse durante la reproducción",
|
||||
"LabelNavigateWithVolumeWhilePlayingDisabled": "Apagado",
|
||||
"LabelNavigateWithVolumeWhilePlayingEnabled": "Encender",
|
||||
@@ -199,11 +208,11 @@
|
||||
"LabelNewestAuthors": "Autores más nuevos",
|
||||
"LabelNewestEpisodes": "Episodios más nuevos",
|
||||
"LabelNo": "No",
|
||||
"LabelNotFinished": "No terminado",
|
||||
"LabelNotFinished": "No finalizado",
|
||||
"LabelNotStarted": "Sin iniciar",
|
||||
"LabelNumEpisodes": "{0} episodios",
|
||||
"LabelNumEpisodesIncomplete": "{0} episodios, {1} incompletos",
|
||||
"LabelNumberOfEpisodes": "N.º de episodios",
|
||||
"LabelNumberOfEpisodes": "Nº de episodios",
|
||||
"LabelOff": "Apagado",
|
||||
"LabelOn": "Encendido",
|
||||
"LabelPassword": "Contraseña",
|
||||
@@ -214,15 +223,16 @@
|
||||
"LabelPlaybackTranscode": "Transcodificar",
|
||||
"LabelPodcast": "Pódcast",
|
||||
"LabelPodcasts": "Pódcast",
|
||||
"LabelPreventIndexing": "Evite que los directorios de pódcast de iTunes y Google indicen su suministro",
|
||||
"LabelPreventIndexing": "Evite que los directorios de pódcast de iTunes y Google indexen su suministro",
|
||||
"LabelProgress": "Progreso",
|
||||
"LabelPubDate": "Fecha de publicación",
|
||||
"LabelPublishYear": "Año de publicación",
|
||||
"LabelPublishedDate": "Publicado {0}",
|
||||
"LabelRSSFeedCustomOwnerEmail": "Correo electrónico de dueño personalizado",
|
||||
"LabelRSSFeedCustomOwnerName": "Nombre de dueño personalizado",
|
||||
"LabelRSSFeedCustomOwnerEmail": "Correo-e de propietario personalizado",
|
||||
"LabelRSSFeedCustomOwnerName": "Nombre de propietario personalizado",
|
||||
"LabelRSSFeedOpen": "Fuente RSS Abierta",
|
||||
"LabelRSSFeedPreventIndexing": "Evitar indización",
|
||||
"LabelRSSFeedSlug": "«Slug» de suministro RSS",
|
||||
"LabelRSSFeedSlug": "Ficha de suministro RSS",
|
||||
"LabelRandomly": "Aleatorio",
|
||||
"LabelRead": "Leído",
|
||||
"LabelReadAgain": "Volver a leer",
|
||||
@@ -232,16 +242,17 @@
|
||||
"LabelScaleElapsedTimeBySpeed": "Escala el tiempo transcurrido según la velocidad",
|
||||
"LabelSeason": "Temporada",
|
||||
"LabelSelectADevice": "Seleccione un dispositivo",
|
||||
"LabelSelectMediaType": "Seleccionar tipo de medio",
|
||||
"LabelSequenceAscending": "Secuencia ascendente",
|
||||
"LabelSequenceDescending": "Secuencia descendente",
|
||||
"LabelSeries": "Serie",
|
||||
"LabelSeries": "Series",
|
||||
"LabelServerAddress": "Dirección del servidor",
|
||||
"LabelSetEbookAsPrimary": "Establecer como primario",
|
||||
"LabelSetEbookAsSupplementary": "Establecer como suplementario",
|
||||
"LabelShakeSensitivity": "Sensibilidad a la sacudida",
|
||||
"LabelShowAll": "Mostrar todo",
|
||||
"LabelSize": "Tamaño",
|
||||
"LabelSleepTimer": "Temporizador de apagado",
|
||||
"LabelSleepTimer": "Temporizador de dormida",
|
||||
"LabelSleepTimerAlmostDoneChime": "Timbrar casi al terminar",
|
||||
"LabelSleepTimerAlmostDoneChimeHelp": "Reproducir una señal acústica cuando queden 30 segundos en el temporizador",
|
||||
"LabelStart": "Iniciar",
|
||||
@@ -282,14 +293,19 @@
|
||||
"MessageAndroid10Downloads": "Android 10 e inferiores utilizarán el almacenamiento interno de aplicaciones para las descargas.",
|
||||
"MessageAttemptingServerConnection": "Intentando conectar con el servidor...",
|
||||
"MessageAudiobookshelfServerNotConnected": "Servidor de Audiobookshelf no conectado",
|
||||
"MessageAudiobookshelfServerRequired": "<strong>¡Importante!</strong> Esta aplicación está diseñada para trabajar con un servidor Audiobookshelf que usted o alguien que usted conoce es el anfitrión. Esta aplicación no proporciona ningún contenido.",
|
||||
"MessageAudiobookshelfServerRequired": "<strong>¡Importante!</strong> Esta aplicación está diseñada para trabajar con un servidor Audiobookshelf que tú o alguien que tú conozcas es el host. Esta aplicación no proporciona ningún contenido.",
|
||||
"MessageBookshelfEmpty": "Estantería vacía",
|
||||
"MessageConfirmAppExit": "¿Desea salir de la aplicación?",
|
||||
"MessageConfirmDeleteEpisodeDownloadQueue": "¿Seguro que desea vaciar la cola de descarga del episodio?",
|
||||
"MessageConfirmDeleteLocalEpisode": "¿Quiere eliminar el episodio local «{0}» del dispositivo? El archivo en el servidor no se verá afectado.",
|
||||
"MessageConfirmDeleteLocalFiles": "¿Quiere quitar los archivos locales de este elemento del dispositivo? Los archivos del servidor y su progreso no se verán afectados.",
|
||||
"MessageConfirmDeleteServerConfig": "¿Quito esta configuración del servidor?",
|
||||
"MessageConfirmDeleteServerEpisode": "¿Seguro que desea eliminar el episodio «{0}» desde el servidor?\nAdvertencia: esto eliminará el archivo de sonido.",
|
||||
"MessageConfirmDisableAutoTimer": "¿Confirma que quiere desactivar el temporizador automático por el resto del día? El temporizador volverá a activarse al final de este periodo de temporizador de apagado automático, o si reinicia la aplicación.",
|
||||
"MessageConfirmDiscardProgress": "¿Confirma que quiere restablecer su progreso?",
|
||||
"MessageConfirmDownloadUsingCellular": "Está a punto de efectuar una descarga con datos móviles. Esto puede conllevar tarifas de datos de la operadora. ¿Quiere continuar?",
|
||||
"MessageConfirmMarkAsFinished": "¿Confirma que quiere marcar este elemento como terminado?",
|
||||
"MessageConfirmPlaybackTime": "¿Inicio la reproducción para «{0}» en {1}?",
|
||||
"MessageConfirmRemoveBookmark": "¿Confirma que quiere quitar el marcador?",
|
||||
"MessageConfirmStreamingUsingCellular": "Está a punto de hacer una transmisión con datos móviles. Esto puede conllevar tarifas de datos de la operadora. ¿Quiere continuar?",
|
||||
"MessageDiscardProgress": "Descartar progreso",
|
||||
@@ -297,6 +313,7 @@
|
||||
"MessageDownloading": "Descargando...",
|
||||
"MessageDownloadingEpisode": "Descargando episodio",
|
||||
"MessageEpisodesQueuedForDownload": "{0} episodio(s) en cola para descargar",
|
||||
"MessageFailedToRefreshToken": "No se pudo actualizar el token, se requiere reiniciar la sesión",
|
||||
"MessageFeedURLWillBe": "El URL del suministro será {0}",
|
||||
"MessageFetching": "Recuperando...",
|
||||
"MessageFollowTheProjectOnGithub": "Siga el proyecto en GitHub",
|
||||
@@ -324,6 +341,9 @@
|
||||
"MessageNoSeries": "Ninguna serie",
|
||||
"MessageNoUpdatesWereNecessary": "No fue necesario actualizar",
|
||||
"MessageNoUserPlaylists": "No tiene ninguna lista de reproducción",
|
||||
"MessageOldServerAuthReLoginRequired": "La autenticación se ha mejorado para seguridad en la versión v2.26.0 del servidor. Todos los usuarios deberán reiniciar sesión.",
|
||||
"MessageOldServerAuthWarning": "El servidor está utilizando un método de autenticación anticuado",
|
||||
"MessageOldServerAuthWarningHelp": "Este servidor está ejecutando una versión más antigua que la v2.26.0. Ha sido añadido un sistema de autenticación más seguro en v2.26.0. Se recomienda encarecidamente que actualice el servidor a la última versión. Si ya tiene el servidor actualizado, acceda de nuevo para utilizar la autenticación nueva.",
|
||||
"MessageOldServerConnectionWarning": "La configuración de la conexión al servidor utiliza un identificador de usuario antiguo. Elimine y vuelva a añadir esta conexión al servidor.",
|
||||
"MessageOldServerConnectionWarningHelp": "Usted configuró originalmente la conexión a este servidor antes de la migración de la base de datos en la versión 2.3.0, publicada en junio de 2023. Una futura actualización del servidor eliminará la posibilidad de iniciar sesión con esta conexión antigua. Por favor, elimine la conexión existente al servidor y conéctese de nuevo (utilizando la misma dirección del servidor y las mismas credenciales). Si tiene algún medio descargado en este dispositivo, será necesario descargarlo de nuevo para sincronizarlo con el servidor.",
|
||||
"MessagePodcastSearchField": "Introduzca el término de búsqueda o el URL del suministro RSS",
|
||||
@@ -350,5 +370,7 @@
|
||||
"ToastPodcastCreateSuccess": "Se creó el pódcast correctamente",
|
||||
"ToastRSSFeedCloseFailed": "Error al cerrar el suministro RSS",
|
||||
"ToastRSSFeedCloseSuccess": "Suministro RSS cerrado",
|
||||
"ToastStreamingNotAllowedOnCellular": "El streaming no está permitido con datos móviles"
|
||||
"ToastStreamingNotAllowedOnCellular": "El streaming no está permitido con datos móviles",
|
||||
"UnitMinutesShort": "{0}m",
|
||||
"UnitSecondsShort": "{0}s"
|
||||
}
|
||||
|
||||
@@ -166,7 +166,9 @@
|
||||
"LabelStatsBestDay": "Parim päev",
|
||||
"LabelStatsDays": "Päevi",
|
||||
"LabelStatsInARow": "järjest",
|
||||
"LabelStatsItemsFinished": "Lõpetatud raamatud",
|
||||
"LabelStatsMinutes": "minuteid",
|
||||
"LabelStatsWeekListening": "Nädala keskmine",
|
||||
"LabelThemeBlack": "Must",
|
||||
"LabelThemeDark": "Pime",
|
||||
"LabelThemeLight": "Hele",
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"ButtonAdd": "افزودن",
|
||||
"ButtonAddNewServer": "افزودن سرور جدید",
|
||||
"ButtonAuthors": "ناشر",
|
||||
"ButtonBack": "بازگشت",
|
||||
"ButtonCancel": "انصراف",
|
||||
"ButtonCancelTimer": "لغو تایمر",
|
||||
"ButtonClearFilter": "حذف صافی",
|
||||
"ButtonClearLogs": "حذف گزارشها",
|
||||
"ButtonCloseFeed": "بستن فید",
|
||||
"ButtonCollections": "مجموعه ها",
|
||||
"ButtonConnect": "اتصال",
|
||||
"ButtonConnectToServer": "اتصال به سرور",
|
||||
"ButtonCreate": "ساختن",
|
||||
"ButtonCreateBookmark": "ساخت نشانک",
|
||||
"ButtonCreateNewPlaylist": "ساخت لیست پخش جدید",
|
||||
"ButtonDelete": "حذف",
|
||||
"ButtonDeleteLocalEpisode": "حذف اپیزود های محلی",
|
||||
"ButtonDeleteLocalFile": "حذف فایل های محلی",
|
||||
"ButtonDeleteLocalItem": "حذف آیتم های محلی",
|
||||
"ButtonDisableAutoTimer": "غیر فعال کردن تایم خودکار",
|
||||
"ButtonDisconnect": "قطع کردن",
|
||||
"ButtonGoToWebClient": "رفتن به نسخه تحت وب سمت کاربر",
|
||||
"ButtonHistory": "تاریخچه",
|
||||
"ButtonHome": "خانه",
|
||||
"ButtonIssues": "مشکلات",
|
||||
"ButtonLatest": "جدیدترین",
|
||||
"ButtonLibrary": "کتابخانه",
|
||||
"ButtonLocalMedia": "رسانههای محلی",
|
||||
"ButtonLogs": "گزارشها",
|
||||
"ButtonManageLocalFiles": "مدیریت فایل های محلی",
|
||||
"ButtonMaskServerAddress": "پنهان کردن آدرس سرور",
|
||||
"ButtonNewFolder": "پوشه جدید",
|
||||
"ButtonNextEpisode": "اپیزود بعدی",
|
||||
"ButtonOk": "تایید",
|
||||
"ButtonOpenFeed": "باز کردن فید",
|
||||
"ButtonOverride": "جایگزینی",
|
||||
"ButtonPause": "توقف",
|
||||
"ButtonPlay": "پخش",
|
||||
"ButtonPlayEpisode": "پخش اپیزود",
|
||||
"ButtonPlaylists": "لیست پخش",
|
||||
"ButtonRead": "خواندن",
|
||||
"ButtonReadLess": "خواندن کمتر",
|
||||
"ButtonReadMore": "خواندن بیشتر",
|
||||
"ButtonRemove": "حذف",
|
||||
"ButtonRemoveFromServer": "حذف از سرور",
|
||||
"ButtonSave": "ذخیره",
|
||||
"ButtonSaveOrder": "ذخیره ترتیب",
|
||||
"ButtonSearch": "جستجو",
|
||||
"ButtonSendEbookToDevice": "ارسال کتاب الکترونیکی به دستگاه",
|
||||
"ButtonSeries": "مجموعه",
|
||||
"ButtonSetTimer": "تنظیم تایمر",
|
||||
"ButtonStream": "پخش آنلاین",
|
||||
"ButtonSubmit": "ثبت",
|
||||
"ButtonSwitchServerUser": "تعویض سرور/کاربر",
|
||||
"ButtonUnmaskServerAddress": "نمایش آدرس سرور"
|
||||
}
|
||||
@@ -27,7 +27,9 @@
|
||||
"ButtonLatest": "Viimeisimmät",
|
||||
"ButtonLibrary": "Kirjasto",
|
||||
"ButtonLocalMedia": "Paikallinen media",
|
||||
"ButtonLogs": "Lokit",
|
||||
"ButtonManageLocalFiles": "Hallitse paikallsiia tiedostoja",
|
||||
"ButtonMaskServerAddress": "Piilota palvelimen osoite",
|
||||
"ButtonNewFolder": "Uusi kansio",
|
||||
"ButtonNextEpisode": "Seuraava jakso",
|
||||
"ButtonOk": "Hyvä on",
|
||||
@@ -51,6 +53,7 @@
|
||||
"ButtonStream": "Suoratoisto",
|
||||
"ButtonSubmit": "Lähetä",
|
||||
"ButtonSwitchServerUser": "Vaihda palvelinta/käyttäjää",
|
||||
"ButtonUnmaskServerAddress": "Näytä palvelimen osoite",
|
||||
"ButtonUserStats": "Käyttäjän tilastot",
|
||||
"ButtonYes": "Kyllä",
|
||||
"HeaderAccount": "Tili",
|
||||
@@ -60,6 +63,7 @@
|
||||
"HeaderChapters": "Luvut",
|
||||
"HeaderCollection": "Kokoelma",
|
||||
"HeaderCollectionItems": "Kokoelman osat",
|
||||
"HeaderConfirm": "Vahvista",
|
||||
"HeaderConnectionStatus": "Kokoelman tila",
|
||||
"HeaderDataSettings": "Tietojen asetukset",
|
||||
"HeaderDetails": "Yksityiskohdat",
|
||||
@@ -88,6 +92,7 @@
|
||||
"HeaderStatsRecentSessions": "Viimeaikaiset istunnot",
|
||||
"HeaderTableOfContents": "Sisällysluettelo",
|
||||
"HeaderUserInterfaceSettings": "Käyttöliittymän asetukset",
|
||||
"HeaderWelcome": "Tervetuloa, <strong>{0</strong>",
|
||||
"HeaderYourStats": "Tilastosi",
|
||||
"LabelAddToPlaylist": "Lisää soittolistaan",
|
||||
"LabelAddedAt": "Lisätty listalle",
|
||||
@@ -110,6 +115,7 @@
|
||||
"LabelAutoSleepTimerAutoRewindHelp": "Kun automaattinen uniajastin päättyy, ensi kerralla toistetaan osa kuunenltua uudelleen.",
|
||||
"LabelAutoSleepTimerHelp": "Toiston alkaessa tällä aikävälillä unikytkin kytketään automaattisesti käyttöön.",
|
||||
"LabelBooks": "Kirjat",
|
||||
"LabelByAuthor": "Tekijältä: {0}",
|
||||
"LabelChapterTrack": "Luvun raita",
|
||||
"LabelChapters": "Luvut",
|
||||
"LabelClosePlayer": "Sulje soitin",
|
||||
@@ -143,6 +149,7 @@
|
||||
"LabelEndOfChapter": "Luvun loppu",
|
||||
"LabelEndTime": "Päättymisaika",
|
||||
"LabelEpisode": "Jakso",
|
||||
"LabelExplicit": "Sopimaton",
|
||||
"LabelFeedURL": "Syötteen URL",
|
||||
"LabelFile": "Tiedosto",
|
||||
"LabelFileBirthtime": "Tiedoston syntymäaika",
|
||||
@@ -151,6 +158,9 @@
|
||||
"LabelFinished": "Valmis",
|
||||
"LabelFolder": "Kansio",
|
||||
"LabelFontBoldness": "Kirjasintyyppien lihavointi",
|
||||
"LabelFontFamily": "Fonttiperhe",
|
||||
"LabelFontFamilySans": "Sans",
|
||||
"LabelFontFamilySerif": "Serif",
|
||||
"LabelFontScale": "Kirjasintyyppien skaalautuminen",
|
||||
"LabelGenre": "Lajityyppi",
|
||||
"LabelGenres": "Lajityypit",
|
||||
@@ -170,6 +180,9 @@
|
||||
"LabelLayout": "Asettelu",
|
||||
"LabelLayoutAuto": "Automaattinen",
|
||||
"LabelLayoutSinglePage": "Yksi sivu",
|
||||
"LabelLibrarySortByProgress": "Edistyminen: Viimeksi päivitetty",
|
||||
"LabelLibrarySortByProgressFinished": "Edistyminen: Valmis",
|
||||
"LabelLibrarySortByProgressStarted": "Edistyminen: Aloitettu",
|
||||
"LabelLight": "Vaalea",
|
||||
"LabelLineSpacing": "Riviväli",
|
||||
"LabelListenAgain": "Kuuntele uudelleen",
|
||||
@@ -217,17 +230,19 @@
|
||||
"LabelPublishedDate": "Julkaistu {0}",
|
||||
"LabelRSSFeedCustomOwnerEmail": "Mukautettu omistajan sähköposti",
|
||||
"LabelRSSFeedCustomOwnerName": "Mukautettu omistajan nimi",
|
||||
"LabelRSSFeedOpen": "RSS Syöte Avoin",
|
||||
"LabelRSSFeedPreventIndexing": "Estä indeksointi",
|
||||
"LabelRSSFeedSlug": "RSS-syöte Slug",
|
||||
"LabelRandomly": "Satunnaisesti",
|
||||
"LabelRead": "Lue",
|
||||
"LabelReadAgain": "Lue uudelleen",
|
||||
"LabelRecentSeries": "Viimeisimmät sarjat",
|
||||
"LabelRecentlyAdded": "Viimeeksi lisätyt",
|
||||
"LabelRecentlyAdded": "Viimeksi lisätyt",
|
||||
"LabelRemoveFromPlaylist": "Poista soittolistalta",
|
||||
"LabelScaleElapsedTimeBySpeed": "Skaalaa kulunut aika nopeuden mukaan",
|
||||
"LabelSeason": "Kausi",
|
||||
"LabelSelectADevice": "Valitse laite",
|
||||
"LabelSelectMediaType": "Valitse mediatyyppi",
|
||||
"LabelSequenceAscending": "Nouseva sekvenssi",
|
||||
"LabelSequenceDescending": "Laskeva sekvenssi",
|
||||
"LabelSeries": "Sarja",
|
||||
@@ -238,6 +253,8 @@
|
||||
"LabelShowAll": "Näytä kaikki",
|
||||
"LabelSize": "Koko",
|
||||
"LabelSleepTimer": "Uniajastin",
|
||||
"LabelSleepTimerAlmostDoneChime": "Soita merkkiääni, kun on ajastin on lähellä loppua",
|
||||
"LabelSleepTimerAlmostDoneChimeHelp": "Soita merkkiääni, kun uniajastimessa on 30 sekuntia jäljellä",
|
||||
"LabelStart": "Aloita",
|
||||
"LabelStartTime": "Aloitusaika",
|
||||
"LabelStatsBestDay": "Paras päivä",
|
||||
@@ -278,12 +295,17 @@
|
||||
"MessageAudiobookshelfServerNotConnected": "Audiobookshelf palvelinta ei ole yhdistetty",
|
||||
"MessageAudiobookshelfServerRequired": "<strong>Tärkeää!</strong> Tämä sovellus on suunniteltu toimimaan sinun tai tuttavasi Audiobookshelf-palvelimen kanssa. Tämä sovellus ei tarjoa sisältöä.",
|
||||
"MessageBookshelfEmpty": "Kirjahylly on tyhjä",
|
||||
"MessageConfirmAppExit": "Haluatko poistua sovelluksesta?",
|
||||
"MessageConfirmDeleteEpisodeDownloadQueue": "Haluatko varmasti tyhjentää jaksojen latausjonon?",
|
||||
"MessageConfirmDeleteLocalEpisode": "Poista jakso \"{0}\" laitteeltasi? Tämä ei vaikuta palvelimella olevaan tiedostoon.",
|
||||
"MessageConfirmDeleteLocalFiles": "Poista tämän kohteen tiedostot laitteestasi? Tämä ei vaikuta palvelimella oleviin tiedostoihin tai edistymiseesi.",
|
||||
"MessageConfirmDeleteServerConfig": "Poistetaanko palvelimen konfiguraatio?",
|
||||
"MessageConfirmDeleteServerEpisode": "Haluatko varmasti poistaa jakson \"{0}\" palvelimelta?\nVaroitus: Tämä poistaa äänitiedoston.",
|
||||
"MessageConfirmDisableAutoTimer": "Oletko varma, että haluat poistaa automaattisen ajastimen käytöstä loppupäivän ajaksi? Ajastin otetaan uudelleen käyttöön tämän automaattisen uniajastimen jakson lopussa tai jos käynnistät sovelluksen uudelleen.",
|
||||
"MessageConfirmDiscardProgress": "Haluatko varmasti nollata edistymisesi?",
|
||||
"MessageConfirmDownloadUsingCellular": "Olet lataamassa mobiilidatan avulla. Tämä voi aiheuttaa operaattorin datamaksuja. Haluatko jatkaa?",
|
||||
"MessageConfirmMarkAsFinished": "Haluatko varmasti merkitä tämän kohteen valmiiksi?",
|
||||
"MessageConfirmPlaybackTime": "Toistetaanko \"{0}\" kohdasta {1}?",
|
||||
"MessageConfirmRemoveBookmark": "Haluatko varmasti poistaa kirjanmerkin?",
|
||||
"MessageConfirmStreamingUsingCellular": "Olet suoratoistamassa matkapuhelindatan avulla. Tämä voi aiheuttaa operaattorin datamaksuja. Haluatko jatkaa?",
|
||||
"MessageDiscardProgress": "Hylkää edistyminen",
|
||||
@@ -291,6 +313,7 @@
|
||||
"MessageDownloading": "Ladataan...",
|
||||
"MessageDownloadingEpisode": "Ladataan jaksoa",
|
||||
"MessageEpisodesQueuedForDownload": "{0} jaksoa on latausjonossa",
|
||||
"MessageFailedToRefreshToken": "Tunnuksen uusiminen epäonnistui, kirjaudu uudelleen sisään",
|
||||
"MessageFeedURLWillBe": "Syötteen URL tulee olemaan {0}",
|
||||
"MessageFetching": "Haetaan...",
|
||||
"MessageFollowTheProjectOnGithub": "Seuraa projektia Githubissa",
|
||||
@@ -311,12 +334,16 @@
|
||||
"MessageNoItems": "Ei kohteita",
|
||||
"MessageNoItemsFound": "Kohteita ei löytynyt",
|
||||
"MessageNoListeningSessions": "Ei kuunteluistuntoja",
|
||||
"MessageNoLogs": "Ei lokeja",
|
||||
"MessageNoMediaFolders": "Ei mediakansioita",
|
||||
"MessageNoNetworkConnection": "Ei verkkoyhteyttä",
|
||||
"MessageNoPodcastsFound": "Podcasteja ei löytynyt",
|
||||
"MessageNoSeries": "Ei sarjoja",
|
||||
"MessageNoUpdatesWereNecessary": "Päivityksiä ei tarvittu",
|
||||
"MessageNoUserPlaylists": "Sinulla ei ole soittolistoja",
|
||||
"MessageOldServerAuthReLoginRequired": "Todennuksen tietoturvaa on parannettu palvelimen versiossa 2.26.0. Kaikkien käyttäjien on kirjauduttava uudelleen sisään.",
|
||||
"MessageOldServerAuthWarning": "Palvelun käyttää vanhentunutta todennusta",
|
||||
"MessageOldServerAuthWarningHelp": "Palvelimessa on vanhempi versio kuin v.2.26.0. Turvallisempi todennus lisättiin versioon v.2.26.0. On suositeltavaa päivittää palvelin uusimpaan versioon. Mikäli olet jo päivittänyt, kirjaudu uudelleen sisään ja käytä uutta todennusta sisäänkirjautumisessa.",
|
||||
"MessageOldServerConnectionWarning": "Palvelinyhteysmääritys käyttää vanhaa käyttäjätunnusta. Poista tämä palvelinyhteys ja lisää se uudelleen.",
|
||||
"MessageOldServerConnectionWarningHelp": "Määritit alun perin yhteyden tähän palvelimeen ennen tietokannan siirtoa versiossa 2.3.0, joka julkaistiin kesäkuussa 2023. Tuleva palvelinpäivitys poistaa mahdollisuuden kirjautua sisään tällä vanhalla yhteydellä. Poista olemassa oleva palvelinyhteys ja muodosta yhteys uudelleen (käyttäen samaa palvelinosoitetta ja tunnistetietoja). Jos sinulla on ladattua mediaa tällä laitteella, media on ladattava uudelleen synkronoitavaksi palvelimen kanssa.",
|
||||
"MessagePodcastSearchField": "Syötä hakutermi tai RSS-syötteen URL-osoite",
|
||||
@@ -343,5 +370,7 @@
|
||||
"ToastPodcastCreateSuccess": "Podcastin luominen onnistui",
|
||||
"ToastRSSFeedCloseFailed": "RSS syötteen sulkeminen epäonnistui",
|
||||
"ToastRSSFeedCloseSuccess": "RSS syöte suljettu",
|
||||
"ToastStreamingNotAllowedOnCellular": "Suoratoisto ei ole sallittu mobiilidatalla"
|
||||
"ToastStreamingNotAllowedOnCellular": "Suoratoisto ei ole sallittu mobiilidatalla",
|
||||
"UnitMinutesShort": "{0}m",
|
||||
"UnitSecondsShort": "{0}s"
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
"HeaderChapters": "Chapitres",
|
||||
"HeaderCollection": "Collection",
|
||||
"HeaderCollectionItems": "Entrées de la collection",
|
||||
"HeaderConfirm": "Confirmer",
|
||||
"HeaderConnectionStatus": "Status de connexion",
|
||||
"HeaderDataSettings": "Paramètres de données",
|
||||
"HeaderDetails": "Détails",
|
||||
@@ -91,6 +92,7 @@
|
||||
"HeaderStatsRecentSessions": "Sessions récentes",
|
||||
"HeaderTableOfContents": "Table des matières",
|
||||
"HeaderUserInterfaceSettings": "Paramètres de l'interface",
|
||||
"HeaderWelcome": "Bienvenue, <strong>{0}</strong>",
|
||||
"HeaderYourStats": "Vos statistiques",
|
||||
"LabelAddToPlaylist": "Ajouter à la liste de lecture",
|
||||
"LabelAddedAt": "Date d’ajout",
|
||||
@@ -113,6 +115,7 @@
|
||||
"LabelAutoSleepTimerAutoRewindHelp": "Lorsque le minuteur nocturne se termine, relire l'élément fera un saut en arrière.",
|
||||
"LabelAutoSleepTimerHelp": "Lorsqu'un éléments est lu entre l'heure de début et de fin, un minuteur nocturne se lance automatiquement.",
|
||||
"LabelBooks": "Livres",
|
||||
"LabelByAuthor": "de {0}",
|
||||
"LabelChapterTrack": "Piste des chapitres",
|
||||
"LabelChapters": "Chapitres",
|
||||
"LabelClosePlayer": "Fermer le lecteur",
|
||||
@@ -155,7 +158,10 @@
|
||||
"LabelFinished": "Fini(e)",
|
||||
"LabelFolder": "Dossier",
|
||||
"LabelFontBoldness": "Graisse de la police",
|
||||
"LabelFontScale": "Taille de la police de caractère",
|
||||
"LabelFontFamily": "Famille de caractères",
|
||||
"LabelFontFamilySans": "Sans",
|
||||
"LabelFontFamilySerif": "Avec empattements",
|
||||
"LabelFontScale": "Taille de la police",
|
||||
"LabelGenre": "Genre",
|
||||
"LabelGenres": "Genres",
|
||||
"LabelHapticFeedback": "Retour haptique",
|
||||
@@ -174,7 +180,10 @@
|
||||
"LabelLayout": "Mise en page",
|
||||
"LabelLayoutAuto": "Automatique",
|
||||
"LabelLayoutSinglePage": "Page seule",
|
||||
"LabelLight": "Léger",
|
||||
"LabelLibrarySortByProgress": "Progression : Mise à jour",
|
||||
"LabelLibrarySortByProgressFinished": "Progression : Terminé",
|
||||
"LabelLibrarySortByProgressStarted": "Progression : En cours",
|
||||
"LabelLight": "Clair",
|
||||
"LabelLineSpacing": "Interligne",
|
||||
"LabelListenAgain": "Écouter à nouveau",
|
||||
"LabelLocalBooks": "Livres locaux",
|
||||
@@ -233,6 +242,7 @@
|
||||
"LabelScaleElapsedTimeBySpeed": "Traduire le temps restant en fonction de la vitesse de lecture",
|
||||
"LabelSeason": "Saison",
|
||||
"LabelSelectADevice": "Sélectionner un Appareil",
|
||||
"LabelSelectMediaType": "Sélectionnez le type de media",
|
||||
"LabelSequenceAscending": "Séquence ascendante",
|
||||
"LabelSequenceDescending": "Séquence descendante",
|
||||
"LabelSeries": "Séries",
|
||||
@@ -285,12 +295,17 @@
|
||||
"MessageAudiobookshelfServerNotConnected": "Serveur Audiobookshelf non connecté",
|
||||
"MessageAudiobookshelfServerRequired": "<strong>Important !</strong> Cette application est faite pour fonctionner avec un serveur Audiobookshelf. L'application ne fournit aucun contenu.",
|
||||
"MessageBookshelfEmpty": "Bibliothèque vide",
|
||||
"MessageConfirmAppExit": "Voulez-vous quitter l'application ?",
|
||||
"MessageConfirmDeleteEpisodeDownloadQueue": "Voulez-vous vraiment vider la file d'attente de téléchargement des épisodes ?",
|
||||
"MessageConfirmDeleteLocalEpisode": "Retirer l’épisode local « {0} » de votre appareil ? Le fichier sur le serveur ne sera pas affecté.",
|
||||
"MessageConfirmDeleteLocalFiles": "Supprimer les fichiers locaux de cet élément de votre appareil ? Les fichiers sur le serveur ainsi que votre progression ne serons pas affectés.",
|
||||
"MessageConfirmDeleteServerConfig": "Supprimer la configuration de ce serveur ?",
|
||||
"MessageConfirmDeleteServerEpisode": "Voulez-vous vraiment supprimer l'épisode \"{0}\" du serveur ?\nAttention : le fichier audio sera supprimé.",
|
||||
"MessageConfirmDisableAutoTimer": "Êtes-vous sûr·e de vouloir désactiver le minuteur automatique pour le reste de la journée ? Le minuteur sera réactivé à la fin de cette période de décompte automatique, ou si vous redémarrez l'application.",
|
||||
"MessageConfirmDiscardProgress": "Êtes-vous sûr·e de vouloir supprimer votre progression ?",
|
||||
"MessageConfirmDownloadUsingCellular": "Vous êtes sur le point d’effectuer un téléchargement en utilisant des données mobiles.Il se peut que des frais de données soient facturés par votre opérateur. Souhaitez-vous continuer ?",
|
||||
"MessageConfirmMarkAsFinished": "Êtes-vous sûr·e de vouloir marquer cette élement comme terminé ?",
|
||||
"MessageConfirmPlaybackTime": "Lancer la lecture de \"{0}\" à {1} ?",
|
||||
"MessageConfirmRemoveBookmark": "Êtes-vous sûr·e de vouloir supprimer le marque-page ?",
|
||||
"MessageConfirmStreamingUsingCellular": "Vous êtes sur le point de diffuser du contenu en utilisant des données mobiles. Il se peut que des frais de données soient facturés par votre opérateur. Souhaitez-vous continuer ?",
|
||||
"MessageDiscardProgress": "Supprimer la progression",
|
||||
@@ -355,5 +370,7 @@
|
||||
"ToastPodcastCreateSuccess": "Podcast créé avec succès",
|
||||
"ToastRSSFeedCloseFailed": "Échec de la fermeture du flux RSS",
|
||||
"ToastRSSFeedCloseSuccess": "Flux RSS fermé",
|
||||
"ToastStreamingNotAllowedOnCellular": "La diffusion sur les données mobile n’est pas autorisée"
|
||||
"ToastStreamingNotAllowedOnCellular": "La diffusion sur les données mobile n’est pas autorisée",
|
||||
"UnitMinutesShort": "{0}min",
|
||||
"UnitSecondsShort": "{0}sec"
|
||||
}
|
||||
|
||||