mirror of
https://github.com/advplyr/audiobookshelf-app.git
synced 2026-08-30 07:07:22 +02:00
Compare commits
139
Commits
v0.12.0-beta
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12025ab59a | ||
|
|
f66840e8e4 | ||
|
|
4c3e098785 | ||
|
|
441086e773 | ||
|
|
1408a4c3ff | ||
|
|
fe67a221ac | ||
|
|
f087cbed6f | ||
|
|
9820db0ef6 | ||
|
|
9664f79b1f | ||
|
|
533396b855 | ||
|
|
005a051cd0 | ||
|
|
f06168fdc2 | ||
|
|
26c6b9149b | ||
|
|
8b70d02332 | ||
|
|
4370b19e12 | ||
|
|
f708e4dc07 | ||
|
|
4bb809c1b9 | ||
|
|
ccf57bb016 | ||
|
|
65b2a1a9a4 | ||
|
|
8e6ef8e718 | ||
|
|
cbea6229b5 | ||
|
|
05205eb646 | ||
|
|
32fc866d8a | ||
|
|
da089338dc | ||
|
|
e19f47b291 | ||
|
|
e7ed54439b | ||
|
|
3db3bbd756 | ||
|
|
24ab13e27e | ||
|
|
195d1d1ef4 | ||
|
|
5383e91249 | ||
|
|
c02e45b703 | ||
|
|
991dd997ee | ||
|
|
daddd5c3c2 | ||
|
|
8b77fae668 | ||
|
|
bba15dacf8 | ||
|
|
bfaeb6ea42 | ||
|
|
5f66f65d6f | ||
|
|
8b9098c877 | ||
|
|
7044925791 | ||
|
|
bcc292b33a | ||
|
|
ae08206c3e | ||
|
|
91315dd9b5 | ||
|
|
8a253e7679 | ||
|
|
04e9c8a329 | ||
|
|
1d163df773 | ||
|
|
4cfa22004c | ||
|
|
e0747e3f19 | ||
|
|
b14e7b7357 | ||
|
|
0123df99d2 | ||
|
|
4a48cb0a9f | ||
|
|
425ef4c3b6 | ||
|
|
e5ef38295d | ||
|
|
b084e54dcc | ||
|
|
d3e55cabf4 | ||
|
|
2cc9ff5a41 | ||
|
|
9562e8f252 | ||
|
|
7726192008 | ||
|
|
f8424c03fd | ||
|
|
58fbfca645 | ||
|
|
6282fd1f95 | ||
|
|
245df5d934 | ||
|
|
dc6184f41d | ||
|
|
b3c3950c55 | ||
|
|
034a134319 | ||
|
|
8142d626c0 | ||
|
|
8b20730b6f | ||
|
|
d27f431f6f | ||
|
|
0df0a206e7 | ||
|
|
6aeb31b591 | ||
|
|
eb2483d039 | ||
|
|
67fbeeeb6c | ||
|
|
5099e39f7b | ||
|
|
34710dbddc | ||
|
|
4798644bea | ||
|
|
bd0cbf8bf0 | ||
|
|
f562a1a5dc | ||
|
|
185cba16eb | ||
|
|
547d086228 | ||
|
|
3a2d5647d4 | ||
|
|
0a67c9b802 | ||
|
|
0254afd5d8 | ||
|
|
853546f3d2 | ||
|
|
b8ac073247 | ||
|
|
56cb4df31a | ||
|
|
7285b0bf26 | ||
|
|
3fdc2e6dcf | ||
|
|
ed2f9d2e45 | ||
|
|
68dafbba75 | ||
|
|
7d17367c1e | ||
|
|
8be08702c1 | ||
|
|
91bc56e1e1 | ||
|
|
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 | ||
|
|
d4e7d0abf9 | ||
|
|
1571bcd9f3 | ||
|
|
5531ced129 | ||
|
|
2b258de5b8 | ||
|
|
4e3d3f7752 | ||
|
|
63863a8800 | ||
|
|
d594fcb08b | ||
|
|
85aab8df06 | ||
|
|
ec1585f7c1 | ||
|
|
3b3bda117c | ||
|
|
c37f2b71d4 | ||
|
|
04de5960f1 | ||
|
|
dad7ac4efd | ||
|
|
d2830c1603 | ||
|
|
6091fd6f79 | ||
|
|
c9935eca2b | ||
|
|
38eacdd7b4 | ||
|
|
1bde917b09 | ||
|
|
0859e219fa | ||
|
|
dac9279320 |
@@ -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.11.0'
|
||||
- 'iOS App - 0.11.0'
|
||||
- 'Android App - 0.10.1'
|
||||
- 'iOS App - 0.10.1'
|
||||
- 'Android App - 0.13.0'
|
||||
- 'iOS App - 0.13.0'
|
||||
- 'Android App - 0.12.0'
|
||||
- 'iOS App - 0.12.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.11.0'
|
||||
- 'iOS App - 0.11.0'
|
||||
- 'Android App - 0.10.1'
|
||||
- 'iOS App - 0.10.1'
|
||||
- 'Android App - 0.13.0'
|
||||
- 'iOS App - 0.13.0'
|
||||
- 'Android App - 0.12.0'
|
||||
- 'iOS App - 0.12.0'
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
|
||||
@@ -36,8 +36,8 @@ android {
|
||||
applicationId "com.audiobookshelf.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 116
|
||||
versionName "0.12.0-beta"
|
||||
versionCode 118
|
||||
versionName "0.14.0-beta"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
manifestPlaceholders = [
|
||||
"appAuthRedirectScheme": "com.audiobookshelf.app"
|
||||
@@ -92,6 +92,7 @@ dependencies {
|
||||
implementation project(':capacitor-cordova-android-plugins')
|
||||
|
||||
implementation "androidx.core:core-ktx:$androidx_core_ktx_version"
|
||||
implementation "androidx.work:work-runtime-ktx:2.9.1"
|
||||
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlin_coroutines_version"
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version"
|
||||
|
||||
@@ -6,12 +6,14 @@
|
||||
|
||||
<!-- Permissions -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission
|
||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="28" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
@@ -98,6 +100,12 @@
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".services.DownloadService"
|
||||
android:enabled="true"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
|
||||
@@ -40,9 +40,6 @@ class MainActivity : BridgeActivity() {
|
||||
val storage = SimpleStorage(this)
|
||||
|
||||
val REQUEST_PERMISSIONS = 1
|
||||
var PERMISSIONS_ALL = arrayOf(
|
||||
Manifest.permission.READ_EXTERNAL_STORAGE
|
||||
)
|
||||
|
||||
public override fun onCreate(savedInstanceState: Bundle?) {
|
||||
DbManager.initialize(applicationContext)
|
||||
@@ -98,11 +95,20 @@ class MainActivity : BridgeActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
val permission = ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE)
|
||||
if (permission != PackageManager.PERMISSION_GRANTED) {
|
||||
ActivityCompat.requestPermissions(this,
|
||||
PERMISSIONS_ALL,
|
||||
REQUEST_PERMISSIONS)
|
||||
requestNeededPermissions()
|
||||
}
|
||||
|
||||
private fun requestNeededPermissions() {
|
||||
val needed = mutableListOf<String>()
|
||||
if (ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
|
||||
needed.add(Manifest.permission.READ_EXTERNAL_STORAGE)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||
ActivityCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||
needed.add(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
if (needed.isNotEmpty()) {
|
||||
ActivityCompat.requestPermissions(this, needed.toTypedArray(), REQUEST_PERMISSIONS)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ data class AudioTrack(
|
||||
var duration: Double,
|
||||
var title: String,
|
||||
var contentUrl: String,
|
||||
var mimeType: String,
|
||||
var mimeType: String?,
|
||||
var metadata: FileMetadata?,
|
||||
var isLocal: Boolean,
|
||||
var localFileId: String?,
|
||||
|
||||
@@ -1,35 +1,51 @@
|
||||
package com.audiobookshelf.app.data
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.support.v4.media.MediaDescriptionCompat
|
||||
import android.util.Log
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
import com.fasterxml.jackson.annotation.JsonSubTypes
|
||||
import com.fasterxml.jackson.annotation.JsonTypeInfo
|
||||
import java.io.File
|
||||
|
||||
enum class LockOrientationSetting {
|
||||
NONE, PORTRAIT, LANDSCAPE
|
||||
NONE,
|
||||
PORTRAIT,
|
||||
LANDSCAPE
|
||||
}
|
||||
|
||||
enum class HapticFeedbackSetting {
|
||||
OFF, LIGHT, MEDIUM, HEAVY
|
||||
OFF,
|
||||
LIGHT,
|
||||
MEDIUM,
|
||||
HEAVY
|
||||
}
|
||||
|
||||
enum class ShakeSensitivitySetting {
|
||||
VERY_LOW, LOW, MEDIUM, HIGH, VERY_HIGH
|
||||
VERY_LOW,
|
||||
LOW,
|
||||
MEDIUM,
|
||||
HIGH,
|
||||
VERY_HIGH
|
||||
}
|
||||
|
||||
enum class DownloadUsingCellularSetting {
|
||||
ASK, ALWAYS, NEVER
|
||||
ASK,
|
||||
ALWAYS,
|
||||
NEVER
|
||||
}
|
||||
|
||||
enum class StreamingUsingCellularSetting {
|
||||
ASK, ALWAYS, NEVER
|
||||
ASK,
|
||||
ALWAYS,
|
||||
NEVER
|
||||
}
|
||||
|
||||
enum class AndroidAutoBrowseSeriesSequenceOrderSetting {
|
||||
ASC, DESC
|
||||
ASC,
|
||||
DESC
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
@@ -53,10 +69,22 @@ data class LocalFile(
|
||||
var contentUrl: String,
|
||||
var basePath: String,
|
||||
var absolutePath: String,
|
||||
var simplePath:String,
|
||||
var mimeType: String?,
|
||||
var size: Long
|
||||
) {
|
||||
@JsonIgnore
|
||||
fun exists(ctx: Context): Boolean {
|
||||
if (contentUrl.startsWith("content:")) {
|
||||
return try {
|
||||
ctx.contentResolver.openFileDescriptor(Uri.parse(contentUrl), "r")?.use { true } ?: false
|
||||
} catch (e: Exception) {
|
||||
Log.w("LocalFile", "Cannot access SAF file $contentUrl", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
return File(absolutePath).exists()
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun isAudioFile(): Boolean {
|
||||
if (mimeType == "application/octet-stream") return true
|
||||
@@ -86,19 +114,20 @@ data class LocalFolder(
|
||||
var contentUrl: String,
|
||||
var basePath: String,
|
||||
var absolutePath: String,
|
||||
var simplePath:String,
|
||||
var storageType: String,
|
||||
var mediaType: String
|
||||
)
|
||||
|
||||
@JsonTypeInfo(use = JsonTypeInfo.Id.DEDUCTION)
|
||||
@JsonSubTypes(
|
||||
JsonSubTypes.Type(LibraryItem::class),
|
||||
JsonSubTypes.Type(LocalLibraryItem::class)
|
||||
)
|
||||
@JsonSubTypes(JsonSubTypes.Type(LibraryItem::class), JsonSubTypes.Type(LocalLibraryItem::class))
|
||||
open class LibraryItemWrapper(var id: String) {
|
||||
@JsonIgnore
|
||||
open fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context): MediaDescriptionCompat { return MediaDescriptionCompat.Builder().build() }
|
||||
open fun getMediaDescription(
|
||||
progress: MediaProgressWrapper?,
|
||||
ctx: Context
|
||||
): MediaDescriptionCompat {
|
||||
return MediaDescriptionCompat.Builder().build()
|
||||
}
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
@@ -178,18 +207,23 @@ data class DeviceSettings(
|
||||
}
|
||||
|
||||
@get:JsonIgnore
|
||||
val jumpBackwardsTimeMs get() = jumpBackwardsTime * 1000L
|
||||
val jumpBackwardsTimeMs
|
||||
get() = jumpBackwardsTime * 1000L
|
||||
@get:JsonIgnore
|
||||
val jumpForwardTimeMs get() = jumpForwardTime * 1000L
|
||||
val jumpForwardTimeMs
|
||||
get() = jumpForwardTime * 1000L
|
||||
@get:JsonIgnore
|
||||
val autoSleepTimerStartHour get() = autoSleepTimerStartTime.split(":")[0].toInt()
|
||||
val autoSleepTimerStartHour
|
||||
get() = autoSleepTimerStartTime.split(":")[0].toInt()
|
||||
@get:JsonIgnore
|
||||
val autoSleepTimerStartMinute get() = autoSleepTimerStartTime.split(":")[1].toInt()
|
||||
val autoSleepTimerStartMinute
|
||||
get() = autoSleepTimerStartTime.split(":")[1].toInt()
|
||||
@get:JsonIgnore
|
||||
val autoSleepTimerEndHour get() = autoSleepTimerEndTime.split(":")[0].toInt()
|
||||
val autoSleepTimerEndHour
|
||||
get() = autoSleepTimerEndTime.split(":")[0].toInt()
|
||||
@get:JsonIgnore
|
||||
val autoSleepTimerEndMinute get() = autoSleepTimerEndTime.split(":")[1].toInt()
|
||||
|
||||
val autoSleepTimerEndMinute
|
||||
get() = autoSleepTimerEndTime.split(":")[1].toInt()
|
||||
|
||||
@JsonIgnore
|
||||
fun getShakeThresholdGravity(): Float { // Used in ShakeDetector
|
||||
@@ -218,4 +252,3 @@ data class DeviceData(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
package com.audiobookshelf.app.data
|
||||
|
||||
data class FolderScanResult(
|
||||
var itemsAdded:Int,
|
||||
var itemsUpdated:Int,
|
||||
var itemsRemoved:Int,
|
||||
var itemsUpToDate:Int,
|
||||
val localFolder:LocalFolder,
|
||||
val localLibraryItems:List<LocalLibraryItem>,
|
||||
)
|
||||
|
||||
data class LocalLibraryItemScanResult(
|
||||
val updated:Boolean,
|
||||
val localLibraryItem:LocalLibraryItem,
|
||||
)
|
||||
@@ -80,7 +80,7 @@ class LocalLibraryItem(
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun hasTracks(episode:PodcastEpisode?): Boolean {
|
||||
fun hasTracks(ctx: Context, episode:PodcastEpisode?): Boolean {
|
||||
var audioTracks = media.getAudioTracks() as MutableList<AudioTrack>
|
||||
if (episode != null) { // Get podcast episode audio track
|
||||
episode.audioTrack?.let { at -> mutableListOf(at) }?.let { tracks -> audioTracks = tracks }
|
||||
@@ -91,15 +91,18 @@ class LocalLibraryItem(
|
||||
if (it.metadata === null) {
|
||||
return false
|
||||
}
|
||||
// Check that file exists
|
||||
val file = File(it.metadata!!.path)
|
||||
if (!file.exists()) {
|
||||
if (!trackExists(ctx, it.contentUrl, it.metadata!!.path)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
private fun trackExists(ctx: Context, contentUrl: String?, path: String): Boolean {
|
||||
return LocalFile("", null, contentUrl ?: "", "", path, null, 0).exists(ctx)
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getPlaybackSession(episode:PodcastEpisode?, deviceInfo:DeviceInfo):PlaybackSession {
|
||||
val localEpisodeId = episode?.id
|
||||
@@ -108,7 +111,7 @@ class LocalLibraryItem(
|
||||
// Get current progress for local media
|
||||
val mediaProgressId = if (localEpisodeId.isNullOrEmpty()) id else "$id-$localEpisodeId"
|
||||
val mediaProgress = DeviceManager.dbManager.getLocalMediaProgress(mediaProgressId)
|
||||
val currentTime = mediaProgress?.currentTime ?: 0.0
|
||||
val currentTime = if (mediaProgress?.isFinished == true) 0.0 else mediaProgress?.currentTime ?: 0.0
|
||||
|
||||
|
||||
val mediaMetadata = media.metadata
|
||||
|
||||
@@ -14,7 +14,6 @@ data class LocalMediaItem(
|
||||
var mediaType: String,
|
||||
var folderId: String,
|
||||
var contentUrl: String,
|
||||
var simplePath: String,
|
||||
var basePath: String,
|
||||
var absolutePath: String,
|
||||
var audioTracks: MutableList<AudioTrack>,
|
||||
|
||||
@@ -46,6 +46,10 @@ class LocalMediaProgress(
|
||||
progress = if (finished) 1.0 else 0.0
|
||||
}
|
||||
|
||||
if (finished) {
|
||||
currentTime = 0.0
|
||||
}
|
||||
|
||||
isFinished = finished
|
||||
lastUpdate = System.currentTimeMillis()
|
||||
finishedAt = if (isFinished) lastUpdate else null
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.audiobookshelf.app.data
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.ImageDecoder
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
@@ -20,6 +21,9 @@ import com.google.android.exoplayer2.MediaMetadata
|
||||
import com.google.android.gms.cast.MediaInfo
|
||||
import com.google.android.gms.cast.MediaQueueItem
|
||||
import com.google.android.gms.common.images.WebImage
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
class PlaybackSession(
|
||||
@@ -202,6 +206,13 @@ class PlaybackSession(
|
||||
return Uri.parse("$serverAddress${audioTrack.contentUrl}?token=${DeviceManager.token}")
|
||||
}
|
||||
|
||||
/** Bitmap for the cover art, once resolved */
|
||||
@JsonIgnore
|
||||
private var resolvedCoverBitmap: Bitmap? = null
|
||||
|
||||
/**
|
||||
* Builds the current session metadata, including the cover art bitmap if it has already been resolved.
|
||||
*/
|
||||
@JsonIgnore
|
||||
fun getMediaMetadataCompat(ctx: Context): MediaMetadataCompat {
|
||||
val coverUri = getCoverUri(ctx)
|
||||
@@ -217,16 +228,41 @@ class PlaybackSession(
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST, displayAuthor)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION, displayAuthor)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, coverUri.toString())
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ART_URI, coverUri.toString())
|
||||
.putString(
|
||||
MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI,
|
||||
coverUri.toString()
|
||||
)
|
||||
|
||||
// Local covers get bitmap
|
||||
if (resolvedCoverBitmap != null) {
|
||||
metadataBuilder
|
||||
.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, resolvedCoverBitmap)
|
||||
.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, resolvedCoverBitmap)
|
||||
} else {
|
||||
metadataBuilder
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, coverUri.toString())
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ART_URI, coverUri.toString())
|
||||
}
|
||||
|
||||
return metadataBuilder.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the cover art bitmap (local covers are decoded synchronously, server-side covers
|
||||
* are fetched asynchronously) and calls `onArtResolved` once it's available
|
||||
*
|
||||
* Returns the Job for the async fetch, or `null` if the bitmap was resolved synchronously.
|
||||
*/
|
||||
@JsonIgnore
|
||||
fun resolveCoverBitmapAsync(
|
||||
ctx: Context,
|
||||
coroutineScope: CoroutineScope,
|
||||
onArtResolved: () -> Unit
|
||||
): Job? {
|
||||
val coverUri = getCoverUri(ctx)
|
||||
|
||||
// Local covers get bitmap synchronously, no async fetch needed
|
||||
if (localLibraryItem?.coverContentUrl != null) {
|
||||
val bitmap =
|
||||
resolvedCoverBitmap =
|
||||
if (Build.VERSION.SDK_INT < 28) {
|
||||
MediaStore.Images.Media.getBitmap(ctx.contentResolver, coverUri)
|
||||
} else {
|
||||
@@ -234,11 +270,18 @@ class PlaybackSession(
|
||||
ImageDecoder.createSource(ctx.contentResolver, coverUri)
|
||||
ImageDecoder.decodeBitmap(source)
|
||||
}
|
||||
metadataBuilder.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, bitmap)
|
||||
metadataBuilder.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, bitmap)
|
||||
onArtResolved()
|
||||
return null
|
||||
}
|
||||
|
||||
return metadataBuilder.build()
|
||||
// Server-side cover: resolve the art bitmap async
|
||||
return coroutineScope.launch {
|
||||
val bitmap = resolveUriAsBitmap(ctx, coverUri)
|
||||
bitmap?.let {
|
||||
resolvedCoverBitmap = it
|
||||
onArtResolved()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
@@ -332,7 +375,7 @@ class PlaybackSession(
|
||||
MediaInfo.Builder(mediaUri.toString())
|
||||
.apply {
|
||||
setContentUrl(mediaUri.toString())
|
||||
setContentType(audioTrack.mimeType)
|
||||
setContentType(audioTrack.mimeType ?: "")
|
||||
setMetadata(castMetadata)
|
||||
setStreamType(MediaInfo.STREAM_TYPE_BUFFERED)
|
||||
}
|
||||
|
||||
@@ -7,570 +7,306 @@ import androidx.documentfile.provider.DocumentFile
|
||||
import com.anggrayudi.storage.file.*
|
||||
import com.audiobookshelf.app.data.*
|
||||
import com.audiobookshelf.app.models.DownloadItem
|
||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.audiobookshelf.app.models.DownloadItemPart
|
||||
import java.io.File
|
||||
|
||||
class FolderScanner(var ctx: Context) {
|
||||
/** Creates local-library records from a completed download manifest. */
|
||||
class FolderScanner(private val ctx: Context) {
|
||||
private val tag = "FolderScanner"
|
||||
private var jacksonMapper =
|
||||
jacksonObjectMapper()
|
||||
.enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||
|
||||
data class DownloadItemScanResult(
|
||||
val localLibraryItem: LocalLibraryItem,
|
||||
var localMediaProgress: LocalMediaProgress?
|
||||
)
|
||||
|
||||
private fun getLocalLibraryItemId(mediaItemId: String): String {
|
||||
return "local_" + DeviceManager.getBase64Id(mediaItemId)
|
||||
}
|
||||
private fun localLibraryItemId(mediaItemId: String) =
|
||||
"local_${DeviceManager.getBase64Id(mediaItemId)}"
|
||||
|
||||
private fun scanInternalDownloadItem(
|
||||
downloadItem: DownloadItem,
|
||||
cb: (DownloadItemScanResult?) -> Unit
|
||||
) {
|
||||
val localLibraryItemId = "local_${downloadItem.libraryItemId}"
|
||||
|
||||
var localEpisodeId: String? = null
|
||||
var localLibraryItem: LocalLibraryItem?
|
||||
if (downloadItem.mediaType == "book") {
|
||||
localLibraryItem =
|
||||
LocalLibraryItem(
|
||||
localLibraryItemId,
|
||||
downloadItem.localFolder.id,
|
||||
downloadItem.itemFolderPath,
|
||||
downloadItem.itemFolderPath,
|
||||
"",
|
||||
false,
|
||||
downloadItem.mediaType,
|
||||
downloadItem.media.getLocalCopy(),
|
||||
mutableListOf(),
|
||||
null,
|
||||
null,
|
||||
true,
|
||||
downloadItem.serverConnectionConfigId,
|
||||
downloadItem.serverAddress,
|
||||
downloadItem.serverUserId,
|
||||
downloadItem.libraryItemId
|
||||
)
|
||||
} else {
|
||||
// Lookup or create podcast local library item
|
||||
localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
|
||||
if (localLibraryItem == null) {
|
||||
Log.d(
|
||||
tag,
|
||||
"[FolderScanner] Podcast local library item not created yet for ${downloadItem.media.metadata.title}"
|
||||
)
|
||||
localLibraryItem =
|
||||
LocalLibraryItem(
|
||||
localLibraryItemId,
|
||||
downloadItem.localFolder.id,
|
||||
downloadItem.itemFolderPath,
|
||||
downloadItem.itemFolderPath,
|
||||
"",
|
||||
false,
|
||||
downloadItem.mediaType,
|
||||
downloadItem.media.getLocalCopy(),
|
||||
mutableListOf(),
|
||||
null,
|
||||
null,
|
||||
true,
|
||||
downloadItem.serverConnectionConfigId,
|
||||
downloadItem.serverAddress,
|
||||
downloadItem.serverUserId,
|
||||
downloadItem.libraryItemId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val audioTracks: MutableList<AudioTrack> = mutableListOf()
|
||||
var foundEBookFile = false
|
||||
|
||||
downloadItem.downloadItemParts.forEach { downloadItemPart ->
|
||||
Log.d(
|
||||
tag,
|
||||
"Scan internal storage item with finalDestinationUri=${downloadItemPart.finalDestinationUri}"
|
||||
)
|
||||
|
||||
val file = File(downloadItemPart.finalDestinationPath)
|
||||
Log.d(tag, "Scan internal storage item created file ${file.name}")
|
||||
|
||||
if (file == null) {
|
||||
Log.e(
|
||||
tag,
|
||||
"scanInternalDownloadItem: Null docFile for path ${downloadItemPart.finalDestinationPath}"
|
||||
)
|
||||
} else {
|
||||
if (downloadItemPart.audioTrack != null) {
|
||||
val audioTrackFromServer = downloadItemPart.audioTrack
|
||||
Log.d(
|
||||
tag,
|
||||
"scanInternalDownloadItem: Audio Track from Server index = ${audioTrackFromServer.index}"
|
||||
)
|
||||
|
||||
val localFileId = DeviceManager.getBase64Id(file.name)
|
||||
Log.d(tag, "Scan internal file localFileId=$localFileId")
|
||||
val localFile =
|
||||
LocalFile(
|
||||
localFileId,
|
||||
private fun createLocalFile(
|
||||
part: DownloadItemPart,
|
||||
externalFile: DocumentFile? = null
|
||||
): LocalFile? {
|
||||
if (part.isInternalStorage) {
|
||||
val file = File(part.finalDestinationPath)
|
||||
if (!file.exists()) return null
|
||||
return LocalFile(
|
||||
DeviceManager.getBase64Id(file.name),
|
||||
file.name,
|
||||
downloadItemPart.finalDestinationUri.toString(),
|
||||
Uri.fromFile(file).toString(),
|
||||
file.getBasePath(ctx),
|
||||
file.absolutePath,
|
||||
file.getSimplePath(ctx),
|
||||
file.mimeType,
|
||||
file.length()
|
||||
)
|
||||
localLibraryItem.localFiles.add(localFile)
|
||||
}
|
||||
|
||||
val trackFileMetadata =
|
||||
FileMetadata(
|
||||
file.name,
|
||||
file.extension,
|
||||
file.absolutePath,
|
||||
file.getBasePath(ctx),
|
||||
file.length()
|
||||
part.completedDestinationUri?.let { contentUrl ->
|
||||
val uri = Uri.parse(contentUrl)
|
||||
val size =
|
||||
try {
|
||||
ctx.contentResolver.openFileDescriptor(uri, "r")?.use { descriptor ->
|
||||
descriptor.statSize.coerceAtLeast(0L)
|
||||
}
|
||||
?: 0L
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "Could not open completed SAF file: $contentUrl", e)
|
||||
return null
|
||||
}
|
||||
// Android 10 DownloadsProvider may not reconstruct a DocumentFile for an audio URI even
|
||||
// though the URI remains readable. Keep the URI as the authoritative local-file location.
|
||||
return LocalFile(
|
||||
DeviceManager.getBase64Id(contentUrl),
|
||||
part.filename,
|
||||
contentUrl,
|
||||
part.localFolderName,
|
||||
part.finalDestinationPath,
|
||||
mimeTypeFor(part),
|
||||
size
|
||||
)
|
||||
}
|
||||
|
||||
// Do not reconstruct a DocumentFile from an absolute path: on Android 10 that becomes a
|
||||
// file:// URI, which DocumentsContract rejects. The caller resolves this from the persisted
|
||||
// SAF tree grant instead.
|
||||
val document = externalFile
|
||||
if (document == null || !document.exists()) {
|
||||
Log.e(tag, "Could not resolve downloaded SAF file: ${part.finalDestinationPath}")
|
||||
return null
|
||||
}
|
||||
return LocalFile(
|
||||
DeviceManager.getBase64Id(document.id),
|
||||
document.name,
|
||||
document.uri.toString(),
|
||||
document.getBasePath(ctx),
|
||||
document.getAbsolutePath(ctx),
|
||||
document.mimeType,
|
||||
document.length()
|
||||
)
|
||||
}
|
||||
|
||||
private fun newLocalLibraryItem(
|
||||
id: String,
|
||||
downloadItem: DownloadItem,
|
||||
basePath: String,
|
||||
absolutePath: String,
|
||||
contentUrl: String
|
||||
) =
|
||||
LocalLibraryItem(
|
||||
id,
|
||||
downloadItem.localFolder.id,
|
||||
basePath,
|
||||
absolutePath,
|
||||
contentUrl,
|
||||
false,
|
||||
downloadItem.mediaType,
|
||||
downloadItem.media.getLocalCopy(),
|
||||
mutableListOf(),
|
||||
null,
|
||||
null,
|
||||
true,
|
||||
downloadItem.serverConnectionConfigId,
|
||||
downloadItem.serverAddress,
|
||||
downloadItem.serverUserId,
|
||||
downloadItem.libraryItemId
|
||||
)
|
||||
|
||||
private fun scanParts(
|
||||
item: DownloadItem,
|
||||
localItem: LocalLibraryItem,
|
||||
externalFolder: DocumentFile? = null,
|
||||
callback: (DownloadItemScanResult?) -> Unit
|
||||
) {
|
||||
val tracks = mutableListOf<AudioTrack>()
|
||||
var foundEbook = false
|
||||
var localEpisodeId: String? = null
|
||||
|
||||
item.downloadItemParts.forEach { part ->
|
||||
val externalFile =
|
||||
if (part.isInternalStorage) {
|
||||
null
|
||||
} else {
|
||||
part.completedDestinationUri?.let { DocumentFileCompat.fromUri(ctx, Uri.parse(it)) }
|
||||
?: resolveExternalFile(externalFolder, part)
|
||||
}
|
||||
Log.d(tag, "Resolve part ${part.filename}: externalFile=${externalFile?.uri}")
|
||||
val localFile = createLocalFile(part, externalFile) ?: return@forEach
|
||||
when {
|
||||
part.audioTrack != null -> {
|
||||
val serverTrack = part.audioTrack
|
||||
localItem.localFiles.removeAll { it.id == localFile.id }
|
||||
localItem.localFiles.add(localFile)
|
||||
val metadata =
|
||||
FileMetadata(
|
||||
localFile.filename ?: "",
|
||||
File(localFile.filename ?: "").extension,
|
||||
localFile.absolutePath,
|
||||
localFile.basePath,
|
||||
localFile.size
|
||||
)
|
||||
// Create new audio track
|
||||
val track =
|
||||
AudioTrack(
|
||||
audioTrackFromServer.index,
|
||||
audioTrackFromServer.startOffset,
|
||||
audioTrackFromServer.duration,
|
||||
serverTrack.index,
|
||||
serverTrack.startOffset,
|
||||
serverTrack.duration,
|
||||
localFile.filename ?: "",
|
||||
localFile.contentUrl,
|
||||
localFile.mimeType ?: "",
|
||||
trackFileMetadata,
|
||||
metadata,
|
||||
true,
|
||||
localFileId,
|
||||
audioTrackFromServer.index
|
||||
)
|
||||
audioTracks.add(track)
|
||||
|
||||
Log.d(
|
||||
tag,
|
||||
"scanInternalDownloadItem: Created Audio Track with index ${track.index} from local file ${localFile.absolutePath}"
|
||||
)
|
||||
|
||||
// Add podcast episodes to library
|
||||
downloadItemPart.episode?.let { podcastEpisode ->
|
||||
val podcast = localLibraryItem.media as Podcast
|
||||
val newEpisode = podcast.addEpisode(track, podcastEpisode)
|
||||
localEpisodeId = newEpisode.id
|
||||
Log.d(
|
||||
tag,
|
||||
"scanInternalDownloadItem: Added episode to podcast ${podcastEpisode.title} ${track.title} | Track index: ${podcastEpisode.audioTrack?.index}"
|
||||
localFile.id,
|
||||
serverTrack.index
|
||||
)
|
||||
tracks.add(track)
|
||||
Log.d(tag, "Added local audio track ${track.contentUrl} (${track.metadata?.path})")
|
||||
part.episode?.let { episode ->
|
||||
val podcast = localItem.media as Podcast
|
||||
localEpisodeId = podcast.addEpisode(track, episode).id
|
||||
}
|
||||
} else if (downloadItemPart.ebookFile != null) {
|
||||
foundEBookFile = true
|
||||
Log.d(tag, "scanInternalDownloadItem: Ebook file found with mimetype=${file.mimeType}")
|
||||
val localFileId = DeviceManager.getBase64Id(file.name)
|
||||
val localFile =
|
||||
LocalFile(
|
||||
localFileId,
|
||||
file.name,
|
||||
Uri.fromFile(file).toString(),
|
||||
file.getBasePath(ctx),
|
||||
file.absolutePath,
|
||||
file.getSimplePath(ctx),
|
||||
file.mimeType,
|
||||
file.length()
|
||||
)
|
||||
localLibraryItem.localFiles.add(localFile)
|
||||
|
||||
val ebookFile =
|
||||
}
|
||||
part.ebookFile != null -> {
|
||||
foundEbook = true
|
||||
localItem.localFiles.removeAll { it.id == localFile.id }
|
||||
localItem.localFiles.add(localFile)
|
||||
(localItem.media as Book).ebookFile =
|
||||
EBookFile(
|
||||
downloadItemPart.ebookFile.ino,
|
||||
downloadItemPart.ebookFile.metadata,
|
||||
downloadItemPart.ebookFile.ebookFormat,
|
||||
part.ebookFile.ino,
|
||||
part.ebookFile.metadata,
|
||||
part.ebookFile.ebookFormat,
|
||||
true,
|
||||
localFileId,
|
||||
localFile.id,
|
||||
localFile.contentUrl
|
||||
)
|
||||
(localLibraryItem.media as Book).ebookFile = ebookFile
|
||||
Log.d(tag, "scanInternalDownloadItem: Ebook file added to lli ${localFile.contentUrl}")
|
||||
} else {
|
||||
val localFileId = DeviceManager.getBase64Id(file.name)
|
||||
val localFile =
|
||||
LocalFile(
|
||||
localFileId,
|
||||
file.name,
|
||||
Uri.fromFile(file).toString(),
|
||||
file.getBasePath(ctx),
|
||||
file.absolutePath,
|
||||
file.getSimplePath(ctx),
|
||||
file.mimeType,
|
||||
file.length()
|
||||
)
|
||||
|
||||
localLibraryItem.coverAbsolutePath = localFile.absolutePath
|
||||
localLibraryItem.coverContentUrl = localFile.contentUrl
|
||||
localLibraryItem.localFiles.add(localFile)
|
||||
}
|
||||
else -> {
|
||||
localItem.coverAbsolutePath = localFile.absolutePath
|
||||
localItem.coverContentUrl = localFile.contentUrl
|
||||
localItem.localFiles.removeAll { it.id == localFile.id }
|
||||
localItem.localFiles.add(localFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (audioTracks.isEmpty() && !foundEBookFile) {
|
||||
Log.d(
|
||||
tag,
|
||||
"scanDownloadItem did not find any audio tracks or ebook file in folder for ${downloadItem.itemFolderPath}"
|
||||
)
|
||||
return cb(null)
|
||||
if (tracks.isEmpty() && !foundEbook) {
|
||||
callback(null)
|
||||
return
|
||||
}
|
||||
if (item.mediaType == "book") {
|
||||
tracks.sortBy { it.index }
|
||||
var expectedIndex = 1
|
||||
var offset = 0.0
|
||||
tracks.forEach { track ->
|
||||
track.index = expectedIndex++
|
||||
track.startOffset = offset
|
||||
offset += track.duration
|
||||
}
|
||||
localItem.media.setAudioTracks(tracks)
|
||||
}
|
||||
|
||||
// For books sort audio tracks then set
|
||||
if (downloadItem.mediaType == "book") {
|
||||
audioTracks.sortBy { it.index }
|
||||
|
||||
var indexCheck = 1
|
||||
var startOffset = 0.0
|
||||
audioTracks.forEach { audioTrack ->
|
||||
if (audioTrack.index != indexCheck || audioTrack.startOffset != startOffset) {
|
||||
audioTrack.index = indexCheck
|
||||
audioTrack.startOffset = startOffset
|
||||
}
|
||||
indexCheck++
|
||||
startOffset += audioTrack.duration
|
||||
}
|
||||
|
||||
localLibraryItem.media.setAudioTracks(audioTracks)
|
||||
}
|
||||
|
||||
val downloadItemScanResult = DownloadItemScanResult(localLibraryItem, null)
|
||||
|
||||
// If library item had media progress then make local media progress and save
|
||||
downloadItem.userMediaProgress?.let { mediaProgress ->
|
||||
val localMediaProgressId =
|
||||
if (downloadItem.episodeId.isNullOrEmpty()) localLibraryItemId
|
||||
else "$localLibraryItemId-$localEpisodeId"
|
||||
val newLocalMediaProgress =
|
||||
val result = DownloadItemScanResult(localItem, null)
|
||||
item.userMediaProgress?.let { progress ->
|
||||
val progressId =
|
||||
if (item.episodeId.isNullOrEmpty()) localItem.id
|
||||
else "${localItem.id}-$localEpisodeId"
|
||||
result.localMediaProgress =
|
||||
LocalMediaProgress(
|
||||
id = localMediaProgressId,
|
||||
localLibraryItemId = localLibraryItemId,
|
||||
localEpisodeId = localEpisodeId,
|
||||
duration = mediaProgress.duration,
|
||||
progress = mediaProgress.progress,
|
||||
currentTime = mediaProgress.currentTime,
|
||||
isFinished = mediaProgress.isFinished,
|
||||
ebookLocation = mediaProgress.ebookLocation,
|
||||
ebookProgress = mediaProgress.ebookProgress,
|
||||
lastUpdate = mediaProgress.lastUpdate,
|
||||
startedAt = mediaProgress.startedAt,
|
||||
finishedAt = mediaProgress.finishedAt,
|
||||
serverConnectionConfigId = downloadItem.serverConnectionConfigId,
|
||||
serverAddress = downloadItem.serverAddress,
|
||||
serverUserId = downloadItem.serverUserId,
|
||||
libraryItemId = downloadItem.libraryItemId,
|
||||
episodeId = downloadItem.episodeId
|
||||
progressId,
|
||||
localItem.id,
|
||||
localEpisodeId,
|
||||
progress.duration,
|
||||
progress.progress,
|
||||
progress.currentTime,
|
||||
progress.isFinished,
|
||||
progress.ebookLocation,
|
||||
progress.ebookProgress,
|
||||
progress.lastUpdate,
|
||||
progress.startedAt,
|
||||
progress.finishedAt,
|
||||
item.serverConnectionConfigId,
|
||||
item.serverAddress,
|
||||
item.serverUserId,
|
||||
item.libraryItemId,
|
||||
item.episodeId
|
||||
)
|
||||
Log.d(
|
||||
tag,
|
||||
"scanLibraryItemFolder: Saving local media progress ${newLocalMediaProgress.id} at progress ${newLocalMediaProgress.progress}"
|
||||
)
|
||||
DeviceManager.dbManager.saveLocalMediaProgress(newLocalMediaProgress)
|
||||
|
||||
downloadItemScanResult.localMediaProgress = newLocalMediaProgress
|
||||
DeviceManager.dbManager.saveLocalMediaProgress(result.localMediaProgress!!)
|
||||
}
|
||||
DeviceManager.dbManager.saveLocalLibraryItem(localItem)
|
||||
callback(result)
|
||||
}
|
||||
|
||||
DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem)
|
||||
|
||||
cb(downloadItemScanResult)
|
||||
private fun findFolderByPath(root: DocumentFile, subPath: String): DocumentFile? {
|
||||
if (subPath.isBlank()) return root
|
||||
var current = root
|
||||
subPath.split('/').filter { it.isNotBlank() }.forEach { segment ->
|
||||
if (segment == "." || segment == "..") return null
|
||||
current = current.findFile(segment) ?: return null
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
// Scan item after download and create local library item
|
||||
fun scanDownloadItem(downloadItem: DownloadItem, cb: (DownloadItemScanResult?) -> Unit) {
|
||||
// If downloading to internal storage handle separately
|
||||
if (downloadItem.isInternalStorage) {
|
||||
scanInternalDownloadItem(downloadItem, cb)
|
||||
/**
|
||||
* DownloadsProvider on Android 10 may expose an audio document without its extension through
|
||||
* DocumentFile.findFile(). Match the manifest first, then match the provider-normalized base
|
||||
* filename. MIME type and server-reported size are unreliable for Opus on this platform.
|
||||
*/
|
||||
private fun resolveExternalFile(folder: DocumentFile?, part: DownloadItemPart): DocumentFile? {
|
||||
if (folder == null) return null
|
||||
folder.findFile(part.filename)?.let {
|
||||
return it
|
||||
}
|
||||
val expectedBaseName = part.filename.substringBeforeLast('.')
|
||||
return folder.listFiles().firstOrNull { document ->
|
||||
document.name == part.filename ||
|
||||
document.fullName == part.filename ||
|
||||
(part.audioTrack != null &&
|
||||
document.isFile &&
|
||||
(document.name ?: "").substringBeforeLast('.') == expectedBaseName) ||
|
||||
(part.audioTrack != null &&
|
||||
document.isFile &&
|
||||
document.fullName.substringBeforeLast('.') == expectedBaseName)
|
||||
}
|
||||
}
|
||||
|
||||
private fun mimeTypeFor(part: DownloadItemPart): String? {
|
||||
return part.audioTrack?.mimeType
|
||||
?: when (part.ebookFile?.ebookFormat?.lowercase()) {
|
||||
"epub" -> "application/epub+zip"
|
||||
"pdf" -> "application/pdf"
|
||||
else -> "image/jpeg"
|
||||
}
|
||||
}
|
||||
|
||||
fun scanDownloadItem(item: DownloadItem, callback: (DownloadItemScanResult?) -> Unit) {
|
||||
if (item.isInternalStorage) {
|
||||
val id = "local_${item.libraryItemId}"
|
||||
val localItem =
|
||||
DeviceManager.dbManager.getLocalLibraryItem(id)
|
||||
?: newLocalLibraryItem(id, item, item.itemFolderPath, item.itemFolderPath, "")
|
||||
scanParts(item, localItem, callback = callback)
|
||||
return
|
||||
}
|
||||
|
||||
val folderDf = DocumentFileCompat.fromUri(ctx, Uri.parse(downloadItem.localFolder.contentUrl))
|
||||
val foldersFound = folderDf?.search(true, DocumentFileType.FOLDER) ?: mutableListOf()
|
||||
|
||||
var itemFolderId = ""
|
||||
var itemFolderUrl = ""
|
||||
var itemFolderBasePath = ""
|
||||
var itemFolderAbsolutePath = ""
|
||||
foldersFound.forEach {
|
||||
// e.g. absolute path is "storage/emulated/0/Audiobooks/Orson Scott Card/Enders Game"
|
||||
// and itemSubfolder is "Orson Scott Card/Enders Game"
|
||||
if (it.getAbsolutePath(ctx).endsWith(downloadItem.itemSubfolder)) {
|
||||
itemFolderId = it.id
|
||||
itemFolderUrl = it.uri.toString()
|
||||
itemFolderBasePath = it.getBasePath(ctx)
|
||||
itemFolderAbsolutePath = it.getAbsolutePath(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
if (itemFolderUrl == "") {
|
||||
Log.d(tag, "scanDownloadItem failed to find media folder")
|
||||
return cb(null)
|
||||
}
|
||||
val df: DocumentFile? = DocumentFileCompat.fromUri(ctx, Uri.parse(itemFolderUrl))
|
||||
|
||||
if (df == null) {
|
||||
Log.e(tag, "Folder Doc File Invalid ${downloadItem.itemFolderPath}")
|
||||
return cb(null)
|
||||
}
|
||||
|
||||
val localLibraryItemId = getLocalLibraryItemId(itemFolderId)
|
||||
Log.d(
|
||||
tag,
|
||||
"scanDownloadItem starting for ${downloadItem.itemFolderPath} | ${df.uri} | Item Folder Id:$itemFolderId | LLI Id:$localLibraryItemId"
|
||||
)
|
||||
|
||||
// Search for files in media item folder
|
||||
// m4b files showing as mimeType application/octet-stream on Android 10 and earlier see #154
|
||||
val filesFound =
|
||||
df.search(
|
||||
false,
|
||||
DocumentFileType.FILE,
|
||||
arrayOf("audio/*", "image/*", "video/mp4", "application/*")
|
||||
)
|
||||
Log.d(tag, "scanDownloadItem ${filesFound.size} files found in ${downloadItem.itemFolderPath}")
|
||||
|
||||
var localEpisodeId: String? = null
|
||||
var localLibraryItem: LocalLibraryItem?
|
||||
if (downloadItem.mediaType == "book") {
|
||||
localLibraryItem =
|
||||
LocalLibraryItem(
|
||||
localLibraryItemId,
|
||||
downloadItem.localFolder.id,
|
||||
itemFolderBasePath,
|
||||
itemFolderAbsolutePath,
|
||||
itemFolderUrl,
|
||||
false,
|
||||
downloadItem.mediaType,
|
||||
downloadItem.media.getLocalCopy(),
|
||||
mutableListOf(),
|
||||
null,
|
||||
null,
|
||||
true,
|
||||
downloadItem.serverConnectionConfigId,
|
||||
downloadItem.serverAddress,
|
||||
downloadItem.serverUserId,
|
||||
downloadItem.libraryItemId
|
||||
)
|
||||
} else {
|
||||
// Lookup or create podcast local library item
|
||||
localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
|
||||
if (localLibraryItem == null) {
|
||||
Log.d(
|
||||
tag,
|
||||
"[FolderScanner] Podcast local library item not created yet for ${downloadItem.media.metadata.title}"
|
||||
)
|
||||
localLibraryItem =
|
||||
LocalLibraryItem(
|
||||
localLibraryItemId,
|
||||
downloadItem.localFolder.id,
|
||||
itemFolderBasePath,
|
||||
itemFolderAbsolutePath,
|
||||
itemFolderUrl,
|
||||
false,
|
||||
downloadItem.mediaType,
|
||||
downloadItem.media.getLocalCopy(),
|
||||
mutableListOf(),
|
||||
null,
|
||||
null,
|
||||
true,
|
||||
downloadItem.serverConnectionConfigId,
|
||||
downloadItem.serverAddress,
|
||||
downloadItem.serverUserId,
|
||||
downloadItem.libraryItemId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val audioTracks: MutableList<AudioTrack> = mutableListOf()
|
||||
var foundEBookFile = false
|
||||
|
||||
filesFound.forEach { docFile ->
|
||||
val itemPart =
|
||||
downloadItem.downloadItemParts.find { itemPart -> itemPart.filename == docFile.name }
|
||||
if (itemPart == null) {
|
||||
if (downloadItem.mediaType == "book"
|
||||
) { // for books every download item should be a file found
|
||||
Log.e(
|
||||
tag,
|
||||
"scanDownloadItem: Item part not found for doc file ${docFile.name} | ${docFile.getAbsolutePath(ctx)} | ${docFile.uri}"
|
||||
)
|
||||
}
|
||||
} else if (itemPart.audioTrack != null) { // Is audio track
|
||||
val audioTrackFromServer = itemPart.audioTrack
|
||||
Log.d(
|
||||
tag,
|
||||
"scanDownloadItem: Audio Track from Server index = ${audioTrackFromServer.index}"
|
||||
)
|
||||
|
||||
val localFileId = DeviceManager.getBase64Id(docFile.id)
|
||||
val localFile =
|
||||
LocalFile(
|
||||
localFileId,
|
||||
docFile.name,
|
||||
docFile.uri.toString(),
|
||||
docFile.getBasePath(ctx),
|
||||
docFile.getAbsolutePath(ctx),
|
||||
docFile.getSimplePath(ctx),
|
||||
docFile.mimeType,
|
||||
docFile.length()
|
||||
)
|
||||
localLibraryItem.localFiles.add(localFile)
|
||||
|
||||
// Create new audio track
|
||||
val trackFileMetadata =
|
||||
FileMetadata(
|
||||
docFile.name ?: "",
|
||||
docFile.extension ?: "",
|
||||
docFile.getAbsolutePath(ctx),
|
||||
docFile.getBasePath(ctx),
|
||||
docFile.length()
|
||||
)
|
||||
val track =
|
||||
AudioTrack(
|
||||
audioTrackFromServer.index,
|
||||
audioTrackFromServer.startOffset,
|
||||
audioTrackFromServer.duration,
|
||||
localFile.filename ?: "",
|
||||
localFile.contentUrl,
|
||||
localFile.mimeType ?: "",
|
||||
trackFileMetadata,
|
||||
true,
|
||||
localFileId,
|
||||
audioTrackFromServer.index
|
||||
)
|
||||
audioTracks.add(track)
|
||||
|
||||
Log.d(
|
||||
tag,
|
||||
"scanDownloadItem: Created Audio Track with index ${track.index} from local file ${localFile.absolutePath}"
|
||||
)
|
||||
|
||||
// Add podcast episodes to library
|
||||
itemPart.episode?.let { podcastEpisode ->
|
||||
val podcast = localLibraryItem.media as Podcast
|
||||
val newEpisode = podcast.addEpisode(track, podcastEpisode)
|
||||
localEpisodeId = newEpisode.id
|
||||
Log.d(
|
||||
tag,
|
||||
"scanDownloadItem: Added episode to podcast ${podcastEpisode.title} ${track.title} | Track index: ${podcastEpisode.audioTrack?.index}"
|
||||
)
|
||||
}
|
||||
} else if (itemPart.ebookFile != null) { // Ebook
|
||||
foundEBookFile = true
|
||||
Log.d(tag, "scanDownloadItem: Ebook file found with mimetype=${docFile.mimeType}")
|
||||
val localFileId = DeviceManager.getBase64Id(docFile.id)
|
||||
val localFile =
|
||||
LocalFile(
|
||||
localFileId,
|
||||
docFile.name,
|
||||
docFile.uri.toString(),
|
||||
docFile.getBasePath(ctx),
|
||||
docFile.getAbsolutePath(ctx),
|
||||
docFile.getSimplePath(ctx),
|
||||
docFile.mimeType,
|
||||
docFile.length()
|
||||
)
|
||||
localLibraryItem.localFiles.add(localFile)
|
||||
|
||||
val ebookFile =
|
||||
EBookFile(
|
||||
itemPart.ebookFile.ino,
|
||||
itemPart.ebookFile.metadata,
|
||||
itemPart.ebookFile.ebookFormat,
|
||||
true,
|
||||
localFileId,
|
||||
localFile.contentUrl
|
||||
)
|
||||
(localLibraryItem.media as Book).ebookFile = ebookFile
|
||||
Log.d(tag, "scanDownloadItem: Ebook file added to lli ${localFile.contentUrl}")
|
||||
} else { // Cover image
|
||||
val localFileId = DeviceManager.getBase64Id(docFile.id)
|
||||
val localFile =
|
||||
LocalFile(
|
||||
localFileId,
|
||||
docFile.name,
|
||||
docFile.uri.toString(),
|
||||
docFile.getBasePath(ctx),
|
||||
docFile.getAbsolutePath(ctx),
|
||||
docFile.getSimplePath(ctx),
|
||||
docFile.mimeType,
|
||||
docFile.length()
|
||||
)
|
||||
|
||||
localLibraryItem.coverAbsolutePath = localFile.absolutePath
|
||||
localLibraryItem.coverContentUrl = localFile.contentUrl
|
||||
localLibraryItem.localFiles.add(localFile)
|
||||
}
|
||||
}
|
||||
|
||||
if (audioTracks.isEmpty() && !foundEBookFile) {
|
||||
Log.d(
|
||||
tag,
|
||||
"scanDownloadItem did not find any audio tracks or ebook file in folder for ${downloadItem.itemFolderPath}"
|
||||
)
|
||||
return cb(null)
|
||||
}
|
||||
|
||||
// For books sort audio tracks then set
|
||||
if (downloadItem.mediaType == "book") {
|
||||
audioTracks.sortBy { it.index }
|
||||
|
||||
var indexCheck = 1
|
||||
var startOffset = 0.0
|
||||
audioTracks.forEach { audioTrack ->
|
||||
if (audioTrack.index != indexCheck || audioTrack.startOffset != startOffset) {
|
||||
audioTrack.index = indexCheck
|
||||
audioTrack.startOffset = startOffset
|
||||
}
|
||||
indexCheck++
|
||||
startOffset += audioTrack.duration
|
||||
}
|
||||
|
||||
localLibraryItem.media.setAudioTracks(audioTracks)
|
||||
}
|
||||
|
||||
val downloadItemScanResult = DownloadItemScanResult(localLibraryItem, null)
|
||||
|
||||
// If library item had media progress then make local media progress and save
|
||||
downloadItem.userMediaProgress?.let { mediaProgress ->
|
||||
val localMediaProgressId =
|
||||
if (downloadItem.episodeId.isNullOrEmpty()) localLibraryItemId
|
||||
else "$localLibraryItemId-$localEpisodeId"
|
||||
val newLocalMediaProgress =
|
||||
LocalMediaProgress(
|
||||
id = localMediaProgressId,
|
||||
localLibraryItemId = localLibraryItemId,
|
||||
localEpisodeId = localEpisodeId,
|
||||
duration = mediaProgress.duration,
|
||||
progress = mediaProgress.progress,
|
||||
currentTime = mediaProgress.currentTime,
|
||||
isFinished = mediaProgress.isFinished,
|
||||
ebookLocation = mediaProgress.ebookLocation,
|
||||
ebookProgress = mediaProgress.ebookProgress,
|
||||
lastUpdate = mediaProgress.lastUpdate,
|
||||
startedAt = mediaProgress.startedAt,
|
||||
finishedAt = mediaProgress.finishedAt,
|
||||
serverConnectionConfigId = downloadItem.serverConnectionConfigId,
|
||||
serverAddress = downloadItem.serverAddress,
|
||||
serverUserId = downloadItem.serverUserId,
|
||||
libraryItemId = downloadItem.libraryItemId,
|
||||
episodeId = downloadItem.episodeId
|
||||
)
|
||||
Log.d(
|
||||
tag,
|
||||
"scanLibraryItemFolder: Saving local media progress ${newLocalMediaProgress.id} at progress ${newLocalMediaProgress.progress}"
|
||||
)
|
||||
|
||||
DeviceManager.dbManager.saveLocalMediaProgress(newLocalMediaProgress)
|
||||
|
||||
downloadItemScanResult.localMediaProgress = newLocalMediaProgress
|
||||
}
|
||||
|
||||
DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem)
|
||||
|
||||
cb(downloadItemScanResult)
|
||||
val root = DocumentFileCompat.fromUri(ctx, Uri.parse(item.localFolder.contentUrl))
|
||||
if (root == null) {
|
||||
Log.e(tag, "Invalid SAF root: ${item.localFolder.contentUrl}")
|
||||
callback(null)
|
||||
return
|
||||
}
|
||||
val itemFolder = findFolderByPath(root, item.itemSubfolder)
|
||||
if (itemFolder == null) {
|
||||
Log.e(tag, "SAF item folder not found: ${item.itemSubfolder}")
|
||||
callback(null)
|
||||
return
|
||||
}
|
||||
val id = localLibraryItemId(itemFolder.id)
|
||||
val localItem =
|
||||
DeviceManager.dbManager.getLocalLibraryItem(id)
|
||||
?: newLocalLibraryItem(
|
||||
id,
|
||||
item,
|
||||
itemFolder.getBasePath(ctx),
|
||||
itemFolder.getAbsolutePath(ctx),
|
||||
itemFolder.uri.toString()
|
||||
)
|
||||
scanParts(item, localItem, itemFolder, callback)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import com.audiobookshelf.app.models.DownloadItem
|
||||
import com.audiobookshelf.app.plugins.AbsLog
|
||||
import com.audiobookshelf.app.plugins.AbsLogger
|
||||
import io.paperdb.Paper
|
||||
import java.io.File
|
||||
|
||||
class DbManager {
|
||||
val tag = "DbManager"
|
||||
@@ -148,7 +147,7 @@ class DbManager {
|
||||
}
|
||||
|
||||
// Make sure all local file ids still exist
|
||||
fun cleanLocalLibraryItems() {
|
||||
fun cleanLocalLibraryItems(context: Context) {
|
||||
val localLibraryItems = getLocalLibraryItems()
|
||||
|
||||
localLibraryItems.forEach { lli ->
|
||||
@@ -157,15 +156,15 @@ class DbManager {
|
||||
// Check local files
|
||||
lli.localFiles =
|
||||
lli.localFiles.filter { localFile ->
|
||||
val file = File(localFile.absolutePath)
|
||||
if (!file.exists()) {
|
||||
val exists = localFile.exists(context)
|
||||
if (!exists) {
|
||||
Log.d(
|
||||
tag,
|
||||
"cleanLocalLibraryItems: Local file ${localFile.absolutePath} was removed from library item ${lli.media.metadata.title}"
|
||||
)
|
||||
hasUpdates = true
|
||||
}
|
||||
file.exists()
|
||||
exists
|
||||
} as
|
||||
MutableList<LocalFile>
|
||||
|
||||
@@ -203,9 +202,11 @@ class DbManager {
|
||||
|
||||
// Check cover still there
|
||||
lli.coverAbsolutePath?.let {
|
||||
val coverFile = File(it)
|
||||
|
||||
if (!coverFile.exists()) {
|
||||
val coverExists =
|
||||
lli.localFiles.any { localFile ->
|
||||
localFile.absolutePath == it && localFile.exists(context)
|
||||
}
|
||||
if (!coverExists) {
|
||||
Log.d(
|
||||
tag,
|
||||
"cleanLocalLibraryItems: Cover $it was removed from library item ${lli.media.metadata.title}"
|
||||
@@ -296,9 +297,7 @@ class DbManager {
|
||||
fun getAllLogs(): List<AbsLog> {
|
||||
val logs: MutableList<AbsLog> = mutableListOf()
|
||||
Paper.book("log").allKeys.forEach { logId ->
|
||||
Paper.book("log").read<AbsLog>(logId)?.let {
|
||||
logs.add(it)
|
||||
}
|
||||
Paper.book("log").read<AbsLog>(logId)?.let { logs.add(it) }
|
||||
}
|
||||
return logs.sortedBy { it.timestamp }
|
||||
}
|
||||
@@ -317,7 +316,10 @@ class DbManager {
|
||||
}
|
||||
}
|
||||
if (logsRemoved > 0) {
|
||||
AbsLogger.info("DbManager", "cleanLogs: Removed $logsRemoved logs older than $numberOfHoursToKeep hours")
|
||||
AbsLogger.info(
|
||||
"DbManager",
|
||||
"cleanLogs: Removed $logsRemoved logs older than $numberOfHoursToKeep hours"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+430
-301
@@ -1,16 +1,10 @@
|
||||
package com.audiobookshelf.app.managers
|
||||
|
||||
import android.app.DownloadManager
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.StatFs
|
||||
import android.util.Log
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import com.anggrayudi.storage.callback.FileCallback
|
||||
import com.anggrayudi.storage.file.DocumentFileCompat
|
||||
import com.anggrayudi.storage.file.MimeType
|
||||
import com.anggrayudi.storage.file.getAbsolutePath
|
||||
import com.anggrayudi.storage.file.moveFileTo
|
||||
import com.anggrayudi.storage.media.FileDescription
|
||||
import com.audiobookshelf.app.MainActivity
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.audiobookshelf.app.device.FolderScanner
|
||||
import com.audiobookshelf.app.models.DownloadItem
|
||||
@@ -19,41 +13,44 @@ import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.getcapacitor.JSObject
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.util.*
|
||||
import java.io.FileInputStream
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.math.max
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.Call
|
||||
|
||||
/** Manages download items and their parts. */
|
||||
/** Manages the process-owned queue for app-managed downloads. */
|
||||
class DownloadItemManager(
|
||||
var downloadManager: DownloadManager,
|
||||
private var folderScanner: FolderScanner,
|
||||
var mainActivity: MainActivity,
|
||||
private val folderScanner: FolderScanner,
|
||||
private val context: Context,
|
||||
private var clientEventEmitter: DownloadEventEmitter
|
||||
) {
|
||||
val tag = "DownloadItemManager"
|
||||
private val maxSimultaneousDownloads = 3
|
||||
private var jacksonMapper =
|
||||
private val tag = "DownloadItemManager"
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val activeCalls = ConcurrentHashMap<String, Call>()
|
||||
private val safFolderLocks = ConcurrentHashMap<String, Any>()
|
||||
private val reservations = mutableMapOf<String, Long>()
|
||||
private val lastPersistTime = mutableMapOf<String, Long>()
|
||||
private var watcherRunning = false
|
||||
private val jacksonMapper =
|
||||
jacksonObjectMapper()
|
||||
.enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||
|
||||
enum class DownloadCheckStatus {
|
||||
InProgress,
|
||||
Successful,
|
||||
Failed
|
||||
}
|
||||
|
||||
var downloadItemQueue: MutableList<DownloadItem> =
|
||||
mutableListOf() // All pending and downloading items
|
||||
var currentDownloadItemParts: MutableList<DownloadItemPart> =
|
||||
mutableListOf() // Item parts currently being downloaded
|
||||
var downloadItemQueue: MutableList<DownloadItem> = mutableListOf()
|
||||
private set
|
||||
var currentDownloadItemParts: MutableList<DownloadItemPart> = mutableListOf()
|
||||
private set
|
||||
|
||||
interface DownloadEventEmitter {
|
||||
fun onDownloadItem(downloadItem: DownloadItem)
|
||||
fun onDownloadItemPartUpdate(downloadItemPart: DownloadItemPart)
|
||||
fun onDownloadItemComplete(jsobj: JSObject)
|
||||
fun onQueueChanged(hasWork: Boolean)
|
||||
}
|
||||
|
||||
interface InternalProgressCallback {
|
||||
@@ -61,323 +58,455 @@ class DownloadItemManager(
|
||||
fun onComplete(failed: Boolean)
|
||||
}
|
||||
|
||||
companion object {
|
||||
var isDownloading: Boolean = false
|
||||
init {
|
||||
IncompleteDownloadCleanup.cleanupExpired(context)
|
||||
}
|
||||
|
||||
/** Adds a download item to the queue and starts processing the queue. */
|
||||
fun addDownloadItem(downloadItem: DownloadItem) {
|
||||
DeviceManager.dbManager.saveDownloadItem(downloadItem)
|
||||
Log.i(tag, "Add download item ${downloadItem.media.metadata.title}")
|
||||
@Synchronized
|
||||
fun setEventEmitter(eventEmitter: DownloadEventEmitter) {
|
||||
clientEventEmitter = eventEmitter
|
||||
downloadItemQueue.forEach(clientEventEmitter::onDownloadItem)
|
||||
notifyQueueChanged()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun restoreQueue() {
|
||||
if (downloadItemQueue.isNotEmpty()) return
|
||||
DeviceManager.dbManager.getDownloadItems().forEach { item ->
|
||||
if (item.isDownloadFinished) {
|
||||
downloadItemQueue.add(item)
|
||||
checkDownloadItemFinished(item)
|
||||
return@forEach
|
||||
}
|
||||
item.downloadItemParts.forEach { part ->
|
||||
if (part.moved) return@forEach
|
||||
if (item.terminalFailureAt != null && part.failed) return@forEach
|
||||
part.downloadId = null
|
||||
part.isMoving = false
|
||||
part.failed = false
|
||||
part.completed = false
|
||||
part.waitingForSpace = false
|
||||
part.bytesDownloaded = File(part.destinationPath).takeIf(File::exists)?.length() ?: 0L
|
||||
}
|
||||
downloadItemQueue.add(item)
|
||||
if (item.terminalFailureAt != null) IncompleteDownloadCleanup.schedule(context, item)
|
||||
clientEventEmitter.onDownloadItem(item)
|
||||
}
|
||||
checkUpdateDownloadQueue()
|
||||
notifyQueueChanged()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun addDownloadItem(downloadItem: DownloadItem) {
|
||||
val existingItem = downloadItemQueue.find { it.id == downloadItem.id }
|
||||
if (existingItem != null) {
|
||||
if (existingItem.terminalFailureAt != null) {
|
||||
retryDownloadItem(existingItem)
|
||||
checkUpdateDownloadQueue()
|
||||
notifyQueueChanged()
|
||||
}
|
||||
return
|
||||
}
|
||||
persist(downloadItem, force = true)
|
||||
downloadItemQueue.add(downloadItem)
|
||||
clientEventEmitter.onDownloadItem(downloadItem)
|
||||
checkUpdateDownloadQueue()
|
||||
notifyQueueChanged()
|
||||
}
|
||||
|
||||
/** Checks and updates the download queue. */
|
||||
private fun retryDownloadItem(item: DownloadItem) {
|
||||
item.terminalFailureAt = null
|
||||
IncompleteDownloadCleanup.cancel(context, item.id)
|
||||
item.downloadItemParts.filter { it.failed }.forEach { part ->
|
||||
part.failed = false
|
||||
part.completed = false
|
||||
part.isMoving = false
|
||||
part.downloadId = null
|
||||
part.retryCount = 0
|
||||
}
|
||||
persist(item, force = true)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun cancelAll() {
|
||||
activeCalls.values.forEach(Call::cancel)
|
||||
activeCalls.clear()
|
||||
downloadItemQueue.forEach { item ->
|
||||
item.downloadItemParts.forEach { part -> File(part.destinationPath).delete() }
|
||||
DeviceManager.dbManager.removeDownloadItem(item.id)
|
||||
}
|
||||
currentDownloadItemParts.clear()
|
||||
reservations.clear()
|
||||
downloadItemQueue.clear()
|
||||
notifyQueueChanged()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun hasWork(): Boolean =
|
||||
downloadItemQueue.any { item ->
|
||||
item.downloadItemParts.any { part ->
|
||||
(!part.completed && !part.failed) || part.isMoving
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun checkUpdateDownloadQueue() {
|
||||
for (downloadItem in downloadItemQueue) {
|
||||
val numPartsToGet = maxSimultaneousDownloads - currentDownloadItemParts.size
|
||||
val nextDownloadItemParts = downloadItem.getNextDownloadItemParts(numPartsToGet)
|
||||
Log.d(
|
||||
tag,
|
||||
"checkUpdateDownloadQueue: numPartsToGet=$numPartsToGet, nextDownloadItemParts=${nextDownloadItemParts.size}"
|
||||
)
|
||||
|
||||
if (nextDownloadItemParts.isNotEmpty()) {
|
||||
processDownloadItemParts(nextDownloadItemParts)
|
||||
downloadItemQueue.toList().forEach { item ->
|
||||
val slots = MAX_SIMULTANEOUS_DOWNLOADS - currentDownloadItemParts.size
|
||||
if (slots <= 0) return@forEach
|
||||
item.getNextDownloadItemParts(slots).forEach { part ->
|
||||
val existingFile = findSharedStorageFile(part)
|
||||
if (existingFile != null) {
|
||||
part.bytesDownloaded = existingFile.length()
|
||||
part.progress = 100L
|
||||
part.completedDestinationUri = existingFile.uri.toString()
|
||||
File(part.destinationPath).delete()
|
||||
completePart(item, part)
|
||||
clientEventEmitter.onDownloadItemPartUpdate(part)
|
||||
} else if (tryReserve(part)) startDownload(item, part)
|
||||
else {
|
||||
part.waitingForSpace = true
|
||||
part.lastUpdateTime = System.currentTimeMillis()
|
||||
persist(item)
|
||||
clientEventEmitter.onDownloadItemPartUpdate(part)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (hasWork()) startWatchingDownloads() else notifyQueueChanged()
|
||||
}
|
||||
|
||||
if (currentDownloadItemParts.size >= maxSimultaneousDownloads) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (currentDownloadItemParts.isNotEmpty()) startWatchingDownloads()
|
||||
}
|
||||
|
||||
/** Processes the download item parts. */
|
||||
private fun processDownloadItemParts(nextDownloadItemParts: List<DownloadItemPart>) {
|
||||
nextDownloadItemParts.forEach {
|
||||
if (it.isInternalStorage) {
|
||||
startInternalDownload(it)
|
||||
} else {
|
||||
startExternalDownload(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Starts an internal download. */
|
||||
private fun startInternalDownload(downloadItemPart: DownloadItemPart) {
|
||||
val file = File(downloadItemPart.finalDestinationPath)
|
||||
file.parentFile?.mkdirs()
|
||||
|
||||
val fileOutputStream = FileOutputStream(downloadItemPart.finalDestinationPath)
|
||||
val internalProgressCallback =
|
||||
private fun startDownload(item: DownloadItem, part: DownloadItemPart) {
|
||||
val stagingFile = File(part.destinationPath)
|
||||
stagingFile.parentFile?.mkdirs()
|
||||
part.downloadId = APP_MANAGED_DOWNLOAD_ID
|
||||
part.waitingForSpace = false
|
||||
part.lastUpdateTime = System.currentTimeMillis()
|
||||
currentDownloadItemParts.add(part)
|
||||
persist(item, force = true)
|
||||
val activeConfig = DeviceManager.serverConnectionConfig
|
||||
val token =
|
||||
if (activeConfig?.id == item.serverConnectionConfigId) activeConfig.token
|
||||
else
|
||||
DeviceManager.getServerConnectionConfig(item.serverConnectionConfigId)?.token
|
||||
?: DeviceManager.token
|
||||
activeCalls[part.id] =
|
||||
InternalDownloadManager(
|
||||
stagingFile,
|
||||
part.fileSize,
|
||||
object : InternalProgressCallback {
|
||||
override fun onProgress(totalBytesWritten: Long, progress: Long) {
|
||||
downloadItemPart.bytesDownloaded = totalBytesWritten
|
||||
downloadItemPart.progress = progress
|
||||
synchronized(this@DownloadItemManager) {
|
||||
if (part !in currentDownloadItemParts) return
|
||||
part.bytesDownloaded = totalBytesWritten
|
||||
part.progress = progress
|
||||
part.lastUpdateTime = System.currentTimeMillis()
|
||||
persist(item)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onComplete(failed: Boolean) {
|
||||
downloadItemPart.failed = failed
|
||||
downloadItemPart.completed = true
|
||||
synchronized(this@DownloadItemManager) {
|
||||
if (part !in currentDownloadItemParts) return
|
||||
part.failed = failed
|
||||
part.completed = !failed
|
||||
part.lastUpdateTime = System.currentTimeMillis()
|
||||
activeCalls.remove(part.id)
|
||||
persist(item, force = true)
|
||||
}
|
||||
}
|
||||
|
||||
Log.d(
|
||||
tag,
|
||||
"Start internal download to destination path ${downloadItemPart.finalDestinationPath} from ${downloadItemPart.serverUrl}"
|
||||
},
|
||||
{ hasAvailableSpace(part) }
|
||||
)
|
||||
InternalDownloadManager(fileOutputStream, internalProgressCallback)
|
||||
.download(downloadItemPart.serverUrl)
|
||||
downloadItemPart.downloadId = 1
|
||||
currentDownloadItemParts.add(downloadItemPart)
|
||||
.download(serverUrl(item, part), token)
|
||||
}
|
||||
|
||||
/** Starts an external download. */
|
||||
private fun startExternalDownload(downloadItemPart: DownloadItemPart) {
|
||||
val dlRequest = downloadItemPart.getDownloadRequest()
|
||||
val downloadId = downloadManager.enqueue(dlRequest)
|
||||
downloadItemPart.downloadId = downloadId
|
||||
Log.d(tag, "checkUpdateDownloadQueue: Starting download item part, downloadId=$downloadId")
|
||||
currentDownloadItemParts.add(downloadItemPart)
|
||||
}
|
||||
|
||||
/** Starts watching the downloads. */
|
||||
@Synchronized
|
||||
private fun startWatchingDownloads() {
|
||||
if (isDownloading) return // Already watching
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
Log.d(tag, "Starting watching downloads")
|
||||
isDownloading = true
|
||||
|
||||
while (currentDownloadItemParts.isNotEmpty()) {
|
||||
val itemParts = currentDownloadItemParts.filter { !it.isMoving }
|
||||
for (downloadItemPart in itemParts) {
|
||||
if (downloadItemPart.isInternalStorage) {
|
||||
handleInternalDownloadPart(downloadItemPart)
|
||||
} else {
|
||||
handleExternalDownloadPart(downloadItemPart)
|
||||
}
|
||||
}
|
||||
|
||||
delay(500)
|
||||
|
||||
if (currentDownloadItemParts.size < maxSimultaneousDownloads) {
|
||||
if (watcherRunning) return
|
||||
watcherRunning = true
|
||||
scope.launch {
|
||||
while (true) {
|
||||
val activeParts =
|
||||
synchronized(this@DownloadItemManager) { currentDownloadItemParts.toList() }
|
||||
activeParts.forEach(::handlePartUpdate)
|
||||
synchronized(this@DownloadItemManager) {
|
||||
checkUpdateDownloadQueue()
|
||||
if (!hasWork()) {
|
||||
watcherRunning = false
|
||||
notifyQueueChanged()
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
|
||||
Log.d(tag, "Finished watching downloads")
|
||||
isDownloading = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Handles an internal download part. */
|
||||
private fun handleInternalDownloadPart(downloadItemPart: DownloadItemPart) {
|
||||
clientEventEmitter.onDownloadItemPartUpdate(downloadItemPart)
|
||||
|
||||
if (downloadItemPart.completed) {
|
||||
val downloadItem = downloadItemQueue.find { it.id == downloadItemPart.downloadItemId }
|
||||
downloadItem?.let { checkDownloadItemFinished(it) }
|
||||
currentDownloadItemParts.remove(downloadItemPart)
|
||||
}
|
||||
}
|
||||
|
||||
/** Handles an external download part. */
|
||||
private fun handleExternalDownloadPart(downloadItemPart: DownloadItemPart) {
|
||||
val downloadCheckStatus = checkDownloadItemPart(downloadItemPart)
|
||||
clientEventEmitter.onDownloadItemPartUpdate(downloadItemPart)
|
||||
|
||||
// Will move to final destination, remove current item parts, and check if download item is
|
||||
// finished
|
||||
handleDownloadItemPartCheck(downloadCheckStatus, downloadItemPart)
|
||||
}
|
||||
|
||||
/** Checks the status of a download item part. */
|
||||
private fun checkDownloadItemPart(downloadItemPart: DownloadItemPart): DownloadCheckStatus {
|
||||
val downloadId = downloadItemPart.downloadId ?: return DownloadCheckStatus.Failed
|
||||
|
||||
val query = DownloadManager.Query().setFilterById(downloadId)
|
||||
downloadManager.query(query).use {
|
||||
if (it.moveToFirst()) {
|
||||
val bytesColumnIndex = it.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)
|
||||
val statusColumnIndex = it.getColumnIndex(DownloadManager.COLUMN_STATUS)
|
||||
val bytesDownloadedColumnIndex =
|
||||
it.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)
|
||||
|
||||
val totalBytes = if (bytesColumnIndex >= 0) it.getInt(bytesColumnIndex) else 0
|
||||
val downloadStatus = if (statusColumnIndex >= 0) it.getInt(statusColumnIndex) else 0
|
||||
val bytesDownloadedSoFar =
|
||||
if (bytesDownloadedColumnIndex >= 0) it.getLong(bytesDownloadedColumnIndex) else 0
|
||||
Log.d(
|
||||
tag,
|
||||
"checkDownloads Download ${downloadItemPart.filename} bytes $totalBytes | bytes dled $bytesDownloadedSoFar | downloadStatus $downloadStatus"
|
||||
)
|
||||
|
||||
return when (downloadStatus) {
|
||||
DownloadManager.STATUS_SUCCESSFUL -> {
|
||||
Log.d(tag, "checkDownloads Download ${downloadItemPart.filename} Successful")
|
||||
downloadItemPart.completed = true
|
||||
downloadItemPart.progress = 1
|
||||
downloadItemPart.bytesDownloaded = bytesDownloadedSoFar
|
||||
|
||||
DownloadCheckStatus.Successful
|
||||
}
|
||||
DownloadManager.STATUS_FAILED -> {
|
||||
Log.d(tag, "checkDownloads Download ${downloadItemPart.filename} Failed")
|
||||
downloadItemPart.completed = true
|
||||
downloadItemPart.failed = true
|
||||
|
||||
DownloadCheckStatus.Failed
|
||||
}
|
||||
else -> {
|
||||
val percentProgress =
|
||||
if (totalBytes > 0) ((bytesDownloadedSoFar * 100L) / totalBytes) else 0
|
||||
Log.d(
|
||||
tag,
|
||||
"checkDownloads Download ${downloadItemPart.filename} Progress = $percentProgress%"
|
||||
)
|
||||
downloadItemPart.progress = percentProgress
|
||||
downloadItemPart.bytesDownloaded = bytesDownloadedSoFar
|
||||
|
||||
DownloadCheckStatus.InProgress
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log.d(tag, "Download ${downloadItemPart.filename} not found in dlmanager")
|
||||
downloadItemPart.completed = true
|
||||
downloadItemPart.failed = true
|
||||
return DownloadCheckStatus.Failed
|
||||
delay(WATCH_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Handles the result of a download item part check. */
|
||||
private fun handleDownloadItemPartCheck(
|
||||
downloadCheckStatus: DownloadCheckStatus,
|
||||
downloadItemPart: DownloadItemPart
|
||||
) {
|
||||
val downloadItem = downloadItemQueue.find { it.id == downloadItemPart.downloadItemId }
|
||||
if (downloadItem == null) {
|
||||
Log.e(
|
||||
tag,
|
||||
"Download item part finished but download item not found ${downloadItemPart.filename}"
|
||||
)
|
||||
currentDownloadItemParts.remove(downloadItemPart)
|
||||
} else if (downloadCheckStatus == DownloadCheckStatus.Successful) {
|
||||
moveDownloadedFile(downloadItem, downloadItemPart)
|
||||
} else if (downloadCheckStatus != DownloadCheckStatus.InProgress) {
|
||||
checkDownloadItemFinished(downloadItem)
|
||||
currentDownloadItemParts.remove(downloadItemPart)
|
||||
private fun handlePartUpdate(part: DownloadItemPart) {
|
||||
clientEventEmitter.onDownloadItemPartUpdate(part)
|
||||
val item =
|
||||
synchronized(this) { downloadItemQueue.find { it.id == part.downloadItemId } }
|
||||
?: run {
|
||||
removeActivePart(part)
|
||||
return
|
||||
}
|
||||
if (!part.completed && !part.failed) {
|
||||
val lastUpdate = part.lastUpdateTime ?: return
|
||||
if (System.currentTimeMillis() - lastUpdate > STALL_TIMEOUT_MS) {
|
||||
Log.w(tag, "Download stalled: ${part.filename}")
|
||||
activeCalls.remove(part.id)?.cancel()
|
||||
failOrRetry(item, part, "Download stalled")
|
||||
}
|
||||
return
|
||||
}
|
||||
if (part.failed) {
|
||||
failOrRetry(item, part, "Transfer failed")
|
||||
return
|
||||
}
|
||||
if (part.isInternalStorage) finalizeInternalFile(item, part) else moveDownloadedFile(item, part)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun failOrRetry(item: DownloadItem, part: DownloadItemPart, reason: String) {
|
||||
removeActivePart(part)
|
||||
part.retryCount += 1
|
||||
reservations.remove(part.destinationPath)
|
||||
if (part.retryCount > MAX_RETRIES) {
|
||||
Log.e(tag, "$reason after $MAX_RETRIES retries: ${part.filename}")
|
||||
part.failed = true
|
||||
part.completed = false
|
||||
part.downloadId = null
|
||||
item.terminalFailureAt = item.terminalFailureAt ?: System.currentTimeMillis()
|
||||
persist(item, force = true)
|
||||
IncompleteDownloadCleanup.schedule(context, item)
|
||||
notifyQueueChanged()
|
||||
return
|
||||
}
|
||||
part.failed = false
|
||||
part.completed = false
|
||||
part.downloadId = null
|
||||
part.isMoving = false
|
||||
persist(item, force = true)
|
||||
}
|
||||
|
||||
private fun finalizeInternalFile(item: DownloadItem, part: DownloadItemPart) {
|
||||
if (part.moved || part.isMoving) return
|
||||
part.isMoving = true
|
||||
val stagingFile = File(part.destinationPath)
|
||||
val finalFile = File(part.finalDestinationPath)
|
||||
finalFile.parentFile?.mkdirs()
|
||||
val backup = File(finalFile.parentFile, ".${finalFile.name}.abs-backup")
|
||||
try {
|
||||
if (backup.exists() && !backup.delete()) throw IllegalStateException("Could not clear backup")
|
||||
if (finalFile.exists() && !finalFile.renameTo(backup))
|
||||
throw IllegalStateException("Could not protect existing file")
|
||||
if (!stagingFile.renameTo(finalFile)) {
|
||||
if (backup.exists()) backup.renameTo(finalFile)
|
||||
throw IllegalStateException("Could not finalize internal staging file")
|
||||
}
|
||||
backup.delete()
|
||||
completePart(item, part)
|
||||
} catch (e: Exception) {
|
||||
part.isMoving = false
|
||||
part.failed = true
|
||||
failOrRetry(item, part, e.message ?: "Internal finalization failed")
|
||||
}
|
||||
}
|
||||
|
||||
/** Moves the downloaded file to its final destination. */
|
||||
private fun moveDownloadedFile(downloadItem: DownloadItem, downloadItemPart: DownloadItemPart) {
|
||||
val file = DocumentFileCompat.fromUri(mainActivity, downloadItemPart.destinationUri)
|
||||
Log.d(tag, "DOWNLOAD: DESTINATION URI ${downloadItemPart.destinationUri}")
|
||||
|
||||
val fcb =
|
||||
object : FileCallback() {
|
||||
override fun onPrepare() {
|
||||
Log.d(tag, "DOWNLOAD: PREPARING MOVE FILE")
|
||||
private fun moveDownloadedFile(item: DownloadItem, part: DownloadItemPart) {
|
||||
if (part.moved || part.isMoving) return
|
||||
val root =
|
||||
DocumentFile.fromTreeUri(context, Uri.parse(part.localFolderUrl))
|
||||
?: return failFinalization(item, part, "Could not resolve SAF destination")
|
||||
part.isMoving = true
|
||||
persist(item, force = true)
|
||||
scope.launch {
|
||||
try {
|
||||
if (!hasAvailableSpace(part))
|
||||
throw IllegalStateException("Insufficient storage for SAF copy")
|
||||
val folderKey = "${root.uri}/${part.finalDestinationSubfolder}"
|
||||
val folderLock = safFolderLocks.computeIfAbsent(folderKey) { Any() }
|
||||
val folder =
|
||||
synchronized(folderLock) { getOrCreateFolder(root, part.finalDestinationSubfolder) }
|
||||
?: throw IllegalStateException("Could not create SAF destination folder")
|
||||
val temporaryName = ".${part.filename}.${part.id.hashCode()}.part"
|
||||
folder.findFile(temporaryName)?.delete()
|
||||
val temporary =
|
||||
folder.createFile(mimeTypeFor(part), temporaryName)
|
||||
?: throw IllegalStateException("Could not create SAF temporary file")
|
||||
val staging = File(part.destinationPath)
|
||||
FileInputStream(staging).use { input ->
|
||||
context.contentResolver.openOutputStream(temporary.uri, "w")?.use { input.copyTo(it) }
|
||||
?: throw IllegalStateException("Could not open SAF output stream")
|
||||
}
|
||||
|
||||
override fun onFailed(errorCode: ErrorCode) {
|
||||
Log.e(tag, "DOWNLOAD: FAILED TO MOVE FILE $errorCode")
|
||||
downloadItemPart.failed = true
|
||||
downloadItemPart.isMoving = false
|
||||
file?.delete()
|
||||
checkDownloadItemFinished(downloadItem)
|
||||
currentDownloadItemParts.remove(downloadItemPart)
|
||||
if (temporary.length() != staging.length())
|
||||
throw IllegalStateException("SAF copy size mismatch")
|
||||
val existing = folder.findFile(part.filename)
|
||||
if (existing != null && !existing.delete())
|
||||
throw IllegalStateException("Could not replace existing file")
|
||||
if (!temporary.renameTo(part.filename))
|
||||
throw IllegalStateException("Could not finalize SAF temporary file")
|
||||
val destination =
|
||||
folder.findFile(part.filename)
|
||||
?: throw IllegalStateException("Could not reopen finalized SAF file")
|
||||
if (destination.length() != staging.length())
|
||||
throw IllegalStateException("SAF final size mismatch")
|
||||
if (!staging.delete()) Log.w(tag, "Could not remove staging file ${staging.name}")
|
||||
part.completedDestinationUri = destination.uri.toString()
|
||||
completePart(item, part)
|
||||
} catch (e: Exception) {
|
||||
failFinalization(item, part, "SAF copy failed: ${e.message}")
|
||||
}
|
||||
|
||||
override fun onCompleted(result: Any) {
|
||||
Log.d(tag, "DOWNLOAD: FILE MOVE COMPLETED")
|
||||
val resultDocFile = result as DocumentFile
|
||||
Log.d(
|
||||
tag,
|
||||
"DOWNLOAD: COMPLETED FILE INFO (name=${resultDocFile.name}) ${resultDocFile.getAbsolutePath(mainActivity)}"
|
||||
)
|
||||
|
||||
// Rename to fix appended .mp3 on m4b/m4a files
|
||||
// REF: https://github.com/anggrayudi/SimpleStorage/issues/94
|
||||
val docNameLowerCase = resultDocFile.name?.lowercase(Locale.getDefault()) ?: ""
|
||||
if (docNameLowerCase.endsWith(".m4b.mp3") || docNameLowerCase.endsWith(".m4a.mp3")
|
||||
) {
|
||||
resultDocFile.renameTo(downloadItemPart.filename)
|
||||
}
|
||||
|
||||
downloadItemPart.moved = true
|
||||
downloadItemPart.isMoving = false
|
||||
checkDownloadItemFinished(downloadItem)
|
||||
currentDownloadItemParts.remove(downloadItemPart)
|
||||
}
|
||||
}
|
||||
|
||||
val localFolderFile =
|
||||
DocumentFileCompat.fromUri(mainActivity, Uri.parse(downloadItemPart.localFolderUrl))
|
||||
if (localFolderFile == null) {
|
||||
// Failed
|
||||
downloadItemPart.failed = true
|
||||
Log.e(tag, "Local Folder File from uri is null")
|
||||
checkDownloadItemFinished(downloadItem)
|
||||
currentDownloadItemParts.remove(downloadItemPart)
|
||||
} else {
|
||||
downloadItemPart.isMoving = true
|
||||
val mimetype = if (downloadItemPart.audioTrack != null) MimeType.AUDIO else MimeType.IMAGE
|
||||
val fileDescription =
|
||||
FileDescription(
|
||||
downloadItemPart.filename,
|
||||
downloadItemPart.finalDestinationSubfolder,
|
||||
mimetype
|
||||
)
|
||||
file?.moveFileTo(mainActivity, localFolderFile, fileDescription, fcb)
|
||||
}
|
||||
@Synchronized
|
||||
private fun failFinalization(item: DownloadItem, part: DownloadItemPart, message: String) {
|
||||
Log.e(tag, message)
|
||||
part.isMoving = false
|
||||
part.failed = true
|
||||
failOrRetry(item, part, message)
|
||||
}
|
||||
|
||||
/** Checks if a download item is finished and processes it. */
|
||||
private fun checkDownloadItemFinished(downloadItem: DownloadItem) {
|
||||
if (downloadItem.isDownloadFinished) {
|
||||
Log.i(tag, "Download Item finished ${downloadItem.media.metadata.title}")
|
||||
@Synchronized
|
||||
private fun completePart(item: DownloadItem, part: DownloadItemPart) {
|
||||
part.moved = true
|
||||
part.completed = true
|
||||
part.failed = false
|
||||
part.isMoving = false
|
||||
reservations.remove(part.destinationPath)
|
||||
removeActivePart(part)
|
||||
persist(item, force = true)
|
||||
checkDownloadItemFinished(item)
|
||||
}
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
folderScanner.scanDownloadItem(downloadItem) { downloadItemScanResult ->
|
||||
Log.d(
|
||||
tag,
|
||||
"Item download complete ${downloadItem.itemTitle} | local library item id: ${downloadItemScanResult?.localLibraryItem?.id}"
|
||||
)
|
||||
|
||||
val jsobj =
|
||||
private fun checkDownloadItemFinished(item: DownloadItem) {
|
||||
if (!item.isDownloadFinished) return
|
||||
scope.launch {
|
||||
folderScanner.scanDownloadItem(item) { scanResult ->
|
||||
val event =
|
||||
JSObject().apply {
|
||||
put("libraryItemId", downloadItem.id)
|
||||
put("localFolderId", downloadItem.localFolder.id)
|
||||
|
||||
downloadItemScanResult?.localLibraryItem?.let { localLibraryItem ->
|
||||
put(
|
||||
"localLibraryItem",
|
||||
JSObject(jacksonMapper.writeValueAsString(localLibraryItem))
|
||||
)
|
||||
put("libraryItemId", item.id)
|
||||
put("localFolderId", item.localFolder.id)
|
||||
scanResult?.localLibraryItem?.let {
|
||||
put("localLibraryItem", JSObject(jacksonMapper.writeValueAsString(it)))
|
||||
}
|
||||
scanResult?.localMediaProgress?.let {
|
||||
put("localMediaProgress", JSObject(jacksonMapper.writeValueAsString(it)))
|
||||
}
|
||||
}
|
||||
clientEventEmitter.onDownloadItemComplete(event)
|
||||
synchronized(this@DownloadItemManager) {
|
||||
downloadItemQueue.remove(item)
|
||||
DeviceManager.dbManager.removeDownloadItem(item.id)
|
||||
notifyQueueChanged()
|
||||
}
|
||||
}
|
||||
downloadItemScanResult?.localMediaProgress?.let { localMediaProgress ->
|
||||
put(
|
||||
"localMediaProgress",
|
||||
JSObject(jacksonMapper.writeValueAsString(localMediaProgress))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
launch(Dispatchers.Main) {
|
||||
clientEventEmitter.onDownloadItemComplete(jsobj)
|
||||
downloadItemQueue.remove(downloadItem)
|
||||
DeviceManager.dbManager.removeDownloadItem(downloadItem.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
private fun tryReserve(part: DownloadItemPart): Boolean {
|
||||
if (part.fileSize <= 0L && currentDownloadItemParts.any { it.fileSize <= 0L }) return false
|
||||
val staging = File(part.destinationPath)
|
||||
staging.parentFile?.mkdirs()
|
||||
val expectedSize = if (part.fileSize > 0L) part.fileSize else UNKNOWN_PART_RESERVATION_BYTES
|
||||
val remaining =
|
||||
(expectedSize - (staging.takeIf(File::exists)?.length() ?: 0L)).coerceAtLeast(0L)
|
||||
val required = if (part.isInternalStorage) remaining else remaining + expectedSize
|
||||
val key = storageKey(staging)
|
||||
val fs = statFsFor(staging)
|
||||
val headroom = max(MIN_FREE_SPACE_BYTES, fs.totalBytes / 20L)
|
||||
val alreadyReserved = reservations.filterKeys { storageKey(File(it)) == key }.values.sum()
|
||||
if (fs.availableBytes - alreadyReserved < required + headroom) return false
|
||||
reservations[part.destinationPath] = required
|
||||
return true
|
||||
}
|
||||
|
||||
private fun hasAvailableSpace(part: DownloadItemPart): Boolean {
|
||||
val staging = File(part.destinationPath)
|
||||
val fs = statFsFor(staging)
|
||||
return fs.availableBytes >= max(MIN_FREE_SPACE_BYTES, fs.totalBytes / 20L)
|
||||
}
|
||||
|
||||
private fun statFsFor(staging: File): StatFs {
|
||||
var directory = staging.parentFile ?: context.filesDir
|
||||
directory.mkdirs()
|
||||
while (!directory.exists()) directory = directory.parentFile ?: context.filesDir
|
||||
return StatFs(directory.absolutePath)
|
||||
}
|
||||
|
||||
private fun storageKey(file: File): String =
|
||||
if (file.absolutePath.startsWith(context.filesDir.absolutePath)) "internal"
|
||||
else "external"
|
||||
|
||||
@Synchronized
|
||||
private fun removeActivePart(part: DownloadItemPart) {
|
||||
activeCalls.remove(part.id)
|
||||
currentDownloadItemParts.remove(part)
|
||||
}
|
||||
|
||||
private fun persist(item: DownloadItem, force: Boolean = false) {
|
||||
val now = System.currentTimeMillis()
|
||||
if (!force && now - (lastPersistTime[item.id] ?: 0L) < PERSIST_INTERVAL_MS) return
|
||||
lastPersistTime[item.id] = now
|
||||
DeviceManager.dbManager.saveDownloadItem(item)
|
||||
}
|
||||
|
||||
private fun notifyQueueChanged() {
|
||||
clientEventEmitter.onQueueChanged(hasWork())
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
activeCalls.values.forEach(Call::cancel)
|
||||
activeCalls.clear()
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
private fun getOrCreateFolder(root: DocumentFile, relativePath: String): DocumentFile? {
|
||||
var current = root
|
||||
relativePath.split('/').filter { it.isNotBlank() }.forEach { segment ->
|
||||
if (segment == "." || segment == "..") return null
|
||||
current = current.findFile(segment) ?: current.createDirectory(segment) ?: return null
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
private fun findSharedStorageFile(part: DownloadItemPart): DocumentFile? {
|
||||
if (part.isInternalStorage) return null
|
||||
val root = DocumentFile.fromTreeUri(context, Uri.parse(part.localFolderUrl)) ?: return null
|
||||
var folder = root
|
||||
part.finalDestinationSubfolder.split('/').filter { it.isNotBlank() }.forEach { segment ->
|
||||
if (segment == "." || segment == "..") return null
|
||||
folder = folder.findFile(segment) ?: return null
|
||||
}
|
||||
val file = folder.findFile(part.filename) ?: return null
|
||||
if (!file.isFile) return null
|
||||
if (part.fileSize > 0L && file.length() != part.fileSize) return null
|
||||
if (part.fileSize <= 0L && file.length() <= 0L) return null
|
||||
return file
|
||||
}
|
||||
|
||||
private fun mimeTypeFor(part: DownloadItemPart): String =
|
||||
part.audioTrack?.mimeType
|
||||
?: when (part.ebookFile?.ebookFormat?.lowercase()) {
|
||||
"epub" -> "application/epub+zip"
|
||||
"pdf" -> "application/pdf"
|
||||
else -> "image/jpeg"
|
||||
}
|
||||
|
||||
private fun serverUrl(item: DownloadItem, part: DownloadItemPart): String {
|
||||
val rawCover = if (part.serverPath.endsWith("/cover")) "?raw=1" else ""
|
||||
return "${item.serverAddress}${part.serverPath}$rawCover"
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val APP_MANAGED_DOWNLOAD_ID = -1L
|
||||
const val MAX_SIMULTANEOUS_DOWNLOADS = 3
|
||||
const val WATCH_INTERVAL_MS = 1_000L
|
||||
const val STALL_TIMEOUT_MS = 60_000L
|
||||
const val MAX_RETRIES = 5
|
||||
const val PERSIST_INTERVAL_MS = 2_000L
|
||||
const val MIN_FREE_SPACE_BYTES = 100L * 1024L * 1024L
|
||||
const val UNKNOWN_PART_RESERVATION_BYTES = 100L * 1024L * 1024L
|
||||
}
|
||||
}
|
||||
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
package com.audiobookshelf.app.managers
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.Worker
|
||||
import androidx.work.WorkerParameters
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.audiobookshelf.app.models.DownloadItem
|
||||
import java.io.File
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/** Removes terminally failed downloads after their retention window elapses. */
|
||||
object IncompleteDownloadCleanup {
|
||||
private const val tag = "IncompleteDownloadCleanup"
|
||||
private const val RETENTION_MS = 24L * 60L * 60L * 1000L
|
||||
private const val WORK_PREFIX = "incomplete-download-"
|
||||
|
||||
fun schedule(context: Context, item: DownloadItem) {
|
||||
val failedAt = item.terminalFailureAt ?: return
|
||||
val delay = (failedAt + RETENTION_MS - System.currentTimeMillis()).coerceAtLeast(0L)
|
||||
val request = OneTimeWorkRequestBuilder<IncompleteDownloadCleanupWorker>()
|
||||
.setInitialDelay(delay, TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
WorkManager.getInstance(context).enqueueUniqueWork(
|
||||
WORK_PREFIX + item.id, ExistingWorkPolicy.REPLACE, request)
|
||||
}
|
||||
|
||||
fun cancel(context: Context, itemId: String) {
|
||||
WorkManager.getInstance(context).cancelUniqueWork(WORK_PREFIX + itemId)
|
||||
}
|
||||
|
||||
/** Removes failures retained longer than 24 hours when scheduled work did not run. */
|
||||
fun cleanupExpired(context: Context) {
|
||||
val now = System.currentTimeMillis()
|
||||
DeviceManager.dbManager.getDownloadItems()
|
||||
.filter { item -> isEligible(item, now) }
|
||||
.forEach { item ->
|
||||
deleteItem(context, item)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isEligible(item: DownloadItem, now: Long): Boolean {
|
||||
val failedAt = item.terminalFailureAt ?: return false
|
||||
if (now - failedAt < RETENTION_MS) return false
|
||||
return item.downloadItemParts.all { part ->
|
||||
part.moved || (part.failed && !part.isMoving)
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteItem(context: Context, item: DownloadItem) {
|
||||
item.downloadItemParts.forEach { part ->
|
||||
deleteAppOwnedFile(context, File(part.destinationPath))
|
||||
if (part.isInternalStorage && part.moved) {
|
||||
deleteAppOwnedFile(context, File(part.finalDestinationPath))
|
||||
} else if (!part.isInternalStorage && part.moved) {
|
||||
part.completedDestinationUri?.let { uriString ->
|
||||
try {
|
||||
DocumentFile.fromSingleUri(context, Uri.parse(uriString))?.delete()
|
||||
} catch (e: Exception) {
|
||||
Log.w(tag, "Could not delete expired SAF document for ${part.filename}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
DeviceManager.dbManager.removeDownloadItem(item.id)
|
||||
cancel(context, item.id)
|
||||
Log.i(tag, "Deleted terminally failed download item ${item.id}")
|
||||
}
|
||||
|
||||
private fun deleteAppOwnedFile(context: Context, file: File) {
|
||||
val path = file.absolutePath
|
||||
val internal = context.filesDir.absolutePath
|
||||
val external = context.getExternalFilesDir(null)?.absolutePath
|
||||
if (path.startsWith(internal) || (external != null && path.startsWith(external))) {
|
||||
if (file.exists() && !file.delete()) Log.w(tag, "Could not delete expired staging file $path")
|
||||
file.parentFile?.takeIf { it.isDirectory && it.list()?.isEmpty() == true }?.delete()
|
||||
} else {
|
||||
Log.w(tag, "Refusing to delete non-app-owned path $path")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class IncompleteDownloadCleanupWorker(context: Context, params: WorkerParameters) : Worker(context, params) {
|
||||
override fun doWork(): Result {
|
||||
DbManager.initialize(applicationContext)
|
||||
IncompleteDownloadCleanup.cleanupExpired(applicationContext)
|
||||
return Result.success()
|
||||
}
|
||||
}
|
||||
+107
-86
@@ -1,114 +1,135 @@
|
||||
package com.audiobookshelf.app.managers
|
||||
|
||||
import android.util.Log
|
||||
import java.io.*
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.TimeUnit
|
||||
import okhttp3.*
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
|
||||
/**
|
||||
* Manages the internal download process.
|
||||
*
|
||||
* @property outputStream The output stream to write the downloaded data.
|
||||
* @property progressCallback The callback to report download progress.
|
||||
*/
|
||||
/** Streams a download into an app-owned staging file. */
|
||||
class InternalDownloadManager(
|
||||
private val outputStream: FileOutputStream,
|
||||
private val progressCallback: DownloadItemManager.InternalProgressCallback
|
||||
) : AutoCloseable {
|
||||
|
||||
private val destinationFile: File,
|
||||
private val expectedSize: Long,
|
||||
private val progressCallback: DownloadItemManager.InternalProgressCallback,
|
||||
private val hasAvailableSpace: () -> Boolean
|
||||
) {
|
||||
private val tag = "InternalDownloadManager"
|
||||
private val client: OkHttpClient =
|
||||
OkHttpClient.Builder().connectTimeout(30, TimeUnit.SECONDS).build()
|
||||
private val writer = BinaryFileWriter(outputStream, progressCallback)
|
||||
|
||||
/**
|
||||
* Downloads a file from the given URL.
|
||||
* Starts or resumes a download.
|
||||
*
|
||||
* @param url The URL to download the file from.
|
||||
* @throws IOException If an I/O error occurs.
|
||||
* @param url download URL
|
||||
* @param token access token sent in the Authorization header
|
||||
* @return active call, used to cancel a stalled transfer
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
fun download(url: String) {
|
||||
val request: Request = Request.Builder().url(url).addHeader("Accept-Encoding", "identity").build()
|
||||
client.newCall(request)
|
||||
.enqueue(
|
||||
fun download(url: String, token: String): Call {
|
||||
destinationFile.parentFile?.mkdirs()
|
||||
val existingBytes = destinationFile.takeIf { it.exists() }?.length() ?: 0L
|
||||
val request =
|
||||
Request.Builder()
|
||||
.url(url)
|
||||
.addHeader("Accept-Encoding", "identity")
|
||||
.addHeader("Authorization", "Bearer $token")
|
||||
.apply { if (existingBytes > 0L) header("Range", "bytes=$existingBytes-") }
|
||||
.build()
|
||||
val call = client.newCall(request)
|
||||
call.enqueue(
|
||||
object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
Log.e(tag, "Download URL $url FAILED", e)
|
||||
Log.e(tag, "Download URL failed", e)
|
||||
progressCallback.onComplete(true)
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.body?.let { responseBody ->
|
||||
val length: Long = response.header("Content-Length")?.toLongOrNull() ?: 0L
|
||||
writer.write(responseBody.byteStream(), length)
|
||||
response.use {
|
||||
try {
|
||||
if (response.code == 416 && expectedSize > 0L && existingBytes == expectedSize
|
||||
) {
|
||||
progressCallback.onProgress(existingBytes, 100L)
|
||||
progressCallback.onComplete(false)
|
||||
return
|
||||
}
|
||||
?: run {
|
||||
Log.e(tag, "Response doesn't contain a file")
|
||||
val append =
|
||||
existingBytes > 0L &&
|
||||
response.code == 206 &&
|
||||
hasExpectedRange(response, existingBytes)
|
||||
if (existingBytes > 0L && !append && response.code != 200) {
|
||||
Log.e(
|
||||
tag,
|
||||
"Invalid resume response ${response.code} for offset $existingBytes"
|
||||
)
|
||||
progressCallback.onComplete(true)
|
||||
return
|
||||
}
|
||||
if (!response.isSuccessful || response.body == null) {
|
||||
Log.e(tag, "Download HTTP failure ${response.code}")
|
||||
progressCallback.onComplete(true)
|
||||
return
|
||||
}
|
||||
|
||||
val startingBytes = if (append) existingBytes else 0L
|
||||
val responseLength = response.body!!.contentLength()
|
||||
val totalLength =
|
||||
if (expectedSize > 0L) expectedSize
|
||||
else if (responseLength >= 0L) startingBytes + responseLength else 0L
|
||||
|
||||
FileOutputStream(destinationFile, append).use { output ->
|
||||
response.body!!.byteStream().use { input ->
|
||||
val buffer = ByteArray(CHUNK_SIZE)
|
||||
var totalBytes = startingBytes
|
||||
while (true) {
|
||||
val read = input.read(buffer)
|
||||
if (read < 0) break
|
||||
if (!hasAvailableSpace())
|
||||
throw IOException("Download paused to preserve free storage")
|
||||
output.write(buffer, 0, read)
|
||||
totalBytes += read
|
||||
val progress =
|
||||
if (totalLength > 0L) (totalBytes * 100L) / totalLength else 0L
|
||||
progressCallback.onProgress(totalBytes, progress.coerceAtMost(100L))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (expectedSize > 0L && destinationFile.length() != expectedSize) {
|
||||
Log.e(
|
||||
tag,
|
||||
"Downloaded size ${destinationFile.length()} did not match $expectedSize"
|
||||
)
|
||||
progressCallback.onComplete(true)
|
||||
} else {
|
||||
progressCallback.onComplete(false)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.e(tag, "Could not write staging file", e)
|
||||
progressCallback.onComplete(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
return call
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the download manager and releases resources.
|
||||
*
|
||||
* @throws Exception If an error occurs during closing.
|
||||
*/
|
||||
@Throws(Exception::class)
|
||||
override fun close() {
|
||||
writer.close()
|
||||
}
|
||||
private fun hasExpectedRange(response: Response, offset: Long): Boolean {
|
||||
val range = response.header("Content-Range") ?: return false
|
||||
val match = CONTENT_RANGE.matchEntire(range) ?: return false
|
||||
return match.groupValues[1].toLongOrNull() == offset &&
|
||||
match.groupValues[2].toLongOrNull()?.let { it >= offset } == true
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes binary data to an output stream.
|
||||
*
|
||||
* @property outputStream The output stream to write the data to.
|
||||
* @property progressCallback The callback to report write progress.
|
||||
*/
|
||||
class BinaryFileWriter(
|
||||
private val outputStream: OutputStream,
|
||||
private val progressCallback: DownloadItemManager.InternalProgressCallback
|
||||
) : AutoCloseable {
|
||||
|
||||
/**
|
||||
* Writes data from the input stream to the output stream.
|
||||
*
|
||||
* @param inputStream The input stream to read the data from.
|
||||
* @param length The total length of the data to be written.
|
||||
* @return The total number of bytes written.
|
||||
* @throws IOException If an I/O error occurs.
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
fun write(inputStream: InputStream, length: Long): Long {
|
||||
BufferedInputStream(inputStream).use { input ->
|
||||
val dataBuffer = ByteArray(CHUNK_SIZE)
|
||||
var totalBytes: Long = 0
|
||||
var readBytes: Int
|
||||
while (input.read(dataBuffer).also { readBytes = it } != -1) {
|
||||
totalBytes += readBytes
|
||||
outputStream.write(dataBuffer, 0, readBytes)
|
||||
progressCallback.onProgress(totalBytes, (totalBytes * 100L) / length)
|
||||
}
|
||||
progressCallback.onComplete(false)
|
||||
return totalBytes
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the writer and releases resources.
|
||||
*
|
||||
* @throws IOException If an error occurs during closing.
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
override fun close() {
|
||||
outputStream.close()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CHUNK_SIZE = 8192 // Increased chunk size for better performance
|
||||
private companion object {
|
||||
const val CHUNK_SIZE = 512 * 1024 // 512 KB
|
||||
val CONTENT_RANGE = Regex("bytes (\\d+)-(\\d+)/(?:\\d+|\\*)")
|
||||
val client =
|
||||
OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(60, TimeUnit.SECONDS)
|
||||
.writeTimeout(60, TimeUnit.SECONDS)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,11 @@ import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.audiobookshelf.app.server.ApiHandler
|
||||
import com.getcapacitor.JSObject
|
||||
import java.util.*
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.launch
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
import kotlin.coroutines.resume
|
||||
@@ -46,6 +49,16 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
|
||||
|
||||
var userSettingsPlaybackRate:Float? = null
|
||||
|
||||
// Android Auto browses on the media browser service main thread, so the server pings
|
||||
// and authorize call must not run there or they block it and trigger an ANR
|
||||
private val androidAutoIOScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
// Android Auto re-requests the browse root every ~2s and multiple clients do so at once,
|
||||
// so overlapping loads are coalesced into one to avoid racing on the shared server state
|
||||
private var isLoadingAndroidAutoItems = false
|
||||
private val pendingAndroidAutoLoadCallbacks = mutableListOf<() -> Unit>()
|
||||
private val androidAutoLoadLock = Any()
|
||||
|
||||
fun getIsLibrary(id:String) : Boolean {
|
||||
return serverLibraries.find { it.id == id } != null
|
||||
}
|
||||
@@ -136,7 +149,7 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
|
||||
// and reset any server data already set
|
||||
val serverConnConfig = if (DeviceManager.isConnectedToServer) DeviceManager.serverConnectionConfig else DeviceManager.deviceData.getLastServerConnectionConfig()
|
||||
|
||||
if (!DeviceManager.isConnectedToServer || !DeviceManager.checkConnectivity(ctx) || serverConnConfig == null || serverConnConfig.id !== serverConfigIdUsed) {
|
||||
if (!DeviceManager.isConnectedToServer || !DeviceManager.checkConnectivity(ctx) || serverConnConfig == null || serverConnConfig.id != serverConfigIdUsed) {
|
||||
podcastEpisodeLibraryItemMap = mutableMapOf()
|
||||
serverLibraries = listOf()
|
||||
serverLibraryItems = mutableListOf()
|
||||
@@ -730,7 +743,10 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
|
||||
return mediaProgress
|
||||
}
|
||||
|
||||
private fun checkSetValidServerConnectionConfig(cb: (Boolean) -> Unit) = runBlocking {
|
||||
// Runs on [androidAutoIOScope] rather than blocking the caller: this is reached from
|
||||
// onLoadChildren on the media browser service main thread, and the pings/authorize below
|
||||
// are network calls, so runBlocking here caused ANRs while browsing in Android Auto
|
||||
private fun checkSetValidServerConnectionConfig(cb: (Boolean) -> Unit) = androidAutoIOScope.launch {
|
||||
Log.d(tag, "checkSetValidServerConnectionConfig | serverConfigIdUsed=$serverConfigIdUsed | lastServerConnectionConfigId=${DeviceManager.deviceData.lastServerConnectionConfigId}")
|
||||
|
||||
coroutineScope {
|
||||
@@ -835,6 +851,26 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
|
||||
fun loadAndroidAutoItems(cb: () -> Unit) {
|
||||
Log.d(tag, "Load android auto items")
|
||||
|
||||
// Coalesce overlapping calls into a single load, every caller still gets its callback
|
||||
synchronized(androidAutoLoadLock) {
|
||||
pendingAndroidAutoLoadCallbacks.add(cb)
|
||||
if (isLoadingAndroidAutoItems) {
|
||||
Log.d(tag, "loadAndroidAutoItems: Load already in progress, queued callback")
|
||||
return
|
||||
}
|
||||
isLoadingAndroidAutoItems = true
|
||||
}
|
||||
|
||||
val onLoadFinished = {
|
||||
val callbacks: List<() -> Unit>
|
||||
synchronized(androidAutoLoadLock) {
|
||||
isLoadingAndroidAutoItems = false
|
||||
callbacks = pendingAndroidAutoLoadCallbacks.toList()
|
||||
pendingAndroidAutoLoadCallbacks.clear()
|
||||
}
|
||||
callbacks.forEach { it() }
|
||||
}
|
||||
|
||||
// Check if any valid server connection if not use locally downloaded books
|
||||
checkSetValidServerConnectionConfig { isConnected ->
|
||||
if (isConnected) {
|
||||
@@ -844,14 +880,14 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
|
||||
loadLibraries { libraries ->
|
||||
if (libraries.isEmpty()) {
|
||||
Log.w(tag, "No libraries returned from server request")
|
||||
cb()
|
||||
onLoadFinished()
|
||||
} else {
|
||||
cb() // Fully loaded
|
||||
onLoadFinished() // Fully loaded
|
||||
}
|
||||
}
|
||||
} else { // Not connected to server
|
||||
Log.d(tag, "loadAndroidAutoItems: Not connected to server")
|
||||
cb()
|
||||
onLoadFinished()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,13 +19,16 @@ data class DownloadItem(
|
||||
val itemTitle: String,
|
||||
val itemSubfolder: String,
|
||||
val media: MediaType,
|
||||
val downloadItemParts: MutableList<DownloadItemPart>
|
||||
val downloadItemParts: MutableList<DownloadItemPart>,
|
||||
@JsonIgnore var terminalFailureAt: Long? = null
|
||||
) {
|
||||
@get:JsonIgnore
|
||||
val isInternalStorage get() = localFolder.id.startsWith("internal-")
|
||||
val isInternalStorage
|
||||
get() = localFolder.id.startsWith("internal-")
|
||||
|
||||
@get:JsonIgnore
|
||||
val isDownloadFinished get() = !downloadItemParts.any { !it.completed || it.isMoving }
|
||||
val isDownloadFinished
|
||||
get() = !downloadItemParts.any { !it.completed || it.isMoving || it.failed }
|
||||
|
||||
@JsonIgnore
|
||||
fun getNextDownloadItemParts(limit: Int): MutableList<DownloadItemPart> {
|
||||
@@ -33,7 +36,7 @@ data class DownloadItem(
|
||||
if (limit == 0) return itemParts
|
||||
|
||||
for (it in downloadItemParts) {
|
||||
if (!it.completed && it.downloadId == null) {
|
||||
if (!it.completed && !it.failed && it.downloadId == null) {
|
||||
itemParts.add(it)
|
||||
if (itemParts.size >= limit) break
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.audiobookshelf.app.models
|
||||
|
||||
import android.app.DownloadManager
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.data.AudioTrack
|
||||
@@ -16,6 +15,7 @@ data class DownloadItemPart(
|
||||
val downloadItemId: String,
|
||||
val filename: String,
|
||||
val fileSize: Long,
|
||||
@JsonIgnore val destinationPath: String,
|
||||
val finalDestinationPath:String,
|
||||
val serverPath: String,
|
||||
val localFolderName: String,
|
||||
@@ -31,28 +31,29 @@ data class DownloadItemPart(
|
||||
@JsonIgnore val uri: Uri,
|
||||
@JsonIgnore val destinationUri: Uri,
|
||||
@JsonIgnore val finalDestinationUri: Uri,
|
||||
@JsonIgnore var completedDestinationUri: String?,
|
||||
val finalDestinationSubfolder: String,
|
||||
var downloadId: Long?,
|
||||
@JsonIgnore var lastUpdateTime: Long?,
|
||||
var progress: Long,
|
||||
var bytesDownloaded: Long
|
||||
var bytesDownloaded: Long,
|
||||
@JsonIgnore var retryCount: Int = 0,
|
||||
@JsonIgnore var waitingForSpace: Boolean = false
|
||||
) {
|
||||
companion object {
|
||||
fun make(downloadItemId:String, filename:String, fileSize: Long, destinationFile: File, finalDestinationFile: File, subfolder:String, serverPath:String, localFolder: LocalFolder, ebookFile: EBookFile?, audioTrack: AudioTrack?, episode: PodcastEpisode?) :DownloadItemPart {
|
||||
val destinationUri = Uri.fromFile(destinationFile)
|
||||
val finalDestinationUri = Uri.fromFile(finalDestinationFile)
|
||||
val rawCover = if (serverPath.endsWith("/cover")) "?raw=1" else ""
|
||||
val downloadUri = Uri.parse("${DeviceManager.serverAddress}${serverPath}$rawCover")
|
||||
|
||||
var downloadUrl = "${DeviceManager.serverAddress}${serverPath}?token=${DeviceManager.token}"
|
||||
if (serverPath.endsWith("/cover")) {
|
||||
downloadUrl += "&raw=1" // Download raw cover image
|
||||
}
|
||||
|
||||
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,
|
||||
filename = filename,
|
||||
fileSize = fileSize,
|
||||
destinationPath = destinationFile.absolutePath,
|
||||
finalDestinationPath = finalDestinationFile.absolutePath,
|
||||
serverPath = serverPath,
|
||||
localFolderName = localFolder.name,
|
||||
@@ -68,8 +69,10 @@ data class DownloadItemPart(
|
||||
uri = downloadUri,
|
||||
destinationUri = destinationUri,
|
||||
finalDestinationUri = finalDestinationUri,
|
||||
completedDestinationUri = null,
|
||||
finalDestinationSubfolder = subfolder,
|
||||
downloadId = null,
|
||||
lastUpdateTime = null,
|
||||
progress = 0,
|
||||
bytesDownloaded = 0
|
||||
)
|
||||
@@ -79,16 +82,4 @@ data class DownloadItemPart(
|
||||
@get:JsonIgnore
|
||||
val isInternalStorage get() = localFolderId.startsWith("internal-")
|
||||
|
||||
@get:JsonIgnore
|
||||
val serverUrl get() = uri.toString()
|
||||
|
||||
@JsonIgnore
|
||||
fun getDownloadRequest(): DownloadManager.Request {
|
||||
val dlRequest = DownloadManager.Request(uri)
|
||||
dlRequest.setTitle(filename)
|
||||
dlRequest.setDescription("Downloading to $localFolderName with filename $filename")
|
||||
dlRequest.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE)
|
||||
dlRequest.setDestinationUri(destinationUri)
|
||||
return dlRequest
|
||||
}
|
||||
}
|
||||
|
||||
+4
-26
@@ -6,10 +6,8 @@ import android.graphics.ImageDecoder
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import android.support.v4.media.MediaMetadataCompat
|
||||
import android.support.v4.media.session.MediaControllerCompat
|
||||
import com.audiobookshelf.app.BuildConfig
|
||||
import com.audiobookshelf.app.R
|
||||
import com.bumptech.glide.Glide
|
||||
import com.google.android.exoplayer2.Player
|
||||
import com.google.android.exoplayer2.ui.PlayerNotificationManager
|
||||
import kotlinx.coroutines.*
|
||||
@@ -36,7 +34,9 @@ class AbMediaDescriptionAdapter (private val controller: MediaControllerCompat,
|
||||
): Bitmap? {
|
||||
val albumArtUri = controller.metadata.description.iconUri
|
||||
val albumBitmap = controller.metadata.description.iconBitmap
|
||||
?: controller.metadata.getBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART)
|
||||
|
||||
// Reuse bitmap from queue navigator (local) or PlaybackSession.resolveCoverBitmapAsync (streaming)
|
||||
// For local cover images, bitmap is set in PlayerNotificationService TimelineQueueNavigator.getMediaDescription
|
||||
if (albumBitmap != null) {
|
||||
return albumBitmap
|
||||
@@ -59,7 +59,7 @@ class AbMediaDescriptionAdapter (private val controller: MediaControllerCompat,
|
||||
} else {
|
||||
serviceScope.launch {
|
||||
currentBitmap = albumArtUri?.let {
|
||||
resolveUriAsBitmap(it)
|
||||
resolveUriAsBitmap(playerNotificationService, it)
|
||||
}
|
||||
currentBitmap?.let { callback.onBitmap(it) }
|
||||
}
|
||||
@@ -69,26 +69,4 @@ class AbMediaDescriptionAdapter (private val controller: MediaControllerCompat,
|
||||
currentBitmap
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun resolveUriAsBitmap(uri: Uri): Bitmap? {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
Glide.with(playerNotificationService)
|
||||
.asBitmap()
|
||||
.load(uri)
|
||||
.placeholder(R.drawable.icon)
|
||||
.error(R.drawable.icon)
|
||||
.submit()
|
||||
.get()
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
|
||||
Glide.with(playerNotificationService)
|
||||
.asBitmap()
|
||||
.load(Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon))
|
||||
.submit()
|
||||
.get()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.audiobookshelf.app.player
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.BuildConfig
|
||||
import com.audiobookshelf.app.R
|
||||
import com.bumptech.glide.Glide
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
private const val TAG = "CoverImageLoader"
|
||||
|
||||
/** Loads [uri] as a bitmap via Glide, falling back to the app icon if the load fails. */
|
||||
suspend fun resolveUriAsBitmap(context: Context, uri: Uri): Bitmap? {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
Glide.with(context)
|
||||
.asBitmap()
|
||||
.load(uri)
|
||||
.placeholder(R.drawable.icon)
|
||||
.error(R.drawable.icon)
|
||||
.submit()
|
||||
.get()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to load cover bitmap for uri: $uri", e)
|
||||
|
||||
Glide.with(context)
|
||||
.asBitmap()
|
||||
.load(Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon))
|
||||
.submit()
|
||||
.get()
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
-2
@@ -54,6 +54,11 @@ import com.google.android.exoplayer2.ui.PlayerNotificationManager
|
||||
import com.google.android.exoplayer2.upstream.*
|
||||
import java.util.*
|
||||
import kotlin.concurrent.schedule
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
const val SLEEP_TIMER_WAKE_UP_EXPIRATION = 120000L // 2m
|
||||
@@ -116,6 +121,9 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
var currentPlaybackSession: PlaybackSession? = null
|
||||
private var initialPlaybackRate: Float? = null
|
||||
|
||||
private val metadataScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||
private var metadataArtJob: Job? = null
|
||||
|
||||
private var isAndroidAuto = false
|
||||
|
||||
// The following are used for the shake detection
|
||||
@@ -198,6 +206,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
castPlayer?.release()
|
||||
mediaSession.release()
|
||||
mediaProgressSyncer.reset()
|
||||
metadataScope.cancel()
|
||||
|
||||
super.onDestroy()
|
||||
}
|
||||
@@ -295,6 +304,12 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
transportControls = mediaController.transportControls
|
||||
|
||||
mediaSessionConnector = MediaSessionConnector(mediaSession)
|
||||
// Without this, the connector's default metadata provider rebuilds metadata from the
|
||||
// player's own state on media item transitions/timeline changes, dropping the cover art
|
||||
// bitmap that PlaybackSession.resolveCoverBitmapAsync resolves separately.
|
||||
mediaSessionConnector.setMediaMetadataProvider { _ ->
|
||||
currentPlaybackSession?.getMediaMetadataCompat(ctx) ?: MediaMetadataCompat.Builder().build()
|
||||
}
|
||||
val queueNavigator: TimelineQueueNavigator =
|
||||
object : TimelineQueueNavigator(mediaSession) {
|
||||
override fun getSupportedQueueNavigatorActions(player: Player): Long {
|
||||
@@ -430,8 +445,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
|
||||
isClosed = false
|
||||
|
||||
val metadata = playbackSession.getMediaMetadataCompat(ctx)
|
||||
mediaSession.setMetadata(metadata)
|
||||
val mediaItems = playbackSession.getMediaItems(ctx)
|
||||
val playbackRateToUse = playbackRate ?: initialPlaybackRate ?: 1f
|
||||
initialPlaybackRate = playbackRate
|
||||
@@ -459,6 +472,13 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
playbackSession
|
||||
) // Save playback session to use when app is closed
|
||||
|
||||
metadataArtJob?.cancel()
|
||||
metadataArtJob =
|
||||
playbackSession.resolveCoverBitmapAsync(ctx, metadataScope) {
|
||||
mediaSessionConnector.invalidateMediaSessionMetadata()
|
||||
}
|
||||
mediaSessionConnector.invalidateMediaSessionMetadata()
|
||||
|
||||
AbsLogger.info("PlayerNotificationService", "preparePlayer: Started playback session for item ${currentPlaybackSession?.mediaItemId}. MediaPlayer ${currentPlaybackSession?.mediaPlayer}")
|
||||
// Notify client
|
||||
clientEventEmitter?.onPlaybackSession(playbackSession)
|
||||
|
||||
@@ -229,7 +229,7 @@ class AbsAudioPlayer : Plugin() {
|
||||
return call.resolve(JSObject("{\"error\":\"Podcast episode not found\"}"))
|
||||
}
|
||||
}
|
||||
if (!it.hasTracks(episode)) {
|
||||
if (!it.hasTracks(mainActivity, episode)) {
|
||||
return call.resolve(JSObject("{\"error\":\"No audio files found on device. Download book again to fix.\"}"))
|
||||
}
|
||||
|
||||
@@ -325,10 +325,10 @@ class AbsAudioPlayer : Plugin() {
|
||||
|
||||
@PluginMethod
|
||||
fun seek(call: PluginCall) {
|
||||
val time:Int = call.getInt("value", 0) ?: 0 // Value in seconds
|
||||
val time: Double = call.getDouble("value", 0.0) ?: 0.0 // Value in seconds, fractional
|
||||
Log.d(tag, "seek action to $time")
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
playerNotificationService.seekPlayer(time * 1000L) // convert to ms
|
||||
playerNotificationService.seekPlayer((time * 1000L).toLong())
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ class AbsDatabase : Plugin() {
|
||||
secureStorage = SecureStorage(mainActivity)
|
||||
|
||||
DeviceManager.dbManager.cleanLocalMediaProgress()
|
||||
DeviceManager.dbManager.cleanLocalLibraryItems()
|
||||
DeviceManager.dbManager.cleanLocalLibraryItems(mainActivity)
|
||||
DeviceManager.dbManager.cleanLogs()
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
package com.audiobookshelf.app.plugins
|
||||
|
||||
import android.app.DownloadManager
|
||||
import android.content.Context
|
||||
import android.os.Environment
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.MainActivity
|
||||
import com.audiobookshelf.app.data.*
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.audiobookshelf.app.device.FolderScanner
|
||||
import com.audiobookshelf.app.models.DownloadItem
|
||||
import com.audiobookshelf.app.models.DownloadItemPart
|
||||
import com.audiobookshelf.app.server.ApiHandler
|
||||
import com.audiobookshelf.app.managers.DownloadItemManager
|
||||
import com.audiobookshelf.app.services.DownloadServiceHost
|
||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.getcapacitor.JSObject
|
||||
@@ -27,9 +25,7 @@ class AbsDownloader : Plugin() {
|
||||
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||
|
||||
lateinit var mainActivity: MainActivity
|
||||
lateinit var downloadManager: DownloadManager
|
||||
lateinit var apiHandler: ApiHandler
|
||||
lateinit var folderScanner: FolderScanner
|
||||
lateinit var downloadItemManager: DownloadItemManager
|
||||
|
||||
private val clientEventEmitter = (object : DownloadItemManager.DownloadEventEmitter {
|
||||
@@ -42,14 +38,44 @@ class AbsDownloader : Plugin() {
|
||||
override fun onDownloadItemComplete(jsobj:JSObject) {
|
||||
notifyListeners("onItemDownloadComplete", jsobj)
|
||||
}
|
||||
override fun onQueueChanged(hasWork: Boolean) {
|
||||
notifyListeners("onQueueChanged", JSObject().put("hasWork", hasWork))
|
||||
}
|
||||
})
|
||||
|
||||
override fun load() {
|
||||
mainActivity = (activity as MainActivity)
|
||||
downloadManager = activity.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
|
||||
folderScanner = FolderScanner(mainActivity)
|
||||
apiHandler = ApiHandler(mainActivity)
|
||||
downloadItemManager = DownloadItemManager(downloadManager, folderScanner, mainActivity, clientEventEmitter)
|
||||
downloadItemManager = DownloadServiceHost.ensure(mainActivity)
|
||||
DownloadServiceHost.attachBridge(mainActivity, clientEventEmitter)
|
||||
}
|
||||
|
||||
override fun handleOnDestroy() {
|
||||
DownloadServiceHost.detachBridge()
|
||||
super.handleOnDestroy()
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun setDownloadNotificationStrings(call: PluginCall) {
|
||||
DownloadServiceHost.setNotificationStrings(
|
||||
mainActivity,
|
||||
call.getString("preparing") ?: "Preparing downloads",
|
||||
call.getString("downloadingFile") ?: "Downloading {0}",
|
||||
call.getString("waitingForStorage") ?: "Waiting for available storage",
|
||||
call.getString("downloads") ?: "Downloads",
|
||||
call.getString("cancel") ?: "Cancel")
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
/** Replays restored queue items when the frontend subscribes to download events. */
|
||||
@PluginMethod(returnType = PluginMethod.RETURN_NONE)
|
||||
override fun addListener(call: PluginCall) {
|
||||
super.addListener(call)
|
||||
if (call.getString("eventName") == "onDownloadItem" && ::downloadItemManager.isInitialized) {
|
||||
downloadItemManager.downloadItemQueue.forEach { item ->
|
||||
notifyListeners("onDownloadItem", JSObject(jacksonMapper.writeValueAsString(item)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
@@ -79,7 +105,7 @@ class AbsDownloader : Plugin() {
|
||||
|
||||
if (localFolder == null && localFolderId.startsWith("internal-")) {
|
||||
Log.d(tag, "Creating new App Storage internal LocalFolder $localFolderId")
|
||||
localFolder = LocalFolder(localFolderId, "Internal App Storage", "", "", "", "", "internal", libraryItem.mediaType)
|
||||
localFolder = LocalFolder(localFolderId, "Internal App Storage", "", "", "", "internal", libraryItem.mediaType)
|
||||
DeviceManager.dbManager.saveLocalFolder(localFolder)
|
||||
}
|
||||
|
||||
@@ -132,7 +158,13 @@ class AbsDownloader : Plugin() {
|
||||
private fun startLibraryItemDownload(libraryItem: LibraryItem, localFolder: LocalFolder, episode:PodcastEpisode?) {
|
||||
val isInternal = localFolder.id.startsWith("internal-")
|
||||
|
||||
val tempFolderPath = if (isInternal) "${mainActivity.filesDir}/downloads/${libraryItem.id}" else mainActivity.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
|
||||
val finalInternalFolderPath = "${mainActivity.filesDir}/downloads/${libraryItem.id}"
|
||||
val tempFolderPath =
|
||||
if (isInternal) {
|
||||
"${mainActivity.filesDir}/download-staging/${libraryItem.id}"
|
||||
} else {
|
||||
"${mainActivity.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) ?: mainActivity.filesDir}/download-staging/${libraryItem.id}"
|
||||
}
|
||||
|
||||
Log.d(tag, "downloadCacheDirectory=$tempFolderPath")
|
||||
|
||||
@@ -143,7 +175,7 @@ class AbsDownloader : Plugin() {
|
||||
val tracks = libraryItem.media.getAudioTracks()
|
||||
Log.d(tag, "Starting library item download with ${tracks.size} tracks")
|
||||
val itemSubfolder = "$bookAuthor/$bookTitle"
|
||||
val itemFolderPath = if (isInternal) "$tempFolderPath" else "${localFolder.absolutePath}/$itemSubfolder"
|
||||
val itemFolderPath = if (isInternal) finalInternalFolderPath else "${localFolder.absolutePath}/$itemSubfolder"
|
||||
val downloadItem = DownloadItem(libraryItem.id, libraryItem.id, null, libraryItem.userMediaProgress,DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, bookTitle, itemSubfolder, libraryItem.media, mutableListOf())
|
||||
|
||||
val book = libraryItem.media as Book
|
||||
@@ -152,17 +184,7 @@ class AbsDownloader : Plugin() {
|
||||
val serverPath = "/api/items/${libraryItem.id}/file/${ebookFile.ino}/download"
|
||||
val destinationFilename = getFilenameFromRelPath(ebookFile.metadata?.relPath ?: "")
|
||||
val finalDestinationFile = File("$itemFolderPath/$destinationFilename")
|
||||
val destinationFile = File("$tempFolderPath/$destinationFilename")
|
||||
|
||||
if (destinationFile.exists()) {
|
||||
Log.d(tag, "TEMP ebook file already exists, removing it from ${destinationFile.absolutePath}")
|
||||
destinationFile.delete()
|
||||
}
|
||||
|
||||
if (finalDestinationFile.exists()) {
|
||||
Log.d(tag, "ebook file already exists, removing it from ${finalDestinationFile.absolutePath}")
|
||||
finalDestinationFile.delete()
|
||||
}
|
||||
val destinationFile = File("$tempFolderPath/$destinationFilename.part")
|
||||
|
||||
val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, fileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,ebookFile,null,null)
|
||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||
@@ -181,17 +203,7 @@ class AbsDownloader : Plugin() {
|
||||
Log.d(tag, "Audio File Server Path $serverPath | AF RelPath ${audioTrack.relPath} | LocalFolder Path ${localFolder.absolutePath} | DestName $destinationFilename")
|
||||
|
||||
val finalDestinationFile = File("$itemFolderPath/$destinationFilename")
|
||||
val destinationFile = File("$tempFolderPath/$destinationFilename")
|
||||
|
||||
if (destinationFile.exists()) {
|
||||
Log.d(tag, "TEMP Audio file already exists, removing it from ${destinationFile.absolutePath}")
|
||||
destinationFile.delete()
|
||||
}
|
||||
|
||||
if (finalDestinationFile.exists()) {
|
||||
Log.d(tag, "Audio file already exists, removing it from ${finalDestinationFile.absolutePath}")
|
||||
finalDestinationFile.delete()
|
||||
}
|
||||
val destinationFile = File("$tempFolderPath/$destinationFilename.part")
|
||||
|
||||
val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, fileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,null,audioTrack,null)
|
||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||
@@ -205,24 +217,14 @@ class AbsDownloader : Plugin() {
|
||||
|
||||
val serverPath = "/api/items/${libraryItem.id}/cover"
|
||||
val destinationFilename = "cover-${libraryItem.id}.jpg"
|
||||
val destinationFile = File("$tempFolderPath/$destinationFilename")
|
||||
val destinationFile = File("$tempFolderPath/$destinationFilename.part")
|
||||
val finalDestinationFile = File("$itemFolderPath/$destinationFilename")
|
||||
|
||||
if (destinationFile.exists()) {
|
||||
Log.d(tag, "TEMP Audio file already exists, removing it from ${destinationFile.absolutePath}")
|
||||
destinationFile.delete()
|
||||
}
|
||||
|
||||
if (finalDestinationFile.exists()) {
|
||||
Log.d(tag, "Cover already exists, removing it from ${finalDestinationFile.absolutePath}")
|
||||
finalDestinationFile.delete()
|
||||
}
|
||||
|
||||
val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, coverFileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,null,null,null)
|
||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||
}
|
||||
|
||||
downloadItemManager.addDownloadItem(downloadItem)
|
||||
DownloadServiceHost.enqueue(mainActivity, downloadItem)
|
||||
}
|
||||
} else {
|
||||
// Podcast episode download
|
||||
@@ -233,7 +235,7 @@ class AbsDownloader : Plugin() {
|
||||
val fileSize = audioTrack?.metadata?.size ?: 0
|
||||
|
||||
Log.d(tag, "Starting podcast episode download")
|
||||
val itemFolderPath = if (isInternal) "$tempFolderPath" else "${localFolder.absolutePath}/$podcastTitle"
|
||||
val itemFolderPath = if (isInternal) finalInternalFolderPath else "${localFolder.absolutePath}/$podcastTitle"
|
||||
val downloadItemId = "${libraryItem.id}-${episode?.id}"
|
||||
val downloadItem = DownloadItem(downloadItemId, libraryItem.id, episode?.id, libraryItem.userMediaProgress, DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, podcastTitle, podcastTitle, libraryItem.media, mutableListOf())
|
||||
|
||||
@@ -241,13 +243,8 @@ class AbsDownloader : Plugin() {
|
||||
var destinationFilename = getFilenameFromRelPath(audioTrack?.relPath ?: "")
|
||||
Log.d(tag, "Audio File Server Path $serverPath | AF RelPath ${audioTrack?.relPath} | LocalFolder Path ${localFolder.absolutePath} | DestName $destinationFilename")
|
||||
|
||||
var destinationFile = File("$tempFolderPath/$destinationFilename")
|
||||
var destinationFile = File("$tempFolderPath/$destinationFilename.part")
|
||||
var finalDestinationFile = File("$itemFolderPath/$destinationFilename")
|
||||
if (finalDestinationFile.exists()) {
|
||||
Log.d(tag, "Audio file already exists, removing it from ${finalDestinationFile.absolutePath}")
|
||||
finalDestinationFile.delete()
|
||||
}
|
||||
|
||||
var downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,fileSize, destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,null,audioTrack,episode)
|
||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||
|
||||
@@ -258,18 +255,14 @@ class AbsDownloader : Plugin() {
|
||||
serverPath = "/api/items/${libraryItem.id}/cover"
|
||||
destinationFilename = "cover.jpg"
|
||||
|
||||
destinationFile = File("$tempFolderPath/$destinationFilename")
|
||||
destinationFile = File("$tempFolderPath/$destinationFilename.part")
|
||||
finalDestinationFile = File("$itemFolderPath/$destinationFilename")
|
||||
|
||||
if (finalDestinationFile.exists()) {
|
||||
Log.d(tag, "Podcast cover already exists - not downloading cover again")
|
||||
} else {
|
||||
downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,coverFileSize,destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,null,null,null)
|
||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||
}
|
||||
}
|
||||
|
||||
downloadItemManager.addDownloadItem(downloadItem)
|
||||
DownloadServiceHost.enqueue(mainActivity, downloadItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.audiobookshelf.app.plugins
|
||||
|
||||
import android.app.AlertDialog
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
@@ -23,14 +24,17 @@ import java.io.File
|
||||
class AbsFileSystem : Plugin() {
|
||||
private val TAG = "AbsFileSystem"
|
||||
private val tag = "AbsFileSystem"
|
||||
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||
private var jacksonMapper =
|
||||
jacksonObjectMapper()
|
||||
.enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||
|
||||
lateinit var mainActivity: MainActivity
|
||||
|
||||
override fun load() {
|
||||
mainActivity = (activity as MainActivity)
|
||||
|
||||
mainActivity.storage.storageAccessCallback = object : StorageAccessCallback {
|
||||
mainActivity.storage.storageAccessCallback =
|
||||
object : StorageAccessCallback {
|
||||
override fun onRootPathNotSelected(
|
||||
requestCode: Int,
|
||||
rootPath: String,
|
||||
@@ -45,7 +49,13 @@ class AbsFileSystem : Plugin() {
|
||||
Log.d(TAG, "STORAGE ACCESS CALLBACK")
|
||||
}
|
||||
|
||||
override fun onExpectedStorageNotSelected(requestCode: Int, selectedFolder: DocumentFile, selectedStorageType: StorageType, expectedBasePath: String, expectedStorageType: StorageType) {
|
||||
override fun onExpectedStorageNotSelected(
|
||||
requestCode: Int,
|
||||
selectedFolder: DocumentFile,
|
||||
selectedStorageType: StorageType,
|
||||
expectedBasePath: String,
|
||||
expectedStorageType: StorageType
|
||||
) {
|
||||
Log.d(TAG, "STORAGE ACCESS CALLBACK")
|
||||
}
|
||||
|
||||
@@ -59,22 +69,48 @@ class AbsFileSystem : Plugin() {
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun setFolderPickerStrings(call: PluginCall) {
|
||||
mainActivity.getSharedPreferences(FOLDER_PICKER_PREFERENCES, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString(KEY_WRITE_ACCESS_REQUIRED, call.getString("writeAccessRequired"))
|
||||
.putString(KEY_ALLOW, call.getString("allow"))
|
||||
.putString(KEY_CANCEL, call.getString("cancel"))
|
||||
.putString(KEY_ACCESS_DENIED, call.getString("accessDenied"))
|
||||
.putString(KEY_PERMISSION_DENIED, call.getString("permissionDenied"))
|
||||
.apply()
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun selectFolder(call: PluginCall) {
|
||||
val mediaType = call.data.getString("mediaType", "book").toString()
|
||||
val REQUEST_CODE_SELECT_FOLDER = 6
|
||||
val REQUEST_CODE_SDCARD_ACCESS = 7
|
||||
|
||||
mainActivity.storage.folderPickerCallback = object : FolderPickerCallback {
|
||||
mainActivity.storage.folderPickerCallback =
|
||||
object : FolderPickerCallback {
|
||||
override fun onFolderSelected(requestCode: Int, folder: DocumentFile) {
|
||||
Log.d(TAG, "ON FOLDER SELECTED ${folder.uri} ${folder.name}")
|
||||
val absolutePath = folder.getAbsolutePath(activity)
|
||||
val storageType = folder.getStorageType(activity)
|
||||
val simplePath = folder.getSimplePath(activity)
|
||||
val basePath = folder.getBasePath(activity)
|
||||
val folderId = android.util.Base64.encodeToString(folder.id.toByteArray(), android.util.Base64.DEFAULT)
|
||||
val folderId =
|
||||
android.util.Base64.encodeToString(
|
||||
folder.id.toByteArray(),
|
||||
android.util.Base64.DEFAULT
|
||||
)
|
||||
|
||||
val localFolder = LocalFolder(folderId, folder.name ?: "", folder.uri.toString(),basePath,absolutePath, simplePath, storageType.toString(), mediaType)
|
||||
val localFolder =
|
||||
LocalFolder(
|
||||
folderId,
|
||||
folder.name ?: "",
|
||||
folder.uri.toString(),
|
||||
basePath,
|
||||
absolutePath,
|
||||
storageType.toString(),
|
||||
mediaType
|
||||
)
|
||||
|
||||
DeviceManager.dbManager.saveLocalFolder(localFolder)
|
||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(localFolder)))
|
||||
@@ -92,32 +128,33 @@ class AbsFileSystem : Plugin() {
|
||||
if (requestCode == REQUEST_CODE_SELECT_FOLDER) {
|
||||
|
||||
val builder: AlertDialog.Builder = AlertDialog.Builder(mainActivity)
|
||||
builder.setMessage(
|
||||
"You have no write access to this storage, thus selecting this folder is useless." +
|
||||
"\nWould you like to grant access to this folder?")
|
||||
builder.setNegativeButton("Dont Allow") { _, _ ->
|
||||
builder.setMessage(folderPickerString(KEY_WRITE_ACCESS_REQUIRED, DEFAULT_WRITE_ACCESS_REQUIRED))
|
||||
builder.setNegativeButton(folderPickerString(KEY_CANCEL, DEFAULT_CANCEL)) { _, _ ->
|
||||
run {
|
||||
jsobj.put("error", "User Canceled, Access Denied")
|
||||
jsobj.put("error", folderPickerString(KEY_ACCESS_DENIED, DEFAULT_ACCESS_DENIED))
|
||||
call.resolve(jsobj)
|
||||
}
|
||||
}
|
||||
builder.setPositiveButton("Allow.") { _, _ -> mainActivity.storageHelper.requestStorageAccess(REQUEST_CODE_SDCARD_ACCESS, initialPath = FileFullPath(mainActivity, storageId, "")) }
|
||||
builder.setPositiveButton(folderPickerString(KEY_ALLOW, DEFAULT_ALLOW)) { _, _ ->
|
||||
mainActivity.storageHelper.requestStorageAccess(
|
||||
REQUEST_CODE_SDCARD_ACCESS,
|
||||
initialPath = FileFullPath(mainActivity, storageId, "")
|
||||
)
|
||||
}
|
||||
builder.show()
|
||||
} else {
|
||||
Log.d(TAG, "STORAGE ACCESS DENIED $requestCode")
|
||||
jsobj.put("error", "Access Denied")
|
||||
jsobj.put("error", folderPickerString(KEY_ACCESS_DENIED, DEFAULT_ACCESS_DENIED))
|
||||
call.resolve(jsobj)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun onStoragePermissionDenied(requestCode: Int) {
|
||||
Log.d(TAG, "STORAGE PERMISSION DENIED $requestCode")
|
||||
val jsobj = JSObject()
|
||||
jsobj.put("error", "Permission Denied")
|
||||
jsobj.put("error", folderPickerString(KEY_PERMISSION_DENIED, DEFAULT_PERMISSION_DENIED))
|
||||
call.resolve(jsobj)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
mainActivity.storage.openFolderPicker(REQUEST_CODE_SELECT_FOLDER)
|
||||
@@ -196,7 +233,8 @@ class AbsFileSystem : Plugin() {
|
||||
if (localLibraryItem?.folderId?.startsWith("internal-") == true) {
|
||||
Log.d(tag, "Deleting internal library item at absolutePath $absolutePath")
|
||||
val file = File(absolutePath)
|
||||
success = if (file.exists()) {
|
||||
success =
|
||||
if (file.exists()) {
|
||||
file.deleteRecursively()
|
||||
} else {
|
||||
true
|
||||
@@ -207,9 +245,11 @@ class AbsFileSystem : Plugin() {
|
||||
val folder = DeviceManager.dbManager.getLocalFolder(folderId)
|
||||
folder?.absolutePath?.let { folderPath ->
|
||||
val splitAbsolutePath = absolutePath.split("/")
|
||||
val fullSubDir = splitAbsolutePath.subList(0, splitAbsolutePath.size - 1).joinToString("/")
|
||||
val fullSubDir =
|
||||
splitAbsolutePath.subList(0, splitAbsolutePath.size - 1).joinToString("/")
|
||||
if (fullSubDir != folderPath) {
|
||||
val subdirHasAnItem = DeviceManager.dbManager.getLocalLibraryItems().any { _localLibraryItem ->
|
||||
val subdirHasAnItem =
|
||||
DeviceManager.dbManager.getLocalLibraryItems().any { _localLibraryItem ->
|
||||
if (_localLibraryItem.id == localLibraryItemId) {
|
||||
false
|
||||
} else {
|
||||
@@ -266,4 +306,23 @@ class AbsFileSystem : Plugin() {
|
||||
call.resolve(JSObject("{\"success\":false}"))
|
||||
}
|
||||
}
|
||||
|
||||
private fun folderPickerString(key: String, defaultValue: String): String =
|
||||
mainActivity.getSharedPreferences(FOLDER_PICKER_PREFERENCES, Context.MODE_PRIVATE)
|
||||
.getString(key, defaultValue) ?: defaultValue
|
||||
|
||||
private companion object {
|
||||
const val FOLDER_PICKER_PREFERENCES = "folder_picker"
|
||||
const val KEY_WRITE_ACCESS_REQUIRED = "write_access_required"
|
||||
const val KEY_ALLOW = "allow"
|
||||
const val KEY_CANCEL = "cancel"
|
||||
const val KEY_ACCESS_DENIED = "access_denied"
|
||||
const val KEY_PERMISSION_DENIED = "permission_denied"
|
||||
const val DEFAULT_WRITE_ACCESS_REQUIRED =
|
||||
"You do not have write access to this folder. Would you like to grant access?"
|
||||
const val DEFAULT_ALLOW = "Allow"
|
||||
const val DEFAULT_CANCEL = "Cancel"
|
||||
const val DEFAULT_ACCESS_DENIED = "Access denied"
|
||||
const val DEFAULT_PERMISSION_DENIED = "Permission denied"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import com.audiobookshelf.app.plugins.AbsLogger
|
||||
import com.audiobookshelf.app.managers.SecureStorage
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import com.getcapacitor.JSArray
|
||||
@@ -48,7 +49,6 @@ class ApiHandler(var ctx:Context) {
|
||||
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||
private var secureStorage = SecureStorage(ctx)
|
||||
|
||||
data class LocalSessionsSyncRequestPayload(val sessions:List<PlaybackSession>, val deviceInfo:DeviceInfo)
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class LocalSessionSyncResult(val id:String, val success:Boolean, val progressSynced:Boolean?, val error:String?)
|
||||
data class LocalSessionsSyncResponsePayload(val results:List<LocalSessionSyncResult>)
|
||||
@@ -652,10 +652,29 @@ class ApiHandler(var ctx:Context) {
|
||||
}
|
||||
}
|
||||
|
||||
fun sendLocalProgressSync(playbackSession:PlaybackSession, cb: (Boolean, String?) -> Unit) {
|
||||
val payload = JSObject(jacksonMapper.writeValueAsString(playbackSession))
|
||||
private fun createPartialPlaybackSession(playbackSession: PlaybackSession): ObjectNode {
|
||||
val json = jacksonMapper.createObjectNode()
|
||||
json.put("id", playbackSession.id)
|
||||
json.put("userId", playbackSession.userId)
|
||||
json.put("libraryItemId", playbackSession.libraryItemId)
|
||||
json.put("episodeId", playbackSession.episodeId)
|
||||
json.put("mediaType", playbackSession.mediaType)
|
||||
json.put("displayTitle", playbackSession.displayTitle)
|
||||
json.put("displayAuthor", playbackSession.displayAuthor)
|
||||
json.put("duration", playbackSession.duration)
|
||||
json.put("playMethod", playbackSession.playMethod)
|
||||
json.put("startedAt", playbackSession.startedAt)
|
||||
json.put("updatedAt", playbackSession.updatedAt)
|
||||
json.put("timeListening", playbackSession.timeListening)
|
||||
json.put("currentTime", playbackSession.currentTime)
|
||||
json.put("mediaPlayer", playbackSession.mediaPlayer)
|
||||
return json
|
||||
}
|
||||
|
||||
postRequest("/api/session/local", payload, null) {
|
||||
fun sendLocalProgressSync(playbackSession:PlaybackSession, cb: (Boolean, String?) -> Unit) {
|
||||
val partialSession = createPartialPlaybackSession(playbackSession)
|
||||
partialSession.set<ObjectNode>("deviceInfo", jacksonMapper.valueToTree(playbackSession.deviceInfo))
|
||||
postRequest("/api/session/local", JSObject(partialSession.toString()), null) {
|
||||
if (!it.getString("error").isNullOrEmpty()) {
|
||||
cb(false, it.getString("error"))
|
||||
} else {
|
||||
@@ -748,10 +767,12 @@ class ApiHandler(var ctx:Context) {
|
||||
val deviceId = Settings.Secure.getString(ctx.contentResolver, Settings.Secure.ANDROID_ID)
|
||||
val deviceInfo = DeviceInfo(deviceId, Build.MANUFACTURER, Build.MODEL, Build.VERSION.SDK_INT, BuildConfig.VERSION_NAME)
|
||||
|
||||
val payload = JSObject(jacksonMapper.writeValueAsString(LocalSessionsSyncRequestPayload(playbackSessions, deviceInfo)))
|
||||
val json = jacksonMapper.createObjectNode();
|
||||
json.putArray("sessions").addAll(playbackSessions.map(::createPartialPlaybackSession))
|
||||
json.set<ObjectNode>("deviceInfo", jacksonMapper.valueToTree(deviceInfo))
|
||||
AbsLogger.info("ApiHandler", "sendSyncLocalSessions: Sending ${playbackSessions.size} saved local playback sessions to server (${DeviceManager.serverConnectionConfigName})")
|
||||
|
||||
postRequest("/api/session/local-all", payload, null) {
|
||||
postRequest("/api/session/local-all", JSObject(json.toString()), null) {
|
||||
if (!it.getString("error").isNullOrEmpty()) {
|
||||
AbsLogger.error("ApiHandler", "sendSyncLocalSessions: Failed to sync local sessions. (${it.getString("error")})")
|
||||
cb(false, it.getString("error"))
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package com.audiobookshelf.app.services
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.audiobookshelf.app.R
|
||||
import com.audiobookshelf.app.models.DownloadItemPart
|
||||
|
||||
/** Android-owned foreground lifecycle for transfers that must outlive the WebView and Activity. */
|
||||
class DownloadService : Service() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
createChannel()
|
||||
startForegroundWithType(DownloadServiceHost.notificationStrings(this).preparing)
|
||||
DownloadServiceHost.attachService(this)
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
when (intent?.action) {
|
||||
ACTION_CANCEL -> DownloadServiceHost.cancelAll(this)
|
||||
else -> {
|
||||
startForegroundWithType(DownloadServiceHost.notificationStrings(this).preparing)
|
||||
DownloadServiceHost.ensure(this)
|
||||
}
|
||||
}
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
DownloadServiceHost.detachService(this)
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
fun onPartUpdate(part: DownloadItemPart) {
|
||||
val strings = DownloadServiceHost.notificationStrings(this)
|
||||
val text =
|
||||
if (part.waitingForSpace) strings.waitingForStorage
|
||||
else strings.downloadingFile.replace("{0}", part.filename)
|
||||
val progress = part.progress.coerceIn(0L, 100L).toInt()
|
||||
val notification = notification(text, progress, part.fileSize > 0L)
|
||||
(getSystemService(NOTIFICATION_SERVICE) as NotificationManager).notify(NOTIFICATION_ID, notification)
|
||||
}
|
||||
|
||||
fun onQueueChanged(hasWork: Boolean) {
|
||||
if (!hasWork) {
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
|
||||
private fun startForegroundWithType(text: String) {
|
||||
val notification = notification(text)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
||||
} else {
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
}
|
||||
}
|
||||
|
||||
private fun notification(text: String, progress: Int = 0, determinate: Boolean = false): Notification {
|
||||
val cancelIntent = PendingIntent.getService(
|
||||
this, 1, Intent(this, DownloadService::class.java).setAction(ACTION_CANCEL), pendingIntentFlags())
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.icon)
|
||||
.setContentTitle(DownloadServiceHost.notificationStrings(this).downloads)
|
||||
.setContentText(text)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setOngoing(true)
|
||||
.setProgress(100, progress, !determinate)
|
||||
.addAction(0, DownloadServiceHost.notificationStrings(this).cancel, cancelIntent)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun createChannel() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
DownloadServiceHost.notificationStrings(this).downloads,
|
||||
NotificationManager.IMPORTANCE_LOW))
|
||||
}
|
||||
|
||||
private fun pendingIntentFlags(): Int = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
|
||||
companion object {
|
||||
private const val CHANNEL_ID = "downloads"
|
||||
private const val NOTIFICATION_ID = 11
|
||||
private const val ACTION_CANCEL = "com.audiobookshelf.app.download.CANCEL"
|
||||
fun intent(context: Context) = Intent(context, DownloadService::class.java)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package com.audiobookshelf.app.services
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.audiobookshelf.app.device.FolderScanner
|
||||
import com.audiobookshelf.app.managers.DbManager
|
||||
import com.audiobookshelf.app.managers.DownloadItemManager
|
||||
import com.audiobookshelf.app.models.DownloadItem
|
||||
import com.getcapacitor.JSObject
|
||||
import java.util.Collections
|
||||
|
||||
/** Shared process owner used by the foreground service and the Capacitor bridge. */
|
||||
object DownloadServiceHost {
|
||||
data class NotificationStrings(
|
||||
val preparing: String,
|
||||
val downloadingFile: String,
|
||||
val waitingForStorage: String,
|
||||
val downloads: String,
|
||||
val cancel: String
|
||||
)
|
||||
|
||||
private var manager: DownloadItemManager? = null
|
||||
private var bridgeEmitter: DownloadItemManager.DownloadEventEmitter = NoopEmitter
|
||||
private var service: DownloadService? = null
|
||||
@Volatile private var bridgeReady = false
|
||||
private val deferredCompletions = Collections.synchronizedList(mutableListOf<JSObject>())
|
||||
|
||||
@Synchronized
|
||||
fun ensure(context: Context): DownloadItemManager {
|
||||
if (manager == null) {
|
||||
val appContext = context.applicationContext
|
||||
DbManager.initialize(appContext)
|
||||
manager = DownloadItemManager(FolderScanner(appContext), appContext, ForwardingEmitter)
|
||||
manager!!.restoreQueue()
|
||||
}
|
||||
return manager!!
|
||||
}
|
||||
|
||||
/** Attaches the frontend after restored queue items have been emitted. */
|
||||
@Synchronized
|
||||
fun attachBridge(context: Context, emitter: DownloadItemManager.DownloadEventEmitter) {
|
||||
bridgeReady = false
|
||||
bridgeEmitter = emitter
|
||||
val queue = ensure(context)
|
||||
queue.setEventEmitter(ForwardingEmitter)
|
||||
bridgeReady = true
|
||||
val completions = synchronized(deferredCompletions) {
|
||||
deferredCompletions.toList().also { deferredCompletions.clear() }
|
||||
}
|
||||
completions.forEach(bridgeEmitter::onDownloadItemComplete)
|
||||
if (queue.hasWork()) startService(context)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun detachBridge() {
|
||||
bridgeReady = false
|
||||
bridgeEmitter = NoopEmitter
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun enqueue(context: Context, item: DownloadItem) {
|
||||
ensure(context).addDownloadItem(item)
|
||||
startService(context)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun cancelAll(context: Context) { ensure(context).cancelAll() }
|
||||
|
||||
fun setNotificationStrings(
|
||||
context: Context,
|
||||
preparing: String,
|
||||
downloadingFile: String,
|
||||
waitingForStorage: String,
|
||||
downloads: String,
|
||||
cancel: String
|
||||
) {
|
||||
context.getSharedPreferences(NOTIFICATION_PREFERENCES, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString(KEY_PREPARING, preparing)
|
||||
.putString(KEY_DOWNLOADING_FILE, downloadingFile)
|
||||
.putString(KEY_WAITING_FOR_STORAGE, waitingForStorage)
|
||||
.putString(KEY_DOWNLOADS, downloads)
|
||||
.putString(KEY_CANCEL, cancel)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun notificationStrings(context: Context): NotificationStrings {
|
||||
val preferences = context.getSharedPreferences(NOTIFICATION_PREFERENCES, Context.MODE_PRIVATE)
|
||||
return NotificationStrings(
|
||||
preferences.getString(KEY_PREPARING, DEFAULT_PREPARING) ?: DEFAULT_PREPARING,
|
||||
preferences.getString(KEY_DOWNLOADING_FILE, DEFAULT_DOWNLOADING_FILE)
|
||||
?: DEFAULT_DOWNLOADING_FILE,
|
||||
preferences.getString(KEY_WAITING_FOR_STORAGE, DEFAULT_WAITING_FOR_STORAGE)
|
||||
?: DEFAULT_WAITING_FOR_STORAGE,
|
||||
preferences.getString(KEY_DOWNLOADS, DEFAULT_DOWNLOADS) ?: DEFAULT_DOWNLOADS,
|
||||
preferences.getString(KEY_CANCEL, DEFAULT_CANCEL) ?: DEFAULT_CANCEL)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun attachService(downloadService: DownloadService) {
|
||||
service = downloadService
|
||||
service?.onQueueChanged(ensure(downloadService).hasWork())
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun detachService(downloadService: DownloadService) {
|
||||
if (service === downloadService) service = null
|
||||
}
|
||||
|
||||
private fun startService(context: Context) {
|
||||
ContextCompat.startForegroundService(context, DownloadService.intent(context))
|
||||
}
|
||||
|
||||
private object ForwardingEmitter : DownloadItemManager.DownloadEventEmitter {
|
||||
override fun onDownloadItem(downloadItem: DownloadItem) { bridgeEmitter.onDownloadItem(downloadItem) }
|
||||
override fun onDownloadItemPartUpdate(downloadItemPart: com.audiobookshelf.app.models.DownloadItemPart) {
|
||||
if (bridgeReady) bridgeEmitter.onDownloadItemPartUpdate(downloadItemPart)
|
||||
service?.onPartUpdate(downloadItemPart)
|
||||
}
|
||||
override fun onDownloadItemComplete(jsobj: JSObject) {
|
||||
if (bridgeReady) bridgeEmitter.onDownloadItemComplete(jsobj) else deferredCompletions.add(jsobj)
|
||||
}
|
||||
override fun onQueueChanged(hasWork: Boolean) {
|
||||
bridgeEmitter.onQueueChanged(hasWork)
|
||||
service?.onQueueChanged(hasWork)
|
||||
}
|
||||
}
|
||||
|
||||
private object NoopEmitter : DownloadItemManager.DownloadEventEmitter {
|
||||
override fun onDownloadItem(downloadItem: DownloadItem) = Unit
|
||||
override fun onDownloadItemPartUpdate(downloadItemPart: com.audiobookshelf.app.models.DownloadItemPart) = Unit
|
||||
override fun onDownloadItemComplete(jsobj: JSObject) = Unit
|
||||
override fun onQueueChanged(hasWork: Boolean) = Unit
|
||||
}
|
||||
|
||||
private const val NOTIFICATION_PREFERENCES = "download_notifications"
|
||||
private const val KEY_PREPARING = "preparing"
|
||||
private const val KEY_DOWNLOADING_FILE = "downloading_file"
|
||||
private const val KEY_WAITING_FOR_STORAGE = "waiting_for_storage"
|
||||
private const val KEY_DOWNLOADS = "downloads"
|
||||
private const val KEY_CANCEL = "cancel"
|
||||
private const val DEFAULT_PREPARING = "Preparing downloads"
|
||||
private const val DEFAULT_DOWNLOADING_FILE = "Downloading {0}"
|
||||
private const val DEFAULT_WAITING_FOR_STORAGE = "Waiting for available storage"
|
||||
private const val DEFAULT_DOWNLOADS = "Downloads"
|
||||
private const val DEFAULT_CANCEL = "Cancel"
|
||||
}
|
||||
+138
-53
@@ -1,20 +1,31 @@
|
||||
<template>
|
||||
<!-- Main container - only shows when playback session exists, applies fullscreen/platform classes -->
|
||||
<div v-if="playbackSession" id="streamContainer" class="fixed top-0 left-0 layout-wrapper right-0 z-50 pointer-events-none" :class="{ fullscreen: showFullscreen, 'ios-player': $platform === 'ios', 'web-player': $platform === 'web' }">
|
||||
<!-- Fullscreen overlays: colored background and menu overlays -->
|
||||
<div v-if="showFullscreen" class="w-full h-full z-10 absolute top-0 left-0 pointer-events-auto" :style="{ backgroundColor: coverRgb }">
|
||||
<!-- Background gradient for player -->
|
||||
<div class="w-full h-full absolute top-0 left-0 pointer-events-none" style="background: var(--gradient-audio-player)" />
|
||||
|
||||
<!-- Collapse button - minimizes player -->
|
||||
<div class="top-4 left-4 absolute cursor-pointer">
|
||||
<span class="material-symbols text-5xl" :class="{ 'text-black text-opacity-75': coverBgIsLight && theme !== 'black' }" @click="collapseFullscreen">keyboard_arrow_down</span>
|
||||
</div>
|
||||
|
||||
<!-- Cast button - Chromecast toggle -->
|
||||
<div v-show="showCastBtn" class="top-6 right-16 absolute cursor-pointer">
|
||||
<span class="material-symbols text-3xl" :class="coverBgIsLight && theme !== 'black' ? 'text-black' : ''" @click="castClick">{{ isCasting ? 'cast_connected' : 'cast' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Player options menu button -->
|
||||
<div class="top-6 right-4 absolute cursor-pointer">
|
||||
<span class="material-symbols text-3xl" :class="{ 'text-black text-opacity-75': coverBgIsLight && theme !== 'black' }" @click="showMoreMenuDialog = true">more_vert</span>
|
||||
</div>
|
||||
<p class="top-4 absolute left-0 right-0 mx-auto text-center uppercase tracking-widest text-opacity-75" :class="{ 'text-black text-opacity-75': coverBgIsLight && theme !== 'black' }" style="font-size: 10px">{{ isDirectPlayMethod ? $strings.LabelPlaybackDirect : isLocalPlayMethod ? $strings.LabelPlaybackLocal : $strings.LabelPlaybackTranscode }}</p>
|
||||
|
||||
<!-- Playback method indicator (Direct/Local/Transcode) -->
|
||||
<p class="top-4 absolute left-0 right-0 mx-auto text-center uppercase tracking-widest text-opacity-75 z-50" :class="{ 'text-black text-opacity-75': coverBgIsLight && theme !== 'black' }" style="font-size: 10px">{{ isDirectPlayMethod ? $strings.LabelPlaybackDirect : isLocalPlayMethod ? $strings.LabelPlaybackLocal : $strings.LabelPlaybackTranscode }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Overall book progress bar -->
|
||||
<div v-if="playerSettings.useChapterTrack && playerSettings.useTotalTrack && showFullscreen" class="absolute total-track w-full z-30 px-6">
|
||||
<div class="flex">
|
||||
<p class="font-mono text-fg" style="font-size: 0.8rem">{{ currentTimePretty }}</p>
|
||||
@@ -22,7 +33,7 @@
|
||||
<p class="font-mono text-fg" style="font-size: 0.8rem">{{ totalTimeRemainingPretty }}</p>
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<div class="h-1 w-full bg-track/50 relative rounded-full">
|
||||
<div class="h-1 w-full bg-track/50 relative rounded-full overflow-hidden">
|
||||
<div ref="totalReadyTrack" class="h-full bg-track-buffered absolute top-0 left-0 pointer-events-none rounded-full" />
|
||||
<div ref="totalBufferedTrack" class="h-full bg-track absolute top-0 left-0 pointer-events-none rounded-full" />
|
||||
<div ref="totalPlayedTrack" class="h-full bg-track-cursor absolute top-0 left-0 pointer-events-none rounded-full" />
|
||||
@@ -30,16 +41,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Book cover wrapper -->
|
||||
<div class="cover-wrapper absolute z-30 pointer-events-auto" @click="clickContainer">
|
||||
<div class="w-full h-full flex justify-center">
|
||||
<covers-book-cover v-if="libraryItem || localLibraryItemCoverSrc" ref="cover" :library-item="libraryItem" :download-cover="localLibraryItemCoverSrc" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" raw @imageLoaded="coverImageLoaded" />
|
||||
</div>
|
||||
|
||||
<!-- Sync failed indicator - shows error icon if progress sync failed -->
|
||||
<div v-if="syncStatus === $constants.SyncStatus.FAILED" class="absolute top-0 left-0 w-full h-full flex items-center justify-center z-30" @click.stop="showSyncsFailedDialog">
|
||||
<span class="material-symbols text-error text-3xl">error</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Title and author text -->
|
||||
<div class="title-author-texts absolute z-30 left-0 right-0 overflow-hidden" @click="clickTitleAndAuthor">
|
||||
<div ref="titlewrapper" class="overflow-hidden relative">
|
||||
<p class="title-text whitespace-nowrap"></p>
|
||||
@@ -48,6 +62,7 @@
|
||||
</div>
|
||||
|
||||
<div id="playerContent" class="playerContainer w-full z-20 absolute bottom-0 left-0 right-0 p-2 pointer-events-auto transition-all" :style="{ backgroundColor: showFullscreen ? '' : coverRgb }" @click="clickContainer">
|
||||
<!-- Top controls bar - fullscreen only: bookmarks, speed, sleep timer, chapters -->
|
||||
<div v-if="showFullscreen" class="absolute bottom-4 left-0 right-0 w-full pb-4 pt-2 mx-auto px-6" style="max-width: 414px">
|
||||
<div class="flex items-center justify-between pointer-events-auto">
|
||||
<span v-if="!isPodcast && serverLibraryItemId && socketConnected" class="material-symbols text-3xl text-fg-muted cursor-pointer" :class="{ fill: bookmarks.length }" @click="$emit('showBookmarks')">bookmark</span>
|
||||
@@ -67,34 +82,36 @@
|
||||
</div>
|
||||
<div v-else class="w-full h-full absolute top-0 left-0 pointer-events-none" style="background: var(--gradient-minimized-audio-player)" />
|
||||
|
||||
<!-- Playback controls - jump buttons, play/pause, chapter navigation -->
|
||||
<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>
|
||||
<div v-show="!playerSettings.lockUi" class="jump-icon text-fg cursor-pointer flex flex-col items-center" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpBackwards">
|
||||
<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>
|
||||
<div v-show="!playerSettings.lockUi" class="jump-icon text-fg cursor-pointer flex flex-col items-center" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpForward">
|
||||
<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 && !isLoading ? 'text-opacity-75' : 'text-opacity-10'" @click.stop="jumpNextChapter">last_page</span>
|
||||
<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>
|
||||
|
||||
<!-- Chapter progress bar -->
|
||||
<div id="playerTrack" class="absolute left-0 w-full px-6">
|
||||
<div class="flex pointer-events-none">
|
||||
<p class="font-mono text-fg" style="font-size: 0.8rem" ref="currentTimestamp">0:00</p>
|
||||
<div class="flex-grow" />
|
||||
<p class="font-mono text-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 overflow-hidden" :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" />
|
||||
@@ -105,7 +122,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chapters modal - lists chapters for navigation -->
|
||||
<modals-chapters-modal v-model="showChapterModal" :current-chapter="currentChapter" :chapters="chapters" :playback-rate="currentPlaybackRate" @select="selectChapter" />
|
||||
<!-- More menu dialog - player settings and options -->
|
||||
<modals-dialog v-model="showMoreMenuDialog" :items="menuItems" width="80vw" @action="clickMenuAction" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -114,7 +133,7 @@
|
||||
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'
|
||||
|
||||
@@ -157,6 +176,7 @@ export default {
|
||||
lockUi: false
|
||||
},
|
||||
isLoading: false,
|
||||
isCheckingServerProgress: false,
|
||||
isDraggingCursor: false,
|
||||
draggingTouchStartX: 0,
|
||||
draggingTouchStartTime: 0,
|
||||
@@ -270,6 +290,9 @@ export default {
|
||||
return 190 * heightScale
|
||||
}
|
||||
},
|
||||
showLoadingState() {
|
||||
return this.isLoading || this.isCheckingServerProgress
|
||||
},
|
||||
showCastBtn() {
|
||||
return this.$store.state.isCastAvailable
|
||||
},
|
||||
@@ -408,17 +431,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
|
||||
@@ -448,9 +468,8 @@ export default {
|
||||
this.showFullscreen = true
|
||||
if (this.titleMarquee) this.titleMarquee.reset()
|
||||
|
||||
// Update track for total time bar if useChapterTrack is set
|
||||
this.$nextTick(() => {
|
||||
this.updateTrack()
|
||||
this.measureAndUpdateTrackWidth()
|
||||
})
|
||||
},
|
||||
collapseFullscreen() {
|
||||
@@ -461,13 +480,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()
|
||||
}
|
||||
@@ -497,12 +516,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() {
|
||||
@@ -530,13 +549,17 @@ export default {
|
||||
this.updateReadyTrack()
|
||||
},
|
||||
updateReadyTrack() {
|
||||
if (!this.$refs.readyTrack) return
|
||||
|
||||
if (this.playerSettings.useChapterTrack) {
|
||||
if (this.$refs.totalReadyTrack) {
|
||||
this.$refs.totalReadyTrack.style.width = this.readyTrackWidth + 'px'
|
||||
if (this.$refs.totalReadyTrack && this.trackWidth) {
|
||||
const readyPercent = (this.readyTrackWidth / this.trackWidth) * 100
|
||||
this.$refs.totalReadyTrack.style.width = readyPercent + '%'
|
||||
}
|
||||
this.$refs.readyTrack.style.width = this.trackWidth + 'px'
|
||||
} else {
|
||||
this.$refs.readyTrack.style.width = this.readyTrackWidth + 'px'
|
||||
this.$refs.readyTrack.style.width = '100%'
|
||||
} else if (this.trackWidth) {
|
||||
const readyPercent = (this.readyTrackWidth / this.trackWidth) * 100
|
||||
this.$refs.readyTrack.style.width = readyPercent + '%'
|
||||
}
|
||||
},
|
||||
updateTimestamp() {
|
||||
@@ -588,25 +611,27 @@ export default {
|
||||
bufferedPercent = Math.max(0, Math.min(1, (this.bufferedTime - this.currentChapter.start) / this.currentChapterDuration))
|
||||
}
|
||||
|
||||
const ptWidth = Math.round(percentDone * this.trackWidth)
|
||||
const playedPercent = percentDone * 100
|
||||
const bufferedPercentWidth = bufferedPercent * 100
|
||||
if (this.$refs.playedTrack) {
|
||||
this.$refs.playedTrack.style.width = ptWidth + 'px'
|
||||
this.$refs.playedTrack.style.width = playedPercent + '%'
|
||||
}
|
||||
if (this.$refs.bufferedTrack) {
|
||||
this.$refs.bufferedTrack.style.width = Math.round(bufferedPercent * this.trackWidth) + 'px'
|
||||
this.$refs.bufferedTrack.style.width = bufferedPercentWidth + '%'
|
||||
}
|
||||
|
||||
if (this.$refs.trackCursor) {
|
||||
this.$refs.trackCursor.style.left = ptWidth - 14 + 'px'
|
||||
if (this.$refs.trackCursor && this.$refs.track) {
|
||||
const trackWidth = this.$refs.track.clientWidth
|
||||
this.$refs.trackCursor.style.left = Math.round(percentDone * trackWidth) - 14 + 'px'
|
||||
}
|
||||
|
||||
if (this.playerSettings.useChapterTrack) {
|
||||
if (this.$refs.totalPlayedTrack) this.$refs.totalPlayedTrack.style.width = Math.round(totalPercentDone * this.trackWidth) + 'px'
|
||||
if (this.$refs.totalBufferedTrack) this.$refs.totalBufferedTrack.style.width = Math.round(totalBufferedPercent * this.trackWidth) + 'px'
|
||||
if (this.$refs.totalPlayedTrack) this.$refs.totalPlayedTrack.style.width = totalPercentDone * 100 + '%'
|
||||
if (this.$refs.totalBufferedTrack) this.$refs.totalBufferedTrack.style.width = totalBufferedPercent * 100 + '%'
|
||||
}
|
||||
},
|
||||
seek(time) {
|
||||
if (this.isLoading) return
|
||||
if (this.showLoadingState) return
|
||||
if (this.seekLoading) {
|
||||
console.error('Already seek loading', this.seekedTime)
|
||||
return
|
||||
@@ -615,12 +640,12 @@ export default {
|
||||
this.seekedTime = time
|
||||
this.seekLoading = true
|
||||
|
||||
AbsAudioPlayer.seek({ value: Math.floor(time) })
|
||||
// Pass fractional seconds so seeks to non-integer chapter starts don't truncate
|
||||
AbsAudioPlayer.seek({ value: time })
|
||||
|
||||
if (this.$refs.playedTrack) {
|
||||
const perc = time / this.totalDuration
|
||||
const ptWidth = Math.round(perc * this.trackWidth)
|
||||
this.$refs.playedTrack.style.width = ptWidth + 'px'
|
||||
this.$refs.playedTrack.style.width = perc * 100 + '%'
|
||||
|
||||
this.$refs.playedTrack.classList.remove('bg-gray-200')
|
||||
this.$refs.playedTrack.classList.add('bg-yellow-300')
|
||||
@@ -638,11 +663,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()
|
||||
@@ -726,7 +754,8 @@ export default {
|
||||
maxTime = minTime + duration
|
||||
}
|
||||
|
||||
const timePerPixel = duration / this.trackWidth
|
||||
const trackWidth = this.$refs.track?.clientWidth || this.trackWidth
|
||||
const timePerPixel = duration / trackWidth
|
||||
const newTime = this.draggingTouchStartTime + timePerPixel * distanceMoved
|
||||
this.draggingCurrentTime = Math.min(maxTime, Math.max(minTime, newTime))
|
||||
|
||||
@@ -864,11 +893,7 @@ export default {
|
||||
this.titleMarquee = new WrappingMarquee(this.$refs.titlewrapper)
|
||||
this.titleMarquee.init(this.title)
|
||||
|
||||
if (this.$refs.track) {
|
||||
this.trackWidth = this.$refs.track.clientWidth
|
||||
} else {
|
||||
console.error('Track not loaded', this.$refs)
|
||||
}
|
||||
this.measureAndUpdateTrackWidth()
|
||||
})
|
||||
},
|
||||
onPlaybackClosed() {
|
||||
@@ -914,15 +939,26 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
// playerContainer width transition is 150ms, remeasure after layout settles
|
||||
await new Promise((resolve) => setTimeout(resolve, 200))
|
||||
this.refreshUI()
|
||||
|
||||
this.isRefreshingUI = false
|
||||
},
|
||||
refreshUI() {
|
||||
this.updateScreenSize()
|
||||
if (this.$refs.track) {
|
||||
measureAndUpdateTrackWidth() {
|
||||
if (!this.$refs.track) {
|
||||
console.error('Track not loaded', this.$refs)
|
||||
return
|
||||
}
|
||||
this.trackWidth = this.$refs.track.clientWidth
|
||||
this.updateTrack()
|
||||
this.updateReadyTrack()
|
||||
}
|
||||
},
|
||||
refreshUI() {
|
||||
this.updateScreenSize()
|
||||
this.$nextTick(() => {
|
||||
this.measureAndUpdateTrackWidth()
|
||||
})
|
||||
},
|
||||
updateScreenSize() {
|
||||
setTimeout(() => {
|
||||
@@ -1013,6 +1049,12 @@ export default {
|
||||
.fullscreen .playerContainer {
|
||||
height: 200px;
|
||||
}
|
||||
@media (orientation: landscape) {
|
||||
.fullscreen .playerContainer {
|
||||
width: 50%;
|
||||
left: 50%;
|
||||
}
|
||||
}
|
||||
#playerContent {
|
||||
box-shadow: 0px -8px 8px #11111155;
|
||||
}
|
||||
@@ -1024,6 +1066,7 @@ export default {
|
||||
transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1);
|
||||
transition-property: margin;
|
||||
bottom: 35px;
|
||||
max-width: 100%;
|
||||
}
|
||||
.fullscreen #playerTrack {
|
||||
bottom: unset;
|
||||
@@ -1040,13 +1083,45 @@ export default {
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
@media (orientation: landscape) {
|
||||
.fullscreen .cover-wrapper {
|
||||
left: 25px !important;
|
||||
top: 50px;
|
||||
width: calc(50% - 25px) !important;
|
||||
height: calc(100% - 85px) !important;
|
||||
}
|
||||
.fullscreen .cover-wrapper > div > div {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
max-width: 100% !important;
|
||||
max-height: 100% !important;
|
||||
}
|
||||
.fullscreen .cover-wrapper > div > div > div {
|
||||
background-color: transparent;
|
||||
}
|
||||
.fullscreen .cover-wrapper img {
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
.total-track {
|
||||
bottom: 215px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
@media (orientation: landscape) {
|
||||
.fullscreen .total-track {
|
||||
left: 50%;
|
||||
width: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (orientation: landscape) {
|
||||
.fullscreen .title-author-texts {
|
||||
left: 50% !important;
|
||||
width: 50% !important;
|
||||
}
|
||||
}
|
||||
.title-author-texts {
|
||||
transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1);
|
||||
transition-property: left, bottom, width, height;
|
||||
@@ -1063,12 +1138,22 @@ export default {
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
@media (orientation: landscape) {
|
||||
.fullscreen .title-author-texts .title-text {
|
||||
font-size: larger !important;
|
||||
}
|
||||
}
|
||||
.title-author-texts .author-text {
|
||||
transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1);
|
||||
transition-property: font-size;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
@media (orientation: landscape) {
|
||||
.fullscreen .title-author-texts .author-text {
|
||||
font-size: inherit !important;
|
||||
}
|
||||
}
|
||||
|
||||
.fullscreen .title-author-texts {
|
||||
bottom: calc(50% - var(--cover-image-height) / 2 + 50px);
|
||||
|
||||
@@ -48,6 +48,9 @@ export default {
|
||||
},
|
||||
isIos() {
|
||||
return this.$platform === 'ios'
|
||||
},
|
||||
currentPlaybackSession() {
|
||||
return this.$store.state.currentPlaybackSession
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -226,7 +229,7 @@ export default {
|
||||
console.log('Already streaming item', startTime)
|
||||
if (startTime !== undefined && startTime !== null) {
|
||||
// seek to start time
|
||||
AbsAudioPlayer.seek({ value: Math.floor(startTime) })
|
||||
AbsAudioPlayer.seek({ value: startTime })
|
||||
} else if (this.$refs.audioPlayer) {
|
||||
this.$refs.audioPlayer.play()
|
||||
}
|
||||
@@ -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 (this.$refs.audioPlayer?.isCheckingServerProgress) {
|
||||
console.log('[AudioPlayerContainer] getServerMediaProgressForCurrentSession: already checking server progress')
|
||||
return null
|
||||
}
|
||||
|
||||
const url = episodeId ? `/api/me/progress/${libraryItemId}/${episodeId}` : `/api/me/progress/${libraryItemId}`
|
||||
|
||||
this.$refs.audioPlayer?.setIsCheckingServerProgress(true)
|
||||
try {
|
||||
const data = await this.$nativeHttp.get(url, { connectTimeout: 7000, readTimeout: 7000 })
|
||||
if (!data || data.libraryItemId !== libraryItemId) return null
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error('[AudioPlayerContainer] Failed to get server media progress', error)
|
||||
return null
|
||||
} finally {
|
||||
this.$refs.audioPlayer?.setIsCheckingServerProgress(false)
|
||||
}
|
||||
},
|
||||
getLocalMediaProgressForCurrentSession() {
|
||||
if (!this.currentPlaybackSession) return null
|
||||
return this.$store.getters['globals/getLocalMediaProgressById'](this.currentPlaybackSession.localLibraryItem?.id, this.currentPlaybackSession.localEpisodeId)
|
||||
},
|
||||
/**
|
||||
* Sync the server media progress with the local media progress
|
||||
*/
|
||||
async syncServerMediaProgressWithLocalMediaProgress(localMediaProgressId, serverMediaProgress) {
|
||||
try {
|
||||
const newLocalMediaProgress = await this.$db.syncServerMediaProgressWithLocalMediaProgress({
|
||||
localMediaProgressId,
|
||||
mediaProgress: serverMediaProgress
|
||||
})
|
||||
if (newLocalMediaProgress?.id) {
|
||||
this.$store.commit('globals/updateLocalMediaProgress', newLocalMediaProgress)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[AudioPlayerContainer] Failed to sync server progress with local media progress', error)
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Check if the server media progress is more recent than the local media progress and sync if so
|
||||
*/
|
||||
async checkSyncServerProgressWithLocalProgress(localMediaProgress) {
|
||||
if (!localMediaProgress) return
|
||||
console.log('[AudioPlayerContainer] checkSyncServerProgressWithLocalProgress: checking server media progress for local media item open in player')
|
||||
const serverMediaProgress = await this.getServerMediaProgressForCurrentSession()
|
||||
if (!serverMediaProgress?.lastUpdate || serverMediaProgress.lastUpdate <= localMediaProgress.lastUpdate) return
|
||||
|
||||
console.log('[AudioPlayerContainer] checkSyncServerProgressWithLocalProgress: server progress is more recent than local progress. Server current time:', serverMediaProgress.currentTime, 'vs local', localMediaProgress.currentTime, `(server lastUpdate=${serverMediaProgress.lastUpdate} > local lastUpdate=${localMediaProgress.lastUpdate})`)
|
||||
if (!this.$refs.audioPlayer?.isPlaying && serverMediaProgress.currentTime !== localMediaProgress.currentTime) {
|
||||
// Use seek() so the native audio player's current session is updated
|
||||
this.$refs.audioPlayer.seek(serverMediaProgress.currentTime)
|
||||
}
|
||||
|
||||
await this.syncServerMediaProgressWithLocalMediaProgress(localMediaProgress.id, serverMediaProgress)
|
||||
},
|
||||
/**
|
||||
* When socket is reconnected after a delay, if a local media item is open in the player (paused)
|
||||
* we fetch the server media progress and sync it if it is more recent than the local progress
|
||||
*
|
||||
* If there is no socket connection we may have missed external progress updates
|
||||
*/
|
||||
async socketReconnected() {
|
||||
if (!this.currentPlaybackSession) return
|
||||
// dont update timestamps if player is playing
|
||||
if (this.$refs.audioPlayer?.isPlaying) return
|
||||
|
||||
if (hasFocus) {
|
||||
if (!this.$refs.audioPlayer?.isPlaying) {
|
||||
const playbackSession = this.$store.state.currentPlaybackSession
|
||||
if (this.$refs.audioPlayer.isLocalPlayMethod) {
|
||||
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))
|
||||
const localMediaProgress = this.getLocalMediaProgressForCurrentSession()
|
||||
if (!localMediaProgress) {
|
||||
console.error('[AudioPlayerContainer] socket reconnected: Local media progress not found')
|
||||
return
|
||||
}
|
||||
const localMediaProgress = this.$store.state.globals.localMediaProgress.find((mp) => {
|
||||
if (localEpisodeId) return mp.localEpisodeId === localEpisodeId
|
||||
return mp.localLibraryItemId === localLibraryItemId
|
||||
})
|
||||
if (localMediaProgress) {
|
||||
|
||||
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 {
|
||||
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) {
|
||||
// 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) {
|
||||
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)
|
||||
})
|
||||
// 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>
|
||||
|
||||
@@ -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>
|
||||
@@ -484,7 +484,7 @@ export default {
|
||||
|
||||
const { value } = await Dialog.confirm({
|
||||
title: this.$strings.HeaderConfirm,
|
||||
message: this.$strings.MessageConfirmDeleteServerConfig,
|
||||
message: this.$strings.MessageConfirmDeleteServerConfig
|
||||
})
|
||||
if (value) {
|
||||
this.processing = true
|
||||
@@ -843,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)
|
||||
|
||||
@@ -79,9 +79,6 @@ export default {
|
||||
episodesCopy: [],
|
||||
showFiltersModal: false,
|
||||
showSortModal: false,
|
||||
sortKey: 'publishedAt',
|
||||
sortDesc: true,
|
||||
filterKey: 'incomplete',
|
||||
fetchingRSSFeed: false,
|
||||
podcastFeedEpisodes: [],
|
||||
showPodcastEpisodeFeed: false,
|
||||
@@ -223,6 +220,27 @@ export default {
|
||||
if (!this.sortKey) return ''
|
||||
const _sel = this.episodeSortItems.find((i) => i.value === this.sortKey)
|
||||
return _sel?.text || ''
|
||||
},
|
||||
filterKey() {
|
||||
return this.$store.getters['user/getUserSetting']('podcastEpisodesFilterBy') || 'incomplete'
|
||||
},
|
||||
sortKey: {
|
||||
get() {
|
||||
return this.$store.getters['user/getUserSetting']('podcastEpisodesOrderBy') || 'publishedAt'
|
||||
},
|
||||
set(val) {
|
||||
this.$store.dispatch('user/updateUserSettings', { podcastEpisodesOrderBy: val })
|
||||
}
|
||||
},
|
||||
sortDesc: {
|
||||
get() {
|
||||
const desc = this.$store.getters['user/getUserSetting']('podcastEpisodesOrderDesc')
|
||||
if (desc == null) return this.mediaMetadata.type === 'episodic'
|
||||
return desc
|
||||
},
|
||||
set(val) {
|
||||
this.$store.dispatch('user/updateUserSettings', { podcastEpisodesOrderDesc: val })
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -277,8 +295,8 @@ export default {
|
||||
this.$store.commit('globals/setShowPlaylistsAddCreateModal', true)
|
||||
},
|
||||
setFilter(filter) {
|
||||
this.filterKey = filter
|
||||
this.showFiltersModal = false
|
||||
this.$store.dispatch('user/updateUserSettings', { podcastEpisodesFilterBy: filter })
|
||||
},
|
||||
showFilters() {
|
||||
this.showFiltersModal = true
|
||||
@@ -291,7 +309,6 @@ export default {
|
||||
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId, episode.id)
|
||||
},
|
||||
init() {
|
||||
this.sortDesc = this.mediaMetadata.type === 'episodic'
|
||||
this.episodesCopy = this.episodes.map((ep) => {
|
||||
return { ...ep }
|
||||
})
|
||||
@@ -316,7 +333,7 @@ export default {
|
||||
},
|
||||
mounted() {
|
||||
if (this.$route.query['episodefilter'] === 'downloaded') {
|
||||
this.filterKey = 'downloaded'
|
||||
this.$store.dispatch('user/updateUserSettings', { podcastEpisodesFilterBy: 'downloaded' })
|
||||
}
|
||||
this.$socket.$on('episode_download_queued', this.episodeDownloadQueued)
|
||||
this.$socket.$on('episode_download_started', this.episodeDownloadStarted)
|
||||
|
||||
@@ -12,7 +12,8 @@ export default {
|
||||
return {
|
||||
downloadItemListener: null,
|
||||
completeListener: null,
|
||||
itemPartUpdateListener: null
|
||||
itemPartUpdateListener: null,
|
||||
queueChangedListener: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -76,17 +77,22 @@ export default {
|
||||
},
|
||||
onDownloadItemPartUpdate(itemPart) {
|
||||
this.$store.commit('globals/updateDownloadItemPart', itemPart)
|
||||
},
|
||||
onQueueChanged(data) {
|
||||
if (!data.hasWork) this.$store.commit('globals/clearItemDownloads')
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
this.downloadItemListener = await AbsDownloader.addListener('onDownloadItem', (data) => this.onDownloadItem(data))
|
||||
this.itemPartUpdateListener = await AbsDownloader.addListener('onDownloadItemPartUpdate', (data) => this.onDownloadItemPartUpdate(data))
|
||||
this.queueChangedListener = await AbsDownloader.addListener('onQueueChanged', (data) => this.onQueueChanged(data))
|
||||
this.completeListener = await AbsDownloader.addListener('onItemDownloadComplete', (data) => this.onItemDownloadComplete(data))
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.downloadItemListener?.remove()
|
||||
this.completeListener?.remove()
|
||||
this.itemPartUpdateListener?.remove()
|
||||
this.queueChangedListener?.remove()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -740,12 +740,12 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 41;
|
||||
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.11.0;
|
||||
MARKETING_VERSION = 0.13.0;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app.dev;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -764,12 +764,12 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 41;
|
||||
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.11.0;
|
||||
MARKETING_VERSION = 0.13.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
|
||||
+20
-20
@@ -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
|
||||
|
||||
|
||||
@@ -511,7 +511,8 @@ class ApiClient {
|
||||
|
||||
public static func reportLocalPlaybackProgress(_ session: PlaybackSession) async -> Bool {
|
||||
return await withCheckedContinuation { continuation in
|
||||
postResourceWithTokenRefresh(endpoint: "api/session/local", parameters: session) { success in
|
||||
let payload = PartialPlaybackSessionSyncPayload(from: session, includeDeviceInfo: true)
|
||||
postResourceWithTokenRefresh(endpoint: "api/session/local", parameters: payload) { success in
|
||||
continuation.resume(returning: success)
|
||||
}
|
||||
}
|
||||
@@ -519,7 +520,10 @@ class ApiClient {
|
||||
|
||||
public static func reportAllLocalPlaybackSessions(_ sessions: [PlaybackSession]) async -> Bool {
|
||||
return await withCheckedContinuation { continuation in
|
||||
let payload = LocalPlaybackSessionSyncAllPayload(sessions: sessions, deviceInfo: sessions.first?.deviceInfo)
|
||||
let payload = LocalPlaybackSessionSyncAllPayload(
|
||||
sessions: sessions.map { PartialPlaybackSessionSyncPayload(from: $0, includeDeviceInfo: false) },
|
||||
deviceInfo: sessions.first?.deviceInfo
|
||||
)
|
||||
postResourceWithTokenRefresh(endpoint: "api/session/local-all", parameters: payload) { success in
|
||||
continuation.resume(returning: success)
|
||||
}
|
||||
@@ -668,8 +672,44 @@ struct LocalMediaProgressSyncResultsPayload: Codable {
|
||||
var numLocalProgressUpdates: Int?
|
||||
}
|
||||
|
||||
struct LocalPlaybackSessionSyncAllPayload: Codable {
|
||||
var sessions: [PlaybackSession]
|
||||
struct PartialPlaybackSessionSyncPayload: Encodable {
|
||||
let id: String
|
||||
let userId: String?
|
||||
let libraryItemId: String?
|
||||
let episodeId: String?
|
||||
let mediaType: String
|
||||
let displayTitle: String?
|
||||
let displayAuthor: String?
|
||||
let duration: Double
|
||||
let playMethod: Int
|
||||
let startedAt: Double?
|
||||
let updatedAt: Double?
|
||||
let timeListening: Double
|
||||
let currentTime: Double
|
||||
let mediaPlayer: String
|
||||
let deviceInfo: [String: String?]?
|
||||
|
||||
init(from session: PlaybackSession, includeDeviceInfo: Bool) {
|
||||
id = session.id
|
||||
userId = session.userId
|
||||
libraryItemId = session.libraryItemId
|
||||
episodeId = session.episodeId
|
||||
mediaType = session.mediaType
|
||||
displayTitle = session.displayTitle
|
||||
displayAuthor = session.displayAuthor
|
||||
duration = session.duration
|
||||
playMethod = session.playMethod
|
||||
startedAt = session.startedAt
|
||||
updatedAt = session.updatedAt
|
||||
timeListening = session.timeListening
|
||||
currentTime = session.currentTime
|
||||
mediaPlayer = session.mediaPlayer
|
||||
deviceInfo = includeDeviceInfo ? session.deviceInfo : nil
|
||||
}
|
||||
}
|
||||
|
||||
struct LocalPlaybackSessionSyncAllPayload: Encodable {
|
||||
var sessions: [PartialPlaybackSessionSyncPayload]
|
||||
var deviceInfo: [String: String?]?
|
||||
}
|
||||
|
||||
|
||||
+41
-13
@@ -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
|
||||
},
|
||||
@@ -218,9 +244,9 @@ export default {
|
||||
AbsLogger.info({ tag: 'default', message: 'Calling syncLocalSessions' })
|
||||
const response = await this.$db.syncLocalSessionsWithServer(isFirstSync)
|
||||
if (response?.error) {
|
||||
console.error('[default] Failed to sync local sessions', response.error)
|
||||
await AbsLogger.error({ tag: 'default', message: `syncLocalSessions: Failed to sync local sessions: ${response.error}` })
|
||||
} else {
|
||||
console.log('[default] Successfully synced local sessions')
|
||||
await AbsLogger.info({ tag: 'default', message: 'syncLocalSessions: Successfully synced local sessions' })
|
||||
// Reload local media progresses
|
||||
await this.$store.dispatch('globals/loadLocalMediaProgress')
|
||||
}
|
||||
@@ -233,11 +259,13 @@ export default {
|
||||
async userMediaProgressUpdated(payload) {
|
||||
const prog = payload.data // MediaProgress
|
||||
await AbsLogger.info({ tag: 'default', message: `userMediaProgressUpdate: Received updated media progress for current user from socket event. Media item id ${payload.id}` })
|
||||
const mediaProgressId = payload.id
|
||||
const itemLabel = `${prog.libraryItemId}${prog.episodeId ? ` episodeId: ${prog.episodeId}` : ''}`
|
||||
|
||||
// Check if this media item is currently open in the player, paused, and this progress update is coming from a different session
|
||||
const isMediaOpenInPlayer = this.$store.getters['getIsMediaStreaming'](prog.libraryItemId, prog.episodeId)
|
||||
if (isMediaOpenInPlayer && this.$store.getters['getCurrentPlaybackSessionId'] !== payload.sessionId && !this.$store.state.playerIsPlaying) {
|
||||
await AbsLogger.info({ tag: 'default', message: `userMediaProgressUpdate: Item is currently open in player, paused and this progress update is coming from a different session. Updating playback time to ${payload.data.currentTime}` })
|
||||
await AbsLogger.info({ tag: 'default', message: `userMediaProgressUpdate: Updating paused player playback time to ${payload.data.currentTime} (${itemLabel}, mediaProgressId: ${mediaProgressId})` })
|
||||
this.$eventBus.$emit('playback-time-update', payload.data.currentTime)
|
||||
}
|
||||
|
||||
@@ -248,17 +276,17 @@ export default {
|
||||
// Progress update is more recent then local progress
|
||||
if (localProg && localProg.lastUpdate < prog.lastUpdate) {
|
||||
if (localProg.currentTime == prog.currentTime && localProg.isFinished == prog.isFinished) {
|
||||
await AbsLogger.info({ tag: 'default', message: `userMediaProgressUpdate: server lastUpdate is more recent but progress is up-to-date (libraryItemId: ${prog.libraryItemId}${prog.episodeId ? ` episodeId: ${prog.episodeId}` : ''})` })
|
||||
await AbsLogger.info({ tag: 'default', message: `userMediaProgressUpdate: server lastUpdate is more recent but progress is up-to-date (${itemLabel}, mediaProgressId: ${mediaProgressId}, server lastUpdate=${prog.lastUpdate} > local lastUpdate=${localProg.lastUpdate})` })
|
||||
return
|
||||
}
|
||||
|
||||
// Server progress is more up-to-date
|
||||
await AbsLogger.info({ tag: 'default', message: `userMediaProgressUpdate: syncing progress from server with local item for "${prog.libraryItemId}" ${prog.episodeId ? `episode ${prog.episodeId}` : ''} | server lastUpdate=${prog.lastUpdate} > local lastUpdate=${localProg.lastUpdate}` })
|
||||
const payload = {
|
||||
await AbsLogger.info({ tag: 'default', message: `userMediaProgressUpdate: Syncing server progress to local (${itemLabel}, mediaProgressId: ${mediaProgressId}, server lastUpdate=${prog.lastUpdate} > local lastUpdate=${localProg.lastUpdate})` })
|
||||
const syncPayload = {
|
||||
localMediaProgressId: localProg.id,
|
||||
mediaProgress: prog
|
||||
}
|
||||
newLocalMediaProgress = await this.$db.syncServerMediaProgressWithLocalMediaProgress(payload)
|
||||
newLocalMediaProgress = await this.$db.syncServerMediaProgressWithLocalMediaProgress(syncPayload)
|
||||
} else if (!localProg) {
|
||||
// Check if local library item exists
|
||||
// local media progress may not exist yet if it hasn't been played
|
||||
@@ -270,20 +298,20 @@ export default {
|
||||
const localEpisode = lliEpisodes.find((ep) => ep.serverEpisodeId === prog.episodeId)
|
||||
if (localEpisode) {
|
||||
// Add new local media progress
|
||||
const payload = {
|
||||
const syncPayload = {
|
||||
localLibraryItemId: localLibraryItem.id,
|
||||
localEpisodeId: localEpisode.id,
|
||||
mediaProgress: prog
|
||||
}
|
||||
newLocalMediaProgress = await this.$db.syncServerMediaProgressWithLocalMediaProgress(payload)
|
||||
newLocalMediaProgress = await this.$db.syncServerMediaProgressWithLocalMediaProgress(syncPayload)
|
||||
}
|
||||
} else {
|
||||
// Add new local media progress
|
||||
const payload = {
|
||||
const syncPayload = {
|
||||
localLibraryItemId: localLibraryItem.id,
|
||||
mediaProgress: prog
|
||||
}
|
||||
newLocalMediaProgress = await this.$db.syncServerMediaProgressWithLocalMediaProgress(payload)
|
||||
newLocalMediaProgress = await this.$db.syncServerMediaProgressWithLocalMediaProgress(syncPayload)
|
||||
}
|
||||
} else {
|
||||
console.log(`[default] userMediaProgressUpdate no local media progress or lli found for this server item ${prog.id}`)
|
||||
@@ -291,7 +319,7 @@ export default {
|
||||
}
|
||||
|
||||
if (newLocalMediaProgress?.id) {
|
||||
await AbsLogger.info({ tag: 'default', message: `userMediaProgressUpdate: local media progress updated for ${newLocalMediaProgress.id}` })
|
||||
await AbsLogger.info({ tag: 'default', message: `userMediaProgressUpdate: Local media progress updated (${itemLabel}, localId: ${newLocalMediaProgress.id})` })
|
||||
this.$store.commit('globals/updateLocalMediaProgress', newLocalMediaProgress)
|
||||
}
|
||||
},
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "audiobookshelf-app",
|
||||
"version": "0.12.0-beta",
|
||||
"version": "0.14.0-beta",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "audiobookshelf-app",
|
||||
"version": "0.12.0-beta",
|
||||
"version": "0.14.0-beta",
|
||||
"dependencies": {
|
||||
"@capacitor-community/keep-awake": "^7.0.0",
|
||||
"@capacitor-community/volume-buttons": "^7.0.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf-app",
|
||||
"version": "0.12.0-beta",
|
||||
"version": "0.14.0-beta",
|
||||
"author": "advplyr",
|
||||
"scripts": {
|
||||
"dev": "nuxt --hostname 0.0.0.0 --port 1337",
|
||||
|
||||
@@ -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
|
||||
|
||||
+15
-3
@@ -19,7 +19,7 @@
|
||||
</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}`">
|
||||
@@ -28,6 +28,15 @@
|
||||
</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>
|
||||
|
||||
<p v-if="seriesResults.length" class="font-semibold text-sm mb-1 mt-2">{{ $strings.LabelSeries }}</p>
|
||||
<template v-for="seriesResult in seriesResults">
|
||||
<div :key="seriesResult.series.id" class="w-full h-16 py-1">
|
||||
@@ -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 = []
|
||||
@@ -111,7 +122,7 @@ export default {
|
||||
return
|
||||
}
|
||||
this.isFetching = true
|
||||
const results = await this.$nativeHttp.get(`/api/libraries/${this.currentLibraryId}/search?q=${value}&limit=5`).catch((error) => {
|
||||
const results = await this.$nativeHttp.get(`/api/libraries/${this.currentLibraryId}/search?q=${value}`).catch((error) => {
|
||||
console.error('Search error', error)
|
||||
return null
|
||||
})
|
||||
@@ -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 || []
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -119,7 +119,6 @@ class AbsDatabaseWeb extends WebPlugin {
|
||||
name: 'Audiobooks',
|
||||
contentUrl: 'test',
|
||||
absolutePath: '/audiobooks',
|
||||
simplePath: 'audiobooks',
|
||||
storageType: 'primary',
|
||||
mediaType: 'book'
|
||||
}
|
||||
@@ -196,7 +195,6 @@ class AbsDatabaseWeb extends WebPlugin {
|
||||
filename: 'lf1.mp3',
|
||||
contentUrl: 'test',
|
||||
absolutePath: 'test',
|
||||
simplePath: 'test',
|
||||
mimeType: 'audio/mpeg',
|
||||
size: 39048290
|
||||
}
|
||||
|
||||
+1
-2
@@ -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') {
|
||||
|
||||
+24
-1
@@ -1,11 +1,15 @@
|
||||
import Vue from 'vue'
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { AbsDownloader, AbsFileSystem } from '@/plugins/capacitor'
|
||||
import enUsStrings from '../strings/en-us.json'
|
||||
|
||||
const defaultCode = 'en-us'
|
||||
let $localStore = null
|
||||
|
||||
const languageCodeMap = {
|
||||
be: { label: 'Беларуская', dateFnsLocale: 'be' },
|
||||
bn: { label: 'বাংলা', dateFnsLocale: 'bn' },
|
||||
bg: { label: 'Български', dateFnsLocale: 'bg' },
|
||||
ca: { label: 'Català', dateFnsLocale: 'ca' },
|
||||
cs: { label: 'Čeština', dateFnsLocale: 'cs' },
|
||||
da: { label: 'Dansk', dateFnsLocale: 'da' },
|
||||
@@ -39,6 +43,24 @@ function supplant(str, subs) {
|
||||
})
|
||||
}
|
||||
|
||||
function syncDownloadNotificationStrings() {
|
||||
if (Capacitor.getPlatform() !== 'android') return
|
||||
AbsDownloader.setDownloadNotificationStrings({
|
||||
preparing: Vue.prototype.$strings.MessagePreparingDownloads,
|
||||
downloadingFile: Vue.prototype.$strings.MessageDownloadingFile,
|
||||
waitingForStorage: Vue.prototype.$strings.MessageWaitingForAvailableStorage,
|
||||
downloads: Vue.prototype.$strings.HeaderDownloads,
|
||||
cancel: Vue.prototype.$strings.ButtonCancel
|
||||
}).catch((error) => console.warn('Failed to update download notification strings', error))
|
||||
AbsFileSystem.setFolderPickerStrings({
|
||||
writeAccessRequired: Vue.prototype.$strings.MessageStorageWriteAccessRequired,
|
||||
allow: Vue.prototype.$strings.ButtonAllow,
|
||||
cancel: Vue.prototype.$strings.ButtonCancel,
|
||||
accessDenied: Vue.prototype.$strings.MessageStorageAccessDenied,
|
||||
permissionDenied: Vue.prototype.$strings.MessageStoragePermissionDenied
|
||||
}).catch((error) => console.warn('Failed to update folder picker strings', error))
|
||||
}
|
||||
|
||||
Vue.prototype.$languageCodeOptions = Object.keys(languageCodeMap).map((code) => {
|
||||
return {
|
||||
text: languageCodeMap[code].label,
|
||||
@@ -106,6 +128,7 @@ async function loadi18n(code) {
|
||||
}
|
||||
|
||||
Vue.prototype.$setDateFnsLocale(languageCodeMap[code].dateFnsLocale)
|
||||
syncDownloadNotificationStrings()
|
||||
|
||||
this.$eventBus.$emit('change-lang', code)
|
||||
return true
|
||||
@@ -143,5 +166,5 @@ async function initialize() {
|
||||
|
||||
export default ({ app, store }, inject) => {
|
||||
$localStore = app.$localStore
|
||||
initialize()
|
||||
initialize().finally(syncDownloadNotificationStrings)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -135,6 +135,9 @@ export const mutations = {
|
||||
removeItemDownload(state, id) {
|
||||
state.itemDownloads = state.itemDownloads.filter((i) => i.id != id)
|
||||
},
|
||||
clearItemDownloads(state) {
|
||||
state.itemDownloads = []
|
||||
},
|
||||
setBookshelfListView(state, val) {
|
||||
state.bookshelfListView = val
|
||||
},
|
||||
|
||||
+5
-3
@@ -12,7 +12,10 @@ export const state = () => ({
|
||||
mobileFilterBy: 'all',
|
||||
playbackRate: 1,
|
||||
collapseSeries: false,
|
||||
collapseBookSeries: false
|
||||
collapseBookSeries: false,
|
||||
podcastEpisodesOrderBy: 'publishedAt',
|
||||
podcastEpisodesOrderDesc: null,
|
||||
podcastEpisodesFilterBy: 'incomplete'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -45,7 +48,7 @@ export const getters = {
|
||||
return state.user.bookmarks.filter((bm) => bm.libraryItemId === libraryItemId)
|
||||
},
|
||||
getUserSetting: (state) => (key) => {
|
||||
return state.settings?.[key] || null
|
||||
return state.settings?.[key] ?? null
|
||||
},
|
||||
getUserCanUpdate: (state) => {
|
||||
return !!state.user?.permissions?.update
|
||||
@@ -231,7 +234,6 @@ export const mutations = {
|
||||
state.user = user
|
||||
},
|
||||
setAccessToken(state, accessToken) {
|
||||
console.log('[user] setAccessToken', accessToken)
|
||||
state.accessToken = accessToken
|
||||
},
|
||||
removeMediaProgress(state, id) {
|
||||
|
||||
+16
-1
@@ -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": "إغلاق المشغل",
|
||||
@@ -177,6 +180,9 @@
|
||||
"LabelLayout": "التنسيق",
|
||||
"LabelLayoutAuto": "تلقائي",
|
||||
"LabelLayoutSinglePage": "صفحة واحدة",
|
||||
"LabelLibrarySortByProgress": "المرحلة: الأحدث",
|
||||
"LabelLibrarySortByProgressFinished": "المرحلة: تم الانتهاء",
|
||||
"LabelLibrarySortByProgressStarted": "المرحلة: تم البدء",
|
||||
"LabelLight": "فاتح",
|
||||
"LabelLineSpacing": "تباعد الأسطر",
|
||||
"LabelListenAgain": "الاستماع مجدداً",
|
||||
@@ -236,6 +242,7 @@
|
||||
"LabelScaleElapsedTimeBySpeed": "تعديل الوقت المنصرم حسب السرعة",
|
||||
"LabelSeason": "الموسم",
|
||||
"LabelSelectADevice": "اختيار جهاز",
|
||||
"LabelSelectMediaType": "اختيار نوع الوسائط",
|
||||
"LabelSequenceAscending": "تسلسل تصاعدي",
|
||||
"LabelSequenceDescending": "تسلسل تنازلي",
|
||||
"LabelSeries": "المسلسلات",
|
||||
@@ -288,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": "إلغاء التقدم",
|
||||
@@ -331,6 +343,7 @@
|
||||
"MessageNoUserPlaylists": "ليس لديك أي قوائم تشغيل",
|
||||
"MessageOldServerAuthReLoginRequired": "تم تحسين المصادقة في الخادم v2.26.0 لضمان الأمان. يُطلب من جميع المستخدمين إعادة تسجيل الدخول.",
|
||||
"MessageOldServerAuthWarning": "الخادم يستخدم مصادقة قديمة",
|
||||
"MessageOldServerAuthWarningHelp": "الخادم يعمل على إصدار أقدم من النسخة (2.26.0)، نظام مصادقة أكثر أماناً أضيف في النسخة (2.26.0). يوصى بشدة تحديث الخادم إلى آخر نسخة. إذا قمت بالفعل بتحديث الخادم قم بإعادة تسجيل الدخول لاستخدام المصادقة الجديدة.",
|
||||
"MessageOldServerConnectionWarning": "تكوين اتصال الخادم يستخدم معرّف مستخدم قديم. يرجى حذف اتصال الخادم هذا وإعادة إضافته.",
|
||||
"MessageOldServerConnectionWarningHelp": "لقد قمتَ بإعداد الاتصال بهذا الخادم قبل ترحيل قاعدة البيانات في الإصدار 2.3.0، الصادر في يونيو 2023. سيؤدي تحديث الخادم المستقبلي إلى إلغاء إمكانية تسجيل الدخول باستخدام هذا الاتصال القديم. يُرجى حذف اتصال الخادم الحالي والاتصال مرة أخرى (باستخدام نفس عنوان الخادم وبيانات الاعتماد). إذا كانت لديك أي وسائط مُنزّلة على هذا الجهاز، فيجب تنزيلها مرة أخرى لمزامنتها مع الخادم.",
|
||||
"MessagePodcastSearchField": "أدخل مصطلح البحث أو عنوان URL الخاص بتغذية RSS",
|
||||
@@ -357,5 +370,7 @@
|
||||
"ToastPodcastCreateSuccess": "تم إنشاء البودكاست بنجاح",
|
||||
"ToastRSSFeedCloseFailed": "فشل إغلاق مغذّي RSS",
|
||||
"ToastRSSFeedCloseSuccess": "تم إغلاق مغذّي RSS",
|
||||
"ToastStreamingNotAllowedOnCellular": "لا يُسمح بالبث عبر البيانات الخلوية"
|
||||
"ToastStreamingNotAllowedOnCellular": "لا يُسمح بالبث عبر البيانات الخلوية",
|
||||
"UnitMinutesShort": "{0}د",
|
||||
"UnitSecondsShort": "{0}ث"
|
||||
}
|
||||
|
||||
+116
-99
@@ -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": "Чытаць больш",
|
||||
@@ -50,40 +50,41 @@
|
||||
"ButtonSendEbookToDevice": "Адправіць электронную кнігу на прыладу",
|
||||
"ButtonSeries": "Серыі",
|
||||
"ButtonSetTimer": "Устанавіць таймер",
|
||||
"ButtonStream": "Трансляцыя",
|
||||
"ButtonStream": "Трансліраваць",
|
||||
"ButtonSubmit": "Адправіць",
|
||||
"ButtonSwitchServerUser": "Змяніць Сервер/Карыстальніка",
|
||||
"ButtonSwitchServerUser": "Змяніць сервер/карыстальніка",
|
||||
"ButtonUnmaskServerAddress": "Паказаць адрас сервера",
|
||||
"ButtonUserStats": "Статыстыка карыстальніка",
|
||||
"ButtonYes": "Так",
|
||||
"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": "Усе",
|
||||
@@ -101,72 +103,76 @@
|
||||
"LabelAndroidAutoBrowseLimitForGrouping": "Ліміт алфавітнага выпадальнага спісу",
|
||||
"LabelAndroidAutoBrowseLimitForGroupingHelp": "Не выкарыстоўваць алфавітны выпадальны спіс, калі элементаў для паказу менш за гэтую колькасць",
|
||||
"LabelAndroidAutoBrowseSeriesSequenceOrder": "Парадак кніг у серыі",
|
||||
"LabelAskConfirmation": "Запытваць пацверджанне",
|
||||
"LabelAskConfirmation": "Запытваць пацвярджэнне",
|
||||
"LabelAuthor": "Аўтар",
|
||||
"LabelAuthorFirstLast": "Аўтар (Імя Прозвішча)",
|
||||
"LabelAuthorLastFirst": "Аўтар (Прозвішча, Імя)",
|
||||
"LabelAuthorFirstLast": "Аўтар (імя, прозвішча)",
|
||||
"LabelAuthorLastFirst": "Аўтар (прозвішча, імя)",
|
||||
"LabelAuthors": "Аўтары",
|
||||
"LabelAutoDownloadEpisodes": "Аўтаматычнае спампаванне эпізодаў",
|
||||
"LabelAutoDownloadEpisodes": "Аўтаматычна спампоўваць выпускі",
|
||||
"LabelAutoRewindTime": "Час аўтаматычнай перамоткі назад",
|
||||
"LabelAutoSleepTimer": "Аўтаматычны таймер сну",
|
||||
"LabelAutoSleepTimerAutoRewind": "Аўтаматычная перамотка таймера сну",
|
||||
"LabelAutoSleepTimerAutoRewindHelp": "Калі таймер аўтаматычнага сну скончыцца, паўторнае прайграванне элемента аўтаматычна перамотвае вашу пазіцыю.",
|
||||
"LabelAutoSleepTimerAutoRewindHelp": "Пасля заканчэння аўтаматычнага таймера сну паўторнае прайграванне элемента аўтаматычна перамотвае вашу пазіцыю.",
|
||||
"LabelAutoSleepTimerHelp": "Калі медыя прайграецца ў вызначаны час паміж пачаткам і канцом, таймер сну будзе запушчаны аўтаматычна.",
|
||||
"LabelBooks": "Кнігі",
|
||||
"LabelChapterTrack": "Дарожка раздзела",
|
||||
"LabelByAuthor": "– {0}",
|
||||
"LabelChapterTrack": "Трэк раздзела",
|
||||
"LabelChapters": "Раздзелы",
|
||||
"LabelClosePlayer": "Зачыніць прайгравальнік",
|
||||
"LabelClosePlayer": "Закрыць прайгравальнік",
|
||||
"LabelCollapseSeries": "Згарнуць серыі",
|
||||
"LabelComplete": "Завершана",
|
||||
"LabelComplete": "Завяршыць",
|
||||
"LabelContinueBooks": "Працягнуць кнігі",
|
||||
"LabelContinueEpisodes": "Працягнуць эпізоды",
|
||||
"LabelContinueListening": "Працягваць слухаць",
|
||||
"LabelContinueEpisodes": "Працягнуць выпускі",
|
||||
"LabelContinueListening": "Працяг праслухоўвання",
|
||||
"LabelContinueReading": "Працягнуць чытанне",
|
||||
"LabelContinueSeries": "Працягнуць серыі",
|
||||
"LabelCustomTime": "Свой час",
|
||||
"LabelDescription": "Апісанне",
|
||||
"LabelDisableAudioFadeOut": "Адключыць змяншэнне гучнасці",
|
||||
"LabelDisableAudioFadeOutHelp": "Гучнасць аўдыё пачне змяншацца, калі на таймеры сну застанецца менш за 1 хвіліну. Уключыце гэтую наладу, каб пазбегнуць змяншэння гучнасці.",
|
||||
"LabelDisableAudioFadeOutHelp": "Гучнасць гуку пачне змяншацца, калі на таймеры сну застанецца менш за 1 хвіліну. Уключыце гэтую наладу, каб пазбегнуць змяншэння гучнасці.",
|
||||
"LabelDisableAutoRewind": "Адключыць аўтаматычную перамотку назад",
|
||||
"LabelDisableShakeToReset": "Адключыць скід страсеннем",
|
||||
"LabelDisableShakeToResetHelp": "Калі патрасці прыладу падчас працы таймера АБО на працягу 2 хвілін пасля заканчэння таймера, таймер сну скідваецца. Уключыце гэтую наладу, каб адключыць скід страсеннем.",
|
||||
"LabelDisableShakeToReset": "Адключыць скід устрэсваннем",
|
||||
"LabelDisableShakeToResetHelp": "Калі патрэсці прыладу падчас працы таймера АБО на працягу 2 хвілін пасля заканчэння таймера, таймер сну скідваецца. Уключыце гэтую наладу, каб адключыць скід устрэсваннем.",
|
||||
"LabelDisableVibrateOnReset": "Адключыць вібрацыю пры скідзе",
|
||||
"LabelDisableVibrateOnResetHelp": "Калі таймер сну скідваецца, ваша прылада будзе вібраваць. Уключыце гэтую наладу, каб адключыць вібрацыю пры скідзе таймера сну.",
|
||||
"LabelDiscover": "Знайсці",
|
||||
"LabelDiscover": "Знаходкі",
|
||||
"LabelDownload": "Спампаваць",
|
||||
"LabelDownloadUsingCellular": "Спампоўка праз мабільны інтэрнэт",
|
||||
"LabelDownloadUsingCellular": "Спампоўваць праз мабільны інтэрнэт",
|
||||
"LabelDownloaded": "Спампавана",
|
||||
"LabelDuration": "Працягласць",
|
||||
"LabelEbook": "Электронная кніга",
|
||||
"LabelEbooks": "Электронныя кнігі",
|
||||
"LabelEnable": "Уключыць",
|
||||
"LabelEnableMp3IndexSeeking": "Уключыць індэксаванне для перамоткі MP3",
|
||||
"LabelEnableMp3IndexSeekingHelp": "Гэтую наладу варта ўключаць толькі, калі ў вас ёсць MP3-файлы, якія няправільна перамотваюцца. Хутчэй за ўсё, гэта выклікана файламі з зменным бітрэйтам (VBR). Гэтая налада прымусова ўключае індэксаванне для перамоткі, пры якім падчас чытання файла ствараецца адпаведнасць паміж часам і байтамі. У некаторых выпадках з вялікімі 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": "Жанры",
|
||||
"LabelHapticFeedback": "Тактыльная зваротная сувязь",
|
||||
"LabelHasEbook": "Мае электронную кнігу",
|
||||
"LabelHasSupplementaryEbook": "Мае дадатковую электронную кнігу",
|
||||
"LabelHeavy": "Моцна",
|
||||
"LabelHigh": "Высокі",
|
||||
"LabelHeavy": "Моцная",
|
||||
"LabelHigh": "Высокая",
|
||||
"LabelHost": "Хост",
|
||||
"LabelInProgress": "У працэсе",
|
||||
"LabelIncomplete": "Незавершана",
|
||||
"LabelInternalAppStorage": "Унутраная памяць прыкладання",
|
||||
"LabelInternalAppStorage": "Унутраная памяць праграмы",
|
||||
"LabelJumpBackwardsTime": "Час пераходу назад",
|
||||
"LabelJumpForwardsTime": "Час пераходу наперад",
|
||||
"LabelKeepScreenAwake": "Трымаць экран уключаным",
|
||||
@@ -174,22 +180,25 @@
|
||||
"LabelLayout": "Знешні выгляд",
|
||||
"LabelLayoutAuto": "Аўто",
|
||||
"LabelLayoutSinglePage": "Аднабаковы",
|
||||
"LabelLight": "Святло",
|
||||
"LabelLibrarySortByProgress": "Прагрэс: апошняе абнаўленне",
|
||||
"LabelLibrarySortByProgressFinished": "Прагрэс: завершана",
|
||||
"LabelLibrarySortByProgressStarted": "Прагрэс: пачата",
|
||||
"LabelLight": "Лёгкая",
|
||||
"LabelLineSpacing": "Міжрадковы інтэрвал",
|
||||
"LabelListenAgain": "Паслухаць зноў",
|
||||
"LabelLocalBooks": "Лакальныя кнігі",
|
||||
"LabelLocalPodcasts": "Лакальныя падкасты",
|
||||
"LabelLockOrientation": "Зафіксаваць арыентацыю",
|
||||
"LabelLockPlayer": "Заблакаваць прайгравальнік",
|
||||
"LabelLow": "Нізкі",
|
||||
"LabelLockPlayer": "Заблакіраваць прайгравальнік",
|
||||
"LabelLow": "Нізкая",
|
||||
"LabelMediaType": "Тып медыя",
|
||||
"LabelMedium": "Сярэдні",
|
||||
"LabelMedium": "Сярэдняя",
|
||||
"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,30 +238,31 @@
|
||||
"LabelReadAgain": "Чытаць зноў",
|
||||
"LabelRecentSeries": "Апошнія серыі",
|
||||
"LabelRecentlyAdded": "Нядаўна дададзеныя",
|
||||
"LabelRemoveFromPlaylist": "Выдаліць з спіса прайгравання",
|
||||
"LabelScaleElapsedTimeBySpeed": "Прыстасаваць мінулы час да хуткасці",
|
||||
"LabelRemoveFromPlaylist": "Выдаліць з плэй-ліста",
|
||||
"LabelScaleElapsedTimeBySpeed": "Маштабаваць мінулы час па хуткасці",
|
||||
"LabelSeason": "Сезон",
|
||||
"LabelSelectADevice": "Выбраць прыладу",
|
||||
"LabelSequenceAscending": "Паслядоўнасць па ўзрастанні",
|
||||
"LabelSequenceDescending": "Паслядоўнасць па змяншэнні",
|
||||
"LabelSelectMediaType": "Выберыце тып медыя",
|
||||
"LabelSequenceAscending": "Па ўзрастанні",
|
||||
"LabelSequenceDescending": "Па ўбыванні",
|
||||
"LabelSeries": "Серыі",
|
||||
"LabelServerAddress": "Адрас сервера",
|
||||
"LabelSetEbookAsPrimary": "Зрабіць асноўным",
|
||||
"LabelSetEbookAsSupplementary": "Зрабіць дадатковым",
|
||||
"LabelShakeSensitivity": "Чуйнасць да трасення",
|
||||
"LabelShakeSensitivity": "Адчувальнасць да ўстрэсвання",
|
||||
"LabelShowAll": "Паказаць усё",
|
||||
"LabelSize": "Памер",
|
||||
"LabelSleepTimer": "Таймер сну",
|
||||
"LabelSleepTimerAlmostDoneChime": "Праіграйце звонак, калі амаль скончана",
|
||||
"LabelSleepTimerAlmostDoneChimeHelp": "Памякчыць звонак, калі на таймеры сну застанецца 30 секунд",
|
||||
"LabelStart": "Пачаць",
|
||||
"LabelSleepTimerAlmostDoneChime": "Гукавы сігнал, калі амаль завершаны",
|
||||
"LabelSleepTimerAlmostDoneChimeHelp": "Прайграць званок, калі да канца таймера сну застанецца 30 секунд",
|
||||
"LabelStart": "Пачатак",
|
||||
"LabelStartTime": "Час пачатку",
|
||||
"LabelStatsBestDay": "Лепшы дзень",
|
||||
"LabelStatsBestDay": "Найлепшы дзень",
|
||||
"LabelStatsDailyAverage": "У сярэднім за дзень",
|
||||
"LabelStatsDays": "Дзён",
|
||||
"LabelStatsDaysListened": "Дзён праслухана",
|
||||
"LabelStatsInARow": "без перапынку",
|
||||
"LabelStatsItemsFinished": "Скончаныя элементы",
|
||||
"LabelStatsItemsFinished": "Элементаў завершана",
|
||||
"LabelStatsMinutes": "хвілін",
|
||||
"LabelStatsMinutesListening": "Хвілін праслухоўвання",
|
||||
"LabelStatsWeekListening": "Праслухана за тыдзень",
|
||||
@@ -260,44 +270,49 @@
|
||||
"LabelTag": "Метка",
|
||||
"LabelTags": "Меткі",
|
||||
"LabelTheme": "Тэма",
|
||||
"LabelThemeBlack": "Чорны",
|
||||
"LabelThemeBlack": "Чорная",
|
||||
"LabelThemeDark": "Цёмная",
|
||||
"LabelThemeLight": "Светлая",
|
||||
"LabelTimeRemaining": "Засталося {0}",
|
||||
"LabelTitle": "Назва",
|
||||
"LabelTitle": "Загаловак",
|
||||
"LabelTotalSize": "Агульны памер",
|
||||
"LabelTotalTrack": "Агульная колькасць дарожак",
|
||||
"LabelTracks": "Дарожкі",
|
||||
"LabelTotalTrack": "Усяго трэкаў",
|
||||
"LabelTracks": "Трэкі",
|
||||
"LabelType": "Тып",
|
||||
"LabelUnknown": "Невядома",
|
||||
"LabelUnlockPlayer": "Разблакаваць прайгравальнік",
|
||||
"LabelUseBookshelfView": "Выкарыстоўваць выгляд кніжнай паліцы",
|
||||
"LabelUnlockPlayer": "Разблакіраваць прайгравальнік",
|
||||
"LabelUseBookshelfView": "Паказваць кніжныя паліцы",
|
||||
"LabelUser": "Карыстальнік",
|
||||
"LabelUsername": "Імя карыстальніка",
|
||||
"LabelVeryHigh": "Вельмі высокі",
|
||||
"LabelVeryLow": "Вельмі нізкі",
|
||||
"LabelVeryHigh": "Вельмі высокая",
|
||||
"LabelVeryLow": "Вельмі нізкая",
|
||||
"LabelYearReviewHide": "Схаваць вынікі года",
|
||||
"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": "Атрыманне...",
|
||||
@@ -306,8 +321,8 @@
|
||||
"MessageItemMissing": "Элемент адсутнічае і патрабуе выпраўлення на серверы. Звычайна элемент пазначаецца як адсутны, таму што файлавыя шляхі недаступныя.",
|
||||
"MessageLoading": "Загрузка...",
|
||||
"MessageLoadingServerData": "Загрузка даных з сервера...",
|
||||
"MessageLocalFolderDescription": "\"Унутраная памяць прыкладання\" даступная толькі для гэтага прыкладання. Гэта прыкладанне працуе толькі з медыя, якія былі спампаваныя непасрэдна праз яго. Каб іншыя прыкладанні маглі атрымаць доступ да медыя, спампаванага гэтым прыкладаннем, можна выкарыстоўваць агульныя тэчкі.",
|
||||
"MessageMarkAsFinished": "Пазначыць як скончана",
|
||||
"MessageLocalFolderDescription": "\"Унутраная памяць праграмы\" даступная толькі для гэтай праграмы. Гэта праграма працуе толькі з медыя, якія былі спампаваныя непасрэдна праз яго. Каб іншыя праграмы маглі атрымаць доступ да медыя, спампаванага гэтай праграмай, можна выкарыстоўваць абагуленыя папкі.",
|
||||
"MessageMarkAsFinished": "Пазначыць як завершаную",
|
||||
"MessageMediaLinkedToADifferentServer": "Медыя звязаны з серверам Audiobookshelf па іншым адрасе ({0}). Сінхранізацыя прагрэсу адбудзецца пры падключэнні да гэтага адрасу сервера.",
|
||||
"MessageMediaLinkedToADifferentUser": "Медыя звязаны з гэтым серверам, але быў спампаваны іншым карыстальнікам. Прагрэс будзе сінхранізавацца толькі для карыстальніка, які яго спампаваў.",
|
||||
"MessageMediaLinkedToServer": "Звязана з серверам {0}",
|
||||
@@ -320,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 была дададзена больш бяспечная сістэма аўтэнтыфікацыі. Настойліва рэкамендуецца абнавіць сервер да апошняй версіі. Калі вы ўжо абнавілі сервер, увайдзіце зноў, каб выкарыстоўваць новую аўтэнтыфікацыю.",
|
||||
@@ -333,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 з абмежаваным трафікам",
|
||||
@@ -343,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} с"
|
||||
}
|
||||
|
||||
+18
-1
@@ -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}сек"
|
||||
}
|
||||
|
||||
+16
-2
@@ -63,6 +63,7 @@
|
||||
"HeaderChapters": "Kapitoly",
|
||||
"HeaderCollection": "Kolekce",
|
||||
"HeaderCollectionItems": "Položky kolekce",
|
||||
"HeaderConfirm": "Potvrzení",
|
||||
"HeaderConnectionStatus": "Stav připojení",
|
||||
"HeaderDataSettings": "Nastavení dat",
|
||||
"HeaderDetails": "Podrobnosti",
|
||||
@@ -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č",
|
||||
@@ -158,7 +161,7 @@
|
||||
"LabelFontFamily": "Rodina písem",
|
||||
"LabelFontFamilySans": "Bezpatkové",
|
||||
"LabelFontFamilySerif": "Patkové",
|
||||
"LabelFontScale": "Měřítko písma",
|
||||
"LabelFontScale": "Velikost písma",
|
||||
"LabelGenre": "Žánr",
|
||||
"LabelGenres": "Žánry",
|
||||
"LabelHapticFeedback": "Haptická odezva",
|
||||
@@ -177,6 +180,9 @@
|
||||
"LabelLayout": "Rozvržení",
|
||||
"LabelLayoutAuto": "Automatické",
|
||||
"LabelLayoutSinglePage": "Jedna stránka",
|
||||
"LabelLibrarySortByProgress": "Pokrok: naposledy aktualizováno",
|
||||
"LabelLibrarySortByProgressFinished": "Pokrok: dokončeno",
|
||||
"LabelLibrarySortByProgressStarted": "Pokrok: začato",
|
||||
"LabelLight": "Slabá",
|
||||
"LabelLineSpacing": "Řádkování",
|
||||
"LabelListenAgain": "Poslouchat znovu",
|
||||
@@ -236,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",
|
||||
@@ -288,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",
|
||||
@@ -358,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"
|
||||
}
|
||||
|
||||
+48
-37
@@ -62,41 +62,41 @@
|
||||
"HeaderAudioTracks": "Lydspor",
|
||||
"HeaderChapters": "Kapitler",
|
||||
"HeaderCollection": "Samling",
|
||||
"HeaderCollectionItems": "Samlingselementer",
|
||||
"HeaderCollectionItems": "Bøger i samlingen",
|
||||
"HeaderConfirm": "Bekræft",
|
||||
"HeaderConnectionStatus": "Forbindelsesstatus",
|
||||
"HeaderDataSettings": "Data indstillinger",
|
||||
"HeaderDetails": "Detaljer",
|
||||
"HeaderDownloads": "Downloads",
|
||||
"HeaderEbookFiles": "E-bogsfiler",
|
||||
"HeaderEpisodes": "Episoder",
|
||||
"HeaderEreaderSettings": "E-læser Indstillinger",
|
||||
"HeaderLatestEpisodes": "Seneste episoder",
|
||||
"HeaderEpisodes": "Afsnit",
|
||||
"HeaderEreaderSettings": "Indstillinger for e-bogslæsere",
|
||||
"HeaderLatestEpisodes": "Nyeste afsnit",
|
||||
"HeaderLibraries": "Biblioteker",
|
||||
"HeaderLocalFolders": "Lokale mapper",
|
||||
"HeaderLocalLibraryItems": "Lokale biblioteks elementer",
|
||||
"HeaderNewPlaylist": "Ny afspilningsliste",
|
||||
"HeaderOpenRSSFeed": "Åbn RSS Feed",
|
||||
"HeaderOpenRSSFeed": "Åbn RSS-feed",
|
||||
"HeaderPlaybackSettings": "Afspilnings indstillinger",
|
||||
"HeaderPlaylist": "Afspilningsliste",
|
||||
"HeaderPlaylistItems": "Afspilningsliste Elementer",
|
||||
"HeaderPlaylistItems": "Elementer i afspilningslisten",
|
||||
"HeaderProgressSyncFailed": "Fremskridt synkronisering fejlede",
|
||||
"HeaderRSSFeed": "RSS Feed",
|
||||
"HeaderRSSFeedGeneral": "RSS Detaljer",
|
||||
"HeaderRSSFeedIsOpen": "RSS Feed er Åben",
|
||||
"HeaderRSSFeedGeneral": "RSS-detaljer",
|
||||
"HeaderRSSFeedIsOpen": "RSS-feedet er åbent",
|
||||
"HeaderSelectDownloadLocation": "Vælg Download Placering",
|
||||
"HeaderSettings": "Indstillinger",
|
||||
"HeaderSleepTimer": "Søvntimer",
|
||||
"HeaderSleepTimer": "Sovefunktion",
|
||||
"HeaderSleepTimerSettings": "Sleep Timer instillinger",
|
||||
"HeaderStatsMinutesListeningChart": "Minutter Lyttet (sidste 7 dage)",
|
||||
"HeaderStatsRecentSessions": "Seneste Sessions",
|
||||
"HeaderStatsMinutesListeningChart": "Minutter lyttet (sidste 7 dage)",
|
||||
"HeaderStatsRecentSessions": "Seneste sessioner",
|
||||
"HeaderTableOfContents": "Indholdsfortegnelse",
|
||||
"HeaderUserInterfaceSettings": "Indstillinger for brugergrænseflade",
|
||||
"HeaderWelcome": "Velkommen, <strong>{0}</strong>",
|
||||
"HeaderYourStats": "Dine Statistikker",
|
||||
"LabelAddToPlaylist": "Tilføj til Afspilningsliste",
|
||||
"HeaderYourStats": "Din statistik",
|
||||
"LabelAddToPlaylist": "Tilføj til afspilningsliste",
|
||||
"LabelAddedAt": "Tilføjet",
|
||||
"LabelAddedDate": "Tilføjet {0}",
|
||||
"LabelAddedDate": "Tilføjede {0}",
|
||||
"LabelAll": "Alle",
|
||||
"LabelAllowSeekingOnMediaControls": "Tillad positionssøgning på mediemeddelelseskontroller",
|
||||
"LabelAlways": "Altid",
|
||||
@@ -108,24 +108,24 @@
|
||||
"LabelAuthorFirstLast": "Forfatter (Fornavn Efternavn)",
|
||||
"LabelAuthorLastFirst": "Forfatter (Efternavn, Fornavn)",
|
||||
"LabelAuthors": "Forfattere",
|
||||
"LabelAutoDownloadEpisodes": "Auto Download Episoder",
|
||||
"LabelAutoDownloadEpisodes": "Hent automatisk afsnit",
|
||||
"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",
|
||||
"LabelByAuthor": "af {0}",
|
||||
"LabelChapterTrack": "Kapitel spor",
|
||||
"LabelChapters": "Kapitler",
|
||||
"LabelClosePlayer": "Luk afspiller",
|
||||
"LabelCollapseSeries": "Fold Serier Sammen",
|
||||
"LabelComplete": "Fuldfør",
|
||||
"LabelCollapseSeries": "Fold serier sammen",
|
||||
"LabelComplete": "Afsluttet",
|
||||
"LabelContinueBooks": "Fortsæt Bøger",
|
||||
"LabelContinueEpisodes": "Fortsæt Episoder",
|
||||
"LabelContinueListening": "Fortsæt med at lytte",
|
||||
"LabelContinueReading": "Fortsæt med at læse",
|
||||
"LabelContinueSeries": "Fortsæt Serien",
|
||||
"LabelContinueSeries": "Fortsæt serien",
|
||||
"LabelCustomTime": "Brugerdefineret tid",
|
||||
"LabelDescription": "Beskrivelse",
|
||||
"LabelDisableAudioFadeOut": "Deaktiver lydudtoning",
|
||||
@@ -136,7 +136,7 @@
|
||||
"LabelDisableVibrateOnReset": "Deaktiver vibrering ved nulstilling",
|
||||
"LabelDisableVibrateOnResetHelp": "Når sleep-timeren nulstilles, vil din enhed vibrere. Aktiver denne indstilling for ikke at vibrere, når sleep-timeren nulstilles.",
|
||||
"LabelDiscover": "Opdag",
|
||||
"LabelDownload": "Download",
|
||||
"LabelDownload": "Hent",
|
||||
"LabelDownloadUsingCellular": "Download med mobildata",
|
||||
"LabelDownloaded": "Downloadet",
|
||||
"LabelDuration": "Varighed",
|
||||
@@ -149,8 +149,8 @@
|
||||
"LabelEndOfChapter": "Slutningen af kapitel",
|
||||
"LabelEndTime": "Sluttidspunkt",
|
||||
"LabelEpisode": "Afsnit",
|
||||
"LabelExplicit": "Eksplisit",
|
||||
"LabelFeedURL": "Feed URL",
|
||||
"LabelExplicit": "Voksenindhold",
|
||||
"LabelFeedURL": "Feed-URL",
|
||||
"LabelFile": "Fil",
|
||||
"LabelFileBirthtime": "Oprettelsestidspunkt for fil",
|
||||
"LabelFileModified": "Fil ændret",
|
||||
@@ -159,8 +159,8 @@
|
||||
"LabelFolder": "Mappe",
|
||||
"LabelFontBoldness": "Skrift tykkelse",
|
||||
"LabelFontFamily": "Skrifttypefamilie",
|
||||
"LabelFontFamilySans": "Font Sans",
|
||||
"LabelFontFamilySerif": "Font Serif",
|
||||
"LabelFontFamilySans": "Sans",
|
||||
"LabelFontFamilySerif": "Serif",
|
||||
"LabelFontScale": "Skriftstørrelse",
|
||||
"LabelGenre": "Genre",
|
||||
"LabelGenres": "Genrer",
|
||||
@@ -177,12 +177,15 @@
|
||||
"LabelJumpForwardsTime": "Spring frem tid",
|
||||
"LabelKeepScreenAwake": "Hold skærm aktiv",
|
||||
"LabelLanguage": "Sprog",
|
||||
"LabelLayout": "Layout",
|
||||
"LabelLayout": "Udseende",
|
||||
"LabelLayoutAuto": "Automatisk",
|
||||
"LabelLayoutSinglePage": "Enkel Side",
|
||||
"LabelLibrarySortByProgress": "Fremgang: Sidst opdateret",
|
||||
"LabelLibrarySortByProgressFinished": "Fremgang: Afsluttet",
|
||||
"LabelLibrarySortByProgressStarted": "Fremgang: Startet",
|
||||
"LabelLight": "Lys",
|
||||
"LabelLineSpacing": "Linjeafstand",
|
||||
"LabelListenAgain": "Lyt Igen",
|
||||
"LabelListenAgain": "Lyt igen",
|
||||
"LabelLocalBooks": "Lokale Bøger",
|
||||
"LabelLocalPodcasts": "Lokale Podcasts",
|
||||
"LabelLockOrientation": "Lås Orientering",
|
||||
@@ -194,8 +197,8 @@
|
||||
"LabelMore": "Mere",
|
||||
"LabelMoreInfo": "Mere info",
|
||||
"LabelName": "Navn",
|
||||
"LabelNarrator": "Fortæller",
|
||||
"LabelNarrators": "Fortællere",
|
||||
"LabelNarrator": "Oplæser",
|
||||
"LabelNarrators": "Oplæsere",
|
||||
"LabelNavigateWithVolume": "Naviger med lydstyrketasterne",
|
||||
"LabelNavigateWithVolumeMirrored": "Spejlet",
|
||||
"LabelNavigateWithVolumeWhilePlaying": "Tillad navigering med lydstyrketasterne , mens du spiller",
|
||||
@@ -203,7 +206,7 @@
|
||||
"LabelNavigateWithVolumeWhilePlayingEnabled": "Tændt",
|
||||
"LabelNever": "Aldrig",
|
||||
"LabelNewestAuthors": "Nyeste Forfattere",
|
||||
"LabelNewestEpisodes": "Nyeste Episoder",
|
||||
"LabelNewestEpisodes": "Nyeste afsnit",
|
||||
"LabelNo": "Nej",
|
||||
"LabelNotFinished": "Ikke færdig",
|
||||
"LabelNotStarted": "Ikke påbegyndt",
|
||||
@@ -220,25 +223,26 @@
|
||||
"LabelPlaybackTranscode": "Transcode",
|
||||
"LabelPodcast": "Podcast",
|
||||
"LabelPodcasts": "Podcast",
|
||||
"LabelPreventIndexing": "Forhindrer, at dit feed bliver indekseret af iTunes og Google podcastkataloger",
|
||||
"LabelPreventIndexing": "Forhindrer at dit feed bliver indekseret af iTunes- og Google-podcastkataloger",
|
||||
"LabelProgress": "Fremskridt",
|
||||
"LabelPubDate": "Udgivelsesdato",
|
||||
"LabelPublishYear": "Udgivelsesår",
|
||||
"LabelPublishedDate": "Publiceret {0}",
|
||||
"LabelPublishedDate": "Udgivet {0}",
|
||||
"LabelRSSFeedCustomOwnerEmail": "Brugerdefineret ejerens e-mail",
|
||||
"LabelRSSFeedCustomOwnerName": "Brugerdefineret ejerens navn",
|
||||
"LabelRSSFeedOpen": "RSS-feed åbent",
|
||||
"LabelRSSFeedPreventIndexing": "Forhindrer indeksering",
|
||||
"LabelRSSFeedPreventIndexing": "Forhindr indeksering",
|
||||
"LabelRSSFeedSlug": "RSS-feed-slug",
|
||||
"LabelRandomly": "Tilfældigt",
|
||||
"LabelRead": "Læst",
|
||||
"LabelReadAgain": "Læs Igen",
|
||||
"LabelReadAgain": "Læs igen",
|
||||
"LabelRecentSeries": "Seneste serier",
|
||||
"LabelRecentlyAdded": "Nyligt Tilføjet",
|
||||
"LabelRemoveFromPlaylist": "Fjern fra Playliste",
|
||||
"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",
|
||||
@@ -258,7 +262,7 @@
|
||||
"LabelStatsDays": "Dage",
|
||||
"LabelStatsDaysListened": "Dage hørt",
|
||||
"LabelStatsInARow": "i træk",
|
||||
"LabelStatsItemsFinished": "Elementer færdige",
|
||||
"LabelStatsItemsFinished": "færdiglæste bøger",
|
||||
"LabelStatsMinutes": "minutter",
|
||||
"LabelStatsMinutesListening": "Minutter hørt",
|
||||
"LabelStatsWeekListening": "Ugens lytning",
|
||||
@@ -289,14 +293,19 @@
|
||||
"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?",
|
||||
"MessageConfirmDeleteServerEpisode": "Er du sikker på at du vil slette episode \"{0}\" fra serveren?\nAdvarsel: Dette sletter lydfilen.",
|
||||
"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?",
|
||||
"MessageConfirmMarkAsFinished": "Er du sikker på at du vil markere dette element færdig?",
|
||||
"MessageConfirmPlaybackTime": "Start afspilning af \"{0}\" på {1}?",
|
||||
"MessageConfirmRemoveBookmark": "Er du sikker på at du vil fjerne bogmærke?",
|
||||
"MessageConfirmStreamingUsingCellular": "Du er ved at streame via mobildata. Dette kan medføre ekstra omkostninger fra din teleoperatør. Vil du fortsætte?",
|
||||
"MessageDiscardProgress": "Kassér fremgang",
|
||||
@@ -361,5 +370,7 @@
|
||||
"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"
|
||||
}
|
||||
|
||||
+3
-3
@@ -121,8 +121,8 @@
|
||||
"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",
|
||||
@@ -143,7 +143,7 @@
|
||||
"LabelEbook": "E-Buch",
|
||||
"LabelEbooks": "E-Bücher",
|
||||
"LabelEnable": "Aktivieren",
|
||||
"LabelEnableMp3IndexSeeking": "MP3-Indexsuche",
|
||||
"LabelEnableMp3IndexSeeking": "MP3-Indexsuche aktivieren",
|
||||
"LabelEnableMp3IndexSeekingHelp": "Diese Einstellung sollte nur aktiviert werden, wenn du MP3-Dateien hast, bei denen das Navigieren (Seeking) nicht korrekt funktioniert. Ungenaues Navigieren ist höchstwahrscheinlich auf MP3-Dateien mit variabler Bitrate (VBR) zurückzuführen. Diese Einstellung erzwingt das Index-Navigieren (Index Seeking), bei dem eine Zeit-zu-Byte-Zuordnung erstellt wird, während die Datei gelesen wird. In einigen Fällen kann es bei großen MP3-Dateien zu einer Verzögerung kommen, wenn gegen Ende der Datei navigiert wird.",
|
||||
"LabelEnd": "Ende",
|
||||
"LabelEndOfChapter": "Ende des Kapitels",
|
||||
|
||||
+56
-5
@@ -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": "Προστέθηκε Στις",
|
||||
@@ -99,6 +101,7 @@
|
||||
"LabelAllowSeekingOnMediaControls": "Να επιτρέπεται η αλλαγή θέσης από τα χειριστήρια ειδοποιήσεων πολυμέσων",
|
||||
"LabelAlways": "Πάντα",
|
||||
"LabelAndroidAutoBrowseLimitForGrouping": "Όριο λίστας κατά αλφαβητική σειρά",
|
||||
"LabelAndroidAutoBrowseLimitForGroupingHelp": "Μην χρησιμοποιείτε την αλφαβητική λίστα περιορισμού όταν υπάρχουν λιγότερα από αυτά τα στοιχεία για εμφάνιση",
|
||||
"LabelAndroidAutoBrowseSeriesSequenceOrder": "Ταξινόμηση βιβλίων σειράς",
|
||||
"LabelAskConfirmation": "Ερώτηση για επιβεβαίωση",
|
||||
"LabelAuthor": "Συγγραφέας",
|
||||
@@ -106,69 +109,117 @@
|
||||
"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": "Απενεργοποίηση αυτόματης επαναφοράς",
|
||||
"LabelDisableShakeToReset": "Απενεργοποιήστε το κούνημα για επαναφορά",
|
||||
"LabelDisableShakeToResetHelp": "Αν κουνήσετε τη συσκευή σας ενώ λειτουργεί ο χρονοδιακόπτης Ή εντός 2 λεπτών από τη λήξη του χρονοδιακόπτη, θα επαναφέρετε τον χρονοδιακόπτη ύπνου. Ενεργοποιήστε αυτήν τη ρύθμιση για να απενεργοποιήσετε το κούνημα για επαναφορά.",
|
||||
"LabelDisableVibrateOnReset": "Απενεργοποίηση δόνησης κατά την επαναφορά",
|
||||
"LabelDisableVibrateOnResetHelp": "Όταν γίνει επαναφορά του χρονοδιακόπτη ύπνου, η συσκευή σας θα δονηθεί. Ενεργοποιήστε αυτήν τη ρύθμιση για να μην δονείται κατά την επαναφορά του χρονοδιακόπτη ύπνου.",
|
||||
"LabelDiscover": "Ανακάλυψη",
|
||||
"LabelDownload": "Λήψη",
|
||||
"LabelDownloadUsingCellular": "Λήψη με χρήση δεδομένων",
|
||||
"LabelDownloaded": "Ληφθέντα",
|
||||
"LabelDuration": "Διάρκεια",
|
||||
"LabelEbook": "Ebook",
|
||||
"LabelEbooks": "Ebooks",
|
||||
"LabelEbook": "Ηλεκτρονικό Βιβλίο/ Ebook",
|
||||
"LabelEbooks": "Ηλεκτρονικά Βιβλία / Ebooks",
|
||||
"LabelEnable": "Ενεργοποίηση",
|
||||
"LabelEnableMp3IndexSeeking": "Ενεργοποίηση αναζήτησης σε mp3 ευρετήριο",
|
||||
"LabelEnableMp3IndexSeekingHelp": "Αυτή η ρύθμιση θα πρέπει να είναι ενεργοποιημένη μόνο εάν έχετε αρχεία mp3 που δεν έχει γίνει σωστή αναζήτηση. Η ανακριβής αναζήτηση πιθανότατα οφείλεται σε αρχεία MP3 με μεταβλητό ρυθμό μετάδοσης bit (VBR). Αυτή η ρύθμιση θα επιβάλει την αναζήτηση ευρετηρίου, στην οποία δημιουργείται μια αντιστοίχιση χρόνου-byte καθώς διαβάζεται το αρχείο. Σε ορισμένες περιπτώσεις, με μεγάλα αρχεία MP3, θα υπάρχει καθυστέρηση κατά την αναζήτηση προς το τέλος του αρχείου.",
|
||||
"LabelEnd": "Τέλος",
|
||||
"LabelEndOfChapter": "Τέλος Κεφαλαίου",
|
||||
"LabelEndTime": "Ώρα λήξης",
|
||||
"LabelEpisode": "Επεισόδιο",
|
||||
"LabelExplicit": "Άσεμνο περιεχόμενο",
|
||||
"LabelFeedURL": "URL ροής",
|
||||
"LabelFile": "Αρχείο",
|
||||
"LabelFileBirthtime": "Ώρα δημιουργίας αρχείου",
|
||||
"LabelFileModified": "Τροποποιημένο αρχείο",
|
||||
"LabelFilename": "Όνομα Αρχείου",
|
||||
"LabelFinished": "Ολοκληρώθηκε",
|
||||
"LabelFolder": "Φάκελος",
|
||||
"LabelFontBoldness": "Ένταση γραμματοσειράς",
|
||||
"LabelFontFamily": "Οικογένεια Γραμματοσειράς",
|
||||
"LabelGenre": "Είδος",
|
||||
"LabelGenres": "Είδη",
|
||||
"LabelFontScale": "Μέγεθος γραμματοσειράς",
|
||||
"LabelGenre": "Λογοτεχνικό Είδος",
|
||||
"LabelGenres": "Λογοτεχνικά Είδη",
|
||||
"LabelHapticFeedback": "Απτική ανατροφοδότηση",
|
||||
"LabelHasEbook": "Έχει ebook",
|
||||
"LabelHasSupplementaryEbook": "Διαθέτει συμπληρωματικό ηλεκτρονικό βιβλίο",
|
||||
"LabelHeavy": "Έντονη",
|
||||
"LabelHigh": "Υψηλή",
|
||||
"LabelHost": "Διακομιστής",
|
||||
"LabelInProgress": "Σε Εξέλιξη",
|
||||
"LabelIncomplete": "Ελειπής",
|
||||
"LabelInternalAppStorage": "Εσωτερικός χώρος αποθήκευσης εφαρμογών",
|
||||
"LabelJumpBackwardsTime": "Πήγαινε πίσω το χρόνο",
|
||||
"LabelJumpForwardsTime": "Πήγαινε μπρος το χρόνο",
|
||||
"LabelKeepScreenAwake": "Να παραμείνει η οθόνη ενεργή",
|
||||
"LabelLanguage": "Γλώσσα",
|
||||
"LabelLayout": "Διάταξη",
|
||||
"LabelLayoutAuto": "Αυτόματα",
|
||||
"LabelLayoutSinglePage": "Μονή Σελίδα",
|
||||
"LabelLibrarySortByProgress": "Πρόοδος: Τελευταία ενημέρωση",
|
||||
"LabelLibrarySortByProgressFinished": "Πρόοδος: Ολοκληρώθηκε",
|
||||
"LabelLibrarySortByProgressStarted": "Πρόοδος: Έναρξη",
|
||||
"LabelLight": "Ανοιχτό",
|
||||
"LabelLineSpacing": "Διάστημα γραμμών",
|
||||
"LabelListenAgain": "Επανάληψη Ακρόασης",
|
||||
"LabelLocalBooks": "Τοπικά Βιβλία",
|
||||
"LabelLocalPodcasts": "Τοπικά Podcasts",
|
||||
"LabelLockOrientation": "Κλείδωμα Προσανατολισμού",
|
||||
"LabelLockPlayer": "Κλείδωμα Αναπαραγωγής",
|
||||
"LabelLow": "Χαμηλά",
|
||||
"LabelMediaType": "Τύπος Πολυμέσων",
|
||||
"LabelMedium": "Μεσαίο",
|
||||
"LabelMissing": "Λείπει η ετικέτα",
|
||||
"LabelMore": "Περισσότερα",
|
||||
"LabelMoreInfo": "Περισσότερες Πληροφορίες",
|
||||
"LabelName": "Όνομα",
|
||||
"LabelNarrator": "Αφηγητής",
|
||||
"LabelNarrators": "Αφηγητές",
|
||||
"LabelNavigateWithVolume": "Περιήγηση με τα πλήκτρα έντασης",
|
||||
"LabelNavigateWithVolumeMirrored": "Καθρεπτισμός",
|
||||
"LabelNavigateWithVolumeWhilePlaying": "Να επιτρέπεται η περιήγησηστα πλήκτρα έντασης ήχου κατά την αναπαραγωγή",
|
||||
"LabelNavigateWithVolumeWhilePlayingDisabled": "Ανενεργό",
|
||||
"LabelNavigateWithVolumeWhilePlayingEnabled": "Ενεργό",
|
||||
"LabelNever": "Ποτέ",
|
||||
"LabelNewestAuthors": "Πρόσφατοι Συγγραφείς",
|
||||
"LabelNewestEpisodes": "Πρόσφατα Επεισόδια",
|
||||
"LabelNo": "Όχι",
|
||||
"LabelNotFinished": "Δεν έχει ολοκληρωθεί",
|
||||
"LabelNotStarted": "Δεν Έχει Ξεκινήσει",
|
||||
"LabelNumEpisodes": "{0} επεισόδια",
|
||||
"LabelNumEpisodesIncomplete": "{0} επεισόδια, {1} μη ολοκληρωμένα",
|
||||
"LabelNumberOfEpisodes": "# Επεισοδίων",
|
||||
"LabelOff": "Ανενεργό",
|
||||
"LabelOn": "Ενεργό",
|
||||
"LabelPassword": "Κωδικός Πρόσβασης",
|
||||
"LabelPath": "Διαδρομή",
|
||||
"LabelPlaybackLocal": "Τοπικά",
|
||||
"LabelPlaybackDirect": "Άμεση αναπαραγωγή",
|
||||
"LabelPlaybackLocal": "Αναπαραγωγή τοπικού αρχείου",
|
||||
"LabelPlaybackSpeed": "Ταχύτητα Αναπαραγωγής",
|
||||
"LabelProgress": "Πρόοδος",
|
||||
"LabelPubDate": "Ημερομηνία έκδοσης",
|
||||
"LabelPublishYear": "Χρονολογία Έκδοσης",
|
||||
"LabelPublishedDate": "Εκδόθηκε {0}",
|
||||
"LabelRandomly": "Τυχαία",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"ButtonAdd": "Add",
|
||||
"ButtonAddNewServer": "Add New Server",
|
||||
"ButtonAllow": "Allow",
|
||||
"ButtonAuthors": "Authors",
|
||||
"ButtonBack": "Back",
|
||||
"ButtonCancel": "Cancel",
|
||||
@@ -312,6 +313,7 @@
|
||||
"MessageDownloadCompleteProcessing": "Download complete. Processing...",
|
||||
"MessageDownloading": "Downloading...",
|
||||
"MessageDownloadingEpisode": "Downloading episode",
|
||||
"MessageDownloadingFile": "Downloading {0}",
|
||||
"MessageEpisodesQueuedForDownload": "{0} Episode(s) queued for download",
|
||||
"MessageFailedToRefreshToken": "Failed to refresh token, re-login required",
|
||||
"MessageFeedURLWillBe": "Feed URL will be {0}",
|
||||
@@ -347,6 +349,7 @@
|
||||
"MessageOldServerConnectionWarning": "Server connection config is using an old user ID. Please delete and re-add this server connection.",
|
||||
"MessageOldServerConnectionWarningHelp": "You originally set up the connection to this server prior to the database migration in 2.3.0, released June 2023. A future server update will remove the ability to sign in with this old connection. Please delete the existing server connection and connect again (using the same server address and credentials). If you have any downloaded media on this device, the media will need to be downloaded again to sync with the server.",
|
||||
"MessagePodcastSearchField": "Enter search term or RSS feed URL",
|
||||
"MessagePreparingDownloads": "Preparing downloads",
|
||||
"MessageProgressSyncFailed": "The most recent attempt to report your listening progress to the server has failed. Progress sync requests will continue to be attempted every 15 seconds to 1 minute while media is playing.",
|
||||
"MessageReportBugsAndContribute": "Report bugs, request features, and contribute on",
|
||||
"MessageSeriesAlreadyDownloaded": "You have already downloaded all books in this series.",
|
||||
@@ -357,6 +360,10 @@
|
||||
"MessageSocketConnectedOverUnmeteredCellular": "Socket connected over unmetered cellular",
|
||||
"MessageSocketConnectedOverUnmeteredWifi": "Socket connected over unmetered wifi",
|
||||
"MessageSocketNotConnected": "Socket not connected",
|
||||
"MessageStorageAccessDenied": "Access denied",
|
||||
"MessageStoragePermissionDenied": "Permission denied",
|
||||
"MessageStorageWriteAccessRequired": "You do not have write access to this folder. Would you like to grant access?",
|
||||
"MessageWaitingForAvailableStorage": "Waiting for available storage",
|
||||
"NoteRSSFeedPodcastAppsHttps": "Warning: Most podcast apps will require the RSS feed URL is using HTTPS",
|
||||
"NoteRSSFeedPodcastAppsPubDate": "Warning: 1 or more of your episodes do not have a Pub Date. Some podcast apps require this.",
|
||||
"ToastBookmarkCreateFailed": "Failed to create bookmark",
|
||||
|
||||
+78
-64
@@ -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,103 +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",
|
||||
"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",
|
||||
@@ -153,38 +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",
|
||||
@@ -195,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",
|
||||
@@ -203,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",
|
||||
@@ -218,16 +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",
|
||||
@@ -237,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",
|
||||
@@ -287,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",
|
||||
@@ -332,6 +343,7 @@
|
||||
"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",
|
||||
@@ -358,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"
|
||||
}
|
||||
|
||||
+21
-1
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"ButtonAdd": "Dodaj",
|
||||
"ButtonAddNewServer": "Dodaj novi poslužitelj",
|
||||
"ButtonAllow": "Dopusti",
|
||||
"ButtonAuthors": "Autori",
|
||||
"ButtonBack": "Natrag",
|
||||
"ButtonCancel": "Odustani",
|
||||
@@ -63,6 +64,7 @@
|
||||
"HeaderChapters": "Poglavlja",
|
||||
"HeaderCollection": "Zbirka",
|
||||
"HeaderCollectionItems": "Stavke u zbirci",
|
||||
"HeaderConfirm": "Potvrdi",
|
||||
"HeaderConnectionStatus": "Stanje veze",
|
||||
"HeaderDataSettings": "Podatkovne postavke",
|
||||
"HeaderDetails": "Pojedinosti",
|
||||
@@ -91,6 +93,7 @@
|
||||
"HeaderStatsRecentSessions": "Nedavne sesije",
|
||||
"HeaderTableOfContents": "Sadržaj",
|
||||
"HeaderUserInterfaceSettings": "Postavke korisničkog sučelja",
|
||||
"HeaderWelcome": "Dobrodošao, <strong>{0}</strong>",
|
||||
"HeaderYourStats": "Vaša statistika",
|
||||
"LabelAddToPlaylist": "Dodaj na popis za izvođenje",
|
||||
"LabelAddedAt": "Dodano",
|
||||
@@ -178,6 +181,9 @@
|
||||
"LabelLayout": "Prikaz",
|
||||
"LabelLayoutAuto": "Automatski",
|
||||
"LabelLayoutSinglePage": "Jedna stranica",
|
||||
"LabelLibrarySortByProgress": "Napredak: Zadnje ažuriranje",
|
||||
"LabelLibrarySortByProgressFinished": "Napredak: Završeno",
|
||||
"LabelLibrarySortByProgressStarted": "Napredak: Započeto",
|
||||
"LabelLight": "Lagano",
|
||||
"LabelLineSpacing": "Razmak između redaka",
|
||||
"LabelListenAgain": "Ponovno poslušaj",
|
||||
@@ -237,6 +243,7 @@
|
||||
"LabelScaleElapsedTimeBySpeed": "Prilagodi preostalo vrijeme brzini izvođenja",
|
||||
"LabelSeason": "Sezona",
|
||||
"LabelSelectADevice": "Odabir uređaja",
|
||||
"LabelSelectMediaType": "Odaberi vrstu medija",
|
||||
"LabelSequenceAscending": "Redoslijed uzlazno",
|
||||
"LabelSequenceDescending": "Redoslijed silazno",
|
||||
"LabelSeries": "Serijal",
|
||||
@@ -289,18 +296,24 @@
|
||||
"MessageAudiobookshelfServerNotConnected": "Poslužitelj Audiobookshelf nije povezan",
|
||||
"MessageAudiobookshelfServerRequired": "<strong>Važno!</strong> Ova je aplikacija predviđena za rad s poslužiteljem Audiobookshelf na vašem poslužitelju kojim upravljate vi ili netko koga znate. Ova aplikacija ne nudi nikakav sadržaj.",
|
||||
"MessageBookshelfEmpty": "Polica s knjigama je prazna",
|
||||
"MessageConfirmAppExit": "Želiš izaći iz aplikacije?",
|
||||
"MessageConfirmDeleteEpisodeDownloadQueue": "Sigurno želiš izbrisati red čekanja za preuzimanje epizode?",
|
||||
"MessageConfirmDeleteLocalEpisode": "Želite li ukloniti preuzeti nastavak \"{0}\" sa svog uređaja? Ovo ne utječe na datoteku na poslužitelju.",
|
||||
"MessageConfirmDeleteLocalFiles": "Želite li ukloniti preuzete datoteke ove stavke sa svog uređaja? Ovo ne utječe na datoteke na poslužitelju niti na vaš napredak.",
|
||||
"MessageConfirmDeleteServerConfig": "Ukloniti ovu konfiguraciju poslužitelja?",
|
||||
"MessageConfirmDeleteServerEpisode": "Sigurno želiš izbrisati epizodu \"{0}\" sa servera?\nUpozorenje: Ovim će se izbrisati audio datoteka.",
|
||||
"MessageConfirmDisableAutoTimer": "Sigurno želite onemogućiti automatski timer do kraja dana? Timer će se ponovno aktivirati po isteku trenutnog automatskog timera ili ako ponovno pokrenete aplikaciju.",
|
||||
"MessageConfirmDiscardProgress": "Sigurno želite resetirati napredak?",
|
||||
"MessageConfirmDownloadUsingCellular": "Spremate se pokrenuti preuzimanje koristeći mobilne podatke. Ovo može uzrokovati dodatne troškove. Želite li nastaviti?",
|
||||
"MessageConfirmMarkAsFinished": "Sigurno želite označiti ovu stavku kao završenu?",
|
||||
"MessageConfirmPlaybackTime": "Započeti reprodukciju za \"{0}\" u {1}?",
|
||||
"MessageConfirmRemoveBookmark": "Sigurno želite ukloniti knjižnu oznaku?",
|
||||
"MessageConfirmStreamingUsingCellular": "Pokrenut ćete strujanje korištenjem mobilnih podataka. Ovo može uzrokovati dodatne troškove. Želite li nastaviti?",
|
||||
"MessageDiscardProgress": "Zanemari napredak",
|
||||
"MessageDownloadCompleteProcessing": "Preuzimanje dovršeno. Obrađujem...",
|
||||
"MessageDownloading": "Preuzimanje...",
|
||||
"MessageDownloadingEpisode": "Preuzimam nastavak",
|
||||
"MessageDownloadingFile": "Preuzimanje {0}",
|
||||
"MessageEpisodesQueuedForDownload": "{0} nastavak(a) u redu za preuzimanje",
|
||||
"MessageFailedToRefreshToken": "Osvježavanje tokena nije uspjelo, potrebna je ponovna prijava",
|
||||
"MessageFeedURLWillBe": "URL izvora bit će {0}",
|
||||
@@ -336,6 +349,7 @@
|
||||
"MessageOldServerConnectionWarning": "Veza s poslužiteljem koristi se starim ID-jem korisnika. Molimo izbrišite i ponovno dodajte ovu vezu s poslužiteljem.",
|
||||
"MessageOldServerConnectionWarningHelp": "Vezu s ovim poslužiteljem postavili ste prije migracije baze podataka u inačici 2.3.0 objavljenoj u lipnju 2023. Nadolazeće ažuriranje poslužitelja onemogućit će prijavu korištenjem ove stare veze. Molimo izbrišite postojeću vezu s poslužiteljem i ponovno se povežite (korištenjem iste adrese poslužitelja i korisničkih podataka). Ukoliko imate preuzetih medijskih datoteka na ovom uređaju, morat ćete ih iznova preuzeti da bi se sinkronizirali s poslužiteljem.",
|
||||
"MessagePodcastSearchField": "Upišite izraz za pretraživanje ili URL RSS izvora",
|
||||
"MessagePreparingDownloads": "Pripremanje preuzimanja",
|
||||
"MessageProgressSyncFailed": "Zadnji pokušaj slanja napretka slušanja poslužitelju nije uspio. Nastavit ćemo s pokušajima slanja napretka svakih 15 do 60 sekundi dok god traje izvođenje medijske datoteke.",
|
||||
"MessageReportBugsAndContribute": "Prijavite pogreške, zatražite funkcije i doprinesite na",
|
||||
"MessageSeriesAlreadyDownloaded": "Već ste preuzeli sve knjige ovog serijala.",
|
||||
@@ -346,6 +360,10 @@
|
||||
"MessageSocketConnectedOverUnmeteredCellular": "Socket je povezan preko mobilne mreže kod koje se ne prati potrošnja",
|
||||
"MessageSocketConnectedOverUnmeteredWifi": "Socket je povezan preko Wi-Fi mreže na kojoj se ne prati potrošnja",
|
||||
"MessageSocketNotConnected": "Socket nije povezan",
|
||||
"MessageStorageAccessDenied": "Pristup zabranjen",
|
||||
"MessageStoragePermissionDenied": "Dozvola odbijena",
|
||||
"MessageStorageWriteAccessRequired": "Nemate pristup za pisanje u ovu mapu. Želite li odobriti pristup?",
|
||||
"MessageWaitingForAvailableStorage": "Čekanje dostupne pohrane",
|
||||
"NoteRSSFeedPodcastAppsHttps": "Pripazite: Većina aplikacija za podcaste iziskuje URL RSS izvora koji se koristi HTTPS protokolom",
|
||||
"NoteRSSFeedPodcastAppsPubDate": "Upozorenje: jedan ili više vaših nastavaka nemaju datum objavljivanja. To je obavezno kod nekih aplikacija za podcaste.",
|
||||
"ToastBookmarkCreateFailed": "Izrada knjižne oznake nije uspjela",
|
||||
@@ -359,5 +377,7 @@
|
||||
"ToastPodcastCreateSuccess": "Podcast uspješno izrađen",
|
||||
"ToastRSSFeedCloseFailed": "Zatvaranje RSS izvora nije uspjelo",
|
||||
"ToastRSSFeedCloseSuccess": "RSS izvor zatvoren",
|
||||
"ToastStreamingNotAllowedOnCellular": "Strujanje korištenjem mobilnih podataka onemogućeno je"
|
||||
"ToastStreamingNotAllowedOnCellular": "Strujanje korištenjem mobilnih podataka onemogućeno je",
|
||||
"UnitMinutesShort": "{0}m",
|
||||
"UnitSecondsShort": "{0}s"
|
||||
}
|
||||
|
||||
+8
-8
@@ -88,7 +88,7 @@
|
||||
"HeaderSettings": "Beállítások",
|
||||
"HeaderSleepTimer": "Alvásidőzítő",
|
||||
"HeaderSleepTimerSettings": "Alvásidőzítő beállításai",
|
||||
"HeaderStatsMinutesListeningChart": "Hallgatási grafikon percekben (az elmúlt 7 napból)",
|
||||
"HeaderStatsMinutesListeningChart": "Hallgatási grafikon percben (az elmúlt 7 napból)",
|
||||
"HeaderStatsRecentSessions": "Legutóbbi munkamenetek",
|
||||
"HeaderTableOfContents": "Tartalomjegyzék",
|
||||
"HeaderUserInterfaceSettings": "Felhasználói felület beállításai",
|
||||
@@ -129,7 +129,7 @@
|
||||
"LabelCustomTime": "Egyéni idő",
|
||||
"LabelDescription": "Leírás",
|
||||
"LabelDisableAudioFadeOut": "Hang fokozatos csendesítés letiltása",
|
||||
"LabelDisableAudioFadeOutHelp": "Az audio hangerő csökkenése kezdődik, amikor kevesebb, mint 1 perc marad az alvásidőzítőből. Engedélyezze ezt a beállítást, hogy ne csendesedjen el fokozatosan a hang.",
|
||||
"LabelDisableAudioFadeOutHelp": "A hangerő csökkenni kezd az alvásidőzítő utolsó percében. Kapcsold be ezt a beállítást a halkítás kikapcsolásához.",
|
||||
"LabelDisableAutoRewind": "Automatikus visszatekerés letiltása",
|
||||
"LabelDisableShakeToReset": "Rázás a visszaállításhoz letiltása",
|
||||
"LabelDisableShakeToResetHelp": "Ha az időzítő futása alatt vagy az időzítő lejárta előtti 2 percben megrázza az eszközt, az alvásidőzítő visszaáll. Engedélyezze ezt a beállítást a rázásra történő visszaállítás letiltásához.",
|
||||
@@ -259,10 +259,10 @@
|
||||
"LabelStartTime": "Kezdési idő",
|
||||
"LabelStatsBestDay": "Legjobb nap",
|
||||
"LabelStatsDailyAverage": "Napi átlag",
|
||||
"LabelStatsDays": "Napok",
|
||||
"LabelStatsDaysListened": "Hallgatással töltött napok",
|
||||
"LabelStatsDays": "Nap",
|
||||
"LabelStatsDaysListened": "Hallgatással töltött nap",
|
||||
"LabelStatsInARow": "egymás után",
|
||||
"LabelStatsItemsFinished": "Befejezett elemek",
|
||||
"LabelStatsItemsFinished": "Befejezett elem",
|
||||
"LabelStatsMinutes": "perc",
|
||||
"LabelStatsMinutesListening": "Hallgatás percekben",
|
||||
"LabelStatsWeekListening": "Heti hallgatás",
|
||||
@@ -300,7 +300,7 @@
|
||||
"MessageConfirmDeleteLocalEpisode": "\"{0}\" helyi epizód eltávolítása az eszközről? A szerveren lévő fájl nem érintett.",
|
||||
"MessageConfirmDeleteLocalFiles": "Ezen elem helyi fájljainak eltávolítása az eszközről? A szerveren lévő fájlok és a haladás nem érintettek.",
|
||||
"MessageConfirmDeleteServerConfig": "Eltávolítja ezt a szerverkonfigurációt?",
|
||||
"MessageConfirmDeleteServerEpisode": "Biztosan törölni szeretnéd a(z) „{0}” epizódot a szerverről?\nFigyelem: ezzel az audi fájl is törlődik.",
|
||||
"MessageConfirmDeleteServerEpisode": "Biztosan törölni szeretnéd a(z) „{0}” epizódot a szerverről?\nFigyelem: ezzel az audiófájl is törlődik.",
|
||||
"MessageConfirmDisableAutoTimer": "Biztos, hogy a mai nap hátralévő részére ki akarja kapcsolni az automatikus időzítőt? Az időzítő újra aktiválódik az automatikus alvásidőzítő időszak végén, vagy ha újraindítja az alkalmazást.",
|
||||
"MessageConfirmDiscardProgress": "Biztosan alaphelyzetbe akarja állítani a haladást?",
|
||||
"MessageConfirmDownloadUsingCellular": "Mobiladat-hálózaton keresztül készülsz letölteni. Ez adatforgalmi díjakkal járhat. Szeretnéd folytatni?",
|
||||
@@ -323,11 +323,11 @@
|
||||
"MessageLoadingServerData": "Szerveradatok betöltése...",
|
||||
"MessageLocalFolderDescription": "A „Belső alkalmazás-tároló” csak ezzel az alkalmazással érhető el. Ez az alkalmazás csak a közvetlenül az alkalmazáson keresztül letöltött médiát támogatja. A megosztott tároló mappák segítségével más alkalmazások is hozzáférhetnek az alkalmazás által letöltött médiához.",
|
||||
"MessageMarkAsFinished": "Befejezettnek jelölés",
|
||||
"MessageMediaLinkedToADifferentServer": "A média egy másik címen található Audiobookshelf szerverhez kapcsolódik ({0}). A haladás szinkronizálva lesz, amikor csatlakozik ehhez a szervercímhez.",
|
||||
"MessageMediaLinkedToADifferentServer": "A média egy másik címen ({0}) lévő Audiobookshelf szerverhez van kapcsolva. A folyamatjelölés akkor lesz szinkronizálva, amikor ehhez a szervercímhez csatlakozik.",
|
||||
"MessageMediaLinkedToADifferentUser": "A média ehhez a szerverhez kapcsolódik, de egy másik felhasználó töltötte le. A haladás csak a letöltést végrehajtó felhasználóhoz lesz szinkronizálva.",
|
||||
"MessageMediaLinkedToServer": "Szerverhez kapcsolt {0}",
|
||||
"MessageMediaLinkedToThisServer": "A letöltött média ehhez a szerverhez kapcsolódik",
|
||||
"MessageMediaNotLinkedToServer": "A média nem kapcsolódik egy Audiobookshelf szerverhez. Nem lesz szinkronizálva a haladás.",
|
||||
"MessageMediaNotLinkedToServer": "A média nincs Audiobookshelf szerverhez kapcsolva. A folyamatjelölés nem lesz szinkronizálva.",
|
||||
"MessageNoBookmarks": "Nincsenek könyvjelzők",
|
||||
"MessageNoChapters": "Nincsenek fejezetek",
|
||||
"MessageNoCollections": "Nincs gyűjtemény",
|
||||
|
||||
+12
-1
@@ -1 +1,12 @@
|
||||
{}
|
||||
{
|
||||
"ButtonAdd": "Bæta við",
|
||||
"ButtonAddNewServer": "Bæta við server",
|
||||
"ButtonAuthors": "Rithöfundar",
|
||||
"ButtonBack": "Bakka",
|
||||
"ButtonCancel": "Hætta við",
|
||||
"ButtonCancelTimer": "Stöðva klukku",
|
||||
"ButtonClearFilter": "Hreinsa síu",
|
||||
"ButtonCollections": "Söfn",
|
||||
"ButtonCreate": "Stofna",
|
||||
"ButtonDelete": "Eyða"
|
||||
}
|
||||
|
||||
+16
-3
@@ -50,7 +50,7 @@
|
||||
"ButtonSendEbookToDevice": "Invia il libro al dispositivo",
|
||||
"ButtonSeries": "Serie",
|
||||
"ButtonSetTimer": "Imposta Timer",
|
||||
"ButtonStream": "Stream",
|
||||
"ButtonStream": "Riproduci",
|
||||
"ButtonSubmit": "Invia",
|
||||
"ButtonSwitchServerUser": "Cambia Server/Utente",
|
||||
"ButtonUnmaskServerAddress": "Mostra l'indirizzo server",
|
||||
@@ -63,6 +63,7 @@
|
||||
"HeaderChapters": "Capitoli",
|
||||
"HeaderCollection": "Raccolta",
|
||||
"HeaderCollectionItems": "Elementi della raccolta",
|
||||
"HeaderConfirm": "Conferma",
|
||||
"HeaderConnectionStatus": "Stato connessione",
|
||||
"HeaderDataSettings": "Impostazioni dei dati",
|
||||
"HeaderDetails": "Dettagli",
|
||||
@@ -91,6 +92,7 @@
|
||||
"HeaderStatsRecentSessions": "Sessioni Recenti",
|
||||
"HeaderTableOfContents": "Indice",
|
||||
"HeaderUserInterfaceSettings": "Impostazioni interfaccia utente",
|
||||
"HeaderWelcome": "Benvenuto, <strong>{0}</strong>",
|
||||
"HeaderYourStats": "Statistiche personali",
|
||||
"LabelAddToPlaylist": "Aggiungi alla playlist",
|
||||
"LabelAddedAt": "Aggiunto il",
|
||||
@@ -178,6 +180,9 @@
|
||||
"LabelLayout": "Disposizione",
|
||||
"LabelLayoutAuto": "Automatico",
|
||||
"LabelLayoutSinglePage": "Pagina singola",
|
||||
"LabelLibrarySortByProgress": "Progresso: ultimo aggiornamento",
|
||||
"LabelLibrarySortByProgressFinished": "Progresso: finito",
|
||||
"LabelLibrarySortByProgressStarted": "Progresso: iniziato",
|
||||
"LabelLight": "Leggera",
|
||||
"LabelLineSpacing": "Interlinea",
|
||||
"LabelListenAgain": "Ascolta ancora",
|
||||
@@ -237,6 +242,7 @@
|
||||
"LabelScaleElapsedTimeBySpeed": "Scala il tempo trascorso in base alla velocità",
|
||||
"LabelSeason": "Stagione",
|
||||
"LabelSelectADevice": "Seletiona dispositivo",
|
||||
"LabelSelectMediaType": "Seleziona il tipo di supporto",
|
||||
"LabelSequenceAscending": "Sequenza ascendente",
|
||||
"LabelSequenceDescending": "Sequenza decrescente",
|
||||
"LabelSeries": "Serie",
|
||||
@@ -289,12 +295,17 @@
|
||||
"MessageAudiobookshelfServerNotConnected": "Audiobookshelf server non connesso",
|
||||
"MessageAudiobookshelfServerRequired": "<strong>Importante!</strong> Questa app è progettata per funzionare con un server Audiobookshelf ospitato da te o da qualcuno che conosci. Questa app non fornisce alcun contenuto.",
|
||||
"MessageBookshelfEmpty": "Scaffale vuoto",
|
||||
"MessageConfirmAppExit": "Volevi uscire dall'app?",
|
||||
"MessageConfirmDeleteEpisodeDownloadQueue": "Sei sicuro di voler cancellare la coda di download degli episodi?",
|
||||
"MessageConfirmDeleteLocalEpisode": "Rimuovi episodi locali \"{0}\" dal tuo dispositivo? i file sul server non verranno toccati.",
|
||||
"MessageConfirmDeleteLocalFiles": "Rimuovi i file locali dell'oggetto? I file sul server e i progressi non verranno toccati.",
|
||||
"MessageConfirmDeleteServerConfig": "Rimuovere questa configurazione del server?",
|
||||
"MessageConfirmDeleteServerEpisode": "Sei sicuro di voler eliminare l'episodio \"{0}\" dal server?\nAttenzione: questa operazione eliminerà il file audio.",
|
||||
"MessageConfirmDisableAutoTimer": "Vuoi davvero disattivare il timer automatico per il resto della giornata? Il timer verrà riattivato alla fine di questo periodo di spegnimento automatico o se riavvii l'app.",
|
||||
"MessageConfirmDiscardProgress": "Sei sicuro/sicura di voler ripristinare i tuoi progressi?",
|
||||
"MessageConfirmDownloadUsingCellular": "Stai per eseguire il download utilizzando la rete dati cellulare. Ciò potrebbe includere addebiti per i dati dell'operatore. Vuoi continuare?",
|
||||
"MessageConfirmMarkAsFinished": "Sei sicuro/sicura di voler contrassegnare questo elemento come finito?",
|
||||
"MessageConfirmPlaybackTime": "Avviare la riproduzione per \"{0}\" alle {1}?",
|
||||
"MessageConfirmRemoveBookmark": "Sei sicuro/sicura di voler rimuovere il segnalibro?",
|
||||
"MessageConfirmStreamingUsingCellular": "Stai per eseguire lo streaming utilizzando la rete dati. Ciò potrebbe includere addebiti per i dati dell'operatore. Vuoi continuare?",
|
||||
"MessageDiscardProgress": "Elimina i progressi",
|
||||
@@ -323,7 +334,7 @@
|
||||
"MessageNoItems": "Nessun oggetto",
|
||||
"MessageNoItemsFound": "Nessun oggetto trovato",
|
||||
"MessageNoListeningSessions": "Nessuna sessione di ascolto",
|
||||
"MessageNoLogs": "Nessun registro",
|
||||
"MessageNoLogs": "Nessun rapporto",
|
||||
"MessageNoMediaFolders": "Nessuna cartella media",
|
||||
"MessageNoNetworkConnection": "Nessuna connessione di rete",
|
||||
"MessageNoPodcastsFound": "Nessun podcast trovato",
|
||||
@@ -359,5 +370,7 @@
|
||||
"ToastPodcastCreateSuccess": "Podcast creato correttamente",
|
||||
"ToastRSSFeedCloseFailed": "Errore chiusura flusso RSS",
|
||||
"ToastRSSFeedCloseSuccess": "Flusso RSS chiuso",
|
||||
"ToastStreamingNotAllowedOnCellular": "Lo streaming non è consentito sui dati mobili"
|
||||
"ToastStreamingNotAllowedOnCellular": "Lo streaming non è consentito sui dati mobili",
|
||||
"UnitMinutesShort": "{0}m",
|
||||
"UnitSecondsShort": "{0}s"
|
||||
}
|
||||
|
||||
+103
-22
@@ -40,7 +40,7 @@
|
||||
"ButtonPlayEpisode": "エピソードを再生",
|
||||
"ButtonPlaylists": "プレイリスト",
|
||||
"ButtonRead": "読む",
|
||||
"ButtonReadLess": "閉じる",
|
||||
"ButtonReadLess": "少なく表示",
|
||||
"ButtonReadMore": "もっと見る",
|
||||
"ButtonRemove": "削除",
|
||||
"ButtonRemoveFromServer": "サーバーから削除",
|
||||
@@ -88,7 +88,7 @@
|
||||
"HeaderSettings": "設定",
|
||||
"HeaderSleepTimer": "スリープタイマー",
|
||||
"HeaderSleepTimerSettings": "スリープタイマー設定",
|
||||
"HeaderStatsMinutesListeningChart": "過去7日間の視聴時間(分)",
|
||||
"HeaderStatsMinutesListeningChart": "過去7日間のリスニング時間(分)",
|
||||
"HeaderStatsRecentSessions": "最近の再生履歴",
|
||||
"HeaderTableOfContents": "目次",
|
||||
"HeaderUserInterfaceSettings": "画面設定",
|
||||
@@ -96,7 +96,7 @@
|
||||
"HeaderYourStats": "再生統計",
|
||||
"LabelAddToPlaylist": "プレイリストの追加",
|
||||
"LabelAddedAt": "追加日時",
|
||||
"LabelAddedDate": "追加日時 {0}",
|
||||
"LabelAddedDate": "追加日時 {0}",
|
||||
"LabelAll": "すべて",
|
||||
"LabelAllowSeekingOnMediaControls": "メディア通知コントロールでの位置情報の取得を許可",
|
||||
"LabelAlways": "常に",
|
||||
@@ -104,28 +104,28 @@
|
||||
"LabelAndroidAutoBrowseLimitForGroupingHelp": "表示項目数がこの数未満の場合、アルファベット順ドローダウンを使用しません",
|
||||
"LabelAndroidAutoBrowseSeriesSequenceOrder": "シリーズ内の順序",
|
||||
"LabelAskConfirmation": "確認を求める",
|
||||
"LabelAuthor": "著者",
|
||||
"LabelAuthorFirstLast": "著者(名 氏)",
|
||||
"LabelAuthorLastFirst": "著者(氏 名)",
|
||||
"LabelAuthors": "著者",
|
||||
"LabelAuthor": "作者",
|
||||
"LabelAuthorFirstLast": "作者(名 氏)",
|
||||
"LabelAuthorLastFirst": "作者(氏 名)",
|
||||
"LabelAuthors": "作者",
|
||||
"LabelAutoDownloadEpisodes": "エピソードの自動ダウンロード",
|
||||
"LabelAutoRewindTime": "自動巻き戻し時間",
|
||||
"LabelAutoSleepTimer": "自動スリープタイマー",
|
||||
"LabelAutoSleepTimerAutoRewind": "スリープタイマー時の自動巻き戻し",
|
||||
"LabelAutoSleepTimerAutoRewindHelp": "自動スリープタイマーが終了した後、再生を再開すると再生位置が自動的に巻き戻されます。",
|
||||
"LabelAutoSleepTimerHelp": "指定した開始時刻から終了時刻の間にメディアを再生すると、スリープタイマーが自動的に開始されます。",
|
||||
"LabelBooks": "ほん",
|
||||
"LabelByAuthor": "著 {0}",
|
||||
"LabelBooks": "本",
|
||||
"LabelByAuthor": "by {0}",
|
||||
"LabelChapterTrack": "チャプタートラック",
|
||||
"LabelChapters": "チャプター",
|
||||
"LabelClosePlayer": "プレイヤーを閉じる",
|
||||
"LabelClosePlayer": "プレーヤーを閉じる",
|
||||
"LabelCollapseSeries": "シリーズを折りたたむ",
|
||||
"LabelComplete": "完了",
|
||||
"LabelContinueBooks": "続きを読む本",
|
||||
"LabelContinueEpisodes": "続きのエピソード",
|
||||
"LabelContinueListening": "続きから聞く",
|
||||
"LabelContinueListening": "続きから聴く",
|
||||
"LabelContinueReading": "続きを読む",
|
||||
"LabelContinueSeries": "シリーズを続く",
|
||||
"LabelContinueSeries": "シリーズを続ける",
|
||||
"LabelCustomTime": "指定時間",
|
||||
"LabelDescription": "説明",
|
||||
"LabelDisableAudioFadeOut": "オーディオフェードアウトを無効",
|
||||
@@ -140,8 +140,8 @@
|
||||
"LabelDownloadUsingCellular": "モバイル通信を使用してダウンロード",
|
||||
"LabelDownloaded": "ダウンロード完了",
|
||||
"LabelDuration": "長さ",
|
||||
"LabelEbook": "Eブック",
|
||||
"LabelEbooks": "Eブック",
|
||||
"LabelEbook": "電子書籍",
|
||||
"LabelEbooks": "電子書籍",
|
||||
"LabelEnable": "有効",
|
||||
"LabelEnableMp3IndexSeeking": "mp3インデックス検索を有効化",
|
||||
"LabelEnableMp3IndexSeekingHelp": "この設定は、正しくシークできないMP3ファイルがある場合にのみ有効にしてください。シークの不正確さは、可変ビットレート(VBR)のMP3ファイルが原因である可能性が高いです。この設定はインデックスシークを強制し、ファイルの読み取り時に時間-バイトマッピングを構築します。大きなMP3ファイルの場合、ファイルの終わり付近をシークする際に遅延が生じる場合があります。",
|
||||
@@ -150,13 +150,13 @@
|
||||
"LabelEndTime": "終了時間",
|
||||
"LabelEpisode": "エピソード",
|
||||
"LabelExplicit": "露骨な表現",
|
||||
"LabelFeedURL": "Feed URL",
|
||||
"LabelFeedURL": "フィードURL",
|
||||
"LabelFile": "ファイル",
|
||||
"LabelFileBirthtime": "ファイル作成日時",
|
||||
"LabelFileModified": "ファイル更新日時",
|
||||
"LabelFilename": "ファイル名",
|
||||
"LabelFinished": "完了",
|
||||
"LabelFolder": "フォルダ",
|
||||
"LabelFolder": "フォルダー",
|
||||
"LabelFontBoldness": "フォントの太さ",
|
||||
"LabelFontFamily": "フォントファミリー",
|
||||
"LabelFontFamilySans": "サン",
|
||||
@@ -165,8 +165,8 @@
|
||||
"LabelGenre": "ジャンル",
|
||||
"LabelGenres": "ジャンル",
|
||||
"LabelHapticFeedback": "ハプティックフィードバック",
|
||||
"LabelHasEbook": "eBookあり",
|
||||
"LabelHasSupplementaryEbook": "付属eBookあり",
|
||||
"LabelHasEbook": "電子書籍あり",
|
||||
"LabelHasSupplementaryEbook": "付属電子書籍あり",
|
||||
"LabelHeavy": "思い",
|
||||
"LabelHigh": "高い",
|
||||
"LabelHost": "ホスト",
|
||||
@@ -178,30 +178,111 @@
|
||||
"LabelLayout": "レイアウト",
|
||||
"LabelLayoutAuto": "自動",
|
||||
"LabelLayoutSinglePage": "単ページ",
|
||||
"LabelLibrarySortByProgress": "進捗: 最終更新",
|
||||
"LabelLibrarySortByProgressFinished": "進捗: 完了",
|
||||
"LabelLibrarySortByProgressStarted": "進捗: 開始済み",
|
||||
"LabelLight": "ライト",
|
||||
"LabelLineSpacing": "行間",
|
||||
"LabelListenAgain": "再度視聴",
|
||||
"LabelListenAgain": "もう一度聴く",
|
||||
"LabelLocalBooks": "ローカルブック",
|
||||
"LabelLocalPodcasts": "ローカルポッドキャスト",
|
||||
"LabelLockPlayer": "プレイヤーのロック",
|
||||
"LabelMediaType": "メディアの種類",
|
||||
"LabelMissing": "消失",
|
||||
"LabelMore": "多い",
|
||||
"LabelMoreInfo": "追加情報",
|
||||
"LabelName": "名",
|
||||
"LabelName": "名前",
|
||||
"LabelNarrator": "ナレーター",
|
||||
"LabelNarrators": "ナレーター",
|
||||
"LabelNavigateWithVolume": "音量キーで操作",
|
||||
"LabelNavigateWithVolumeWhilePlaying": "再生中に音量キーで操作を許可",
|
||||
"LabelNavigateWithVolumeWhilePlayingDisabled": "オフ",
|
||||
"LabelNavigateWithVolumeWhilePlayingEnabled": "オン",
|
||||
"LabelNewestAuthors": "最新の著者",
|
||||
"LabelNewestAuthors": "最新の作者",
|
||||
"LabelNewestEpisodes": "最新エピソード",
|
||||
"LabelNotFinished": "未完了",
|
||||
"LabelNotStarted": "未開始",
|
||||
"LabelNumEpisodes": "{0} エピソード",
|
||||
"LabelNumEpisodesIncomplete": "{0} エピソード, {1} 未完了",
|
||||
"LabelNumberOfEpisodes": "エピソード数",
|
||||
"LabelPassword": "パスワード",
|
||||
"LabelPath": "パス",
|
||||
"LabelPlaybackLocal": "ローカル",
|
||||
"LabelPodcast": "ポッドキャスト",
|
||||
"LabelPodcasts": "ポッドキャスト",
|
||||
"LabelPreventIndexing": "フィードがiTunesおよびGoogleのポッドキャストディレクトリにインデックス登録されるのを防ぎます",
|
||||
"LabelPublishYear": "公開年"
|
||||
"LabelProgress": "進捗",
|
||||
"LabelPubDate": "公開日",
|
||||
"LabelPublishYear": "公開年",
|
||||
"LabelPublishedDate": "{0}発行",
|
||||
"LabelRSSFeedCustomOwnerEmail": "カスタムオーナーメール",
|
||||
"LabelRSSFeedCustomOwnerName": "カスタムオーナー名",
|
||||
"LabelRSSFeedOpen": "RSSフィードを公開",
|
||||
"LabelRSSFeedPreventIndexing": "インデックス化を防止",
|
||||
"LabelRSSFeedSlug": "RSSフィードスラグ",
|
||||
"LabelRandomly": "ランダム",
|
||||
"LabelRead": "読む",
|
||||
"LabelReadAgain": "もう一度読む",
|
||||
"LabelRecentSeries": "最近のシリーズ",
|
||||
"LabelRecentlyAdded": "最近追加",
|
||||
"LabelSeason": "シーズン",
|
||||
"LabelSeries": "シリーズ",
|
||||
"LabelSetEbookAsPrimary": "メインとして設定",
|
||||
"LabelSetEbookAsSupplementary": "付属として設定",
|
||||
"LabelShowAll": "すべて表示",
|
||||
"LabelSize": "サイズ",
|
||||
"LabelSleepTimer": "スリープタイマー",
|
||||
"LabelStart": "開始",
|
||||
"LabelStatsBestDay": "日ごとの最長",
|
||||
"LabelStatsDailyAverage": "日ごとの平均",
|
||||
"LabelStatsDays": "日数",
|
||||
"LabelStatsDaysListened": "聴いた日数",
|
||||
"LabelStatsInARow": "連続",
|
||||
"LabelStatsItemsFinished": "完了したアイテム",
|
||||
"LabelStatsMinutes": "分",
|
||||
"LabelStatsMinutesListening": "聴いた時間(分)",
|
||||
"LabelStatsWeekListening": "週間リスニング",
|
||||
"LabelTag": "タグ",
|
||||
"LabelTags": "タグ",
|
||||
"LabelTheme": "テーマ",
|
||||
"LabelThemeDark": "ダーク",
|
||||
"LabelThemeLight": "ライト",
|
||||
"LabelTimeRemaining": "残り{0}",
|
||||
"LabelTitle": "タイトル",
|
||||
"LabelTracks": "トラック",
|
||||
"LabelType": "タイプ",
|
||||
"LabelUnknown": "不明",
|
||||
"LabelUser": "ユーザー",
|
||||
"LabelUsername": "ユーザー名",
|
||||
"LabelYearReviewHide": "年間振り返りを非表示",
|
||||
"LabelYearReviewShow": "年間振り返りを表示",
|
||||
"LabelYourBookmarks": "ブックマーク",
|
||||
"LabelYourProgress": "進捗状況",
|
||||
"MessageDownloadingEpisode": "エピソードをダウンロード中",
|
||||
"MessageEpisodesQueuedForDownload": "{0}件のエピソードがダウンロードキューに追加されました",
|
||||
"MessageFeedURLWillBe": "フィードURLは {0} になります",
|
||||
"MessageFetching": "取得中...",
|
||||
"MessageLoading": "読み込み中...",
|
||||
"MessageMarkAsFinished": "完了済みにする",
|
||||
"MessageNoBookmarks": "ブックマークなし",
|
||||
"MessageNoChapters": "チャプターなし",
|
||||
"MessageNoCollections": "コレクションなし",
|
||||
"MessageNoItems": "アイテムなし",
|
||||
"MessageNoItemsFound": "アイテムが見つかりません",
|
||||
"MessageNoListeningSessions": "リスニングセッションなし",
|
||||
"MessageNoPodcastsFound": "ポッドキャストが見つかりません",
|
||||
"MessageNoUpdatesWereNecessary": "更新は不要でした",
|
||||
"MessageNoUserPlaylists": "プレイリストがありません",
|
||||
"MessagePodcastSearchField": "検索キーワードまたはRSSフィードURLを入力",
|
||||
"MessageReportBugsAndContribute": "バグ報告、機能リクエスト、コントリビューション:",
|
||||
"NoteRSSFeedPodcastAppsHttps": "警告: ほとんどのポッドキャストアプリはHTTPSのRSSフィードURLが必要です",
|
||||
"NoteRSSFeedPodcastAppsPubDate": "警告: 1件以上のエピソードに公開日がありません。一部のポッドキャストアプリではこれが必要です。",
|
||||
"ToastBookmarkCreateFailed": "ブックマークの作成に失敗しました",
|
||||
"ToastItemMarkedAsFinishedFailed": "完了としてマークするのに失敗しました",
|
||||
"ToastItemMarkedAsNotFinishedFailed": "未完了としてマークするのに失敗しました",
|
||||
"ToastPlaylistCreateFailed": "プレイリストの作成に失敗しました",
|
||||
"ToastPodcastCreateFailed": "ポッドキャストの作成に失敗しました",
|
||||
"ToastPodcastCreateSuccess": "ポッドキャストを作成しました",
|
||||
"ToastRSSFeedCloseFailed": "RSSフィードのクローズに失敗しました",
|
||||
"ToastRSSFeedCloseSuccess": "RSSフィードをクローズしました"
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
+24
-7
@@ -41,7 +41,7 @@
|
||||
"ButtonPlaylists": "Afspeellijsten",
|
||||
"ButtonRead": "Lezen",
|
||||
"ButtonReadLess": "Minder lezen",
|
||||
"ButtonReadMore": "Meer lezen",
|
||||
"ButtonReadMore": "Lees meer",
|
||||
"ButtonRemove": "Verwijder",
|
||||
"ButtonRemoveFromServer": "Verwijder van server",
|
||||
"ButtonSave": "Opslaan",
|
||||
@@ -63,11 +63,12 @@
|
||||
"HeaderChapters": "Hoofdstukken",
|
||||
"HeaderCollection": "Collectie",
|
||||
"HeaderCollectionItems": "Collectie-objecten",
|
||||
"HeaderConfirm": "Bevestigen",
|
||||
"HeaderConnectionStatus": "Verbindingsstatus",
|
||||
"HeaderDataSettings": "Data Instellingen",
|
||||
"HeaderDataSettings": "Gegevens Instellingen",
|
||||
"HeaderDetails": "Details",
|
||||
"HeaderDownloads": "Downloads",
|
||||
"HeaderEbookFiles": "Ebook bestanden",
|
||||
"HeaderEbookFiles": "E-book bestanden",
|
||||
"HeaderEpisodes": "Afleveringen",
|
||||
"HeaderEreaderSettings": "Ereader-instellingen",
|
||||
"HeaderLatestEpisodes": "Laatste afleveringen",
|
||||
@@ -91,6 +92,7 @@
|
||||
"HeaderStatsRecentSessions": "Recente sessies",
|
||||
"HeaderTableOfContents": "Inhoudsopgave",
|
||||
"HeaderUserInterfaceSettings": "Gebruiker Interface Instellingen",
|
||||
"HeaderWelcome": "Welkom, <strong>{0}</strong>",
|
||||
"HeaderYourStats": "Je statistieken",
|
||||
"LabelAddToPlaylist": "Toevoegen aan afspeellijst",
|
||||
"LabelAddedAt": "Toegevoegd op",
|
||||
@@ -113,6 +115,7 @@
|
||||
"LabelAutoSleepTimerAutoRewindHelp": "Wanneer de automatische slaap timer afloop zal het item terugspoelen naar de vorige positie.",
|
||||
"LabelAutoSleepTimerHelp": "Wanneer er tussen de opgegeven begin- en eindtijd media wordt afgespeeld, start er automatisch een slaaptimer.",
|
||||
"LabelBooks": "Boeken",
|
||||
"LabelByAuthor": "door {0}",
|
||||
"LabelChapterTrack": "Hoofdstuk Track",
|
||||
"LabelChapters": "Hoofdstukken",
|
||||
"LabelClosePlayer": "Sluit speler",
|
||||
@@ -155,6 +158,9 @@
|
||||
"LabelFinished": "Voltooid",
|
||||
"LabelFolder": "Map",
|
||||
"LabelFontBoldness": "Lettertype Dikte",
|
||||
"LabelFontFamily": "Letterfamilie",
|
||||
"LabelFontFamilySans": "Sans",
|
||||
"LabelFontFamilySerif": "Serif",
|
||||
"LabelFontScale": "Lettertype schaal",
|
||||
"LabelGenre": "Genre",
|
||||
"LabelGenres": "Categorieën",
|
||||
@@ -174,6 +180,9 @@
|
||||
"LabelLayout": "Layout",
|
||||
"LabelLayoutAuto": "Automatisch",
|
||||
"LabelLayoutSinglePage": "Enkele pagina",
|
||||
"LabelLibrarySortByProgress": "Voortgang: Laatst geüpdatet",
|
||||
"LabelLibrarySortByProgressFinished": "Voortgang: Voltooid",
|
||||
"LabelLibrarySortByProgressStarted": "Voortgang: Gestart",
|
||||
"LabelLight": "Licht",
|
||||
"LabelLineSpacing": "Regelruimte",
|
||||
"LabelListenAgain": "Opnieuw Beluisteren",
|
||||
@@ -233,6 +242,7 @@
|
||||
"LabelScaleElapsedTimeBySpeed": "Schaal verstreken tijd per snelheid",
|
||||
"LabelSeason": "Seizoen",
|
||||
"LabelSelectADevice": "Apparaat Selecteren",
|
||||
"LabelSelectMediaType": "Selecteer mediatype",
|
||||
"LabelSequenceAscending": "Sequentie Oplopend",
|
||||
"LabelSequenceDescending": "Sequentie Aflopend",
|
||||
"LabelSeries": "Serie",
|
||||
@@ -285,12 +295,17 @@
|
||||
"MessageAudiobookshelfServerNotConnected": "Audiobookshelf server niet geconnecteerd",
|
||||
"MessageAudiobookshelfServerRequired": "<strong>Important!</strong> Deze app is ontworpen om te werken met een Audiobookshelf-server die u of iemand die u kent host. Deze app biedt geen content.",
|
||||
"MessageBookshelfEmpty": "Boekenplank leeg",
|
||||
"MessageConfirmDeleteLocalEpisode": "Lokale aflevering \"{0}\" van uw apparaat verwijderen? Het bestand op de server blijft onaangetast.",
|
||||
"MessageConfirmAppExit": "Wil je de app afsluiten?",
|
||||
"MessageConfirmDeleteEpisodeDownloadQueue": "Weet je zeker dat je de downloadwachtrij voor afleveringen wilt wissen?",
|
||||
"MessageConfirmDeleteLocalEpisode": "Lokale aflevering \"{0}\" van uw apparaat verwijderen? Het bestand op de server blijft bewaard.",
|
||||
"MessageConfirmDeleteLocalFiles": "Lokale bestanden van dit item van uw apparaat verwijderen? De bestanden op de server en uw voortgang worden niet beïnvloed.",
|
||||
"MessageConfirmDeleteServerConfig": "Verwijder deze server configuratie?",
|
||||
"MessageConfirmDeleteServerEpisode": "Ben je zeker dat je aflevering “{0}” van de server wil verwijderen?\nWaarschuwing: Dit zal het audiobestand verwijderden.",
|
||||
"MessageConfirmDisableAutoTimer": "Weet u zeker dat je de automatische timer voor de rest van vandaag wilt uitschakelen? De timer wordt automatisch weer ingeschakeld aan het einde van deze sluimerperiode of als u de app opnieuw start.",
|
||||
"MessageConfirmDiscardProgress": "Weet u zeker dat u uw voortgang wilt resetten?",
|
||||
"MessageConfirmDownloadUsingCellular": "U staat op het punt om te downloaden met behulp van mobiele data. Dit kan kosten voor data van de provider met zich meebrengen. Wilt u doorgaan?",
|
||||
"MessageConfirmMarkAsFinished": "Weet u zeker dat u dit item als voltooid wilt markeren?",
|
||||
"MessageConfirmPlaybackTime": "Start afspelen van “{0}” op {1}?",
|
||||
"MessageConfirmRemoveBookmark": "Weet u zeker dat u de bladwijzer wilt verwijderen?",
|
||||
"MessageConfirmStreamingUsingCellular": "U staat op het punt om te streamen met behulp van mobiele data. Dit kan kosten voor data van de provider met zich meebrengen. Wilt u doorgaan?",
|
||||
"MessageDiscardProgress": "Voortgang negeren",
|
||||
@@ -303,7 +318,7 @@
|
||||
"MessageFetching": "Aan het ophalen...",
|
||||
"MessageFollowTheProjectOnGithub": "Volg dit project op Github",
|
||||
"MessageItemDownloadCompleteFailedToCreate": "Itemdownload voltooid, maar het aanmaken van een bibliotheekitem is mislukt",
|
||||
"MessageItemMissing": "Item ontbreekt en moet opgelost worden op de server. Een item is typisch gemarkeerd als ontbrekend wanneer het file pad niet toegankelijk is.",
|
||||
"MessageItemMissing": "Item is niet beschikbaar en moet opgelost worden op de server. Meestal is een item gemarkeerd als missend in verband met niet bereikbare bestandspaden.",
|
||||
"MessageLoading": "Aan het laden...",
|
||||
"MessageLoadingServerData": "Server data laden...",
|
||||
"MessageLocalFolderDescription": "'Interne app-opslag' is alleen toegankelijk voor deze app. Deze app ondersteunt alleen media die rechtstreeks via de app is gedownload. Gedeelde opslagmappen kunnen worden gebruikt om andere apps toegang te geven tot media die door deze app zijn gedownload.",
|
||||
@@ -331,7 +346,7 @@
|
||||
"MessageOldServerAuthWarningHelp": "Deze server draait een versie ouder dan v2.26.0. Een veiliger authenticatie systeem is geïntroduceerd in v2.26.0. Het wordt met klem aangeraden om de server te updaten naar de laatste versie. Mocht de server al geüpdatet zijn, log dan opnieuw in om het nieuwe authenticatie mechanisme te gebruiken.",
|
||||
"MessageOldServerConnectionWarning": "Server connectie configuratie gebruikt een oud user ID. Gelieve deze server connectie te verwijderen en opnieuw toe te voegen.",
|
||||
"MessageOldServerConnectionWarningHelp": "U hebt de verbinding met deze server oorspronkelijk ingesteld vóór de databasemigratie in versie 2.3.0, uitgebracht in juni 2023. Een toekomstige serverupdate zal de mogelijkheid om in te loggen met deze oude verbinding verwijderen. Verwijder de bestaande serververbinding en maak opnieuw verbinding (met hetzelfde serveradres en dezelfde inloggegevens). Als u gedownloade media op dit apparaat hebt, moet u deze opnieuw downloaden om te synchroniseren met de server.",
|
||||
"MessagePodcastSearchField": "Zoekterm of RSS feed URL invullen",
|
||||
"MessagePodcastSearchField": "Voer zoekterm of RSS-feed-URL in",
|
||||
"MessageProgressSyncFailed": "De meest recente poging om je luistervoortgang aan de server te rapporteren is mislukt. Verzoeken om voortgangssynchronisatie worden nog steeds elke 15 seconden tot 1 minuut uitgevoerd terwijl de media wordt afgespeeld.",
|
||||
"MessageReportBugsAndContribute": "Rapporteer bugs, vraag functionaliteiten aan en draag bij op",
|
||||
"MessageSeriesAlreadyDownloaded": "Je hebt alle boeken in deze serie al gedownload.",
|
||||
@@ -355,5 +370,7 @@
|
||||
"ToastPodcastCreateSuccess": "Podcast aangemaakt",
|
||||
"ToastRSSFeedCloseFailed": "Sluiten RSS-feed mislukt",
|
||||
"ToastRSSFeedCloseSuccess": "RSS-feed gesloten",
|
||||
"ToastStreamingNotAllowedOnCellular": "Streamen is niet toegstaan via mobiele verbinding"
|
||||
"ToastStreamingNotAllowedOnCellular": "Streamen is niet toegstaan via mobiele verbinding",
|
||||
"UnitMinutesShort": "{0}m",
|
||||
"UnitSecondsShort": "{0}s"
|
||||
}
|
||||
|
||||
+19
-1
@@ -63,6 +63,7 @@
|
||||
"HeaderChapters": "Kapittel",
|
||||
"HeaderCollection": "Samlinger",
|
||||
"HeaderCollectionItems": "Samlingsgjenstander",
|
||||
"HeaderConfirm": "Bekreft",
|
||||
"HeaderConnectionStatus": "Tilkoblingsstatus",
|
||||
"HeaderDataSettings": "Datainnstillinger",
|
||||
"HeaderDetails": "Detaljer",
|
||||
@@ -91,6 +92,7 @@
|
||||
"HeaderStatsRecentSessions": "Siste sesjoner",
|
||||
"HeaderTableOfContents": "Innholdsfortegnelse",
|
||||
"HeaderUserInterfaceSettings": "Innstillinger for Brukergrensesnitt",
|
||||
"HeaderWelcome": "Velkommen, <strong>{0}</strong>",
|
||||
"HeaderYourStats": "Din statistikk",
|
||||
"LabelAddToPlaylist": "Legg til i spilleliste",
|
||||
"LabelAddedAt": "Lagt Til",
|
||||
@@ -113,6 +115,7 @@
|
||||
"LabelAutoSleepTimerAutoRewindHelp": "Når automatisk søvntidsur fullføres og du gjenopptar avspillingen vil posisjonen din automatisk spoles tilbake.",
|
||||
"LabelAutoSleepTimerHelp": "Ved avspilling av media mellom angitt start og slutt vil automatisk søvntidsur starte automatisk.",
|
||||
"LabelBooks": "Bøker",
|
||||
"LabelByAuthor": "av {0}",
|
||||
"LabelChapterTrack": "Kapittelspor",
|
||||
"LabelChapters": "Kapitler",
|
||||
"LabelClosePlayer": "Lukk spiller",
|
||||
@@ -155,6 +158,9 @@
|
||||
"LabelFinished": "Fullført",
|
||||
"LabelFolder": "Mappe",
|
||||
"LabelFontBoldness": "Skrifttykkelse",
|
||||
"LabelFontFamily": "Skriftfamilie",
|
||||
"LabelFontFamilySans": "Sans",
|
||||
"LabelFontFamilySerif": "Serif",
|
||||
"LabelFontScale": "Font størrelse",
|
||||
"LabelGenre": "Sjanger",
|
||||
"LabelGenres": "Sjangers",
|
||||
@@ -174,6 +180,9 @@
|
||||
"LabelLayout": "Utseende",
|
||||
"LabelLayoutAuto": "Automatisk",
|
||||
"LabelLayoutSinglePage": "Enkeltside",
|
||||
"LabelLibrarySortByProgress": "Fremdrift: Sist oppdatert",
|
||||
"LabelLibrarySortByProgressFinished": "Fremdrift: Fullført",
|
||||
"LabelLibrarySortByProgressStarted": "Fremdrift: Startet",
|
||||
"LabelLight": "Lett",
|
||||
"LabelLineSpacing": "Linjemellomrom",
|
||||
"LabelListenAgain": "Lytt igjen",
|
||||
@@ -233,6 +242,7 @@
|
||||
"LabelScaleElapsedTimeBySpeed": "Skaler forløpt tid etter hastighet",
|
||||
"LabelSeason": "Sesong",
|
||||
"LabelSelectADevice": "Velg en enhet",
|
||||
"LabelSelectMediaType": "Velg medietype",
|
||||
"LabelSequenceAscending": "Stigende rekkefølge",
|
||||
"LabelSequenceDescending": "Synkende rekkefølge",
|
||||
"LabelSeries": "Serier",
|
||||
@@ -285,12 +295,17 @@
|
||||
"MessageAudiobookshelfServerNotConnected": "Ikke tilkoblet Audiobookshelf-tjener",
|
||||
"MessageAudiobookshelfServerRequired": "<strong>Viktig!</strong> Programmet fungerer kun med en Audiobookshelf-tjener som du eller noen annen vertstjener. Programmet tilbyr ikke innhold.",
|
||||
"MessageBookshelfEmpty": "Tom bokhylle",
|
||||
"MessageConfirmAppExit": "Vil du avslutte appen?",
|
||||
"MessageConfirmDeleteEpisodeDownloadQueue": "Er du sikker på at du vil tømme nedlastingskøen for episoder?",
|
||||
"MessageConfirmDeleteLocalEpisode": "Fjen episoden «{0}» fra enheten? Filen på tjeneren forblir som den er.",
|
||||
"MessageConfirmDeleteLocalFiles": "Fjern dette elementets filer fra enheten? Filene på tjeneren og framdriften forblir som den er.",
|
||||
"MessageConfirmDeleteServerConfig": "Fjerne denne serverkonfigurasjonen?",
|
||||
"MessageConfirmDeleteServerEpisode": "Er du sikker på at du vil slette episoden \"{0}\" fra serveren?\nAdvarsel: Dette vil slette lydfilen.",
|
||||
"MessageConfirmDisableAutoTimer": "Er du sikker på at du vil slå av den automatiske tidsuret for resten av dagen? Tidsuret vil bli reaktivert på slutten av perioden til dette automatiske søvn-tidsuret, eller hvis du starter appen på nytt.",
|
||||
"MessageConfirmDiscardProgress": "Er du sikker på at du vil tilbakestille fremdriften din?",
|
||||
"MessageConfirmDownloadUsingCellular": "Last ned over mobildata selv om kostnader kan tilkomme?",
|
||||
"MessageConfirmMarkAsFinished": "Er du sikker på at du vil merke element som fullført?",
|
||||
"MessageConfirmPlaybackTime": "Starte avspilling av \"{0}\" ved {1}?",
|
||||
"MessageConfirmRemoveBookmark": "Er du sikker på at du vil fjerne bokmerket?",
|
||||
"MessageConfirmStreamingUsingCellular": "Strøm via mobildata selv om kostnader kan tilkomme?",
|
||||
"MessageDiscardProgress": "Forkast framdrift",
|
||||
@@ -298,6 +313,7 @@
|
||||
"MessageDownloading": "Laster ned …",
|
||||
"MessageDownloadingEpisode": "Laster ned episode",
|
||||
"MessageEpisodesQueuedForDownload": "{0} Episode(r) lagt til i kø for nedlasting",
|
||||
"MessageFailedToRefreshToken": "Klarte ikke å oppdatere token, ny innlogging kreves",
|
||||
"MessageFeedURLWillBe": "Feed URL vil bli {0}",
|
||||
"MessageFetching": "Henter...",
|
||||
"MessageFollowTheProjectOnGithub": "Følg prosjektet på GitHub",
|
||||
@@ -354,5 +370,7 @@
|
||||
"ToastPodcastCreateSuccess": "Podcast opprettet",
|
||||
"ToastRSSFeedCloseFailed": "Misslykkes å lukke RSS feed",
|
||||
"ToastRSSFeedCloseSuccess": "RSS feed lukket",
|
||||
"ToastStreamingNotAllowedOnCellular": "Strømming tillates ikke over mobildata"
|
||||
"ToastStreamingNotAllowedOnCellular": "Strømming tillates ikke over mobildata",
|
||||
"UnitMinutesShort": "{0}m",
|
||||
"UnitSecondsShort": "{0}s"
|
||||
}
|
||||
|
||||
+18
-6
@@ -32,7 +32,7 @@
|
||||
"ButtonMaskServerAddress": "Ukryj adres serwera",
|
||||
"ButtonNewFolder": "Nowy folder",
|
||||
"ButtonNextEpisode": "Następny odcinek",
|
||||
"ButtonOk": "Ok",
|
||||
"ButtonOk": "OK",
|
||||
"ButtonOpenFeed": "Otwórz feed",
|
||||
"ButtonOverride": "Nadpisz",
|
||||
"ButtonPause": "Wstrzymaj",
|
||||
@@ -63,6 +63,7 @@
|
||||
"HeaderChapters": "Rozdziały",
|
||||
"HeaderCollection": "Kolekcja",
|
||||
"HeaderCollectionItems": "Elementy kolekcji",
|
||||
"HeaderConfirm": "Potwierdzić",
|
||||
"HeaderConnectionStatus": "Status połączenia",
|
||||
"HeaderDataSettings": "Ustawienia danych",
|
||||
"HeaderDetails": "Szczegóły",
|
||||
@@ -91,6 +92,7 @@
|
||||
"HeaderStatsRecentSessions": "Ostatnie sesje",
|
||||
"HeaderTableOfContents": "Spis treści",
|
||||
"HeaderUserInterfaceSettings": "Ustawienia inferfejsu użytkownika",
|
||||
"HeaderWelcome": "Witaj, <strong>{0}</strong>",
|
||||
"HeaderYourStats": "Twoje statystyki",
|
||||
"LabelAddToPlaylist": "Dodaj do playlisty",
|
||||
"LabelAddedAt": "Dodano",
|
||||
@@ -113,10 +115,11 @@
|
||||
"LabelAutoSleepTimerAutoRewindHelp": "Po zakończeniu automatycznego wyłącznika czasowego ponowne odtworzenie elementu spowoduje automatyczne przewinięcie pozycji do tyłu.",
|
||||
"LabelAutoSleepTimerHelp": "Podczas odtwarzania multimediów między określonym czasem rozpoczęcia i zakończenia automatycznie uruchomi się wyłącznik czasowy.",
|
||||
"LabelBooks": "Książki",
|
||||
"LabelByAuthor": "Autor {0}",
|
||||
"LabelChapterTrack": "Postęp rozdziału",
|
||||
"LabelChapters": "Rozdziały",
|
||||
"LabelClosePlayer": "Zamknij odtwarzacz",
|
||||
"LabelCollapseSeries": "Podsumuj serię",
|
||||
"LabelCollapseSeries": "Zwiń serię",
|
||||
"LabelComplete": "Ukończone",
|
||||
"LabelContinueBooks": "Kontynuuj książki",
|
||||
"LabelContinueEpisodes": "Kontynuuj odcinki",
|
||||
@@ -177,6 +180,7 @@
|
||||
"LabelLayout": "Układ",
|
||||
"LabelLayoutAuto": "Automatycznie",
|
||||
"LabelLayoutSinglePage": "Pojedyncza strona",
|
||||
"LabelLibrarySortByProgress": "Postęp: Ostatnia aktualizacja",
|
||||
"LabelLibrarySortByProgressFinished": "Postęp: Ukończony",
|
||||
"LabelLibrarySortByProgressStarted": "Postęp: Rozpoczęty",
|
||||
"LabelLight": "Jasny",
|
||||
@@ -238,6 +242,7 @@
|
||||
"LabelScaleElapsedTimeBySpeed": "Skaluj czas, który upłynął według prędkości",
|
||||
"LabelSeason": "Sezon",
|
||||
"LabelSelectADevice": "Wybierz urządzenie",
|
||||
"LabelSelectMediaType": "Wybierz rodzaj medi",
|
||||
"LabelSequenceAscending": "Koleność Rosnąca",
|
||||
"LabelSequenceDescending": "Kolejność Malejąca",
|
||||
"LabelSeries": "Serie",
|
||||
@@ -264,7 +269,7 @@
|
||||
"LabelStreamingUsingCellular": "Transmisja przy użyciu sieci komórkowej",
|
||||
"LabelTag": "Znacznik",
|
||||
"LabelTags": "Znaczniki",
|
||||
"LabelTheme": "Kompozycja",
|
||||
"LabelTheme": "Motyw",
|
||||
"LabelThemeBlack": "Czarny",
|
||||
"LabelThemeDark": "Ciemny",
|
||||
"LabelThemeLight": "Jasny",
|
||||
@@ -290,12 +295,17 @@
|
||||
"MessageAudiobookshelfServerNotConnected": "Brak połączenia z serwerem Audiobookshelf",
|
||||
"MessageAudiobookshelfServerRequired": "<strong>Important!</strong> Ta aplikacja jest przeznaczona do współpracy z serwerem Audiobookshelf, który dostarczasz Ty lub ktoś, kogo znasz. Ta aplikacja nie dostarcza żadnych treści..",
|
||||
"MessageBookshelfEmpty": "Półka pusta",
|
||||
"MessageConfirmAppExit": "Czy chcesz zamknąć aplikację?",
|
||||
"MessageConfirmDeleteEpisodeDownloadQueue": "Czy na pewno chcesz wyczyścić kolejkę pobierania odcinków?",
|
||||
"MessageConfirmDeleteLocalEpisode": "Usunąć lokalny odcinek \"{0}\" ze swojego urządzenia? Nie będzie to miało wpływu na plik na serwerze.",
|
||||
"MessageConfirmDeleteLocalFiles": "Usunąć lokalne pliki tego elementu ze swojego urządzenia? Nie będzie to miało wpływu na pliki na serwerze i Twoje postępy.",
|
||||
"MessageConfirmDisableAutoTimer": "Czy na pewno chcesz wyłączyć automatyczny timer na resztę dnia? Timer zostanie ponownie aktywowany po upływie czasu automatycznego uśpienia lub po ponownym uruchomieniu aplikacji..",
|
||||
"MessageConfirmDeleteServerConfig": "Usunąć konfigurację tego serwera?",
|
||||
"MessageConfirmDeleteServerEpisode": "Czy na pewno chcesz usunąć odcinek „{0}” z serwera?\nOstrzeżenie: spowoduje to usunięcie pliku audio.",
|
||||
"MessageConfirmDisableAutoTimer": "Czy na pewno chcesz wyłączyć automatyczny timer na resztę dnia? Timer zostanie ponownie włączony pod koniec tego okresu automatycznego uśpienia lub po ponownym uruchomieniu aplikacji.",
|
||||
"MessageConfirmDiscardProgress": "Na pewno chcesz zresetować postęp?",
|
||||
"MessageConfirmDownloadUsingCellular": "Zamierzasz pobrać poprzez dane komórkowe. Może to wpłynąć na wysokość rachunku. Czy chcesz kontynuować?",
|
||||
"MessageConfirmMarkAsFinished": "Na pewno chcesz oznaczyć ten element jako ukończony?",
|
||||
"MessageConfirmPlaybackTime": "Rozpocząć odtwarzanie pliku „{0}” w {1}?",
|
||||
"MessageConfirmRemoveBookmark": "Na pewno chcesz usunąć zakładkę?",
|
||||
"MessageConfirmStreamingUsingCellular": "Transmisja strumieniowa będzie odbywać się przy użyciu danych komórkowych. Może to obejmować opłaty za transmisję danych operatora. Czy chcesz kontynuować?",
|
||||
"MessageDiscardProgress": "Zresetuj postęp",
|
||||
@@ -337,7 +347,7 @@
|
||||
"MessageOldServerConnectionWarning": "Konfiguracja połączenia z serwerem używa starego identyfikatora użytkownika. Usuń i dodaj ponownie to połączenie z serwerem.",
|
||||
"MessageOldServerConnectionWarningHelp": "Połączenie z tym serwerem zostało pierwotnie skonfigurowane przed migracją bazy danych w wersji 2.3.0, wydanej w czerwcu 2023 r. Przyszła aktualizacja serwera usunie możliwość logowania się przy użyciu tego starego połączenia. Usuń istniejące połączenie z serwerem i połącz się ponownie (używając tego samego adresu serwera i danych logowania). Jeśli na tym urządzeniu znajdują się pobrane pliki multimedialne, konieczne będzie ich ponowne pobranie w celu synchronizacji z serwerem.",
|
||||
"MessagePodcastSearchField": "Wprowadź wyszukiwane hasło lub adres URL kanału RSS",
|
||||
"MessageProgressSyncFailed": "Ostatnia próba zgłoszenia postępów słuchania do serwera nie powiodła się. Żądania synchronizacji postępu będą wysyłane co 15 sekund do 1 minuty podczas odtwarzania multimediów.",
|
||||
"MessageProgressSyncFailed": "Ostatnia próba zgłoszenia postępu słuchania do serwera nie powiodła się. Żądania synchronizacji postępu będą nadal podejmowane co 15 sekund do 1 minuty podczas odtwarzania multimediów.",
|
||||
"MessageReportBugsAndContribute": "Zgłoś błędy, pomysły i pomóż rozwijać aplikację na",
|
||||
"MessageSeriesAlreadyDownloaded": "Seria książek już pobrana.",
|
||||
"MessageSeriesDownloadConfirm": "Pobrać serię?",
|
||||
@@ -360,5 +370,7 @@
|
||||
"ToastPodcastCreateSuccess": "Podcast został pomyślnie utworzony",
|
||||
"ToastRSSFeedCloseFailed": "Zamknięcie kanału RSS nie powiodło się",
|
||||
"ToastRSSFeedCloseSuccess": "Zamknięcie kanału RSS powiodło się",
|
||||
"ToastStreamingNotAllowedOnCellular": "Transmisja nie jest dozwolona w przy użyciu danych komórkowych"
|
||||
"ToastStreamingNotAllowedOnCellular": "Transmisja nie jest dozwolona w przy użyciu danych komórkowych",
|
||||
"UnitMinutesShort": "{0}min",
|
||||
"UnitSecondsShort": "{0}sek"
|
||||
}
|
||||
|
||||
+13
-6
@@ -27,7 +27,7 @@
|
||||
"ButtonLatest": "Mais Recentes",
|
||||
"ButtonLibrary": "Biblioteca",
|
||||
"ButtonLocalMedia": "Mídia Local",
|
||||
"ButtonLogs": "Logs",
|
||||
"ButtonLogs": "Registros",
|
||||
"ButtonManageLocalFiles": "Gerenciar Arquivos Locais",
|
||||
"ButtonMaskServerAddress": "Mascarar endereço do servidor",
|
||||
"ButtonNewFolder": "Nova Pasta",
|
||||
@@ -50,7 +50,7 @@
|
||||
"ButtonSendEbookToDevice": "Enviar Ebook para Dispositivo",
|
||||
"ButtonSeries": "Série",
|
||||
"ButtonSetTimer": "Definir Timer",
|
||||
"ButtonStream": "Stream",
|
||||
"ButtonStream": "Transmissão",
|
||||
"ButtonSubmit": "Enviar",
|
||||
"ButtonSwitchServerUser": "Trocar Servidor/Usuário",
|
||||
"ButtonUnmaskServerAddress": "Revelar endereço do servidor",
|
||||
@@ -140,8 +140,8 @@
|
||||
"LabelDownloadUsingCellular": "Download usando o celular",
|
||||
"LabelDownloaded": "Download realizado",
|
||||
"LabelDuration": "Duração",
|
||||
"LabelEbook": "Ebook",
|
||||
"LabelEbooks": "Ebooks",
|
||||
"LabelEbook": "E-book",
|
||||
"LabelEbooks": "E-books",
|
||||
"LabelEnable": "Habilitar",
|
||||
"LabelEnableMp3IndexSeeking": "Ativar busca por índice mp3",
|
||||
"LabelEnableMp3IndexSeekingHelp": "Esta configuração só deve ser ativada se a busca em arquivos mp3 não estiver funcionando corretamente. Buscas incorretas são normalmente causadas por arquivos MP3 com bitrate variável (VBR). Esta configuração forçará a busca por índice, que é uma consulta do tempo-para-byte, elaborado durante a leitura do arquivo. Em alguns casos, com arquivos MP3 maiores, haverá um atraso ao buscar perto do final do arquivo.",
|
||||
@@ -295,12 +295,17 @@
|
||||
"MessageAudiobookshelfServerNotConnected": "Servidor Audiobookshelf não conectado",
|
||||
"MessageAudiobookshelfServerRequired": "<strong>Importante!</strong> Esse aplicativo foi desenvolvido para funcionar com um servidor Audiobookshelf que você ou algum conhecido seu está mantendo. Esse aplicativo não oferece qualquer conteúdo.",
|
||||
"MessageBookshelfEmpty": "Estante vazia",
|
||||
"MessageConfirmAppExit": "Deseja sair do aplicativo?",
|
||||
"MessageConfirmDeleteEpisodeDownloadQueue": "Tem certeza de que deseja limpar a fila de downloads de episódios?",
|
||||
"MessageConfirmDeleteLocalEpisode": "Remover episódio local \"{0}\" do seu dispositivo? O arquivo no servidor não será afetado.",
|
||||
"MessageConfirmDeleteLocalFiles": "Remover arquivos locais deste item do seu dispositivo? Os arquivos no servidor e o seu progresso não serão afetados.",
|
||||
"MessageConfirmDeleteServerConfig": "Remover esta configuração do servidor?",
|
||||
"MessageConfirmDeleteServerEpisode": "Tem certeza de que deseja excluir o episódio \"{0}\" do servidor?\nAviso: Isso excluirá o arquivo de áudio.",
|
||||
"MessageConfirmDisableAutoTimer": "Tem certeza de que deseja desabilitar o timer automático para o resto do dia? O timer será re-ativado ao final do período do timer automático ou se você reiniciar o aplicativo.",
|
||||
"MessageConfirmDiscardProgress": "Tem certeza de que deseja resetar o seu progresso?",
|
||||
"MessageConfirmDownloadUsingCellular": "Você irá fazer o download usando dados do celular. Isso pode incidir em cobrança por sua operadora. Deseja continuar?",
|
||||
"MessageConfirmMarkAsFinished": "Tem certeza de que deseja marcar esse item como concluído?",
|
||||
"MessageConfirmPlaybackTime": "Iniciar reprodução para \"{0}\" em {1}?",
|
||||
"MessageConfirmRemoveBookmark": "Tem certeza de que deseja remover o marcador?",
|
||||
"MessageConfirmStreamingUsingCellular": "Você irá fazer o streaming usando dados do celular. Isso pode incidir em cobrança por sua operadora. Deseja continuar?",
|
||||
"MessageDiscardProgress": "Descartar Progresso",
|
||||
@@ -308,7 +313,7 @@
|
||||
"MessageDownloading": "Realizando o download...",
|
||||
"MessageDownloadingEpisode": "Realizando o downloading do episódio",
|
||||
"MessageEpisodesQueuedForDownload": "{0} Episódio(s) na fila de download",
|
||||
"MessageFailedToRefreshToken": "Falha ao atualizar o token, faça o login novamente.",
|
||||
"MessageFailedToRefreshToken": "Falha ao atualizar o token, faça o login novamente",
|
||||
"MessageFeedURLWillBe": "URL do Feed será {0}",
|
||||
"MessageFetching": "Buscando...",
|
||||
"MessageFollowTheProjectOnGithub": "Siga este projeto no GitHub",
|
||||
@@ -365,5 +370,7 @@
|
||||
"ToastPodcastCreateSuccess": "Podcast criado",
|
||||
"ToastRSSFeedCloseFailed": "Falha ao fechar feed RSS",
|
||||
"ToastRSSFeedCloseSuccess": "Feed RSS fechado",
|
||||
"ToastStreamingNotAllowedOnCellular": "Streaming não é permitido usando dados do celular"
|
||||
"ToastStreamingNotAllowedOnCellular": "Streaming não é permitido usando dados do celular",
|
||||
"UnitMinutesShort": "{0}m",
|
||||
"UnitSecondsShort": "{0}s"
|
||||
}
|
||||
|
||||
+21
-4
@@ -23,7 +23,7 @@
|
||||
"ButtonGoToWebClient": "Deschide în browse",
|
||||
"ButtonHistory": "Istoric",
|
||||
"ButtonHome": "Acasă",
|
||||
"ButtonIssues": "Erori",
|
||||
"ButtonIssues": "Probleme",
|
||||
"ButtonLatest": "Noutăți",
|
||||
"ButtonLibrary": "Bibliotecă",
|
||||
"ButtonLocalMedia": "Media locală",
|
||||
@@ -40,7 +40,7 @@
|
||||
"ButtonPlayEpisode": "Redă episodul",
|
||||
"ButtonPlaylists": "Liste",
|
||||
"ButtonRead": "Citește",
|
||||
"ButtonReadLess": "Mai puțin",
|
||||
"ButtonReadLess": "Citește Mai Puțin",
|
||||
"ButtonReadMore": "Afișează mai mult",
|
||||
"ButtonRemove": "Elimină",
|
||||
"ButtonRemoveFromServer": "Elimină de pe server",
|
||||
@@ -63,6 +63,7 @@
|
||||
"HeaderChapters": "Capitole",
|
||||
"HeaderCollection": "Colecție",
|
||||
"HeaderCollectionItems": "Conținutul colecției",
|
||||
"HeaderConfirm": "Confirmă",
|
||||
"HeaderConnectionStatus": "Starea conexiunii",
|
||||
"HeaderDataSettings": "Configurare date",
|
||||
"HeaderDetails": "Detalii",
|
||||
@@ -91,6 +92,7 @@
|
||||
"HeaderStatsRecentSessions": "Sesiuni recente",
|
||||
"HeaderTableOfContents": "Cuprins",
|
||||
"HeaderUserInterfaceSettings": "Setări interfață",
|
||||
"HeaderWelcome": "Bun venit, <strong>{0}</strong>",
|
||||
"HeaderYourStats": "Progresul tău",
|
||||
"LabelAddToPlaylist": "Adaugă în listă",
|
||||
"LabelAddedAt": "Adăugat la",
|
||||
@@ -113,6 +115,7 @@
|
||||
"LabelAutoSleepTimerAutoRewindHelp": "Când timerul de somn se termină, la reluarea redării vei fi mutat automat puțin înapoi.",
|
||||
"LabelAutoSleepTimerHelp": "Când redai media între orele specificate, se va porni automat timerul de somn.",
|
||||
"LabelBooks": "Cărți",
|
||||
"LabelByAuthor": "de {0}",
|
||||
"LabelChapterTrack": "Marcaje capitole",
|
||||
"LabelChapters": "Capitole",
|
||||
"LabelClosePlayer": "Închide playerul",
|
||||
@@ -147,7 +150,7 @@
|
||||
"LabelEndTime": "Oră de sfârșit",
|
||||
"LabelEpisode": "Episod",
|
||||
"LabelExplicit": "Explicit",
|
||||
"LabelFeedURL": "URL flux",
|
||||
"LabelFeedURL": "Flux URL",
|
||||
"LabelFile": "Fișier",
|
||||
"LabelFileBirthtime": "Data creării fișierului",
|
||||
"LabelFileModified": "Fișier modificat",
|
||||
@@ -155,6 +158,9 @@
|
||||
"LabelFinished": "Finalizat",
|
||||
"LabelFolder": "Dosar",
|
||||
"LabelFontBoldness": "Grosimea fontului",
|
||||
"LabelFontFamily": "Familia Fontului",
|
||||
"LabelFontFamilySans": "Sans",
|
||||
"LabelFontFamilySerif": "Serif",
|
||||
"LabelFontScale": "Mărimea fontului",
|
||||
"LabelGenre": "Gen",
|
||||
"LabelGenres": "Genuri",
|
||||
@@ -174,6 +180,9 @@
|
||||
"LabelLayout": "Aspect",
|
||||
"LabelLayoutAuto": "Automat",
|
||||
"LabelLayoutSinglePage": "Pagină unică",
|
||||
"LabelLibrarySortByProgress": "Progres: Ultima Actualizare",
|
||||
"LabelLibrarySortByProgressFinished": "Progres: Finalizat",
|
||||
"LabelLibrarySortByProgressStarted": "Progres: Început",
|
||||
"LabelLight": "Deschis",
|
||||
"LabelLineSpacing": "Spațiere între rânduri",
|
||||
"LabelListenAgain": "Ascultă din nou",
|
||||
@@ -233,6 +242,7 @@
|
||||
"LabelScaleElapsedTimeBySpeed": "Ajustează timpul scurs după viteză",
|
||||
"LabelSeason": "Sezon",
|
||||
"LabelSelectADevice": "Alege un dispozitiv",
|
||||
"LabelSelectMediaType": "Alege tipul suportului media",
|
||||
"LabelSequenceAscending": "Ordine crescătoare",
|
||||
"LabelSequenceDescending": "Ordine descrescătoare",
|
||||
"LabelSeries": "Serii",
|
||||
@@ -285,12 +295,17 @@
|
||||
"MessageAudiobookshelfServerNotConnected": "Serverul Audiobookshelf nu este conectat",
|
||||
"MessageAudiobookshelfServerRequired": "<strong>Important!</strong> Această aplicație este proiectată să funcționeze cu un server Audiobookshelf găzduit de tine sau de cineva cunoscut. Aplicația nu oferă conținut propriu.",
|
||||
"MessageBookshelfEmpty": "Raftul este gol",
|
||||
"MessageConfirmAppExit": "Doriți să ieșiti din aplicație?",
|
||||
"MessageConfirmDeleteEpisodeDownloadQueue": "Sunteți sigur că doriți să goliți coada de descărcări a episoadelor?",
|
||||
"MessageConfirmDeleteLocalEpisode": "Ștergi episodul local „{0}” de pe dispozitiv? Fișierul de pe server nu va fi afectat.",
|
||||
"MessageConfirmDeleteLocalFiles": "Ștergi fișierele locale ale acestui element de pe dispozitiv? Fișierele de pe server și progresul nu vor fi afectate.",
|
||||
"MessageConfirmDeleteServerConfig": "Stergeți această configurație a serverului?",
|
||||
"MessageConfirmDeleteServerEpisode": "Sunteți sigur că doriți să ștergeți episodul \"{0}\" de pe server?\nAtenție: Această acțiune va șterge fișierul audio.",
|
||||
"MessageConfirmDisableAutoTimer": "Sigur vrei să dezactivezi timerul automat pentru restul zilei? Acesta va fi reactivat la finalul perioadei de somn automat sau când repornești aplicația.",
|
||||
"MessageConfirmDiscardProgress": "Ești sigur că vrei să îți resetezi progresul?",
|
||||
"MessageConfirmDownloadUsingCellular": "Urmează să descarci folosind date mobile. Acest lucru poate genera costuri suplimentare. Vrei să continui?",
|
||||
"MessageConfirmMarkAsFinished": "Sigur vrei să marchezi acest element ca finalizat?",
|
||||
"MessageConfirmPlaybackTime": "Porniți redarea pentru \"{0}\" la \"{1}\"?",
|
||||
"MessageConfirmRemoveBookmark": "Sigur vrei să ștergi semnul de carte?",
|
||||
"MessageConfirmStreamingUsingCellular": "Urmează să redai online folosind date mobile. Acest lucru poate genera costuri suplimentare. Vrei să continui?",
|
||||
"MessageDiscardProgress": "Resetează progresul",
|
||||
@@ -355,5 +370,7 @@
|
||||
"ToastPodcastCreateSuccess": "Podcast creat cu succes",
|
||||
"ToastRSSFeedCloseFailed": "Nu s-a putut închide fluxul RSS",
|
||||
"ToastRSSFeedCloseSuccess": "Flux RSS închis",
|
||||
"ToastStreamingNotAllowedOnCellular": "Redarea online nu este permisă pe date mobile"
|
||||
"ToastStreamingNotAllowedOnCellular": "Redarea online nu este permisă pe date mobile",
|
||||
"UnitMinutesShort": "{0}m",
|
||||
"UnitSecondsShort": "{0}s"
|
||||
}
|
||||
|
||||
+22
-2
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"ButtonAdd": "Добавить",
|
||||
"ButtonAddNewServer": "Добавить новый сервер",
|
||||
"ButtonAllow": "Разрешить",
|
||||
"ButtonAuthors": "Авторы",
|
||||
"ButtonBack": "Назад",
|
||||
"ButtonCancel": "Отмена",
|
||||
@@ -63,6 +64,7 @@
|
||||
"HeaderChapters": "Главы",
|
||||
"HeaderCollection": "Коллекция",
|
||||
"HeaderCollectionItems": "Элементы коллекции",
|
||||
"HeaderConfirm": "Подтвердить",
|
||||
"HeaderConnectionStatus": "Состояние подключения",
|
||||
"HeaderDataSettings": "Настройки данных",
|
||||
"HeaderDetails": "Подробности",
|
||||
@@ -91,6 +93,7 @@
|
||||
"HeaderStatsRecentSessions": "Последние сеансы",
|
||||
"HeaderTableOfContents": "Содержание",
|
||||
"HeaderUserInterfaceSettings": "Настройки интерфейса",
|
||||
"HeaderWelcome": "Добро пожаловать,<strong>{0}</strong>",
|
||||
"HeaderYourStats": "Ваша статистика",
|
||||
"LabelAddToPlaylist": "Добавить в плейлист",
|
||||
"LabelAddedAt": "Дата добавления",
|
||||
@@ -163,7 +166,7 @@
|
||||
"LabelGenre": "Жанр",
|
||||
"LabelGenres": "Жанры",
|
||||
"LabelHapticFeedback": "Тактильная отдача",
|
||||
"LabelHasEbook": "Есть e-книга",
|
||||
"LabelHasEbook": "Есть электронная книга",
|
||||
"LabelHasSupplementaryEbook": "Есть дополнительная e-книга",
|
||||
"LabelHeavy": "Тяжелый",
|
||||
"LabelHigh": "Сильно",
|
||||
@@ -178,6 +181,9 @@
|
||||
"LabelLayout": "Макет",
|
||||
"LabelLayoutAuto": "Авто",
|
||||
"LabelLayoutSinglePage": "Одна страница",
|
||||
"LabelLibrarySortByProgress": "Прогресс: Последнее обновление",
|
||||
"LabelLibrarySortByProgressFinished": "Прогресс: Закончена",
|
||||
"LabelLibrarySortByProgressStarted": "Прогресс: Начата",
|
||||
"LabelLight": "Подсветка",
|
||||
"LabelLineSpacing": "Межстрочный интервал",
|
||||
"LabelListenAgain": "Послушать снова",
|
||||
@@ -237,6 +243,7 @@
|
||||
"LabelScaleElapsedTimeBySpeed": "Масштабирование затраченного времени по скорости",
|
||||
"LabelSeason": "Сезон",
|
||||
"LabelSelectADevice": "Выбор девайса",
|
||||
"LabelSelectMediaType": "Выберите тип медиа",
|
||||
"LabelSequenceAscending": "Последовательность по возрастанию",
|
||||
"LabelSequenceDescending": "Последовательность по убыванию",
|
||||
"LabelSeries": "Серия",
|
||||
@@ -289,18 +296,24 @@
|
||||
"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": "Отбросить прогресс",
|
||||
"MessageDownloadCompleteProcessing": "Загрузка завершена. Обработка...",
|
||||
"MessageDownloading": "Загрузка...",
|
||||
"MessageDownloadingEpisode": "Скачивание выпуска",
|
||||
"MessageDownloadingFile": "Загрузка {0}",
|
||||
"MessageEpisodesQueuedForDownload": "{0} выпуск(ов) запланировано для скачивания",
|
||||
"MessageFailedToRefreshToken": "Не удалось обновить токен, требуется повторный вход в систему",
|
||||
"MessageFeedURLWillBe": "URL-адрес ленты будет {0}",
|
||||
@@ -336,6 +349,7 @@
|
||||
"MessageOldServerConnectionWarning": "Конфигурация подключения к серверу использует старый идентификатор пользователя. Удалите и повторно добавьте это подключение к серверу.",
|
||||
"MessageOldServerConnectionWarningHelp": "Первоначально вы настроили подключение к этому серверу до миграции базы данных в версии 2.3.0, выпущенной в июне 2023 года. Будущее обновление сервера уберет возможность входа с помощью этого старого подключения. Удалите существующее подключение к серверу и подключитесь снова (используя тот же адрес сервера и учетные данные). Если на этом устройстве есть загруженные медиафайлы, их необходимо будет загрузить снова для синхронизации с сервером.",
|
||||
"MessagePodcastSearchField": "Введите поисковый запрос или URL-адрес RSS-ленты",
|
||||
"MessagePreparingDownloads": "Подготовка загрузок",
|
||||
"MessageProgressSyncFailed": "Последняя попытка сообщить о вашем прогрессе прослушивания на сервер не удалась. Запросы на синхронизацию прогресса будут продолжаться каждые 15 секунд в течении 1 минуты во время воспроизведения медиа.",
|
||||
"MessageReportBugsAndContribute": "Сообщайте об ошибках, запрашивайте функции и вносите свой вклад на",
|
||||
"MessageSeriesAlreadyDownloaded": "Вы уже скачали все книги из этой серии.",
|
||||
@@ -346,6 +360,10 @@
|
||||
"MessageSocketConnectedOverUnmeteredCellular": "Сокет подключен по безлимитной сотовой связи",
|
||||
"MessageSocketConnectedOverUnmeteredWifi": "Сокет подключен через безлимитный Wi-Fi",
|
||||
"MessageSocketNotConnected": "Сокет не подключен",
|
||||
"MessageStorageAccessDenied": "Доступ запрещен",
|
||||
"MessageStoragePermissionDenied": "Недостаточно прав",
|
||||
"MessageStorageWriteAccessRequired": "У вас нет доступа на запись в эту папку. Хотите предоставить доступ?",
|
||||
"MessageWaitingForAvailableStorage": "Ожидание доступного хранилища",
|
||||
"NoteRSSFeedPodcastAppsHttps": "Предупреждение: Большинству приложений подкастов потребуется, чтобы URL-адрес RSS-ленты использовал HTTPS",
|
||||
"NoteRSSFeedPodcastAppsPubDate": "Предупреждение: 1 или более выпусков не имеют даты публикации. Некоторые приложения для подкастов требуют этого.",
|
||||
"ToastBookmarkCreateFailed": "Не удалось создать закладку",
|
||||
@@ -359,5 +377,7 @@
|
||||
"ToastPodcastCreateSuccess": "Подкаст успешно создан",
|
||||
"ToastRSSFeedCloseFailed": "Не удалось закрыть RSS-ленту",
|
||||
"ToastRSSFeedCloseSuccess": "RSS-лента закрыта",
|
||||
"ToastStreamingNotAllowedOnCellular": "Потоковая передача данных по сотовой сети запрещена"
|
||||
"ToastStreamingNotAllowedOnCellular": "Потоковая передача данных по сотовой сети запрещена",
|
||||
"UnitMinutesShort": "{0}м",
|
||||
"UnitSecondsShort": "{0}с"
|
||||
}
|
||||
|
||||
+17
-4
@@ -63,6 +63,7 @@
|
||||
"HeaderChapters": "Kapitoly",
|
||||
"HeaderCollection": "Zbierky",
|
||||
"HeaderCollectionItems": "Položky zbierky",
|
||||
"HeaderConfirm": "Potvrď",
|
||||
"HeaderConnectionStatus": "Stav pripojenia",
|
||||
"HeaderDataSettings": "Nastavenie dát",
|
||||
"HeaderDetails": "Podrobnosti",
|
||||
@@ -91,6 +92,7 @@
|
||||
"HeaderStatsRecentSessions": "Nedávne relácie",
|
||||
"HeaderTableOfContents": "Obsah",
|
||||
"HeaderUserInterfaceSettings": "Nastavenia používateľského rozhrania",
|
||||
"HeaderWelcome": "Vitajte, <strong>{0}</strong>",
|
||||
"HeaderYourStats": "Vaše štatistiky",
|
||||
"LabelAddToPlaylist": "Pridať do playlistu",
|
||||
"LabelAddedAt": "Pridané",
|
||||
@@ -113,7 +115,7 @@
|
||||
"LabelAutoSleepTimerAutoRewindHelp": "Keď Auto časovač spánku skončí, pred nasledujúcim prehratím položky sa prehrávanie automaticky posunie.",
|
||||
"LabelAutoSleepTimerHelp": "keď sa prehrávanie začne vo vymedzenom časovom intervale, časovač spánku sa automaticky spustí tiež.",
|
||||
"LabelBooks": "Knihy",
|
||||
"LabelByAuthor": "od",
|
||||
"LabelByAuthor": "od {0}",
|
||||
"LabelChapterTrack": "Zvuková stopa kapitoly",
|
||||
"LabelChapters": "Kapitoly",
|
||||
"LabelClosePlayer": "Zavrieť prehrávač",
|
||||
@@ -156,7 +158,7 @@
|
||||
"LabelFinished": "Ukončené",
|
||||
"LabelFolder": "Priečinok",
|
||||
"LabelFontBoldness": "Hrúbka písma",
|
||||
"LabelFontFamily": "Písmo",
|
||||
"LabelFontFamily": "písmo",
|
||||
"LabelFontFamilySans": "Sans",
|
||||
"LabelFontFamilySerif": "Serif",
|
||||
"LabelFontScale": "Veľkosť písma",
|
||||
@@ -178,6 +180,9 @@
|
||||
"LabelLayout": "Rozloženie",
|
||||
"LabelLayoutAuto": "Auto",
|
||||
"LabelLayoutSinglePage": "Jedna stránka",
|
||||
"LabelLibrarySortByProgress": "Stav: Naposledy aktualizované",
|
||||
"LabelLibrarySortByProgressFinished": "Stav: Dokončené",
|
||||
"LabelLibrarySortByProgressStarted": "Stav: Začal",
|
||||
"LabelLight": "Svetlá",
|
||||
"LabelLineSpacing": "Riadkovanie",
|
||||
"LabelListenAgain": "Počúvať znova",
|
||||
@@ -219,7 +224,7 @@
|
||||
"LabelPodcast": "Podcast",
|
||||
"LabelPodcasts": "Podcasty",
|
||||
"LabelPreventIndexing": "Zabráni indexácii vašich zdrojov službami iTunes a Google podcast directories",
|
||||
"LabelProgress": "Stav",
|
||||
"LabelProgress": "Aktuálny stav",
|
||||
"LabelPubDate": "Dátum publikovania",
|
||||
"LabelPublishYear": "Rok vydania",
|
||||
"LabelPublishedDate": "Vydané {0}",
|
||||
@@ -237,6 +242,7 @@
|
||||
"LabelScaleElapsedTimeBySpeed": "Zmeniť ubehnutý čas podľa rýchlosti",
|
||||
"LabelSeason": "Sezóna",
|
||||
"LabelSelectADevice": "Vyberte zariadenie",
|
||||
"LabelSelectMediaType": "Vybrať typ média",
|
||||
"LabelSequenceAscending": "Vzostupné poradie",
|
||||
"LabelSequenceDescending": "Zostupné poradie",
|
||||
"LabelSeries": "Série",
|
||||
@@ -289,12 +295,17 @@
|
||||
"MessageAudiobookshelfServerNotConnected": "Audiobookshelf server nie je pripojený",
|
||||
"MessageAudiobookshelfServerRequired": "<strong>Dôležité!</strong> Táto aplikácia je navrhnutá na fungovanie so Audiobookshelf serverom, ktorý patrí vám alebo niekomu inému. Táto aplikácia sama o sebe neposkytuje žiadny obsah.",
|
||||
"MessageBookshelfEmpty": "Polička je prázdna",
|
||||
"MessageConfirmAppExit": "Chceli ste ukončiť aplikáciu?",
|
||||
"MessageConfirmDeleteEpisodeDownloadQueue": "Naozaj chcete vymazať frontu sťahovania epizód?",
|
||||
"MessageConfirmDeleteLocalEpisode": "Želáte si odstrániť lokálnu epizódu \"{0}\" z vášho zariadenia? Súbor na serveri zostane nezmenený.",
|
||||
"MessageConfirmDeleteLocalFiles": "Želáte si odstrániť z vášho zariadenia lokálne súbory patriace k tejto položke? Súbory na serveri a váš aktuálny stav počúvania zostanú nezmenené.",
|
||||
"MessageConfirmDeleteServerConfig": "Odstrániť túto konfiguráciu servera?",
|
||||
"MessageConfirmDeleteServerEpisode": "Naozaj chcete odstrániť epizódu „{0}“ zo servera?\nUpozornenie: Týmto sa vymaže aj zvukový súbor.",
|
||||
"MessageConfirmDisableAutoTimer": "Ste si istí, že chcete vypnúť automatický časovač pre zvyšok dnešného dňa? Časovač sa znova zapne na konci nastavenej doby pre automatický časovač spánku alebo pri reštarte aplikácie.",
|
||||
"MessageConfirmDiscardProgress": "Ste si istý, že chcete resetovať váš aktuálny stav počúvania?",
|
||||
"MessageConfirmDownloadUsingCellular": "Chystáte sa sťahovať pri používaní mobilných dát. Toto môže mať za následok zvýšené poplatky u vášho poskytovateľ mobilnej siete. Chcete pokračovať?",
|
||||
"MessageConfirmMarkAsFinished": "Ste si istý, že chcete označiť túto položku ako dokončenú?",
|
||||
"MessageConfirmPlaybackTime": "Spustiť prehrávanie „{0}“ od {1}?",
|
||||
"MessageConfirmRemoveBookmark": "Ste si istý, že chcete odstrániť túto záložku?",
|
||||
"MessageConfirmStreamingUsingCellular": "Chystáte sa streamovať pri používaní mobilných dát. To môže mať za následok zvýšené poplatky za využité dáta. Chcete pokračovať?",
|
||||
"MessageDiscardProgress": "Odstrániť aktuálny stav počúvania",
|
||||
@@ -359,5 +370,7 @@
|
||||
"ToastPodcastCreateSuccess": "Podcast bol vytvorený",
|
||||
"ToastRSSFeedCloseFailed": "Odstránenie RSS zdroja zlyhalo",
|
||||
"ToastRSSFeedCloseSuccess": "RSS zdroj bol odstránený",
|
||||
"ToastStreamingNotAllowedOnCellular": "Streamovanie cez mobilné dáta nie je povolené"
|
||||
"ToastStreamingNotAllowedOnCellular": "Streamovanie cez mobilné dáta nie je povolené",
|
||||
"UnitMinutesShort": "{0}m",
|
||||
"UnitSecondsShort": "{0}s"
|
||||
}
|
||||
|
||||
+16
-2
@@ -51,7 +51,7 @@
|
||||
"ButtonSeries": "Serije",
|
||||
"ButtonSetTimer": "Nastavi časovnik",
|
||||
"ButtonStream": "Pretakaj",
|
||||
"ButtonSubmit": "Potrdi",
|
||||
"ButtonSubmit": "Pošlji",
|
||||
"ButtonSwitchServerUser": "Preklopi strežnik/uporabnik",
|
||||
"ButtonUnmaskServerAddress": "Odkrij naslov strežnika",
|
||||
"ButtonUserStats": "Uporabniška statistika",
|
||||
@@ -63,6 +63,7 @@
|
||||
"HeaderChapters": "Poglavja",
|
||||
"HeaderCollection": "Zbirka",
|
||||
"HeaderCollectionItems": "Elementi zbirke",
|
||||
"HeaderConfirm": "Potrdi",
|
||||
"HeaderConnectionStatus": "Stanje povezave",
|
||||
"HeaderDataSettings": "Podatkovne nastavitve",
|
||||
"HeaderDetails": "Podrobnosti",
|
||||
@@ -91,6 +92,7 @@
|
||||
"HeaderStatsRecentSessions": "Nedavne seje",
|
||||
"HeaderTableOfContents": "Kazalo",
|
||||
"HeaderUserInterfaceSettings": "Nastavitve uporabniškega vmesnika",
|
||||
"HeaderWelcome": "Dobrodošel, <strong>{0}</strong>",
|
||||
"HeaderYourStats": "Tvoja statistika",
|
||||
"LabelAddToPlaylist": "Dodaj na seznam predvajanja",
|
||||
"LabelAddedAt": "Dodano ob",
|
||||
@@ -113,6 +115,7 @@
|
||||
"LabelAutoSleepTimerAutoRewindHelp": "Ko se samodejni časovnik za spanje konča, bo ponovno predvajanje elementa samodejno previlo na zadnji položaj.",
|
||||
"LabelAutoSleepTimerHelp": "Pri predvajanju medijev med določenim začetnim in končnim časom se samodejno zažene časovnik spanja.",
|
||||
"LabelBooks": "knjig",
|
||||
"LabelByAuthor": "od {0}",
|
||||
"LabelChapterTrack": "Posnetek poglavja",
|
||||
"LabelChapters": "Poglavja",
|
||||
"LabelClosePlayer": "Zapri predvajalnik",
|
||||
@@ -177,6 +180,9 @@
|
||||
"LabelLayout": "Postavitev",
|
||||
"LabelLayoutAuto": "Auto",
|
||||
"LabelLayoutSinglePage": "Ena stran",
|
||||
"LabelLibrarySortByProgress": "Napredek: Zadnja posodobitev",
|
||||
"LabelLibrarySortByProgressFinished": "Napredek: Končano",
|
||||
"LabelLibrarySortByProgressStarted": "Napredek: Začelo se je",
|
||||
"LabelLight": "Svetlo",
|
||||
"LabelLineSpacing": "Vrstični razmak",
|
||||
"LabelListenAgain": "Poslušaj znova",
|
||||
@@ -236,6 +242,7 @@
|
||||
"LabelScaleElapsedTimeBySpeed": "Meri pretečeni čas glede na hitrost",
|
||||
"LabelSeason": "Sezona",
|
||||
"LabelSelectADevice": "Izberite napravo",
|
||||
"LabelSelectMediaType": "Izberite vrsto medija",
|
||||
"LabelSequenceAscending": "Zaporedje naraščajoče",
|
||||
"LabelSequenceDescending": "Zaporedje padajoče",
|
||||
"LabelSeries": "Serije",
|
||||
@@ -288,12 +295,17 @@
|
||||
"MessageAudiobookshelfServerNotConnected": "Strežnik Audiobookshelf ni povezan",
|
||||
"MessageAudiobookshelfServerRequired": "<strong>Pomembno!</strong> Ta aplikacija je zasnovana za delo s strežnikom Audiobookshelf, ki ga gostite vi ali nekdo, ki ga poznate. Ta aplikacija ne ponuja nobene vsebine.",
|
||||
"MessageBookshelfEmpty": "Knjižna polica prazna",
|
||||
"MessageConfirmAppExit": "Ste želeli zapustiti aplikacijo?",
|
||||
"MessageConfirmDeleteEpisodeDownloadQueue": "Ali ste prepričani, da želite počistiti čakalno vrsto za prenos epizod?",
|
||||
"MessageConfirmDeleteLocalEpisode": "Ali želite odstraniti lokalno epizodo \"{0}\" iz svoje naprave? Datoteka na strežniku ne bo prizadeta.",
|
||||
"MessageConfirmDeleteLocalFiles": "Želite odstraniti lokalne datoteke tega elementa iz naprave? Datoteke na strežniku in vaš napredek ne bodo prizadeti.",
|
||||
"MessageConfirmDeleteServerConfig": "Odstrani to konfiguracijo strežnika?",
|
||||
"MessageConfirmDeleteServerEpisode": "Ali ste prepričani, da želite epizodo \"{0}\" izbrisati s strežnika?\nOpozorilo: S tem boste izbrisali zvočno datoteko.",
|
||||
"MessageConfirmDisableAutoTimer": "Ali si prepričani, da želiš onemogočiti samodejni časovnik do konca današnjega dne? Časovnik bo znova omogočen ob koncu tega obdobja samodejnega preklopa v mirovanje ali če znova zaženeš aplikacijo.",
|
||||
"MessageConfirmDiscardProgress": "Ali ste prepričani, da želite ponastaviti svoj napredek?",
|
||||
"MessageConfirmDownloadUsingCellular": "Prenesli boste z uporabo mobilnih podatkov. To lahko vključuje stroške prenosa podatkov. Želite nadaljevati?",
|
||||
"MessageConfirmMarkAsFinished": "Ali ste prepričani, da želite označiti ta element kot dokončan?",
|
||||
"MessageConfirmPlaybackTime": "Začni predvajanje skladbe \"{0}\" ob {1}?",
|
||||
"MessageConfirmRemoveBookmark": "Ali ste prepričani, da želite odstraniti zaznamek?",
|
||||
"MessageConfirmStreamingUsingCellular": "Pretakali boste prek mobilnih podatkov. To lahko vključuje stroške prenosa podatkov. Želite nadaljevati?",
|
||||
"MessageDiscardProgress": "Zavrzi napredek",
|
||||
@@ -358,5 +370,7 @@
|
||||
"ToastPodcastCreateSuccess": "Podcast je bil uspešno ustvarjen",
|
||||
"ToastRSSFeedCloseFailed": "Vira RSS ni bilo mogoče zapreti",
|
||||
"ToastRSSFeedCloseSuccess": "Vir RSS je bil zaprt",
|
||||
"ToastStreamingNotAllowedOnCellular": "Pretakanje ni dovoljeno na mobilnih podatkih"
|
||||
"ToastStreamingNotAllowedOnCellular": "Pretakanje ni dovoljeno na mobilnih podatkih",
|
||||
"UnitMinutesShort": "{0}m",
|
||||
"UnitSecondsShort": "{0}s"
|
||||
}
|
||||
|
||||
+189
-189
@@ -4,164 +4,164 @@
|
||||
"ButtonAuthors": "Författare",
|
||||
"ButtonBack": "Tillbaka",
|
||||
"ButtonCancel": "Avbryt",
|
||||
"ButtonCancelTimer": "Inaktivera Timer",
|
||||
"ButtonCancelTimer": "Avbryt timern",
|
||||
"ButtonClearFilter": "Rensa filter",
|
||||
"ButtonClearLogs": "Rensa Loggar",
|
||||
"ButtonCloseFeed": "Stäng flöde",
|
||||
"ButtonClearLogs": "Rensa loggar",
|
||||
"ButtonCloseFeed": "Stäng flödet",
|
||||
"ButtonCollections": "Samlingar",
|
||||
"ButtonConnect": "Anslut",
|
||||
"ButtonConnectToServer": "Anslut till Server",
|
||||
"ButtonConnectToServer": "Anslut till server",
|
||||
"ButtonCreate": "Skapa",
|
||||
"ButtonCreateBookmark": "Skapa Bokmärke",
|
||||
"ButtonCreateNewPlaylist": "Skapa Ny Spellista",
|
||||
"ButtonDelete": "Radera",
|
||||
"ButtonDeleteLocalEpisode": "Radera lokal episod",
|
||||
"ButtonDeleteLocalFile": "Radera lokal fil",
|
||||
"ButtonDeleteLocalItem": "Radera lokalt objekt",
|
||||
"ButtonDisableAutoTimer": "Inaktivera Automatisk Timer",
|
||||
"ButtonDisconnect": "Koppla ur",
|
||||
"ButtonCreateBookmark": "Skapa bokmärke",
|
||||
"ButtonCreateNewPlaylist": "Skapa ny spellista",
|
||||
"ButtonDelete": "Ta bort",
|
||||
"ButtonDeleteLocalEpisode": "Ta bort lokalt avsnitt",
|
||||
"ButtonDeleteLocalFile": "Ta bort lokal fil",
|
||||
"ButtonDeleteLocalItem": "Ta bort lokalt objekt",
|
||||
"ButtonDisableAutoTimer": "Inaktivera automatisk insomningstimer",
|
||||
"ButtonDisconnect": "Koppla från",
|
||||
"ButtonGoToWebClient": "Öppna i webbläsare",
|
||||
"ButtonHistory": "Historik",
|
||||
"ButtonHome": "Hem",
|
||||
"ButtonHome": "Startsida",
|
||||
"ButtonIssues": "Objekt med problem",
|
||||
"ButtonLatest": "Senaste",
|
||||
"ButtonLibrary": "Bibliotek",
|
||||
"ButtonLocalMedia": "Lokal media",
|
||||
"ButtonLocalMedia": "Lokala medier",
|
||||
"ButtonLogs": "Loggar",
|
||||
"ButtonManageLocalFiles": "Hantera Lokal Filer",
|
||||
"ButtonManageLocalFiles": "Hantera lokala filer",
|
||||
"ButtonMaskServerAddress": "Dölj serveradress",
|
||||
"ButtonNewFolder": "Ny Katalog",
|
||||
"ButtonNextEpisode": "Ny Episod",
|
||||
"ButtonNewFolder": "Ny mapp",
|
||||
"ButtonNextEpisode": "Nästa avsnitt",
|
||||
"ButtonOk": "OK",
|
||||
"ButtonOpenFeed": "Öppna flöde",
|
||||
"ButtonOpenFeed": "Öppna flödet",
|
||||
"ButtonOverride": "Åsidosätt",
|
||||
"ButtonPause": "Pausa",
|
||||
"ButtonPlay": "Spela",
|
||||
"ButtonPlayEpisode": "Spela Episod",
|
||||
"ButtonPlay": "Spela upp",
|
||||
"ButtonPlayEpisode": "Spela upp avsnitt",
|
||||
"ButtonPlaylists": "Spellistor",
|
||||
"ButtonRead": "Läs",
|
||||
"ButtonReadLess": "Visa mindre",
|
||||
"ButtonReadMore": "Visa mer",
|
||||
"ButtonRemove": "Ta bort",
|
||||
"ButtonRemoveFromServer": "Ta bort från Server",
|
||||
"ButtonRemoveFromServer": "Ta bort från servern",
|
||||
"ButtonSave": "Spara",
|
||||
"ButtonSaveOrder": "Spara Ordning",
|
||||
"ButtonSaveOrder": "Spara ordning",
|
||||
"ButtonSearch": "Sök",
|
||||
"ButtonSendEbookToDevice": "Skicka E-bok till Enhet",
|
||||
"ButtonSeries": "Serie",
|
||||
"ButtonSetTimer": "Ställ in timer",
|
||||
"ButtonStream": "Ström",
|
||||
"ButtonSubmit": "Spara",
|
||||
"ButtonSwitchServerUser": "Byt Server/Användare",
|
||||
"ButtonSendEbookToDevice": "Skicka e-bok till enhet",
|
||||
"ButtonSeries": "Serier",
|
||||
"ButtonSetTimer": "Ställ in insomningstimer",
|
||||
"ButtonStream": "Strömma",
|
||||
"ButtonSubmit": "Skicka",
|
||||
"ButtonSwitchServerUser": "Byt server/användare",
|
||||
"ButtonUnmaskServerAddress": "Visa serveradress",
|
||||
"ButtonUserStats": "Användarstatistik",
|
||||
"ButtonYes": "Ja",
|
||||
"HeaderAccount": "Konto",
|
||||
"HeaderAdvanced": "Avancerad",
|
||||
"HeaderAndroidAutoSettings": "Android Auto Inställningar",
|
||||
"HeaderAudioTracks": "Ljudfiler",
|
||||
"HeaderAdvanced": "Avancerat",
|
||||
"HeaderAndroidAutoSettings": "Inställningar för Android Auto",
|
||||
"HeaderAudioTracks": "Ljudspår",
|
||||
"HeaderChapters": "Kapitel",
|
||||
"HeaderCollection": "Samling",
|
||||
"HeaderCollectionItems": "Böcker i samlingen",
|
||||
"HeaderCollectionItems": "Objekt i samlingen",
|
||||
"HeaderConfirm": "Bekräfta",
|
||||
"HeaderConnectionStatus": "Anslutnings Status",
|
||||
"HeaderDataSettings": "Data Inställningar",
|
||||
"HeaderConnectionStatus": "Anslutningsstatus",
|
||||
"HeaderDataSettings": "Datainställningar",
|
||||
"HeaderDetails": "Detaljer",
|
||||
"HeaderDownloads": "Nedladdningar",
|
||||
"HeaderDownloads": "Hämtningar",
|
||||
"HeaderEbookFiles": "E-boksfiler",
|
||||
"HeaderEpisodes": "Avsnitt",
|
||||
"HeaderEreaderSettings": "E-boksinställningar",
|
||||
"HeaderEreaderSettings": "Inställningar för e-boksläsare",
|
||||
"HeaderLatestEpisodes": "Senaste avsnitten",
|
||||
"HeaderLibraries": "Bibliotek",
|
||||
"HeaderLocalFolders": "Lokala Kataloger",
|
||||
"HeaderLocalLibraryItems": "Lokala Bibliotek Filer",
|
||||
"HeaderNewPlaylist": "Ny Spellista",
|
||||
"HeaderOpenRSSFeed": "Öppna RSS-flöde",
|
||||
"HeaderLocalFolders": "Lokala mappar",
|
||||
"HeaderLocalLibraryItems": "Lokala biblioteksobjekt",
|
||||
"HeaderNewPlaylist": "Ny spellista",
|
||||
"HeaderOpenRSSFeed": "Öppna RSS-flödet",
|
||||
"HeaderPlaybackSettings": "Uppspelningsinställningar",
|
||||
"HeaderPlaylist": "Spellista",
|
||||
"HeaderPlaylistItems": "Böcker i spellistan",
|
||||
"HeaderProgressSyncFailed": "Progress Sync misslyckades",
|
||||
"HeaderRSSFeed": "RSS flöde",
|
||||
"HeaderPlaylistItems": "Objekt i spellistan",
|
||||
"HeaderProgressSyncFailed": "Synkroniseringen av uppspelningspositionen misslyckades",
|
||||
"HeaderRSSFeed": "RSS-flöde",
|
||||
"HeaderRSSFeedGeneral": "RSS-information",
|
||||
"HeaderRSSFeedIsOpen": "RSS-flödet är öppet",
|
||||
"HeaderSelectDownloadLocation": "Välj Nedladdnings Plats",
|
||||
"HeaderSelectDownloadLocation": "Välj hämtningsplats",
|
||||
"HeaderSettings": "Inställningar",
|
||||
"HeaderSleepTimer": "Timer för att sova",
|
||||
"HeaderSleepTimerSettings": "Sovtimer Inställningar",
|
||||
"HeaderStatsMinutesListeningChart": "Minuters lyssning (senaste 7 dagarna)",
|
||||
"HeaderStatsRecentSessions": "Senaste tillfällena",
|
||||
"HeaderSleepTimer": "Insomningstimer",
|
||||
"HeaderSleepTimerSettings": "Inställningar för insomningstimer",
|
||||
"HeaderStatsMinutesListeningChart": "Lyssnade minuter (senaste 7 dagarna)",
|
||||
"HeaderStatsRecentSessions": "Senaste lyssningstillfällena",
|
||||
"HeaderTableOfContents": "Innehållsförteckning",
|
||||
"HeaderUserInterfaceSettings": "Användargränssnittsinställningar",
|
||||
"HeaderWelcome": "Välkommen, <strong>{0}</strong>",
|
||||
"HeaderYourStats": "Din statistik",
|
||||
"LabelAddToPlaylist": "Lägg till i en spellista",
|
||||
"LabelAddedAt": "Tillagd vid",
|
||||
"LabelAddedDate": "Adderad {0}",
|
||||
"LabelAddToPlaylist": "Lägg till i spellista",
|
||||
"LabelAddedAt": "Tillagd",
|
||||
"LabelAddedDate": "Tillagd {0}",
|
||||
"LabelAll": "Alla",
|
||||
"LabelAllowSeekingOnMediaControls": "Tillåt positionssökning på medieaviseringskontroller",
|
||||
"LabelAllowSeekingOnMediaControls": "Tillåt spolning med mediekontrollerna i aviseringen",
|
||||
"LabelAlways": "Alltid",
|
||||
"LabelAndroidAutoBrowseLimitForGrouping": "Alfabetisk neddragningsgräns",
|
||||
"LabelAndroidAutoBrowseLimitForGroupingHelp": "Använd inte alfabetisk neddragning när det finns mindre än denna mängd objekt att visa",
|
||||
"LabelAndroidAutoBrowseSeriesSequenceOrder": "Serier bokföljd",
|
||||
"LabelAskConfirmation": "Fråga efter bekräftelse",
|
||||
"LabelAndroidAutoBrowseLimitForGrouping": "Gräns för alfabetisk gruppering",
|
||||
"LabelAndroidAutoBrowseLimitForGroupingHelp": "Gruppera inte alfabetiskt när färre än detta antal objekt visas",
|
||||
"LabelAndroidAutoBrowseSeriesSequenceOrder": "Seriernas ordningsföljd",
|
||||
"LabelAskConfirmation": "Begär bekräftelse",
|
||||
"LabelAuthor": "Författare",
|
||||
"LabelAuthorFirstLast": "Författare (Förnamn Efternamn)",
|
||||
"LabelAuthorLastFirst": "Författare (Efternamn, Förnamn)",
|
||||
"LabelAuthorFirstLast": "Författare (förnamn efternamn)",
|
||||
"LabelAuthorLastFirst": "Författare (efternamn, förnamn)",
|
||||
"LabelAuthors": "Författare",
|
||||
"LabelAutoDownloadEpisodes": "Automatisk nedladdning av avsnitt",
|
||||
"LabelAutoRewindTime": "Automatisk tillbakaspolningstid",
|
||||
"LabelAutoDownloadEpisodes": "Automatisk hämtning av avsnitt",
|
||||
"LabelAutoRewindTime": "Automatisk återspolningstid",
|
||||
"LabelAutoSleepTimer": "Automatisk insomningstimer",
|
||||
"LabelAutoSleepTimerAutoRewind": "Automatisk insomningstimer automatisk bakåtspolning",
|
||||
"LabelAutoSleepTimerAutoRewindHelp": "När den automatiska insomningstimern är klar spolas din position automatiskt tillbaka.",
|
||||
"LabelAutoSleepTimerAutoRewind": "Automatisk återspolning efter insomningstimern",
|
||||
"LabelAutoSleepTimerAutoRewindHelp": "När den automatiska insomningstimern löper ut spolas uppspelningspositionen automatiskt tillbaka.",
|
||||
"LabelAutoSleepTimerHelp": "Vid uppspelning av media mellan de angivna start- och sluttiderna startar en insomningstimer automatiskt.",
|
||||
"LabelBooks": "Böcker",
|
||||
"LabelByAuthor": "av {0}",
|
||||
"LabelChapterTrack": "Kapitel Spår",
|
||||
"LabelChapterTrack": "Kapitelspår",
|
||||
"LabelChapters": "Kapitel",
|
||||
"LabelClosePlayer": "Stäng spelaren",
|
||||
"LabelCollapseSeries": "Komprimera serier",
|
||||
"LabelComplete": "Komplett",
|
||||
"LabelContinueBooks": "Fortsätt Böcker",
|
||||
"LabelContinueEpisodes": "Fortsätt Episoder",
|
||||
"LabelCollapseSeries": "Fäll ihop serier",
|
||||
"LabelComplete": "Slutförd",
|
||||
"LabelContinueBooks": "Fortsätt med böcker",
|
||||
"LabelContinueEpisodes": "Fortsätt med avsnitt",
|
||||
"LabelContinueListening": "Fortsätt att lyssna",
|
||||
"LabelContinueReading": "Fortsätt att läsa",
|
||||
"LabelContinueSeries": "Forsätt Serie",
|
||||
"LabelContinueSeries": "Fortsätt med serien",
|
||||
"LabelCustomTime": "Anpassad tid",
|
||||
"LabelDescription": "Beskrivning",
|
||||
"LabelDisableAudioFadeOut": "Inaktivera att ljudet tonas ut",
|
||||
"LabelDisableAudioFadeOutHelp": "Ljudvolymen börjar minska när det är mindre än 1 minut kvar på insomningstimern. Aktivera den här inställningen för att inte tona ut.",
|
||||
"LabelDisableAutoRewind": "Inaktivera automatisk tillbakaspolning",
|
||||
"LabelDisableShakeToReset": "Inaktivera skakning för att återställa",
|
||||
"LabelDisableShakeToResetHelp": "Om du skakar enheten medan timern är igång eller inom 2 minuter efter att timern har löpt ut återställs insomningstimern. Aktivera den här inställningen för att inaktivera skakning för att återställa.",
|
||||
"LabelDisableAudioFadeOut": "Inaktivera uttoning av ljudet",
|
||||
"LabelDisableAudioFadeOutHelp": "Ljudvolymen börjar sänkas när mindre än en minut återstår av insomningstimern. Aktivera alternativet för att förhindra uttoningen.",
|
||||
"LabelDisableAutoRewind": "Inaktivera automatisk återspolning",
|
||||
"LabelDisableShakeToReset": "Inaktivera återställning genom skakning",
|
||||
"LabelDisableShakeToResetHelp": "Om du skakar enheten medan timern är aktiv, eller inom två minuter efter att den har löpt ut, återställs insomningstimern. Aktivera alternativet för att inaktivera funktionen.",
|
||||
"LabelDisableVibrateOnReset": "Inaktivera vibration vid återställning",
|
||||
"LabelDisableVibrateOnResetHelp": "När insomningstimern återställs kommer din enhet att vibrera. Aktivera den här inställningen för att inte vibrera när insomningstimern återställs.",
|
||||
"LabelDiscover": "Några förslag",
|
||||
"LabelDownload": "Ladda ner",
|
||||
"LabelDownloadUsingCellular": "Ladda ner med mobildata",
|
||||
"LabelDownloaded": "Nedladdat",
|
||||
"LabelDisableVibrateOnResetHelp": "Enheten vibrerar när insomningstimern återställs. Aktivera alternativet för att inaktivera vibrationen.",
|
||||
"LabelDiscover": "Upptäck",
|
||||
"LabelDownload": "Hämta",
|
||||
"LabelDownloadUsingCellular": "Hämta med mobildata",
|
||||
"LabelDownloaded": "Hämtad",
|
||||
"LabelDuration": "Längd",
|
||||
"LabelEbook": "E-bok",
|
||||
"LabelEbooks": "E-böcker",
|
||||
"LabelEnable": "Aktivera",
|
||||
"LabelEnableMp3IndexSeeking": "Aktivera mp3 index sökning",
|
||||
"LabelEnableMp3IndexSeekingHelp": "Den här inställningen bör endast aktiveras om du har mp3-filer som inte söker korrekt. Felaktig sökning beror med största sannolikhet på mp3-filer med variabel bitrate (VBR). Den här inställningen tvingar fram indexsökning, där en tid-till-byte-mappning byggs upp när filen läses. I vissa fall med stora mp3-filer blir det en fördröjning vid sökning mot slutet av filen.",
|
||||
"LabelEnableMp3IndexSeeking": "Aktivera indexbaserad spolning i MP3-filer",
|
||||
"LabelEnableMp3IndexSeekingHelp": "Aktivera endast alternativet om spolningen inte fungerar korrekt i vissa MP3-filer. Problemet orsakas oftast av MP3-filer med variabel bithastighet (VBR). Indexbaserad spolning skapar en koppling mellan tid och byteposition medan filen läses. Stora MP3-filer kan därför reagera långsammare när du spolar mot slutet.",
|
||||
"LabelEnd": "Slut",
|
||||
"LabelEndOfChapter": "Slut av kapitel",
|
||||
"LabelEndOfChapter": "Kapitlets slut",
|
||||
"LabelEndTime": "Sluttid",
|
||||
"LabelEpisode": "Avsnitt",
|
||||
"LabelExplicit": "Bestämd",
|
||||
"LabelFeedURL": "URL-adress för flödet",
|
||||
"LabelExplicit": "Vuxeninnehåll",
|
||||
"LabelFeedURL": "Flödets URL",
|
||||
"LabelFile": "Fil",
|
||||
"LabelFileBirthtime": "Tidpunkt, adderad",
|
||||
"LabelFileModified": "Tidpunkt, ändrad",
|
||||
"LabelFileBirthtime": "Skapad",
|
||||
"LabelFileModified": "Ändrad",
|
||||
"LabelFilename": "Filnamn",
|
||||
"LabelFinished": "Avslutad",
|
||||
"LabelFolder": "Mapp",
|
||||
"LabelFontBoldness": "Fetstil",
|
||||
"LabelFontFamily": "Typsnittsfamilj",
|
||||
"LabelFontFamilySans": "Utan klackar",
|
||||
"LabelFontFamilySerif": "Med klackar",
|
||||
"LabelFontScale": "Storlek på typsnitt",
|
||||
"LabelFontFamily": "Teckensnittsfamilj",
|
||||
"LabelFontFamilySans": "Sans serif",
|
||||
"LabelFontFamilySerif": "Serif",
|
||||
"LabelFontScale": "Teckenstorlek",
|
||||
"LabelGenre": "Genre",
|
||||
"LabelGenres": "Genrer",
|
||||
"LabelHapticFeedback": "Haptisk återkoppling",
|
||||
@@ -172,89 +172,89 @@
|
||||
"LabelHost": "Värd",
|
||||
"LabelInProgress": "Pågående",
|
||||
"LabelIncomplete": "Ofullständigt",
|
||||
"LabelInternalAppStorage": "Intern App Lagring",
|
||||
"LabelJumpBackwardsTime": "hoppa-bakåt-tid",
|
||||
"LabelJumpForwardsTime": "hoppa-framåt-tid",
|
||||
"LabelInternalAppStorage": "Appens interna lagring",
|
||||
"LabelJumpBackwardsTime": "Hoppintervall bakåt",
|
||||
"LabelJumpForwardsTime": "Hoppintervall framåt",
|
||||
"LabelKeepScreenAwake": "Håll skärmen vaken",
|
||||
"LabelLanguage": "Språk",
|
||||
"LabelLayout": "Layout",
|
||||
"LabelLayoutAuto": "Automatisk",
|
||||
"LabelLayoutSinglePage": "En sida",
|
||||
"LabelLibrarySortByProgress": "Status: Senast uppdaterad",
|
||||
"LabelLibrarySortByProgressFinished": "Status: Avslutad",
|
||||
"LabelLibrarySortByProgressStarted": "Status: Startad",
|
||||
"LabelLibrarySortByProgress": "Förlopp: senast uppdaterat",
|
||||
"LabelLibrarySortByProgressFinished": "Förlopp: avslutat",
|
||||
"LabelLibrarySortByProgressStarted": "Förlopp: påbörjat",
|
||||
"LabelLight": "Lätt",
|
||||
"LabelLineSpacing": "Radavstånd",
|
||||
"LabelListenAgain": "Lyssna igen",
|
||||
"LabelLocalBooks": "Lokala böcker",
|
||||
"LabelLocalPodcasts": "Lokala podcasts",
|
||||
"LabelLocalPodcasts": "Lokala poddar",
|
||||
"LabelLockOrientation": "Lås skärmorientering",
|
||||
"LabelLockPlayer": "Lås spelare",
|
||||
"LabelLockPlayer": "Lås spelaren",
|
||||
"LabelLow": "Låg",
|
||||
"LabelMediaType": "Mediatyp",
|
||||
"LabelMedium": "Medel",
|
||||
"LabelMissing": "Saknar",
|
||||
"LabelMissing": "Saknas",
|
||||
"LabelMore": "Mer",
|
||||
"LabelMoreInfo": "Mer information",
|
||||
"LabelName": "Namn",
|
||||
"LabelNarrator": "Berättare",
|
||||
"LabelNarrators": "Berättare",
|
||||
"LabelNarrator": "Uppläsare",
|
||||
"LabelNarrators": "Uppläsare",
|
||||
"LabelNavigateWithVolume": "Navigera med volymknapparna",
|
||||
"LabelNavigateWithVolumeMirrored": "Speglad",
|
||||
"LabelNavigateWithVolumeWhilePlaying": "Tillåt volymknappar att navigera medan du spelar",
|
||||
"LabelNavigateWithVolumeWhilePlaying": "Använd volymknapparna för navigering under uppspelning",
|
||||
"LabelNavigateWithVolumeWhilePlayingDisabled": "Av",
|
||||
"LabelNavigateWithVolumeWhilePlayingEnabled": "På",
|
||||
"LabelNever": "Aldrig",
|
||||
"LabelNewestAuthors": "Senaste författarna",
|
||||
"LabelNewestEpisodes": "Senaste avsnitten",
|
||||
"LabelNewestAuthors": "Senast tillagda författare",
|
||||
"LabelNewestEpisodes": "Senast tillagda avsnitt",
|
||||
"LabelNo": "Nej",
|
||||
"LabelNotFinished": "Ej avslutad",
|
||||
"LabelNotStarted": "Ej påbörjad",
|
||||
"LabelNotFinished": "Inte avslutad",
|
||||
"LabelNotStarted": "Inte påbörjad",
|
||||
"LabelNumEpisodes": "{0} avsnitt",
|
||||
"LabelNumEpisodesIncomplete": "{0} avsnitt, {1} ofärdiga",
|
||||
"LabelNumberOfEpisodes": "# av Avsnitt",
|
||||
"LabelNumEpisodesIncomplete": "{0} avsnitt, {1} inte slutförda",
|
||||
"LabelNumberOfEpisodes": "Antal avsnitt",
|
||||
"LabelOff": "Av",
|
||||
"LabelOn": "På",
|
||||
"LabelPassword": "Lösenord",
|
||||
"LabelPath": "Sökväg",
|
||||
"LabelPlaybackDirect": "Direct",
|
||||
"LabelPlaybackDirect": "Direkt",
|
||||
"LabelPlaybackLocal": "Lokal",
|
||||
"LabelPlaybackSpeed": "Uppspelningshastighet",
|
||||
"LabelPlaybackTranscode": "Transkodning",
|
||||
"LabelPodcast": "Podcast",
|
||||
"LabelPodcasts": "Podcasts",
|
||||
"LabelPreventIndexing": "Förhindra att ditt flöde indexeras av sökmotorer från iTunes och Google",
|
||||
"LabelProgress": "Framsteg",
|
||||
"LabelPlaybackTranscode": "Omkodning",
|
||||
"LabelPodcast": "Podd",
|
||||
"LabelPodcasts": "Poddar",
|
||||
"LabelPreventIndexing": "Förhindra att flödet indexeras av poddkatalogerna iTunes och Google",
|
||||
"LabelProgress": "Förlopp",
|
||||
"LabelPubDate": "Publiceringsdatum",
|
||||
"LabelPublishYear": "Utgivningsår",
|
||||
"LabelPublishedDate": "Publicerad {0}",
|
||||
"LabelRSSFeedCustomOwnerEmail": "Anpassad ägarens e-post",
|
||||
"LabelRSSFeedCustomOwnerEmail": "Anpassad e-postadress för ägaren",
|
||||
"LabelRSSFeedCustomOwnerName": "Anpassat ägarnamn",
|
||||
"LabelRSSFeedOpen": "RSS-flöde öppet",
|
||||
"LabelRSSFeedOpen": "RSS-flödet är öppet",
|
||||
"LabelRSSFeedPreventIndexing": "Förhindra indexering",
|
||||
"LabelRSSFeedSlug": "RSS-flödesslag",
|
||||
"LabelRandomly": "Slumpartat",
|
||||
"LabelRSSFeedSlug": "Slug för RSS-flödet",
|
||||
"LabelRandomly": "Slumpmässigt",
|
||||
"LabelRead": "Läs",
|
||||
"LabelReadAgain": "Läs igen",
|
||||
"LabelRecentSeries": "Senaste serierna",
|
||||
"LabelRecentlyAdded": "Nyligen adderade",
|
||||
"LabelRecentlyAdded": "Nyligen tillagda",
|
||||
"LabelRemoveFromPlaylist": "Ta bort från spellista",
|
||||
"LabelScaleElapsedTimeBySpeed": "Skaländra uppspelningsposition efter uppspelningshastighet.",
|
||||
"LabelScaleElapsedTimeBySpeed": "Justera den visade uppspelningstiden efter uppspelningshastigheten.",
|
||||
"LabelSeason": "Säsong",
|
||||
"LabelSelectADevice": "Välj en enhet",
|
||||
"LabelSelectMediaType": "Ange mediatyp",
|
||||
"LabelSelectMediaType": "Välj mediatyp",
|
||||
"LabelSequenceAscending": "Stigande",
|
||||
"LabelSequenceDescending": "Fallande",
|
||||
"LabelSeries": "Serie",
|
||||
"LabelServerAddress": "Server-adress",
|
||||
"LabelServerAddress": "Serveradress",
|
||||
"LabelSetEbookAsPrimary": "Ange som primär",
|
||||
"LabelSetEbookAsSupplementary": "Ange som kompletterande",
|
||||
"LabelShakeSensitivity": "Skakkänslighet",
|
||||
"LabelShowAll": "Visa alla",
|
||||
"LabelSize": "Storlek",
|
||||
"LabelSleepTimer": "Sovtimer",
|
||||
"LabelSleepTimerAlmostDoneChime": "Spela ett ljud när det är nästan klart",
|
||||
"LabelSleepTimerAlmostDoneChimeHelp": "Spela ett ljud när sovtimern har 30 sekunder kvar",
|
||||
"LabelSleepTimer": "Insomningstimer",
|
||||
"LabelSleepTimerAlmostDoneChime": "Spela upp ett ljud när timern nästan har löpt ut",
|
||||
"LabelSleepTimerAlmostDoneChimeHelp": "Spela upp ett ljud när 30 sekunder återstår av insomningstimern",
|
||||
"LabelStart": "Start",
|
||||
"LabelStartTime": "Starttid",
|
||||
"LabelStatsBestDay": "Bästa dag",
|
||||
@@ -275,8 +275,8 @@
|
||||
"LabelThemeLight": "Ljust",
|
||||
"LabelTimeRemaining": "{0} kvar",
|
||||
"LabelTitle": "Titel",
|
||||
"LabelTotalSize": "Total Storlek",
|
||||
"LabelTotalTrack": "Total Spår",
|
||||
"LabelTotalSize": "Total storlek",
|
||||
"LabelTotalTrack": "Totalt antal spår",
|
||||
"LabelTracks": "Ljudspår",
|
||||
"LabelType": "Typ",
|
||||
"LabelUnknown": "Okänd",
|
||||
@@ -284,93 +284,93 @@
|
||||
"LabelUseBookshelfView": "Använd bokhyllevyn",
|
||||
"LabelUser": "Användare",
|
||||
"LabelUsername": "Användarnamn",
|
||||
"LabelVeryHigh": "Väldigt hög",
|
||||
"LabelVeryLow": "Väldigt låg",
|
||||
"LabelVeryHigh": "Mycket hög",
|
||||
"LabelVeryLow": "Mycket låg",
|
||||
"LabelYearReviewHide": "Dölj årets sammanställning",
|
||||
"LabelYearReviewShow": "Visa årets sammanställning",
|
||||
"LabelYourBookmarks": "Dina bokmärken",
|
||||
"LabelYourProgress": "Framsteg",
|
||||
"MessageAndroid10Downloads": "Android 10 och lägre använder intern applagring för nedladdningar.",
|
||||
"MessageAttemptingServerConnection": "Försöker ansluta till server...",
|
||||
"MessageAudiobookshelfServerNotConnected": "Audiobookshelf-server är ej ansluten",
|
||||
"MessageAudiobookshelfServerRequired": "<strong>Viktigt!</strong> Den här appen måste användas tillsammans med en Audiobookshelf-server som hostas av dig eller dina bekanta. Appen tillhandahåller själv inget innehåll.",
|
||||
"LabelYourProgress": "Ditt förlopp",
|
||||
"MessageAndroid10Downloads": "Android 10 och lägre använder intern applagring för hämtningar.",
|
||||
"MessageAttemptingServerConnection": "Försöker ansluta till servern…",
|
||||
"MessageAudiobookshelfServerNotConnected": "Audiobookshelf-servern är inte ansluten",
|
||||
"MessageAudiobookshelfServerRequired": "<strong>Viktigt!</strong> Appen måste användas tillsammans med en Audiobookshelf-server som du eller någon du känner är värd för. Appen tillhandahåller inget eget innehåll.",
|
||||
"MessageBookshelfEmpty": "Bokhyllan är tom",
|
||||
"MessageConfirmAppExit": "Vill du avsluta appen?",
|
||||
"MessageConfirmDeleteEpisodeDownloadQueue": "Är du säker på att du vill rensa kön för nedladdning av avsnitt?",
|
||||
"MessageConfirmDeleteEpisodeDownloadQueue": "Är du säker på att du vill rensa kön för hämtning av avsnitt?",
|
||||
"MessageConfirmDeleteLocalEpisode": "Ta bort lokalt avsnitt \"{0}\" från enheten? Filen på servern påverkas inte.",
|
||||
"MessageConfirmDeleteLocalFiles": "Ta bort lokala filer för detta objekt från din enhet? Filerna på servern och din uppspelningsposition raderas inte.",
|
||||
"MessageConfirmDeleteServerConfig": "Vill du radera serverkonfigurationen?",
|
||||
"MessageConfirmDeleteServerEpisode": "Är du säkert på att du vill radera avsnitt \"{0}\" från servern?\nOBSERVERA: Ljudfilen kommer då att raderas.",
|
||||
"MessageConfirmDisableAutoTimer": "Är du säker att du vill stänga av auto timer för resten av idag? Timern kommer att sättas på igen vid slutet av den här auto-sömn timer perioden, eller om du startar om appen.",
|
||||
"MessageConfirmDeleteLocalFiles": "Ta bort lokala filer för detta objekt från din enhet? Filerna på servern och din uppspelningsposition tas inte bort.",
|
||||
"MessageConfirmDeleteServerConfig": "Vill du ta bort serverkonfigurationen?",
|
||||
"MessageConfirmDeleteServerEpisode": "Vill du verkligen ta bort avsnittet ”{0}” från servern?\nOBSERVERA: Ljudfilen tas då bort.",
|
||||
"MessageConfirmDisableAutoTimer": "Vill du inaktivera den automatiska insomningstimern under resten av dagen? Funktionen aktiveras igen när den nuvarande timerperioden är slut eller när appen startas om.",
|
||||
"MessageConfirmDiscardProgress": "Är du säker på att du vill återställa din uppspelningsposition?",
|
||||
"MessageConfirmDownloadUsingCellular": "Du kommer påbörja en nedladdning över mobildata. Detta kan leda till avgifter från din operatör. Vill du fortsätta?",
|
||||
"MessageConfirmMarkAsFinished": "Är du säker på att du vill färdigmarkera det här objektet.",
|
||||
"MessageConfirmDownloadUsingCellular": "Du håller på att starta en hämtning via mobildata. Det kan medföra avgifter från operatören. Vill du fortsätta?",
|
||||
"MessageConfirmMarkAsFinished": "Vill du markera objektet som avslutat?",
|
||||
"MessageConfirmPlaybackTime": "Vill du starta uppspelning av \"{0}\" på {1}?",
|
||||
"MessageConfirmRemoveBookmark": "Är du säker på att du vill ta bort bokmärket?",
|
||||
"MessageConfirmStreamingUsingCellular": "Du kommer påbörja en strömmning över mobildata. Detta kan leda till avgifter från din operatör. Vill du fortsätta?",
|
||||
"MessageDiscardProgress": "Avfärda uppspelningsposition",
|
||||
"MessageDownloadCompleteProcessing": "Nedladdning klar. Behandlar...",
|
||||
"MessageDownloading": "Laddar ned...",
|
||||
"MessageDownloadingEpisode": "Laddar ner avsnitt",
|
||||
"MessageEpisodesQueuedForDownload": "{0} avsnitt i kö för nedladdning",
|
||||
"MessageFailedToRefreshToken": "Misslyckades med att uppdatera token, nytt inloggningsförsök krävs",
|
||||
"MessageFeedURLWillBe": "Flödes-URL kommer att vara {0}",
|
||||
"MessageFetching": "Hämtar...",
|
||||
"MessageFollowTheProjectOnGithub": "Följ projektet på Github",
|
||||
"MessageItemDownloadCompleteFailedToCreate": "Objektet laddades ned men det gick inte att skapa ett biblioteksobjekt",
|
||||
"MessageItemMissing": "Objekt saknas och måste fixas på servern. Vanligast när ett objekt är makerad som saknad beror det på att sökvägen inte är nåbar.",
|
||||
"MessageLoading": "Laddar...",
|
||||
"MessageLoadingServerData": "Läser in serverdata...",
|
||||
"MessageLocalFolderDescription": "'Intern lagring för appen' är endast tillgänglig för denna app. Denna app stöder endast media som har laddats ner direkt via appen. Delade lagringsmappar kan användas för att tillåta andra appar att få åtkomst till media som laddats ner av denna app.",
|
||||
"MessageConfirmStreamingUsingCellular": "Du håller på att starta strömning via mobildata. Det kan medföra avgifter från operatören. Vill du fortsätta?",
|
||||
"MessageDiscardProgress": "Återställ uppspelningspositionen",
|
||||
"MessageDownloadCompleteProcessing": "Hämtningen är klar. Bearbetar…",
|
||||
"MessageDownloading": "Hämtar…",
|
||||
"MessageDownloadingEpisode": "Hämtar avsnitt",
|
||||
"MessageEpisodesQueuedForDownload": "{0} avsnitt har lagts i hämtningskön",
|
||||
"MessageFailedToRefreshToken": "Det gick inte att förnya token. Du måste logga in igen.",
|
||||
"MessageFeedURLWillBe": "Flödets URL blir {0}",
|
||||
"MessageFetching": "Hämtar…",
|
||||
"MessageFollowTheProjectOnGithub": "Följ projektet på GitHub",
|
||||
"MessageItemDownloadCompleteFailedToCreate": "Objektet hämtades men det gick inte att skapa ett biblioteksobjekt",
|
||||
"MessageItemMissing": "Objektet saknas och måste åtgärdas på servern. Det beror oftast på att sökvägen till objektet inte kan nås.",
|
||||
"MessageLoading": "Läser in…",
|
||||
"MessageLoadingServerData": "Läser in serverdata…",
|
||||
"MessageLocalFolderDescription": "'Intern lagring för appen' är endast tillgänglig för denna app. Denna app stöder endast media som har hämtats direkt via appen. Delade lagringsmappar kan användas för att tillåta andra appar att få åtkomst till media som hämtats av denna app.",
|
||||
"MessageMarkAsFinished": "Markera som avslutad",
|
||||
"MessageMediaLinkedToADifferentServer": "Detta media är länkat till en Audiobookshelf-server på annan adress ({0}). Uppspelningspositioner kommer synkas nästa gång appen kan ansluta till servern.",
|
||||
"MessageMediaLinkedToADifferentUser": "Detta media är länkat till den här servern men laddades ned av en annan användare. Uppspelningspositioner kommer endast synkas för den användaren som laddade ned objektet.",
|
||||
"MessageMediaLinkedToServer": "Länkad till server {0}",
|
||||
"MessageMediaLinkedToThisServer": "Nedladdad media är länkad till denna server",
|
||||
"MessageMediaNotLinkedToServer": "Detta media är inte länkat till en Audiobookshelf-server. Inga uppspelningspositioner kommer synkas.",
|
||||
"MessageMediaLinkedToADifferentServer": "Det här mediet är länkat till en Audiobookshelf-server på en annan adress ({0}). Uppspelningspositionen synkroniseras nästa gång appen kan ansluta till servern.",
|
||||
"MessageMediaLinkedToADifferentUser": "Det här mediet är länkat till servern men hämtades av en annan användare. Uppspelningspositionen synkroniseras endast för användaren som hämtade objektet.",
|
||||
"MessageMediaLinkedToServer": "Länkad till servern {0}",
|
||||
"MessageMediaLinkedToThisServer": "Det hämtade mediet är länkat till den här servern",
|
||||
"MessageMediaNotLinkedToServer": "Det här mediet är inte länkat till en Audiobookshelf-server. Uppspelningspositionen synkroniseras inte.",
|
||||
"MessageNoBookmarks": "Inga bokmärken",
|
||||
"MessageNoChapters": "Inga kapitel",
|
||||
"MessageNoCollections": "Inga Samlingar",
|
||||
"MessageNoCollections": "Inga samlingar",
|
||||
"MessageNoItems": "Inga objekt",
|
||||
"MessageNoItemsFound": "Inga objekt hittades",
|
||||
"MessageNoListeningSessions": "Inga lyssningstillfällen",
|
||||
"MessageNoLogs": "Inga loggar",
|
||||
"MessageNoMediaFolders": "Inga Mediamappar",
|
||||
"MessageNoMediaFolders": "Inga mediemappar",
|
||||
"MessageNoNetworkConnection": "Ingen nätverksanslutning",
|
||||
"MessageNoPodcastsFound": "Inga podcasts hittade",
|
||||
"MessageNoPodcastsFound": "Inga poddar hittades",
|
||||
"MessageNoSeries": "Inga serier",
|
||||
"MessageNoUpdatesWereNecessary": "Inga uppdateringar var nödvändiga",
|
||||
"MessageNoUpdatesWereNecessary": "Inga uppdateringar behövdes",
|
||||
"MessageNoUserPlaylists": "Du har inga spellistor",
|
||||
"MessageOldServerAuthReLoginRequired": "Autentiseringen har förbättrats för ökad säkerhet i server v2.26.0. Alla användare måste logga in igen.",
|
||||
"MessageOldServerAuthReLoginRequired": "Autentiseringen har förbättrats i serverversion 2.26.0. Alla användare måste logga in igen.",
|
||||
"MessageOldServerAuthWarning": "Servern använder en gammal autentisering",
|
||||
"MessageOldServerAuthWarningHelp": "Den här servern kör en version äldre än v2.26.0. Ett säkrare autentiseringssystem finns i v2.26.0. Det rekommenderas starkt att uppdatera servern till den senaste versionen. Om du redan har uppdaterat servern, logga in igen för att använda den nya autentiseringen.",
|
||||
"MessageOldServerConnectionWarning": "Serveranslutningskonfigurationen använder ett gammalt användar-ID. Vänligen ta bort och lägg till denna serveranslutning igen.",
|
||||
"MessageOldServerConnectionWarningHelp": "Du ställde ursprungligen in anslutningen till denna server före databasflytten i version 2.3.0, som släpptes i juni 2023. En framtida serveruppdatering kommer att ta bort möjligheten att logga in med denna gamla anslutning. Vänligen ta bort den befintliga serveranslutningen och anslut igen (med samma serveradress och inloggningsuppgifter). Om du har nedladdad media på den här enheten måste den laddas ner igen för att synkroniseras med servern.",
|
||||
"MessagePodcastSearchField": "Skriv sökfrågan eller URL-adressen för RSS-flödet",
|
||||
"MessageProgressSyncFailed": "Det senaste försöket att rapportera din lyssningsprogress till servern misslyckades. Försök att synkronisera progressen kommer att fortsätta göras var 15:e sekund till 1 minut medan media spelas.",
|
||||
"MessageReportBugsAndContribute": "Rapportera buggar, begär funktioner och bidra på",
|
||||
"MessageSeriesAlreadyDownloaded": "Du har redan laddat ner alla böcker i den här serien.",
|
||||
"MessageSeriesDownloadConfirm": "Vill du ladda ner de saknade {0} boken/böckerna med {1} fil(er), totalt {2}, till mappen {3}?",
|
||||
"MessageSeriesDownloadConfirmIos": "Vill du ladda ner de saknade {0} boken/böckerna med {1} fil(er), totalt {2}?",
|
||||
"MessageSocketConnectedOverMeteredCellular": "Socket ansluten via mätad mobilanslutning",
|
||||
"MessageSocketConnectedOverMeteredWifi": "Socket connected over metered Wi-Fi",
|
||||
"MessageSocketConnectedOverUnmeteredCellular": "Socket ansluten via omättad mobilanslutning",
|
||||
"MessageSocketConnectedOverUnmeteredWifi": "Socket ansluten via omättat Wi-Fi",
|
||||
"MessageSocketNotConnected": "Socket inte ansluten",
|
||||
"NoteRSSFeedPodcastAppsHttps": "VARNING: De flesta applikationer för podcasts kräver att URL:en för RSS-flödet använder HTTPS",
|
||||
"NoteRSSFeedPodcastAppsPubDate": "VARNING: Ett eller flera av dina avsnitt saknar publiceringsdatum. Vissa applikationer för podcasts kräver detta.",
|
||||
"MessageOldServerConnectionWarning": "Serveranslutningen använder ett äldre användar-ID. Ta bort anslutningen och lägg till den igen.",
|
||||
"MessageOldServerConnectionWarningHelp": "Anslutningen till servern skapades före databasflytten i version 2.3.0, som gavs ut i juni 2023. En framtida serveruppdatering tar bort möjligheten att logga in med den äldre anslutningen. Ta bort serveranslutningen och anslut igen med samma serveradress och inloggningsuppgifter. Om du har hämtade medier på enheten måste de hämtas igen för att kunna synkroniseras med servern.",
|
||||
"MessagePodcastSearchField": "Ange en sökfras eller RSS-flödets URL",
|
||||
"MessageProgressSyncFailed": "Det senaste försöket att skicka uppspelningspositionen till servern misslyckades. Appen fortsätter att försöka synkronisera positionen med 15 sekunders till en minuts mellanrum medan mediet spelas upp.",
|
||||
"MessageReportBugsAndContribute": "Rapportera fel, föreslå funktioner och bidra på",
|
||||
"MessageSeriesAlreadyDownloaded": "Du har redan hämtat alla böcker i serien.",
|
||||
"MessageSeriesDownloadConfirm": "Vill du hämta de {0} saknade böckerna med sammanlagt {1} filer ({2}) till mappen {3}?",
|
||||
"MessageSeriesDownloadConfirmIos": "Vill du hämta de {0} saknade böckerna med sammanlagt {1} filer ({2})?",
|
||||
"MessageSocketConnectedOverMeteredCellular": "Socketanslutning via mobilnät med datagräns",
|
||||
"MessageSocketConnectedOverMeteredWifi": "Socketanslutning via Wi-Fi med datagräns",
|
||||
"MessageSocketConnectedOverUnmeteredCellular": "Socketanslutning via mobilnät utan datagräns",
|
||||
"MessageSocketConnectedOverUnmeteredWifi": "Socketanslutning via Wi-Fi utan datagräns",
|
||||
"MessageSocketNotConnected": "Socketanslutning saknas",
|
||||
"NoteRSSFeedPodcastAppsHttps": "VARNING: De flesta poddappar kräver att RSS-flödets URL använder HTTPS",
|
||||
"NoteRSSFeedPodcastAppsPubDate": "VARNING: Ett eller flera avsnitt saknar publiceringsdatum. Vissa poddappar kräver ett sådant datum.",
|
||||
"ToastBookmarkCreateFailed": "Det gick inte att skapa bokmärket",
|
||||
"ToastBookmarkRemoveFailed": "Det gick inte att ta bort bokmärket",
|
||||
"ToastBookmarkUpdateFailed": "Det gick inte att uppdatera bokmärket",
|
||||
"ToastDownloadNotAllowedOnCellular": "Nedladdning tillåts inte över mobildata",
|
||||
"ToastItemMarkedAsFinishedFailed": "Misslyckades med att markera den som avslutad",
|
||||
"ToastItemMarkedAsNotFinishedFailed": "Misslyckades med att markera den som ej avslutad",
|
||||
"ToastDownloadNotAllowedOnCellular": "Hämtning tillåts inte över mobildata",
|
||||
"ToastItemMarkedAsFinishedFailed": "Det gick inte att markera objektet som avslutat",
|
||||
"ToastItemMarkedAsNotFinishedFailed": "Det gick inte att markera objektet som inte avslutat",
|
||||
"ToastPlaylistCreateFailed": "Det gick inte att skapa spellistan",
|
||||
"ToastPodcastCreateFailed": "Misslyckades med att skapa podcasten",
|
||||
"ToastPodcastCreateSuccess": "Podcasten skapades framgångsrikt",
|
||||
"ToastRSSFeedCloseFailed": "Misslyckades med att stänga RSS-flödet",
|
||||
"ToastRSSFeedCloseSuccess": "RSS-flödet stängt",
|
||||
"ToastStreamingNotAllowedOnCellular": "Strömning tillåts inte över mobildata.",
|
||||
"UnitMinutesShort": "{0}m",
|
||||
"ToastPodcastCreateFailed": "Det gick inte att skapa podden",
|
||||
"ToastPodcastCreateSuccess": "Podden skapades",
|
||||
"ToastRSSFeedCloseFailed": "Det gick inte att stänga RSS-flödet",
|
||||
"ToastRSSFeedCloseSuccess": "RSS-flödet har stängts",
|
||||
"ToastStreamingNotAllowedOnCellular": "Strömning tillåts inte över mobildata",
|
||||
"UnitMinutesShort": "{0} min",
|
||||
"UnitSecondsShort": "{0} s"
|
||||
}
|
||||
|
||||
+376
@@ -0,0 +1,376 @@
|
||||
{
|
||||
"ButtonAdd": "கூட்டு",
|
||||
"ButtonAddNewServer": "புதிய சேவையகத்தைச் சேர்க்கவும்",
|
||||
"ButtonAuthors": "ஆசிரியர்கள்",
|
||||
"ButtonBack": "பின்",
|
||||
"ButtonCancel": "ரத்துசெய்",
|
||||
"ButtonCancelTimer": "டைமரை ரத்து வெற்றி",
|
||||
"ButtonClearFilter": "Clear வடிப்பி",
|
||||
"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": "சேவையக முகவரியை அவிழ்த்து விடுங்கள்",
|
||||
"ButtonUserStats": "பயனர் புள்ளிவிவரங்கள்",
|
||||
"ButtonYes": "ஆம்",
|
||||
"HeaderAccount": "கணக்கு",
|
||||
"HeaderAdvanced": "மேம்பட்ட",
|
||||
"HeaderAndroidAutoSettings": "ஆண்ட்ராய்டு தானி அமைப்புகள்",
|
||||
"HeaderAudioTracks": "ஆடியோ டிராக்குகள்",
|
||||
"HeaderChapters": "பாடங்கள்",
|
||||
"HeaderCollection": "சேகரிப்பு",
|
||||
"HeaderCollectionItems": "சேகரிப்பு பொருட்கள்",
|
||||
"HeaderConfirm": "உறுதிப்படுத்தவும்",
|
||||
"HeaderConnectionStatus": "இணைப்பு நிலை",
|
||||
"HeaderDataSettings": "தரவு அமைப்புகள்",
|
||||
"HeaderDetails": "விவரங்கள்",
|
||||
"HeaderDownloads": "பதிவிறக்கங்கள்",
|
||||
"HeaderEbookFiles": "மின்புத்தக கோப்புகள்",
|
||||
"HeaderEpisodes": "அத்தியாயங்கள்",
|
||||
"HeaderEreaderSettings": "Ereader அமைப்புகள்",
|
||||
"HeaderLatestEpisodes": "அண்மைக் கால அத்தியாயங்கள்",
|
||||
"HeaderLibraries": "நூலகங்கள்",
|
||||
"HeaderLocalFolders": "உள்ளக கோப்புறைகள்",
|
||||
"HeaderLocalLibraryItems": "உள்ளக நூலகப் பொருட்கள்",
|
||||
"HeaderNewPlaylist": "புதிய பிளேலிச்ட்",
|
||||
"HeaderOpenRSSFeed": "ஆர்எச்எச் ஊட்டத்தைத் திறக்கவும்",
|
||||
"HeaderPlaybackSettings": "பின்னணி அமைப்புகள்",
|
||||
"HeaderPlaylist": "பிளேலிச்ட்",
|
||||
"HeaderPlaylistItems": "பிளேலிச்ட் உருப்படிகள்",
|
||||
"HeaderProgressSyncFailed": "முன்னேற்ற ஒத்திசைவு தோல்வியடைந்தது",
|
||||
"HeaderRSSFeed": "ஆர்எச்எச் ஊட்டம்",
|
||||
"HeaderRSSFeedGeneral": "ஆர்எச்எச் விவரங்கள்",
|
||||
"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": "ஆட்டோ ரிவைண்டை முடக்கு",
|
||||
"LabelDisableShakeToReset": "மீட்டமைக்க சேக்கை முடக்கவும்",
|
||||
"LabelDisableShakeToResetHelp": "நேரங்குறிகருவி இயங்கும் போது உங்கள் சாதனத்தை அசைப்பது அல்லது நேரங்குறிகருவி காலாவதியான 2 நிமிடங்களுக்குள் ச்லீப் டைமரை மீட்டமைக்கும். மீட்டமைக்க சேக்கை முடக்க இந்த அமைப்பை இயக்கவும்.",
|
||||
"LabelDisableVibrateOnReset": "மீட்டமைக்கும்போது அதிர்வை முடக்கு",
|
||||
"LabelDisableVibrateOnResetHelp": "ச்லீப் நேரங்குறிகருவி மீட்டமைக்கப்படும் போது, உங்கள் சாதனம் அதிர்வுறும். ச்லீப் நேரங்குறிகருவி ரீசெட் ஆகும்போது அதிர்வடையாமல் இருக்க இந்த அமைப்பை இயக்கவும்.",
|
||||
"LabelDiscover": "கண்டுபிடி",
|
||||
"LabelDownload": "பதிவிறக்கம்",
|
||||
"LabelDownloadUsingCellular": "செல்லுலார் பயன்படுத்தி பதிவிறக்கவும்",
|
||||
"LabelDownloaded": "பதிவிறக்கம் செய்யப்பட்டது",
|
||||
"LabelDuration": "கால அளவு",
|
||||
"LabelEbook": "மின்புத்தகம்",
|
||||
"LabelEbooks": "மின்புத்தகங்கள்",
|
||||
"LabelEnable": "இயக்கு",
|
||||
"LabelEnableMp3IndexSeeking": "mp3 இன்டெக்ச் தேடலை இயக்கு",
|
||||
"LabelEnableMp3IndexSeekingHelp": "சரியாகத் தேடாத mp3 கோப்புகள் இருந்தால் மட்டுமே இந்த அமைப்பு இயக்கப்படும். மாறக்கூடிய பிட்ரேட் (VBR) MP3 கோப்புகள் காரணமாக துல்லியமற்ற தேடுதல் பெரும்பாலும் இருக்கலாம். இந்த அமைப்பு குறியீட்டு தேடலை கட்டாயப்படுத்தும், இதில் கோப்பு படிக்கப்படும் போது பைட் மேப்பிங் கட்டமைக்கப்படும். பெரிய MP3 கோப்புகளுடன் சில சமயங்களில் கோப்பின் முடிவைத் தேடும்போது நேரந்தவறுகை ஏற்படும்.",
|
||||
"LabelEnd": "முடிவு",
|
||||
"LabelEndOfChapter": "அத்தியாயத்தின் முடிவு",
|
||||
"LabelEndTime": "முடிவு நேரம்",
|
||||
"LabelEpisode": "அத்தியாயம்",
|
||||
"LabelExplicit": "வெளிப்படையானது",
|
||||
"LabelFeedURL": "ஊட்ட முகவரி",
|
||||
"LabelFile": "கோப்பு",
|
||||
"LabelFileBirthtime": "கோப்பு பிறந்த நேரம்",
|
||||
"LabelFileModified": "கோப்பு மாற்றப்பட்டது",
|
||||
"LabelFilename": "கோப்புப்பெயர்",
|
||||
"LabelFinished": "முடிந்தது",
|
||||
"LabelFolder": "கோப்புறை",
|
||||
"LabelFontBoldness": "எழுத்துரு துணிவு",
|
||||
"LabelFontFamily": "எழுத்துரு குடும்பம்",
|
||||
"LabelFontFamilySans": "சான்ச்",
|
||||
"LabelFontFamilySerif": "செரிஃப்",
|
||||
"LabelFontScale": "எழுத்துரு அளவு",
|
||||
"LabelGenre": "வகை",
|
||||
"LabelGenres": "வகைகள்",
|
||||
"LabelHapticFeedback": "ஆப்டிக் பின்னூட்டம்",
|
||||
"LabelHasEbook": "மின்புத்தகம் உள்ளது",
|
||||
"LabelHasSupplementaryEbook": "துணை மின்புத்தகம் உள்ளது",
|
||||
"LabelHeavy": "கனமானது",
|
||||
"LabelHigh": "உயர்",
|
||||
"LabelHost": "விருந்தோம்பி",
|
||||
"LabelInProgress": "செயல்பாட்டில் உள்ளது",
|
||||
"LabelIncomplete": "முழுமையற்றது",
|
||||
"LabelInternalAppStorage": "உள் பயன்பாட்டு சேமிப்பு",
|
||||
"LabelJumpBackwardsTime": "நேரம் பின்னோக்கி குதிக்கவும்",
|
||||
"LabelJumpForwardsTime": "முன்னோக்கி செல்லும் நேரம்",
|
||||
"LabelKeepScreenAwake": "திரையை விழித்திருக்கவும்",
|
||||
"LabelLanguage": "மொழி",
|
||||
"LabelLayout": "மனையமைவு",
|
||||
"LabelLayoutAuto": "தானி",
|
||||
"LabelLayoutSinglePage": "ஒற்றை பக்கம்",
|
||||
"LabelLibrarySortByProgress": "முன்னேற்றம்: கடைசியாக புதுப்பிக்கப்பட்டது",
|
||||
"LabelLibrarySortByProgressFinished": "முன்னேற்றம்: முடிந்தது",
|
||||
"LabelLibrarySortByProgressStarted": "முன்னேற்றம்: தொடங்கப்பட்டது",
|
||||
"LabelLight": "ஒளி",
|
||||
"LabelLineSpacing": "வரி இடைவெளி",
|
||||
"LabelListenAgain": "மீண்டும் கேளுங்கள்",
|
||||
"LabelLocalBooks": "உள்ளக புத்தகங்கள்",
|
||||
"LabelLocalPodcasts": "உள்ளக பாட்காச்ட்கள்",
|
||||
"LabelLockOrientation": "பூட்டு நோக்குநிலை",
|
||||
"LabelLockPlayer": "லாக் பிளேயர்",
|
||||
"LabelLow": "குறைந்த",
|
||||
"LabelMediaType": "ஊடக வகை",
|
||||
"LabelMedium": "சராசரி",
|
||||
"LabelMissing": "காணவில்லை",
|
||||
"LabelMore": "மேலும்",
|
||||
"LabelMoreInfo": "மேலும் செய்தி",
|
||||
"LabelName": "பெயர்",
|
||||
"LabelNarrator": "கதை சொல்பவர்",
|
||||
"LabelNarrators": "உரையாசிரியர்கள்",
|
||||
"LabelNavigateWithVolume": "தொகுதி விசைகள் மூலம் செல்லவும்",
|
||||
"LabelNavigateWithVolumeMirrored": "பிரதிபலித்தது",
|
||||
"LabelNavigateWithVolumeWhilePlaying": "விளையாடும் போது வால்யூம் கீகளை வழிசெலுத்த அனுமதிக்கவும்",
|
||||
"LabelNavigateWithVolumeWhilePlayingDisabled": "அணை",
|
||||
"LabelNavigateWithVolumeWhilePlayingEnabled": "அன்று",
|
||||
"LabelNever": "ஒருபோதும்",
|
||||
"LabelNewestAuthors": "புதிய ஆசிரியர்கள்",
|
||||
"LabelNewestEpisodes": "புதிய அத்தியாயங்கள்",
|
||||
"LabelNo": "இல்லை",
|
||||
"LabelNotFinished": "முடிக்கவில்லை",
|
||||
"LabelNotStarted": "தொடங்கப்படவில்லை",
|
||||
"LabelNumEpisodes": "{0} அத்தியாயங்கள்",
|
||||
"LabelNumEpisodesIncomplete": "{0} அத்தியாயங்கள், {1} முழுமையடையவில்லை",
|
||||
"LabelNumberOfEpisodes": "# அத்தியாயங்கள்",
|
||||
"LabelOff": "அணை",
|
||||
"LabelOn": "அன்று",
|
||||
"LabelPassword": "கடவுச்சொல்",
|
||||
"LabelPath": "பாதை",
|
||||
"LabelPlaybackDirect": "நேரடி",
|
||||
"LabelPlaybackLocal": "உள்ளக",
|
||||
"LabelPlaybackSpeed": "பின்னணி விரைவு",
|
||||
"LabelPlaybackTranscode": "டிரான்ச்கோடு",
|
||||
"LabelPodcast": "பாட்காச்ட்",
|
||||
"LabelPodcasts": "பாட்காச்ட்கள்",
|
||||
"LabelPreventIndexing": "ஐடியூன்ச் மற்றும் கூகுள் பாட்காச்ட் கோப்பகங்களால் உங்கள் ஊட்டத்தை அட்டவணைப்படுத்துவதைத் தடுக்கவும்",
|
||||
"LabelProgress": "முன்னேற்றம்",
|
||||
"LabelPubDate": "பப் தேதி",
|
||||
"LabelPublishYear": "வெளியிடும் ஆண்டு",
|
||||
"LabelPublishedDate": "வெளியிடப்பட்டது {0}",
|
||||
"LabelRSSFeedCustomOwnerEmail": "தனிப்பயன் உரிமையாளர் மின்னஞ்சல்",
|
||||
"LabelRSSFeedCustomOwnerName": "தனிப்பயன் உரிமையாளர் பெயர்",
|
||||
"LabelRSSFeedOpen": "ஆர்எச்எச் ஊட்டம் திறக்கப்பட்டது",
|
||||
"LabelRSSFeedPreventIndexing": "அட்டவணைப்படுத்தலைத் தடுக்கவும்",
|
||||
"LabelRSSFeedSlug": "RSS தீவனம் குருட்டடி",
|
||||
"LabelRandomly": "தற்செயலாக",
|
||||
"LabelRead": "படிக்கவும்",
|
||||
"LabelReadAgain": "மீண்டும் படிக்கவும்",
|
||||
"LabelRecentSeries": "அண்மைக் கால தொடர்",
|
||||
"LabelRecentlyAdded": "அண்மைக் காலத்தில் சேர்க்கப்பட்டது",
|
||||
"LabelRemoveFromPlaylist": "பிளேலிச்ட்டில் இருந்து அகற்று",
|
||||
"LabelScaleElapsedTimeBySpeed": "வேகத்தின் மூலம் கடந்த நேரத்தை அளவிடவும்",
|
||||
"LabelSeason": "பருவம்",
|
||||
"LabelSelectADevice": "ஒரு சாதனத்தைத் தேர்ந்தெடுக்கவும்",
|
||||
"LabelSelectMediaType": "மீடியா வகையைத் தேர்ந்தெடுக்கவும்",
|
||||
"LabelSequenceAscending": "வரிசை ஏறுவரிசை",
|
||||
"LabelSequenceDescending": "வரிசை இறங்கு",
|
||||
"LabelSeries": "தொடர்",
|
||||
"LabelServerAddress": "சேவையக முகவரி",
|
||||
"LabelSetEbookAsPrimary": "முதன்மையாக அமைக்கவும்",
|
||||
"LabelSetEbookAsSupplementary": "துணையாக அமைக்கவும்",
|
||||
"LabelShakeSensitivity": "குலுக்கல் உணர்திறன்",
|
||||
"LabelShowAll": "அனைத்தையும் காட்டு",
|
||||
"LabelSize": "அளவு",
|
||||
"LabelSleepTimer": "ச்லீப் நேரங்குறிகருவி",
|
||||
"LabelSleepTimerAlmostDoneChime": "ஏறக்குறைய முடிந்ததும் ஒரு மணி ஒலியை இயக்கவும்",
|
||||
"LabelSleepTimerAlmostDoneChimeHelp": "ச்லீப் டைமரில் இன்னும் 30 வினாடிகள் இருக்கும் போது சைம் அடிக்கவும்",
|
||||
"LabelStart": "தொடங்கு",
|
||||
"LabelStartTime": "தொடக்க நேரம்",
|
||||
"LabelStatsBestDay": "சிறந்த நாள்",
|
||||
"LabelStatsDailyAverage": "அன்றாடம் சராசரி",
|
||||
"LabelStatsDays": "நாட்கள்",
|
||||
"LabelStatsDaysListened": "கேட்ட நாட்கள்",
|
||||
"LabelStatsInARow": "ஒரு வரிசையில்",
|
||||
"LabelStatsItemsFinished": "பொருட்கள் முடிந்தது",
|
||||
"LabelStatsMinutes": "நிமிடங்கள்",
|
||||
"LabelStatsMinutesListening": "நிமிடங்கள் கேட்டல்",
|
||||
"LabelStatsWeekListening": "வாரம் கேட்பது",
|
||||
"LabelStreamingUsingCellular": "செல்லுலார் பயன்படுத்தி ச்ட்ரீமிங்",
|
||||
"LabelTag": "குறிச்சொல்",
|
||||
"LabelTags": "குறிச்சொற்கள்",
|
||||
"LabelTheme": "கருப்பொருள்",
|
||||
"LabelThemeBlack": "கருப்பு",
|
||||
"LabelThemeDark": "இருள்",
|
||||
"LabelThemeLight": "ஒளி",
|
||||
"LabelTimeRemaining": "{0} மீதமுள்ளது",
|
||||
"LabelTitle": "தலைப்பு",
|
||||
"LabelTotalSize": "மொத்த அளவு",
|
||||
"LabelTotalTrack": "மொத்த ட்ராக்",
|
||||
"LabelTracks": "தடங்கள்",
|
||||
"LabelType": "வகை",
|
||||
"LabelUnknown": "தெரியவில்லை",
|
||||
"LabelUnlockPlayer": "பிளேயரைத் திறக்கவும்",
|
||||
"LabelUseBookshelfView": "புத்தக அலமாரி காட்சியைப் பயன்படுத்தவும்",
|
||||
"LabelUser": "பயனர்",
|
||||
"LabelUsername": "பயனர் பெயர்",
|
||||
"LabelVeryHigh": "மிக உயர்ந்தது",
|
||||
"LabelVeryLow": "மிகவும் குறைவு",
|
||||
"LabelYearReviewHide": "மதிப்பாய்வில் ஆண்டை மறை",
|
||||
"LabelYearReviewShow": "மதிப்பாய்வில் ஆண்டு பார்க்கவும்",
|
||||
"LabelYourBookmarks": "உங்கள் புக்மார்க்குகள்",
|
||||
"LabelYourProgress": "உங்கள் முன்னேற்றம்",
|
||||
"MessageAndroid10Downloads": "ஆண்ட்ராய்டு 10 மற்றும் அதற்குக் கீழே உள்ளவை பதிவிறக்கங்களுக்கு உள் பயன்பாட்டு சேமிப்பிடத்தைப் பயன்படுத்தும்.",
|
||||
"MessageAttemptingServerConnection": "சர்வர் இணைப்பு முயற்சிக்கிறது...",
|
||||
"MessageAudiobookshelfServerNotConnected": "ஆடியோபுக் செல்ஃப் சர்வர் இணைக்கப்படவில்லை",
|
||||
"MessageAudiobookshelfServerRequired": "<strong>முக்கியம்!</strong> இந்த ஆப்ச் நீங்கள் அல்லது உங்களுக்குத் தெரிந்த ஒருவர் புரவலன் செய்யும் ஆடியோபுக் செல்ஃப் சர்வருடன் வேலை செய்ய வடிவமைக்கப்பட்டுள்ளது. இந்த ஆப்ச் எந்த உள்ளடக்கத்தையும் வழங்காது.",
|
||||
"MessageBookshelfEmpty": "புத்தக அலமாரி வெறுமை",
|
||||
"MessageConfirmAppExit": "பயன்பாட்டிலிருந்து வெளியேற விரும்புகிறீர்களா?",
|
||||
"MessageConfirmDeleteEpisodeDownloadQueue": "எபிசோட் பதிவிறக்க வரிசையை நிச்சயமாக அழிக்க விரும்புகிறீர்களா?",
|
||||
"MessageConfirmDeleteLocalEpisode": "உங்கள் சாதனத்திலிருந்து \"{0}\" என்ற உள்ளக எபிசோடை அகற்றவா? சர்வரில் உள்ள கோப்பு பாதிக்கப்படாது.",
|
||||
"MessageConfirmDeleteLocalFiles": "உங்கள் சாதனத்திலிருந்து இந்த உருப்படியின் உள்ளக கோப்புகளை அகற்றவா? சர்வரில் உள்ள கோப்புகள் மற்றும் உங்கள் முன்னேற்றம் பாதிக்கப்படாது.",
|
||||
"MessageConfirmDeleteServerConfig": "இந்த சர்வர் கட்டமைப்பை அகற்றவா?",
|
||||
"MessageConfirmDeleteServerEpisode": "சர்வரிலிருந்து \"{0}\" அத்தியாயத்தை நிச்சயமாக நீக்க விரும்புகிறீர்களா? \nஎச்சரிக்கை: இது ஆடியோ கோப்பை நீக்கும்.",
|
||||
"MessageConfirmDisableAutoTimer": "இன்று முழுவதும் ஆட்டோ டைமரை முடக்க விரும்புகிறீர்களா? இந்த ஆட்டோ-ச்லீப் நேரங்குறிகருவி காலத்தின் முடிவில் அல்லது நீங்கள் ஆப்சை மறுதொடக்கம் செய்தால் நேரங்குறிகருவி மீண்டும் இயக்கப்படும்.",
|
||||
"MessageConfirmDiscardProgress": "உங்கள் முன்னேற்றத்தை மீட்டமைக்க விரும்புகிறீர்களா?",
|
||||
"MessageConfirmDownloadUsingCellular": "செல்லுலார் தரவைப் பயன்படுத்தி பதிவிறக்கம் செய்ய உள்ளீர்கள். இதில் கேரியர் டேட்டா கட்டணங்கள் இருக்கலாம். நீங்கள் தொடர விரும்புகிறீர்களா?",
|
||||
"MessageConfirmMarkAsFinished": "இந்த உருப்படி முடிந்ததாக நிச்சயமாகக் குறிக்க விரும்புகிறீர்களா?",
|
||||
"MessageConfirmPlaybackTime": "\"{0}\"க்கான பிளேபேக்கை {1}க்கு தொடங்கவா?",
|
||||
"MessageConfirmRemoveBookmark": "புத்தகக்குறியை நிச்சயமாக அகற்ற விரும்புகிறீர்களா?",
|
||||
"MessageConfirmStreamingUsingCellular": "செல்லுலார் தரவைப் பயன்படுத்தி ச்ட்ரீம் செய்ய உள்ளீர்கள். இதில் கேரியர் டேட்டா கட்டணங்கள் இருக்கலாம். நீங்கள் தொடர விரும்புகிறீர்களா?",
|
||||
"MessageDiscardProgress": "முன்னேற்றத்தை நிராகரிக்கவும்",
|
||||
"MessageDownloadCompleteProcessing": "பதிவிறக்கம் முடிந்தது. செயலாக்குகிறது...",
|
||||
"MessageDownloading": "பதிவிறக்குகிறது...",
|
||||
"MessageDownloadingEpisode": "அத்தியாயத்தைப் பதிவிறக்குகிறது",
|
||||
"MessageEpisodesQueuedForDownload": "{0} எபிசோட்(கள்) பதிவிறக்கம் செய்ய வரிசையில் உள்ளன",
|
||||
"MessageFailedToRefreshToken": "கிள்ளாக்கைப் புதுப்பிக்க முடியவில்லை, மீண்டும் உள்நுழைய வேண்டும்",
|
||||
"MessageFeedURLWillBe": "ஊட்ட முகவரி {0} ஆக இருக்கும்",
|
||||
"MessageFetching": "எடுக்கிறது...",
|
||||
"MessageFollowTheProjectOnGithub": "Github இல் திட்டத்தைப் பின்பற்றவும்",
|
||||
"MessageItemDownloadCompleteFailedToCreate": "உருப்படி பதிவிறக்கம் முடிந்தது, ஆனால் நூலக உருப்படியை உருவாக்க முடியவில்லை",
|
||||
"MessageItemMissing": "உருப்படி காணவில்லை மற்றும் சர்வரில் சரி செய்யப்பட வேண்டும். கோப்பு பாதைகள் அணுக முடியாததால் பொதுவாக ஒரு உருப்படி விடுபட்டதாகக் குறிக்கப்படுகிறது.",
|
||||
"MessageLoading": "ஏற்றுகிறது...",
|
||||
"MessageLoadingServerData": "சேவையகத் தரவை ஏற்றுகிறது...",
|
||||
"MessageLocalFolderDescription": "இந்த ஆப்சால் மட்டுமே 'இன்டர்னல் ஆப் ச்டோரேச்' அணுக முடியும். பயன்பாட்டின் மூலம் நேரடியாகப் பதிவிறக்கம் செய்யப்பட்ட மீடியாவை மட்டுமே இந்தப் பயன்பாடு ஆதரிக்கிறது. இந்தப் பயன்பாட்டினால் பதிவிறக்கம் செய்யப்பட்ட மீடியாவை அணுக, பிற பயன்பாடுகளை அனுமதிக்க, பகிரப்பட்ட சேமிப்பகக் கோப்புறைகளைப் பயன்படுத்தலாம்.",
|
||||
"MessageMarkAsFinished": "முடிந்ததாகக் குறிக்கவும்",
|
||||
"MessageMediaLinkedToADifferentServer": "மீடியா வேறு முகவரியில் ({0}) ஆடியோபுக் செல்ஃப் சர்வருடன் இணைக்கப்பட்டுள்ளது. இந்த சேவையக முகவரியுடன் இணைக்கப்படும் போது முன்னேற்றம் ஒத்திசைக்கப்படும்.",
|
||||
"MessageMediaLinkedToADifferentUser": "மீடியா இந்த சேவையகத்துடன் இணைக்கப்பட்டுள்ளது, ஆனால் வேறு பயனரால் பதிவிறக்கம் செய்யப்பட்டது. பதிவிறக்கம் செய்த பயனருடன் மட்டுமே முன்னேற்றம் ஒத்திசைக்கப்படும்.",
|
||||
"MessageMediaLinkedToServer": "சேவையகத்துடன் இணைக்கப்பட்டது {0}",
|
||||
"MessageMediaLinkedToThisServer": "பதிவிறக்கம் செய்யப்பட்ட மீடியா இந்த சேவையகத்துடன் இணைக்கப்பட்டுள்ளது",
|
||||
"MessageMediaNotLinkedToServer": "மீடியா ஆடியோபுக் செல்ஃப் சர்வருடன் இணைக்கப்படவில்லை. எந்த முன்னேற்றமும் ஒத்திசைக்கப்படாது.",
|
||||
"MessageNoBookmarks": "புக்மார்க்குகள் இல்லை",
|
||||
"MessageNoChapters": "அத்தியாயங்கள் இல்லை",
|
||||
"MessageNoCollections": "சேகரிப்புகள் இல்லை",
|
||||
"MessageNoItems": "பொருட்கள் இல்லை",
|
||||
"MessageNoItemsFound": "பொருட்கள் எதுவும் கிடைக்கவில்லை",
|
||||
"MessageNoListeningSessions": "கேட்கும் அமர்வுகள் இல்லை",
|
||||
"MessageNoLogs": "பதிவுகள் இல்லை",
|
||||
"MessageNoMediaFolders": "மீடியா கோப்புறைகள் இல்லை",
|
||||
"MessageNoNetworkConnection": "பிணைய இணைப்பு இல்லை",
|
||||
"MessageNoPodcastsFound": "பாட்காச்ட்கள் எதுவும் இல்லை",
|
||||
"MessageNoSeries": "தொடர் இல்லை",
|
||||
"MessageNoUpdatesWereNecessary": "புதுப்பிப்புகள் தேவையில்லை",
|
||||
"MessageNoUserPlaylists": "உங்களிடம் பிளேலிச்ட்கள் இல்லை",
|
||||
"MessageOldServerAuthReLoginRequired": "சர்வர் v2.26.0 இல் பாதுகாப்பிற்காக ஏற்பு மேம்படுத்தப்பட்டுள்ளது. அனைத்து பயனர்களும் மீண்டும் உள்நுழைய வேண்டும்.",
|
||||
"MessageOldServerAuthWarning": "சர்வர் காலாவதியான அங்கீகாரத்தைப் பயன்படுத்துகிறது",
|
||||
"MessageOldServerAuthWarningHelp": "இந்த சர்வர் v2.26.0 ஐ விட பழைய பதிப்பை இயக்குகிறது. மிகவும் பாதுகாப்பான அங்கீகார அமைப்பு v2.26.0 இல் சேர்க்கப்பட்டது. சேவையகத்தை அண்மைக் கால பதிப்பிற்கு புதுப்பிக்க பரிந்துரைக்கப்படுகிறது. நீங்கள் ஏற்கனவே சேவையகத்தைப் புதுப்பித்திருந்தால், புதிய அங்கீகாரத்தைப் பயன்படுத்த மீண்டும் உள்நுழையவும்.",
|
||||
"MessageOldServerConnectionWarning": "சேவையக இணைப்பு கட்டமைப்பு பழைய பயனர் ஐடியைப் பயன்படுத்துகிறது. இந்த சர்வர் இணைப்பை நீக்கிவிட்டு மீண்டும் சேர்க்கவும்.",
|
||||
"MessageOldServerConnectionWarningHelp": "சூன் 2023 இல் வெளியிடப்பட்ட 2.3.0 இல் தரவுத்தள இடம்பெயர்வுக்கு முன்னதாக இந்த சேவையகத்திற்கான இணைப்பை நீங்கள் முதலில் அமைத்தீர்கள். எதிர்கால சேவையக புதுப்பிப்பு இந்த பழைய இணைப்பில் உள்நுழைவதற்கான திறனை அகற்றும். ஏற்கனவே உள்ள சர்வர் இணைப்பை நீக்கிவிட்டு மீண்டும் இணைக்கவும் (அதே சேவையக முகவரி மற்றும் சான்றுகளைப் பயன்படுத்தி). இந்தச் சாதனத்தில் பதிவிறக்கம் செய்யப்பட்ட மீடியா ஏதேனும் இருந்தால், சேவையகத்துடன் ஒத்திசைக்க மீடியாவை மீண்டும் பதிவிறக்கம் செய்ய வேண்டும்.",
|
||||
"MessagePodcastSearchField": "தேடல் சொல் அல்லது RSS ஊட்ட முகவரி ஐ உள்ளிடவும்",
|
||||
"MessageProgressSyncFailed": "நீங்கள் கேட்கும் முன்னேற்றத்தை சர்வரில் தெரிவிக்கும் மிகச் அண்மைக் கால முயற்சி தோல்வியடைந்தது. மீடியா இயங்கும் போது முன்னேற்ற ஒத்திசைவு கோரிக்கைகள் ஒவ்வொரு 15 வினாடிகள் முதல் 1 மணித்துளி வரை தொடர்ந்து முயற்சிக்கும்.",
|
||||
"MessageReportBugsAndContribute": "பிழைகளைப் புகாரளிக்கவும், அம்சங்களைக் கோரவும் மற்றும் பங்களிக்கவும்",
|
||||
"MessageSeriesAlreadyDownloaded": "இந்தத் தொடரில் உள்ள அனைத்து புத்தகங்களையும் நீங்கள் ஏற்கனவே பதிவிறக்கம் செய்துவிட்டீர்கள்.",
|
||||
"MessageSeriesDownloadConfirm": "விடுபட்ட {0} புத்தகம்(களை) {1} கோப்பு(கள்), மொத்தம் {2}ஐ {3} கோப்புறையில் பதிவிறக்கவா?",
|
||||
"MessageSeriesDownloadConfirmIos": "விடுபட்ட {0} புத்தகம்(களை) {1} கோப்பு(கள்), மொத்தம் {2}ஐப் பதிவிறக்கவா?",
|
||||
"MessageSocketConnectedOverMeteredCellular": "அளவிடப்பட்ட செல்லுலார் வழியாக இணைக்கப்பட்ட சாக்கெட்",
|
||||
"MessageSocketConnectedOverMeteredWifi": "மீட்டர் வைஃபை மூலம் சாக்கெட் இணைக்கப்பட்டுள்ளது",
|
||||
"MessageSocketConnectedOverUnmeteredCellular": "அளவிடப்படாத செல்லுலார் மீது சாக்கெட் இணைக்கப்பட்டுள்ளது",
|
||||
"MessageSocketConnectedOverUnmeteredWifi": "அளவிடப்படாத வைஃபை வழியாக சாக்கெட் இணைக்கப்பட்டுள்ளது",
|
||||
"MessageSocketNotConnected": "சாக்கெட் இணைக்கப்படவில்லை",
|
||||
"NoteRSSFeedPodcastAppsHttps": "எச்சரிக்கை: பெரும்பாலான போட்காச்ட் பயன்பாடுகளுக்கு RSS ஊட்ட முகவரி HTTPSஐப் பயன்படுத்த வேண்டும்",
|
||||
"NoteRSSFeedPodcastAppsPubDate": "எச்சரிக்கை: உங்களின் 1 அல்லது அதற்கு மேற்பட்ட எபிசோடுகளில் பப் தேதி இல்லை. சில பாட்காச்ட் பயன்பாடுகளுக்கு இது தேவைப்படுகிறது.",
|
||||
"ToastBookmarkCreateFailed": "புத்தகக்குறியை உருவாக்க முடியவில்லை",
|
||||
"ToastBookmarkRemoveFailed": "புத்தகக்குறியை அகற்ற முடியவில்லை",
|
||||
"ToastBookmarkUpdateFailed": "புக்மார்க்கைப் புதுப்பிக்க முடியவில்லை",
|
||||
"ToastDownloadNotAllowedOnCellular": "செல்லுலார் தரவுகளில் பதிவிறக்கம் அனுமதிக்கப்படாது",
|
||||
"ToastItemMarkedAsFinishedFailed": "முடிந்தது எனக் குறிக்க முடியவில்லை",
|
||||
"ToastItemMarkedAsNotFinishedFailed": "முடிக்கப்படவில்லை எனக் குறிக்க முடியவில்லை",
|
||||
"ToastPlaylistCreateFailed": "பிளேலிச்ட்டை உருவாக்க முடியவில்லை",
|
||||
"ToastPodcastCreateFailed": "பாட்காச்டை உருவாக்க முடியவில்லை",
|
||||
"ToastPodcastCreateSuccess": "பாட்காச்ட் வெற்றிகரமாக உருவாக்கப்பட்டது",
|
||||
"ToastRSSFeedCloseFailed": "RSS ஊட்டத்தை மூடுவதில் தோல்வி",
|
||||
"ToastRSSFeedCloseSuccess": "RSS ஊட்டம் மூடப்பட்டது",
|
||||
"ToastStreamingNotAllowedOnCellular": "செல்லுலார் தரவுகளில் ச்ட்ரீமிங் அனுமதிக்கப்படவில்லை",
|
||||
"UnitMinutesShort": "{0}மீ",
|
||||
"UnitSecondsShort": "{0}வி"
|
||||
}
|
||||
+14
-1
@@ -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": "Дата додавання",
|
||||
@@ -178,6 +180,9 @@
|
||||
"LabelLayout": "Вигляд",
|
||||
"LabelLayoutAuto": "Авто",
|
||||
"LabelLayoutSinglePage": "Одна",
|
||||
"LabelLibrarySortByProgress": "Прогрес: Останнє оновлення",
|
||||
"LabelLibrarySortByProgressFinished": "Прогрес: Завершено",
|
||||
"LabelLibrarySortByProgressStarted": "Прогрес: Розпочато",
|
||||
"LabelLight": "Легко",
|
||||
"LabelLineSpacing": "Інтервал",
|
||||
"LabelListenAgain": "Слухати знову",
|
||||
@@ -237,6 +242,7 @@
|
||||
"LabelScaleElapsedTimeBySpeed": "Час відповідно швидкості",
|
||||
"LabelSeason": "Сезон",
|
||||
"LabelSelectADevice": "Обрати пристрій",
|
||||
"LabelSelectMediaType": "Оберіть тип медіа",
|
||||
"LabelSequenceAscending": "Послідовність за зростанням",
|
||||
"LabelSequenceDescending": "Послідовність за спаданням",
|
||||
"LabelSeries": "Серії",
|
||||
@@ -289,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": "Скинути прогрес",
|
||||
@@ -359,5 +370,7 @@
|
||||
"ToastPodcastCreateSuccess": "Подкаст успішно створено",
|
||||
"ToastRSSFeedCloseFailed": "Не вдалося закрити RSS-канал",
|
||||
"ToastRSSFeedCloseSuccess": "RSS-канал закрито",
|
||||
"ToastStreamingNotAllowedOnCellular": "Трансляція через мобільний інтернет заборонена"
|
||||
"ToastStreamingNotAllowedOnCellular": "Трансляція через мобільний інтернет заборонена",
|
||||
"UnitMinutesShort": "{0}хв",
|
||||
"UnitSecondsShort": "{0}сек"
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
+72
-65
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"ButtonAdd": "添加",
|
||||
"ButtonAddNewServer": "添加新服务器",
|
||||
"ButtonAllow": "允许",
|
||||
"ButtonAuthors": "作者",
|
||||
"ButtonBack": "返回",
|
||||
"ButtonCancel": "取消",
|
||||
@@ -39,7 +40,7 @@
|
||||
"ButtonPlay": "播放",
|
||||
"ButtonPlayEpisode": "播放剧集",
|
||||
"ButtonPlaylists": "播放列表",
|
||||
"ButtonRead": "读取",
|
||||
"ButtonRead": "阅读",
|
||||
"ButtonReadLess": "收起",
|
||||
"ButtonReadMore": "阅读更多",
|
||||
"ButtonRemove": "移除",
|
||||
@@ -88,53 +89,53 @@
|
||||
"HeaderSettings": "设置",
|
||||
"HeaderSleepTimer": "睡眠计时",
|
||||
"HeaderSleepTimerSettings": "睡眠计时器设置",
|
||||
"HeaderStatsMinutesListeningChart": "收听分钟数 (最近7天)",
|
||||
"HeaderStatsMinutesListeningChart": "收听分钟数(最近7天)",
|
||||
"HeaderStatsRecentSessions": "历史会话",
|
||||
"HeaderTableOfContents": "目录",
|
||||
"HeaderUserInterfaceSettings": "用户界面设置",
|
||||
"HeaderWelcome": "欢迎, <strong>{0}</strong>",
|
||||
"HeaderWelcome": "欢迎,<strong>{0}</strong>",
|
||||
"HeaderYourStats": "你的统计数据",
|
||||
"LabelAddToPlaylist": "添加到播放列表",
|
||||
"LabelAddedAt": "添加于",
|
||||
"LabelAddedDate": "已添加 {0}",
|
||||
"LabelAll": "全部",
|
||||
"LabelAllowSeekingOnMediaControls": "允许在媒体通知控件上查找位置",
|
||||
"LabelAllowSeekingOnMediaControls": "允许在媒体通知控件上拖动进度",
|
||||
"LabelAlways": "总是",
|
||||
"LabelAndroidAutoBrowseLimitForGrouping": "按字母顺序排列的下限",
|
||||
"LabelAndroidAutoBrowseLimitForGroupingHelp": "当要显示的项目数量少于此数量时, 不要使用按字母顺序排列",
|
||||
"LabelAndroidAutoBrowseSeriesSequenceOrder": "系列图书订单",
|
||||
"LabelAndroidAutoBrowseLimitForGroupingHelp": "当要显示的项目数量少于此数量时,不要使用按字母顺序排列",
|
||||
"LabelAndroidAutoBrowseSeriesSequenceOrder": "系列图书顺序",
|
||||
"LabelAskConfirmation": "要求确认",
|
||||
"LabelAuthor": "作者",
|
||||
"LabelAuthorFirstLast": "作者 (姓 名)",
|
||||
"LabelAuthorLastFirst": "作者 (名, 姓)",
|
||||
"LabelAuthorFirstLast": "作者(名 姓)",
|
||||
"LabelAuthorLastFirst": "作者(姓,名)",
|
||||
"LabelAuthors": "作者",
|
||||
"LabelAutoDownloadEpisodes": "自动下载剧集",
|
||||
"LabelAutoRewindTime": "自动倒带时间",
|
||||
"LabelAutoSleepTimer": "自动睡眠定时器",
|
||||
"LabelAutoSleepTimerAutoRewind": "自动睡眠定时器自动倒带",
|
||||
"LabelAutoSleepTimerAutoRewindHelp": "当自动睡眠计时器结束时, 再次播放该项目将自动倒带你之前的位置.",
|
||||
"LabelAutoSleepTimerHelp": "当在指定的时间范围内播放媒体时, 睡眠计时器将自动启动.",
|
||||
"LabelAutoSleepTimerAutoRewindHelp": "当自动睡眠计时器结束时,再次播放该项目将自动倒带你之前的位置。",
|
||||
"LabelAutoSleepTimerHelp": "当在指定的时间范围内播放媒体时,睡眠计时器将自动启动。",
|
||||
"LabelBooks": "图书",
|
||||
"LabelByAuthor": "作者: {0}",
|
||||
"LabelByAuthor": "作者:{0}",
|
||||
"LabelChapterTrack": "章节音轨",
|
||||
"LabelChapters": "章节",
|
||||
"LabelClosePlayer": "关闭播放器",
|
||||
"LabelCollapseSeries": "折叠系列",
|
||||
"LabelComplete": "已完成",
|
||||
"LabelContinueBooks": "继续看书",
|
||||
"LabelContinueBooks": "继续阅读",
|
||||
"LabelContinueEpisodes": "继续剧集",
|
||||
"LabelContinueListening": "继续收听",
|
||||
"LabelContinueReading": "继续阅读",
|
||||
"LabelContinueSeries": "继续收听系列",
|
||||
"LabelContinueSeries": "在追系列",
|
||||
"LabelCustomTime": "自定义时间",
|
||||
"LabelDescription": "描述",
|
||||
"LabelDisableAudioFadeOut": "禁用音频淡出",
|
||||
"LabelDisableAudioFadeOutHelp": "当睡眠计时器的剩余时间不到1分钟时, 音频音量将开始降低.启用此设置不淡出.",
|
||||
"LabelDisableAudioFadeOutHelp": "当睡眠计时器的剩余时间不到1分钟时,音频音量将开始降低。启用此设置可禁用淡出。",
|
||||
"LabelDisableAutoRewind": "禁用自动倒带",
|
||||
"LabelDisableShakeToReset": "禁用抖动设备重置",
|
||||
"LabelDisableShakeToResetHelp": "在计时器运行时摇晃设备, 或者在计时器到期后 2 分钟内摇晃设备将重置睡眠计时器. 启用此设置可禁用抖动以重置.",
|
||||
"LabelDisableShakeToReset": "禁用摇晃设备重置",
|
||||
"LabelDisableShakeToResetHelp": "在计时器运行时摇晃设备,或者在计时器到期后 2 分钟内摇晃设备将重置睡眠计时器。启用此设置禁用摇晃设备重置。",
|
||||
"LabelDisableVibrateOnReset": "重置时禁用振动",
|
||||
"LabelDisableVibrateOnResetHelp": "当睡眠计时器重置时, 你的设备会振动. 启用此设置以在睡眠计时器重置时不振动.",
|
||||
"LabelDisableVibrateOnResetHelp": "当睡眠计时器重置时,你的设备会振动。启用此设置以在睡眠计时器重置时不振动。",
|
||||
"LabelDiscover": "发现",
|
||||
"LabelDownload": "下载",
|
||||
"LabelDownloadUsingCellular": "使用移动数据网络进行下载",
|
||||
@@ -143,8 +144,8 @@
|
||||
"LabelEbook": "电子书",
|
||||
"LabelEbooks": "电子书",
|
||||
"LabelEnable": "启用",
|
||||
"LabelEnableMp3IndexSeeking": "启用 mp3 索引搜索",
|
||||
"LabelEnableMp3IndexSeekingHelp": "只有当你的 mp3 文件未正确查找时, 才应启用此设置. 不准确的搜索很可能是由于可变二进制 (VBR) MP3 文件. 此设置将强制进行索引查找, 即在读取文件时构建时间到字节的映射. 在某些情况下, 对于大型MP3文件, 在查找文件末尾时会有延迟.",
|
||||
"LabelEnableMp3IndexSeeking": "启用 mp3 索引定位",
|
||||
"LabelEnableMp3IndexSeekingHelp": "仅在 MP3 文件无法正常快进/倒退(定位)时开启此设置。定位不准确通常是由可变码率(VBR)MP3 文件引起的。开启后将强制执行“索引定位”——即在读取文件时建立时间到字节的映射关系。对于体积较大的 MP3 文件,在快进到接近尾部的位置时,可能会出现短暂的加载延迟。",
|
||||
"LabelEnd": "结束",
|
||||
"LabelEndOfChapter": "章节结束",
|
||||
"LabelEndTime": "结束时间",
|
||||
@@ -180,9 +181,9 @@
|
||||
"LabelLayout": "布局",
|
||||
"LabelLayoutAuto": "自动",
|
||||
"LabelLayoutSinglePage": "单页",
|
||||
"LabelLibrarySortByProgress": "进度: 上次更新",
|
||||
"LabelLibrarySortByProgressFinished": "进度: 已完成",
|
||||
"LabelLibrarySortByProgressStarted": "进度: 已开始",
|
||||
"LabelLibrarySortByProgress": "进度:最近更新",
|
||||
"LabelLibrarySortByProgressFinished": "进度:已完成",
|
||||
"LabelLibrarySortByProgressStarted": "进度:已开始",
|
||||
"LabelLight": "轻",
|
||||
"LabelLineSpacing": "行间距",
|
||||
"LabelListenAgain": "再次收听",
|
||||
@@ -207,12 +208,12 @@
|
||||
"LabelNever": "从不",
|
||||
"LabelNewestAuthors": "最新作者",
|
||||
"LabelNewestEpisodes": "最新剧集",
|
||||
"LabelNo": "取消",
|
||||
"LabelNo": "否",
|
||||
"LabelNotFinished": "未听完",
|
||||
"LabelNotStarted": "未开始",
|
||||
"LabelNumEpisodes": "{0} 个剧集",
|
||||
"LabelNumEpisodesIncomplete": "共 {0} 集, {1} 集未完成",
|
||||
"LabelNumberOfEpisodes": "# 集数",
|
||||
"LabelNumEpisodesIncomplete": "共 {0} 集,{1} 集未完成",
|
||||
"LabelNumberOfEpisodes": "集数",
|
||||
"LabelOff": "关闭",
|
||||
"LabelOn": "打开",
|
||||
"LabelPassword": "密码",
|
||||
@@ -232,7 +233,7 @@
|
||||
"LabelRSSFeedCustomOwnerName": "自定义所有者名称",
|
||||
"LabelRSSFeedOpen": "打开 RSS 源",
|
||||
"LabelRSSFeedPreventIndexing": "防止索引",
|
||||
"LabelRSSFeedSlug": "RSS 源段",
|
||||
"LabelRSSFeedSlug": "RSS 源别名(Slug)",
|
||||
"LabelRandomly": "随机",
|
||||
"LabelRead": "阅读",
|
||||
"LabelReadAgain": "再次阅读",
|
||||
@@ -249,7 +250,7 @@
|
||||
"LabelServerAddress": "服务器地址",
|
||||
"LabelSetEbookAsPrimary": "设置为主",
|
||||
"LabelSetEbookAsSupplementary": "设置为补充",
|
||||
"LabelShakeSensitivity": "抖动灵敏度",
|
||||
"LabelShakeSensitivity": "摇晃灵敏度",
|
||||
"LabelShowAll": "全部显示",
|
||||
"LabelSize": "文件大小",
|
||||
"LabelSleepTimer": "睡眠定时",
|
||||
@@ -290,44 +291,45 @@
|
||||
"LabelYearReviewShow": "查看年度回顾",
|
||||
"LabelYourBookmarks": "你的书签",
|
||||
"LabelYourProgress": "你的进度",
|
||||
"MessageAndroid10Downloads": "Android 10 及以下版本将使用内部应用程序存储进行下载.",
|
||||
"MessageAttemptingServerConnection": "正在尝试连接服务器...",
|
||||
"MessageAndroid10Downloads": "Android 10 及以下版本将使用内部应用程序存储进行下载。",
|
||||
"MessageAttemptingServerConnection": "正在尝试连接服务器…",
|
||||
"MessageAudiobookshelfServerNotConnected": "Audiobookshelf 服务器未连接",
|
||||
"MessageAudiobookshelfServerRequired": "<strong>重要提示!</strong>此应用旨在与你或你认识的人托管的 Audiobookshelf 服务器配合使用. 此应用不提供任何内容.",
|
||||
"MessageAudiobookshelfServerRequired": "<strong>重要提示!</strong>此应用旨在与你或你认识的人托管的 Audiobookshelf 服务器配合使用。此应用不提供任何内容。",
|
||||
"MessageBookshelfEmpty": "书架是空的",
|
||||
"MessageConfirmAppExit": "确定要退出应用吗?",
|
||||
"MessageConfirmAppExit": "确定要退出应用吗?",
|
||||
"MessageConfirmDeleteEpisodeDownloadQueue": "确定要清空剧集下载队列吗?",
|
||||
"MessageConfirmDeleteLocalEpisode": "要从设备中删除本地剧集 \"{0}\" 吗? 服务器上的文件将不受影响.",
|
||||
"MessageConfirmDeleteLocalFiles": "要从设备中删除此项目的本地文件吗? 服务器上的文件和你的进度将不受影响.",
|
||||
"MessageConfirmDeleteServerConfig": "确定要移除此服务器配置吗?",
|
||||
"MessageConfirmDeleteServerEpisode": "确定要从服务器删除剧集 \"{0}\" 吗?\n警告: 这将会删除音频文件.",
|
||||
"MessageConfirmDisableAutoTimer": "你确定要在今天剩余时间内禁用自动定时器吗? 定时器将在自动睡眠定时器周期结束时或你重新启动应用时重新启用.",
|
||||
"MessageConfirmDiscardProgress": "你确定要重置进度吗?",
|
||||
"MessageConfirmDownloadUsingCellular": "你即将使用移动数据进行下载. 这可能包括运营商数据费用. 是否要继续?",
|
||||
"MessageConfirmMarkAsFinished": "你确定要要将此项目标记为已完成吗?",
|
||||
"MessageConfirmPlaybackTime": "确定从 {1} 开始播放 \"{0}\" 吗?",
|
||||
"MessageConfirmRemoveBookmark": "你确定要删除书签吗?",
|
||||
"MessageConfirmStreamingUsingCellular": "你将使用移动数据进行流媒体播放. 这可能包括运营商数据费用. 是否要继续?",
|
||||
"MessageConfirmDeleteLocalEpisode": "要从设备中删除本地剧集 “{0}” 吗?服务器上的文件将不受影响。",
|
||||
"MessageConfirmDeleteLocalFiles": "要从设备中删除此项目的本地文件吗?服务器上的文件和你的进度将不受影响。",
|
||||
"MessageConfirmDeleteServerConfig": "确定要移除此服务器配置吗?",
|
||||
"MessageConfirmDeleteServerEpisode": "确定要从服务器删除剧集 “{0}” 吗?\n警告:这将会删除音频文件。",
|
||||
"MessageConfirmDisableAutoTimer": "你确定要在今天剩余时间内禁用自动定时器吗?定时器将在自动睡眠定时器周期结束时或你重新启动应用时重新启用。",
|
||||
"MessageConfirmDiscardProgress": "你确定要重置进度吗?",
|
||||
"MessageConfirmDownloadUsingCellular": "你即将使用移动数据进行下载。这可能包括运营商数据费用。是否要继续?",
|
||||
"MessageConfirmMarkAsFinished": "你确定要将此项目标记为已完成吗?",
|
||||
"MessageConfirmPlaybackTime": "确定从 {1} 开始播放 “{0}” 吗?",
|
||||
"MessageConfirmRemoveBookmark": "你确定要删除书签吗?",
|
||||
"MessageConfirmStreamingUsingCellular": "你将使用移动数据进行流媒体播放。这可能包括运营商数据费用。是否要继续?",
|
||||
"MessageDiscardProgress": "放弃进度",
|
||||
"MessageDownloadCompleteProcessing": "下载完成. 正在处理...",
|
||||
"MessageDownloading": "下载中...",
|
||||
"MessageDownloadCompleteProcessing": "下载完成。正在处理…",
|
||||
"MessageDownloading": "下载中…",
|
||||
"MessageDownloadingEpisode": "正在下载剧集",
|
||||
"MessageDownloadingFile": "正在下载 {0}",
|
||||
"MessageEpisodesQueuedForDownload": "{0} 个剧集排队等待下载",
|
||||
"MessageFailedToRefreshToken": "无法刷新令牌,需要重新登录",
|
||||
"MessageFeedURLWillBe": "源 URL 将改为 {0}",
|
||||
"MessageFetching": "正在获取...",
|
||||
"MessageFetching": "正在获取…",
|
||||
"MessageFollowTheProjectOnGithub": "在 GitHub 上关注此项目",
|
||||
"MessageItemDownloadCompleteFailedToCreate": "项目下载完成, 但未能创建库项目",
|
||||
"MessageItemMissing": "项目丢失, 必须在服务器上修复. 项目被标记为丢失通常是因为文件路径无法访问.",
|
||||
"MessageLoading": "正在加载...",
|
||||
"MessageLoadingServerData": "正在加载服务器数据...",
|
||||
"MessageLocalFolderDescription": "'内部应用存储' 仅可由此应用程序访问. 此应用程序仅支持直接通过此应用程序下载的媒体. 共享存储文件夹可用于允许其他应用程序访问此应用程序下载的媒体.",
|
||||
"MessageMarkAsFinished": "标记为已听完",
|
||||
"MessageMediaLinkedToADifferentServer": "媒体链接到了一个地址不同 ({0}) 的 Audiobookshelf 服务器上. 连接到此服务器地址时将同步进度.",
|
||||
"MessageMediaLinkedToADifferentUser": "媒体链接到此服务器, 但是由其他用户下载. 进度将只同步到下载的用户.",
|
||||
"MessageItemDownloadCompleteFailedToCreate": "项目下载完成,但未能创建库项目",
|
||||
"MessageItemMissing": "项目丢失,必须在服务器上修复。项目被标记为丢失通常是因为文件路径无法访问。",
|
||||
"MessageLoading": "正在加载…",
|
||||
"MessageLoadingServerData": "正在加载服务器数据…",
|
||||
"MessageLocalFolderDescription": "“内部应用存储” 仅可由此应用程序访问。此应用程序仅支持直接通过此应用程序下载的媒体。共享存储文件夹可用于允许其他应用程序访问此应用程序下载的媒体。",
|
||||
"MessageMarkAsFinished": "标记为已完成",
|
||||
"MessageMediaLinkedToADifferentServer": "媒体链接到了一个地址不同({0})的 Audiobookshelf 服务器上。连接到此服务器地址时将同步进度。",
|
||||
"MessageMediaLinkedToADifferentUser": "媒体链接到此服务器,但是由其他用户下载。进度将只同步到下载的用户。",
|
||||
"MessageMediaLinkedToServer": "链接到服务器 {0}",
|
||||
"MessageMediaLinkedToThisServer": "下载的媒体链接到此服务器",
|
||||
"MessageMediaNotLinkedToServer": "媒体未链接到 Audiobookshelf 服务器. 不会同步任何进度.",
|
||||
"MessageMediaNotLinkedToServer": "媒体未链接到 Audiobookshelf 服务器。不会同步任何进度。",
|
||||
"MessageNoBookmarks": "没有书签",
|
||||
"MessageNoChapters": "没有章节",
|
||||
"MessageNoCollections": "没有收藏",
|
||||
@@ -341,24 +343,29 @@
|
||||
"MessageNoSeries": "没有系列",
|
||||
"MessageNoUpdatesWereNecessary": "无需更新",
|
||||
"MessageNoUserPlaylists": "你没有播放列表",
|
||||
"MessageOldServerAuthReLoginRequired": "服务器 v2.26.0 中的身份验证已得到改进, 以提高安全性. 所有用户都需要重新登录.",
|
||||
"MessageOldServerAuthReLoginRequired": "服务器 v2.26.0 中的身份验证已得到改进,以提高安全性。所有用户都需要重新登录。",
|
||||
"MessageOldServerAuthWarning": "服务器正在使用过时的身份验证",
|
||||
"MessageOldServerAuthWarningHelp": "此服务器运行的版本早于 v2.26.0. 在 v2.26.0 中添加了更安全的身份验证系统. 强烈建议将服务器更新到最新版本. 如果您已经更新了服务器, 请重新登录以使用新的身份验证.",
|
||||
"MessageOldServerConnectionWarning": "服务器连接配置正在使用旧的用户 ID. 请删除并重新添加此服务器连接.",
|
||||
"MessageOldServerConnectionWarningHelp": "您最初在 2023 年 6 月发布的 2.3.0 版本中进行数据库迁移之前设置了与此服务器的连接. 未来的服务器更新将取消使用此旧连接登录的功能. 请删除现有的服务器连接并重新连接 (使用相同的服务器地址和凭据). 如果您在此设备上有任何下载的媒体, 则需要再次下载该媒体才能与服务器同步.",
|
||||
"MessageOldServerAuthWarningHelp": "此服务器运行的版本早于 v2.26.0。在 v2.26.0 中添加了更安全的身份验证系统。强烈建议将服务器更新到最新版本。如果您已经更新了服务器,请重新登录以使用新的身份验证。",
|
||||
"MessageOldServerConnectionWarning": "服务器连接配置正在使用旧的用户 ID。请删除并重新添加此服务器连接。",
|
||||
"MessageOldServerConnectionWarningHelp": "您最初在 2023 年 6 月发布的 2.3.0 版本中进行数据库迁移之前设置了与此服务器的连接。未来的服务器更新将取消使用此旧连接登录的功能。请删除现有的服务器连接并重新连接(使用相同的服务器地址和凭据)。如果您在此设备上有任何下载的媒体,则需要再次下载该媒体才能与服务器同步。",
|
||||
"MessagePodcastSearchField": "输入搜索词或 RSS 源 URL",
|
||||
"MessageProgressSyncFailed": "向服务器报告收听进度的最新尝试失败. 播放媒体时, 将继续每 15 秒至 1 分钟尝试一次进度同步请求.",
|
||||
"MessageReportBugsAndContribute": "反馈问题, 建议功能或参与贡献, 请访问",
|
||||
"MessageSeriesAlreadyDownloaded": "你已下载此系列的所有书籍.",
|
||||
"MessageSeriesDownloadConfirm": "将缺失的 {0} 本图书和 {1} 个文件 (共 {2} 个) 下载至文件夹 {3}?",
|
||||
"MessageSeriesDownloadConfirmIos": "是否下载缺失的 {0} 本书和 {1} 个文件,总计 {2} 个?",
|
||||
"MessagePreparingDownloads": "正在准备下载",
|
||||
"MessageProgressSyncFailed": "向服务器报告收听进度的最新尝试失败。播放媒体时,将继续每 15 秒至 1 分钟尝试一次进度同步请求。",
|
||||
"MessageReportBugsAndContribute": "反馈问题,建议功能或参与贡献,请访问",
|
||||
"MessageSeriesAlreadyDownloaded": "你已下载此系列的所有书籍。",
|
||||
"MessageSeriesDownloadConfirm": "将缺失的 {0} 本图书和 {1} 个文件(共 {2} 个)下载至文件夹 {3}?",
|
||||
"MessageSeriesDownloadConfirmIos": "是否下载缺失的 {0} 本书和 {1} 个文件,总计 {2} 个?",
|
||||
"MessageSocketConnectedOverMeteredCellular": "通过按流量计费的移动数据网络完成连接",
|
||||
"MessageSocketConnectedOverMeteredWifi": "通过按流量计费的无线网络完成连接",
|
||||
"MessageSocketConnectedOverUnmeteredCellular": "通过不计量的移动数据网络完成连接",
|
||||
"MessageSocketConnectedOverUnmeteredWifi": "通过不计量的无线网络完成连接",
|
||||
"MessageSocketNotConnected": "未连接",
|
||||
"NoteRSSFeedPodcastAppsHttps": "警告: 大多数播客应用程序都需要 RSS 源 URL 使用 HTTPS",
|
||||
"NoteRSSFeedPodcastAppsPubDate": "警告: 你的一集或多集没有发布日期. 一些播客应用程序要求这样做.",
|
||||
"MessageStorageAccessDenied": "拒绝访问",
|
||||
"MessageStoragePermissionDenied": "权限不足",
|
||||
"MessageStorageWriteAccessRequired": "你没有此文件夹的写入权限,是否要授予该权限?",
|
||||
"MessageWaitingForAvailableStorage": "等待可用存储空间",
|
||||
"NoteRSSFeedPodcastAppsHttps": "警告:大多数播客应用程序都需要 RSS 源 URL 使用 HTTPS",
|
||||
"NoteRSSFeedPodcastAppsPubDate": "警告:你的一集或多集没有发布日期。一些播客应用程序要求这样做。",
|
||||
"ToastBookmarkCreateFailed": "创建书签失败",
|
||||
"ToastBookmarkRemoveFailed": "书签删除失败",
|
||||
"ToastBookmarkUpdateFailed": "书签更新失败",
|
||||
|
||||
+70
-1
@@ -1,11 +1,13 @@
|
||||
{
|
||||
"ButtonAdd": "添加",
|
||||
"ButtonAddNewServer": "添加新服務器",
|
||||
"ButtonAllow": "允許",
|
||||
"ButtonAuthors": "作者",
|
||||
"ButtonBack": "返回",
|
||||
"ButtonCancel": "取消",
|
||||
"ButtonCancelTimer": "取消計時器",
|
||||
"ButtonClearFilter": "清楚過濾器",
|
||||
"ButtonClearLogs": "清除記錄檔",
|
||||
"ButtonCloseFeed": "關閉源",
|
||||
"ButtonCollections": "收藏",
|
||||
"ButtonConnect": "連接",
|
||||
@@ -26,9 +28,12 @@
|
||||
"ButtonLatest": "最新",
|
||||
"ButtonLibrary": "媒體庫",
|
||||
"ButtonLocalMedia": "本地媒體",
|
||||
"ButtonLogs": "記錄",
|
||||
"ButtonManageLocalFiles": "管理本地文件",
|
||||
"ButtonMaskServerAddress": "隱藏伺服器地址",
|
||||
"ButtonNewFolder": "新建文件夾",
|
||||
"ButtonNextEpisode": "下一集",
|
||||
"ButtonOk": "好",
|
||||
"ButtonOpenFeed": "打開源",
|
||||
"ButtonOverride": "複寫",
|
||||
"ButtonPause": "暫停",
|
||||
@@ -36,6 +41,8 @@
|
||||
"ButtonPlayEpisode": "播放劇集",
|
||||
"ButtonPlaylists": "播放列表",
|
||||
"ButtonRead": "讀取",
|
||||
"ButtonReadLess": "顯示較少",
|
||||
"ButtonReadMore": "顯示更多",
|
||||
"ButtonRemove": "移除",
|
||||
"ButtonRemoveFromServer": "從服務器中移除",
|
||||
"ButtonSave": "保存",
|
||||
@@ -47,14 +54,17 @@
|
||||
"ButtonStream": "串流",
|
||||
"ButtonSubmit": "提交",
|
||||
"ButtonSwitchServerUser": "切換服務器/用戶",
|
||||
"ButtonUnmaskServerAddress": "顯示完整伺服器地址",
|
||||
"ButtonUserStats": "用戶信息",
|
||||
"ButtonYes": "確定",
|
||||
"HeaderAccount": "賬號",
|
||||
"HeaderAdvanced": "高級",
|
||||
"HeaderAndroidAutoSettings": "安卓自動設定",
|
||||
"HeaderAudioTracks": "音軌",
|
||||
"HeaderChapters": "章節",
|
||||
"HeaderCollection": "收藏",
|
||||
"HeaderCollectionItems": "收藏項目",
|
||||
"HeaderConfirm": "確認",
|
||||
"HeaderConnectionStatus": "連接狀態",
|
||||
"HeaderDataSettings": "數據設置",
|
||||
"HeaderDetails": "詳情",
|
||||
@@ -71,6 +81,7 @@
|
||||
"HeaderPlaybackSettings": "播放設置",
|
||||
"HeaderPlaylist": "播放列表",
|
||||
"HeaderPlaylistItems": "播放列表項目",
|
||||
"HeaderProgressSyncFailed": "進度同步失敗",
|
||||
"HeaderRSSFeed": "RSS 源",
|
||||
"HeaderRSSFeedGeneral": "RSS 詳細信息",
|
||||
"HeaderRSSFeedIsOpen": "RSS 源已打開",
|
||||
@@ -82,12 +93,17 @@
|
||||
"HeaderStatsRecentSessions": "歷史會話",
|
||||
"HeaderTableOfContents": "目錄",
|
||||
"HeaderUserInterfaceSettings": "用戶界面設定",
|
||||
"HeaderWelcome": "歡迎,<strong>{0}</strong>",
|
||||
"HeaderYourStats": "你的統計數據",
|
||||
"LabelAddToPlaylist": "新增到播放列表",
|
||||
"LabelAddedAt": "新增於",
|
||||
"LabelAddedDate": "新增{0}",
|
||||
"LabelAll": "全部",
|
||||
"LabelAllowSeekingOnMediaControls": "允許在媒體通知控件上查找位置",
|
||||
"LabelAlways": "總是",
|
||||
"LabelAndroidAutoBrowseLimitForGrouping": "英文字母索引門檻",
|
||||
"LabelAndroidAutoBrowseLimitForGroupingHelp": "當顯示項目數量少於此數值時,不使用英文字母索引跳轉",
|
||||
"LabelAndroidAutoBrowseSeriesSequenceOrder": "系列書籍排序",
|
||||
"LabelAskConfirmation": "請求確認",
|
||||
"LabelAuthor": "作者",
|
||||
"LabelAuthorFirstLast": "作者 (姓 名)",
|
||||
@@ -100,6 +116,7 @@
|
||||
"LabelAutoSleepTimerAutoRewindHelp": "當自動睡眠定時器結束時,再次播放該項目將自動回到當前的位置。",
|
||||
"LabelAutoSleepTimerHelp": "當在指定的時間範圍內播放時,睡眠定時器將自動啟動。",
|
||||
"LabelBooks": "圖書",
|
||||
"LabelByAuthor": "由{0}",
|
||||
"LabelChapterTrack": "章節音軌",
|
||||
"LabelChapters": "章節",
|
||||
"LabelClosePlayer": "關閉播放器",
|
||||
@@ -133,6 +150,7 @@
|
||||
"LabelEndOfChapter": "章節結束",
|
||||
"LabelEndTime": "結束時間",
|
||||
"LabelEpisode": "劇集",
|
||||
"LabelExplicit": "限制級",
|
||||
"LabelFeedURL": "源鏈接",
|
||||
"LabelFile": "文件",
|
||||
"LabelFileBirthtime": "檔案創建時間",
|
||||
@@ -141,6 +159,9 @@
|
||||
"LabelFinished": "已聽完",
|
||||
"LabelFolder": "資料夾",
|
||||
"LabelFontBoldness": "字體粗細",
|
||||
"LabelFontFamily": "字體家族",
|
||||
"LabelFontFamilySans": "無襯線字體",
|
||||
"LabelFontFamilySerif": "襯線字體",
|
||||
"LabelFontScale": "字體比例",
|
||||
"LabelGenre": "流派",
|
||||
"LabelGenres": "流派",
|
||||
@@ -155,10 +176,14 @@
|
||||
"LabelInternalAppStorage": "應用內部存儲",
|
||||
"LabelJumpBackwardsTime": "快退時間",
|
||||
"LabelJumpForwardsTime": "快進時間",
|
||||
"LabelKeepScreenAwake": "保持螢幕常亮",
|
||||
"LabelLanguage": "語言",
|
||||
"LabelLayout": "布局",
|
||||
"LabelLayoutAuto": "自動",
|
||||
"LabelLayoutSinglePage": "單頁",
|
||||
"LabelLibrarySortByProgress": "進度:最後更新",
|
||||
"LabelLibrarySortByProgressFinished": "進度:已完成",
|
||||
"LabelLibrarySortByProgressStarted": "進度:已開始",
|
||||
"LabelLight": "明亮",
|
||||
"LabelLineSpacing": "行間距",
|
||||
"LabelListenAgain": "再次收聽",
|
||||
@@ -169,6 +194,7 @@
|
||||
"LabelLow": "低",
|
||||
"LabelMediaType": "媒體類型",
|
||||
"LabelMedium": "中等",
|
||||
"LabelMissing": "缺失",
|
||||
"LabelMore": "更多",
|
||||
"LabelMoreInfo": "更多信息",
|
||||
"LabelName": "名稱",
|
||||
@@ -176,12 +202,18 @@
|
||||
"LabelNarrators": "講述者",
|
||||
"LabelNavigateWithVolume": "使用音量鍵導航",
|
||||
"LabelNavigateWithVolumeMirrored": "鏡像",
|
||||
"LabelNavigateWithVolumeWhilePlaying": "播放時允許使用音量鍵導航",
|
||||
"LabelNavigateWithVolumeWhilePlayingDisabled": "關閉",
|
||||
"LabelNavigateWithVolumeWhilePlayingEnabled": "開啟",
|
||||
"LabelNever": "從不",
|
||||
"LabelNewestAuthors": "最新作者",
|
||||
"LabelNewestEpisodes": "最新劇集",
|
||||
"LabelNo": "不",
|
||||
"LabelNotFinished": "未聽完",
|
||||
"LabelNotStarted": "未開始",
|
||||
"LabelNumEpisodes": "{0}集",
|
||||
"LabelNumEpisodesIncomplete": "{0}集,{1} 未完成",
|
||||
"LabelNumberOfEpisodes": "集數",
|
||||
"LabelOff": "關閉",
|
||||
"LabelOn": "打開",
|
||||
"LabelPassword": "密碼",
|
||||
@@ -196,8 +228,10 @@
|
||||
"LabelProgress": "進度",
|
||||
"LabelPubDate": "出版日期",
|
||||
"LabelPublishYear": "發布年份",
|
||||
"LabelPublishedDate": "已發布{0}",
|
||||
"LabelRSSFeedCustomOwnerEmail": "自定義所有者電子郵件",
|
||||
"LabelRSSFeedCustomOwnerName": "自定義所有者名稱",
|
||||
"LabelRSSFeedOpen": "開啟RSS訂閱",
|
||||
"LabelRSSFeedPreventIndexing": "防止索引",
|
||||
"LabelRSSFeedSlug": "RSS 源段",
|
||||
"LabelRandomly": "隨機",
|
||||
@@ -209,6 +243,9 @@
|
||||
"LabelScaleElapsedTimeBySpeed": "按速度縮放播放時間",
|
||||
"LabelSeason": "季",
|
||||
"LabelSelectADevice": "選賊一個設備",
|
||||
"LabelSelectMediaType": "選擇媒體類型",
|
||||
"LabelSequenceAscending": "遞增排序",
|
||||
"LabelSequenceDescending": "遞減排序",
|
||||
"LabelSeries": "系列",
|
||||
"LabelServerAddress": "服務器地址",
|
||||
"LabelSetEbookAsPrimary": "設定為主",
|
||||
@@ -217,6 +254,8 @@
|
||||
"LabelShowAll": "全部顯示",
|
||||
"LabelSize": "檔案大小",
|
||||
"LabelSleepTimer": "睡眠定時",
|
||||
"LabelSleepTimerAlmostDoneChime": "快結束時播放提示音",
|
||||
"LabelSleepTimerAlmostDoneChimeHelp": "睡眠定時器剩30秒時播放提示音",
|
||||
"LabelStart": "開始",
|
||||
"LabelStartTime": "開始時間",
|
||||
"LabelStatsBestDay": "最好的一天",
|
||||
@@ -232,6 +271,7 @@
|
||||
"LabelTag": "標籤",
|
||||
"LabelTags": "標籤",
|
||||
"LabelTheme": "主題",
|
||||
"LabelThemeBlack": "黑色",
|
||||
"LabelThemeDark": "黑暗",
|
||||
"LabelThemeLight": "明亮",
|
||||
"LabelTimeRemaining": "剩餘 {0}",
|
||||
@@ -240,12 +280,15 @@
|
||||
"LabelTotalTrack": "音軌總數",
|
||||
"LabelTracks": "音軌",
|
||||
"LabelType": "類型",
|
||||
"LabelUnknown": "未知",
|
||||
"LabelUnlockPlayer": "解鎖播放器",
|
||||
"LabelUseBookshelfView": "使用書架視圖",
|
||||
"LabelUser": "使用者",
|
||||
"LabelUsername": "使用者名",
|
||||
"LabelVeryHigh": "非常高",
|
||||
"LabelVeryLow": "非常低",
|
||||
"LabelYearReviewHide": "隱藏年度回顧",
|
||||
"LabelYearReviewShow": "顯示年度回顧",
|
||||
"LabelYourBookmarks": "你的書籤",
|
||||
"LabelYourProgress": "你的進度",
|
||||
"MessageAndroid10Downloads": "Android 10 及以下版本將使用內部應用程序存儲進行下載。",
|
||||
@@ -253,24 +296,34 @@
|
||||
"MessageAudiobookshelfServerNotConnected": "AudioBookShelf 服務器未連接",
|
||||
"MessageAudiobookshelfServerRequired": "<strong>重要!</strong>此應用程式需搭配您或您認識的人所架設的 Audiobookshelf 伺服器使用。本應用程式不提供任何內容。",
|
||||
"MessageBookshelfEmpty": "書架是空的",
|
||||
"MessageConfirmAppExit": "要離開應用程式嗎?",
|
||||
"MessageConfirmDeleteEpisodeDownloadQueue": "確定要清除單集下載佇列嗎?",
|
||||
"MessageConfirmDeleteLocalEpisode": "從您的設備中移除本地劇集「{0}」嗎?伺服器上的文件將不受影響。",
|
||||
"MessageConfirmDeleteLocalFiles": "要從您的設備中移除此項目的本地文件嗎?伺服器上的文件和您的進度將不受影響。",
|
||||
"MessageConfirmDeleteServerConfig": "是否要移除此伺服器設定?",
|
||||
"MessageConfirmDeleteServerEpisode": "確定要從伺服器刪除集數{0}嗎?\n警告:這將會刪除該音訊檔案。",
|
||||
"MessageConfirmDisableAutoTimer": "確定要在今天剩餘時間內停止使用自動計時器嗎?此計時器將在此自動睡眠計時週期結束或在重新啟動應用程式時重新啟用。",
|
||||
"MessageConfirmDiscardProgress": "您確定要重置進度嗎?",
|
||||
"MessageConfirmDownloadUsingCellular": "您即將使用行動數據下載。這可能會產生運營商的數據費用。您確定要繼續嗎?",
|
||||
"MessageConfirmMarkAsFinished": "您確定要將此項目標記為已完成嗎?",
|
||||
"MessageConfirmPlaybackTime": "要在 {1} 開始播放「{0}」嗎?",
|
||||
"MessageConfirmRemoveBookmark": "您確定要刪除書籤嗎?",
|
||||
"MessageConfirmStreamingUsingCellular": "您即將使用行動數據進行串流。這可能會產生運營商的數據費用。您確定要繼續嗎?",
|
||||
"MessageDiscardProgress": "放棄進度",
|
||||
"MessageDownloadCompleteProcessing": "下載完成,正在處理...",
|
||||
"MessageDownloading": "下载中...",
|
||||
"MessageDownloadingEpisode": "正在下載劇集",
|
||||
"MessageDownloadingFile": "下載{0}",
|
||||
"MessageEpisodesQueuedForDownload": "{0} 個劇集排隊等待下載",
|
||||
"MessageFailedToRefreshToken": "重新整理金鑰失敗,請重新登入",
|
||||
"MessageFeedURLWillBe": "源 URL 將改為 {0}",
|
||||
"MessageFetching": "正在獲取...",
|
||||
"MessageFollowTheProjectOnGithub": "在 Github 上 Follow 這個項目",
|
||||
"MessageItemDownloadCompleteFailedToCreate": "項目下載完成但無法創建庫項目",
|
||||
"MessageItemMissing": "項目遺失且必須在伺服器端修正。項目通常會因無法存取檔案路徑而被標記為遺失。",
|
||||
"MessageLoading": "讀取...",
|
||||
"MessageLoadingServerData": "正在加載服務器數據...",
|
||||
"MessageLocalFolderDescription": "「應用程式內部儲存空間」僅供此應用程式存取,且僅支援直接透過此應用程式下載的媒體。使用共用儲存資料夾可讓其他應用程式存取此應用程式下載的媒體。",
|
||||
"MessageMarkAsFinished": "標記為已聽完",
|
||||
"MessageMediaLinkedToADifferentServer": "媒體鏈接到了一個地址不同 {0} 的 AudioBookShelf 伺服器上,連接到此伺服器地址時將同步進度。",
|
||||
"MessageMediaLinkedToADifferentUser": "媒體連接到此伺服器,但是尤其他用戶下載,進度將僅同步到下載的用戶。",
|
||||
@@ -279,15 +332,25 @@
|
||||
"MessageMediaNotLinkedToServer": "媒體未被鏈接到 AudioBookShelf 伺服器,不會同步任何進度。",
|
||||
"MessageNoBookmarks": "沒有書籤",
|
||||
"MessageNoChapters": "沒有章節",
|
||||
"MessageNoCollections": "沒有合集",
|
||||
"MessageNoItems": "沒有項目",
|
||||
"MessageNoItemsFound": "沒有找到任何項目",
|
||||
"MessageNoListeningSessions": "沒有收聽會話",
|
||||
"MessageNoLogs": "沒有記錄",
|
||||
"MessageNoMediaFolders": "沒有媒體文件夾",
|
||||
"MessageNoNetworkConnection": "沒有網絡連接",
|
||||
"MessageNoPodcastsFound": "沒有找到播客",
|
||||
"MessageNoSeries": "沒有系列",
|
||||
"MessageNoUpdatesWereNecessary": "無需更新",
|
||||
"MessageNoUserPlaylists": "您沒有播放列表",
|
||||
"MessageOldServerAuthReLoginRequired": "伺服器在 v2.26.0 版本中提升了安全性與驗證機制,所有使用者皆須重新登入。",
|
||||
"MessageOldServerAuthWarning": "伺服器正在使用過時的身分驗證機制",
|
||||
"MessageOldServerAuthWarningHelp": "此伺服器運行的版本早於 v2.26.0。v2.26.0 新增了更安全的身份驗證系統,強烈建議將伺服器更新至最新版本。若您已經更新伺服器,請重新登入以使用新的身份驗證。",
|
||||
"MessageOldServerConnectionWarning": "伺服器連線設定使用了舊的使用者 ID。請刪除並重新新增此伺服器連線。",
|
||||
"MessageOldServerConnectionWarningHelp": "您最初是在 2023 年 6 月發布的 2.3.0 資料庫遷移之前設定此伺服器的連線。未來的伺服器更新將不再支援以此舊連線登入。請刪除現有的伺服器連線並重新連線(使用相同的伺服器位址與憑證)。若此裝置上有任何已下載的媒體,將需要重新下載以與伺服器同步。",
|
||||
"MessagePodcastSearchField": "輸入搜尋關鍵字或 RSS 訂閱源網址",
|
||||
"MessagePreparingDownloads": "準備下載",
|
||||
"MessageProgressSyncFailed": "最近一次向伺服器回報您的收聽進度失敗。播放媒體時,系統將繼續嘗試每 15 秒至 1 分鐘發送一次進度同步請求。",
|
||||
"MessageReportBugsAndContribute": "報告錯誤、請求功能和做出貢獻",
|
||||
"MessageSeriesAlreadyDownloaded": "您已下載此係列所有書籍。",
|
||||
"MessageSeriesDownloadConfirm": "是否下載缺失的 {0} 本書和 {1} 個文件(共 {2} 個項目)至文件夾 {3} ?",
|
||||
@@ -297,6 +360,10 @@
|
||||
"MessageSocketConnectedOverUnmeteredCellular": "套接字已通過非計量的行動數據連接",
|
||||
"MessageSocketConnectedOverUnmeteredWifi": "套接字已通過非計量的 Wi-Fi 連接",
|
||||
"MessageSocketNotConnected": "套接字未連接",
|
||||
"MessageStorageAccessDenied": "拒絕存取",
|
||||
"MessageStoragePermissionDenied": "拒絕權限",
|
||||
"MessageStorageWriteAccessRequired": "您沒有此資料夾的寫入權限。是否要授予權限?",
|
||||
"MessageWaitingForAvailableStorage": "等待可用儲存空間",
|
||||
"NoteRSSFeedPodcastAppsHttps": "警告:大多數播客應用程式要求 RSS 訂閱源 URL 使用 HTTPS",
|
||||
"NoteRSSFeedPodcastAppsPubDate": "警告:您的一個或多個劇集沒有發布日期。某些播客應用程式要求提供此資訊。",
|
||||
"ToastBookmarkCreateFailed": "創建書簽失敗",
|
||||
@@ -310,5 +377,7 @@
|
||||
"ToastPodcastCreateSuccess": "已成功創建播客",
|
||||
"ToastRSSFeedCloseFailed": "關閉 RSS 源失敗",
|
||||
"ToastRSSFeedCloseSuccess": "RSS 源已關閉",
|
||||
"ToastStreamingNotAllowedOnCellular": "行動數據下不允許串流播放"
|
||||
"ToastStreamingNotAllowedOnCellular": "行動數據下不允許串流播放",
|
||||
"UnitMinutesShort": "{0}分",
|
||||
"UnitSecondsShort": "{0}秒"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { FastAverageColor } from 'fast-average-color'
|
||||
import { imageHttpDataToBlob } from '@/utils/imageHttpBlob'
|
||||
|
||||
/**
|
||||
* True when the cover URL is http(s) and not same-origin with the WebView (or browser tab).
|
||||
* Same-origin URLs (e.g. Capacitor file bridge on localhost) can use FastAverageColor directly.
|
||||
*/
|
||||
export function shouldFetchCoverViaNativeHttp(coverUrl) {
|
||||
if (!coverUrl || typeof coverUrl !== 'string') return false
|
||||
if (!coverUrl.startsWith('http://') && !coverUrl.startsWith('https://')) return false
|
||||
try {
|
||||
return new URL(coverUrl).origin !== window.location.origin
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Average color for a cover image. On native, cross-origin http(s) covers are loaded with
|
||||
* CapacitorHttp (no WebView CORS), then sampled via a same-origin blob URL.
|
||||
* @param {*} vm - component instance with $nativeHttp (this)
|
||||
* @param {string} fullCoverUrl
|
||||
* @returns {Promise<{ rgba: string, isLight: boolean }|null>}
|
||||
*/
|
||||
export async function getAverageColorFromCoverUrl(vm, fullCoverUrl) {
|
||||
if (!fullCoverUrl) return null
|
||||
|
||||
const fac = new FastAverageColor()
|
||||
let objectUrl = null
|
||||
try {
|
||||
let resource = fullCoverUrl
|
||||
|
||||
if (Capacitor.isNativePlatform() && shouldFetchCoverViaNativeHttp(fullCoverUrl)) {
|
||||
const raw = await vm.$nativeHttp.get(fullCoverUrl, {
|
||||
responseType: 'blob',
|
||||
connectTimeout: 15000,
|
||||
readTimeout: 30000
|
||||
})
|
||||
const blob = imageHttpDataToBlob(raw)
|
||||
if (!blob) {
|
||||
throw new Error('Cover image response could not be converted to a blob')
|
||||
}
|
||||
objectUrl = URL.createObjectURL(blob)
|
||||
resource = objectUrl
|
||||
}
|
||||
|
||||
const color = await fac.getColorAsync(resource)
|
||||
return { rgba: color.rgba, isLight: color.isLight }
|
||||
} catch (e) {
|
||||
console.error('[coverAverageColor]', e)
|
||||
return null
|
||||
} finally {
|
||||
if (objectUrl) {
|
||||
URL.revokeObjectURL(objectUrl)
|
||||
}
|
||||
fac.destroy()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Normalize CapacitorHttp binary responses (Blob, base64 string, data URL, ArrayBuffer) to a Blob.
|
||||
* @param {unknown} data - CapacitorHttp response `data` when responseType is blob/arraybuffer
|
||||
* @param {string} [mimeType='image/jpeg'] - fallback Content-Type
|
||||
* @returns {Blob|null}
|
||||
*/
|
||||
export function imageHttpDataToBlob(data, mimeType = 'image/jpeg') {
|
||||
if (data == null) return null
|
||||
if (typeof Blob !== 'undefined' && data instanceof Blob) return data
|
||||
if (data instanceof ArrayBuffer) return new Blob([data], { type: mimeType })
|
||||
if (ArrayBuffer.isView(data)) return new Blob([data], { type: mimeType })
|
||||
if (typeof data === 'object' && typeof data.base64 === 'string') {
|
||||
return base64ToBlob(data.base64, mimeType)
|
||||
}
|
||||
if (typeof data === 'string') {
|
||||
if (data.startsWith('data:')) {
|
||||
const match = /^data:([^;]+);base64,([\s\S]+)$/.exec(data)
|
||||
if (match) {
|
||||
const type = match[1] || mimeType
|
||||
return base64ToBlob(match[2], type)
|
||||
}
|
||||
}
|
||||
try {
|
||||
return base64ToBlob(data, mimeType)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function base64ToBlob(base64, type) {
|
||||
const binaryString = atob(base64)
|
||||
const len = binaryString.length
|
||||
const bytes = new Uint8Array(len)
|
||||
for (let i = 0; i < len; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i)
|
||||
}
|
||||
return new Blob([bytes], { type })
|
||||
}
|
||||
Reference in New Issue
Block a user