mirror of
https://github.com/advplyr/audiobookshelf-app.git
synced 2026-08-03 18:38:49 +02:00
Compare commits
74
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
bd0cbf8bf0 | ||
|
|
f562a1a5dc | ||
|
|
185cba16eb | ||
|
|
547d086228 | ||
|
|
3a2d5647d4 | ||
|
|
0a67c9b802 | ||
|
|
0254afd5d8 | ||
|
|
853546f3d2 | ||
|
|
b8ac073247 | ||
|
|
56cb4df31a | ||
|
|
7285b0bf26 | ||
|
|
3fdc2e6dcf | ||
|
|
7d17367c1e | ||
|
|
91bc56e1e1 | ||
|
|
d4e7d0abf9 |
@@ -72,10 +72,10 @@ body:
|
|||||||
description: Please ensure your app is up to date. *If you are using a 3rd-party app, please reach out to them directly.*
|
description: Please ensure your app is up to date. *If you are using a 3rd-party app, please reach out to them directly.*
|
||||||
multiple: true
|
multiple: true
|
||||||
options:
|
options:
|
||||||
|
- 'Android App - 0.13.0'
|
||||||
|
- 'iOS App - 0.13.0'
|
||||||
- 'Android App - 0.12.0'
|
- 'Android App - 0.12.0'
|
||||||
- 'iOS App - 0.12.0'
|
- 'iOS App - 0.12.0'
|
||||||
- 'Android App - 0.11.0'
|
|
||||||
- 'iOS App - 0.11.0'
|
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
|
|||||||
@@ -43,10 +43,10 @@ body:
|
|||||||
description: Please ensure your app is up to date. *If you are using a 3rd-party app, please reach out to them directly.*
|
description: Please ensure your app is up to date. *If you are using a 3rd-party app, please reach out to them directly.*
|
||||||
multiple: true
|
multiple: true
|
||||||
options:
|
options:
|
||||||
|
- 'Android App - 0.13.0'
|
||||||
|
- 'iOS App - 0.13.0'
|
||||||
- 'Android App - 0.12.0'
|
- 'Android App - 0.12.0'
|
||||||
- 'iOS App - 0.12.0'
|
- 'iOS App - 0.12.0'
|
||||||
- 'Android App - 0.11.0'
|
|
||||||
- 'iOS App - 0.11.0'
|
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: textarea
|
- type: textarea
|
||||||
|
|||||||
@@ -36,8 +36,8 @@ android {
|
|||||||
applicationId "com.audiobookshelf.app"
|
applicationId "com.audiobookshelf.app"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 116
|
versionCode 117
|
||||||
versionName "0.12.0-beta"
|
versionName "0.13.0-beta"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
manifestPlaceholders = [
|
manifestPlaceholders = [
|
||||||
"appAuthRedirectScheme": "com.audiobookshelf.app"
|
"appAuthRedirectScheme": "com.audiobookshelf.app"
|
||||||
@@ -92,6 +92,7 @@ dependencies {
|
|||||||
implementation project(':capacitor-cordova-android-plugins')
|
implementation project(':capacitor-cordova-android-plugins')
|
||||||
|
|
||||||
implementation "androidx.core:core-ktx:$androidx_core_ktx_version"
|
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-core:$kotlin_coroutines_version"
|
||||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version"
|
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version"
|
||||||
|
|||||||
@@ -6,12 +6,14 @@
|
|||||||
|
|
||||||
<!-- Permissions -->
|
<!-- Permissions -->
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<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.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission
|
<uses-permission
|
||||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||||
android:maxSdkVersion="28" />
|
android:maxSdkVersion="28" />
|
||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
@@ -98,6 +100,12 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</service>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".services.DownloadService"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="dataSync" />
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
android:authorities="${applicationId}.fileprovider"
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
|||||||
@@ -40,9 +40,6 @@ class MainActivity : BridgeActivity() {
|
|||||||
val storage = SimpleStorage(this)
|
val storage = SimpleStorage(this)
|
||||||
|
|
||||||
val REQUEST_PERMISSIONS = 1
|
val REQUEST_PERMISSIONS = 1
|
||||||
var PERMISSIONS_ALL = arrayOf(
|
|
||||||
Manifest.permission.READ_EXTERNAL_STORAGE
|
|
||||||
)
|
|
||||||
|
|
||||||
public override fun onCreate(savedInstanceState: Bundle?) {
|
public override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
DbManager.initialize(applicationContext)
|
DbManager.initialize(applicationContext)
|
||||||
@@ -98,11 +95,20 @@ class MainActivity : BridgeActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val permission = ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE)
|
requestNeededPermissions()
|
||||||
if (permission != PackageManager.PERMISSION_GRANTED) {
|
}
|
||||||
ActivityCompat.requestPermissions(this,
|
|
||||||
PERMISSIONS_ALL,
|
private fun requestNeededPermissions() {
|
||||||
REQUEST_PERMISSIONS)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,74 +1,102 @@
|
|||||||
package com.audiobookshelf.app.data
|
package com.audiobookshelf.app.data
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
import android.support.v4.media.MediaDescriptionCompat
|
import android.support.v4.media.MediaDescriptionCompat
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||||
import com.fasterxml.jackson.annotation.JsonSubTypes
|
import com.fasterxml.jackson.annotation.JsonSubTypes
|
||||||
import com.fasterxml.jackson.annotation.JsonTypeInfo
|
import com.fasterxml.jackson.annotation.JsonTypeInfo
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
enum class LockOrientationSetting {
|
enum class LockOrientationSetting {
|
||||||
NONE, PORTRAIT, LANDSCAPE
|
NONE,
|
||||||
|
PORTRAIT,
|
||||||
|
LANDSCAPE
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class HapticFeedbackSetting {
|
enum class HapticFeedbackSetting {
|
||||||
OFF, LIGHT, MEDIUM, HEAVY
|
OFF,
|
||||||
|
LIGHT,
|
||||||
|
MEDIUM,
|
||||||
|
HEAVY
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class ShakeSensitivitySetting {
|
enum class ShakeSensitivitySetting {
|
||||||
VERY_LOW, LOW, MEDIUM, HIGH, VERY_HIGH
|
VERY_LOW,
|
||||||
|
LOW,
|
||||||
|
MEDIUM,
|
||||||
|
HIGH,
|
||||||
|
VERY_HIGH
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class DownloadUsingCellularSetting {
|
enum class DownloadUsingCellularSetting {
|
||||||
ASK, ALWAYS, NEVER
|
ASK,
|
||||||
|
ALWAYS,
|
||||||
|
NEVER
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class StreamingUsingCellularSetting {
|
enum class StreamingUsingCellularSetting {
|
||||||
ASK, ALWAYS, NEVER
|
ASK,
|
||||||
|
ALWAYS,
|
||||||
|
NEVER
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class AndroidAutoBrowseSeriesSequenceOrderSetting {
|
enum class AndroidAutoBrowseSeriesSequenceOrderSetting {
|
||||||
ASC, DESC
|
ASC,
|
||||||
|
DESC
|
||||||
}
|
}
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
data class ServerConnectionConfig(
|
data class ServerConnectionConfig(
|
||||||
var id:String,
|
var id: String,
|
||||||
var index:Int,
|
var index: Int,
|
||||||
var name:String,
|
var name: String,
|
||||||
var address:String,
|
var address: String,
|
||||||
// version added after 0.9.81-beta
|
// version added after 0.9.81-beta
|
||||||
var version:String?,
|
var version: String?,
|
||||||
var userId:String,
|
var userId: String,
|
||||||
var username:String,
|
var username: String,
|
||||||
var token:String,
|
var token: String,
|
||||||
var customHeaders:Map<String, String>?
|
var customHeaders: Map<String, String>?
|
||||||
)
|
)
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
data class LocalFile(
|
data class LocalFile(
|
||||||
var id:String,
|
var id: String,
|
||||||
var filename:String?,
|
var filename: String?,
|
||||||
var contentUrl:String,
|
var contentUrl: String,
|
||||||
var basePath:String,
|
var basePath: String,
|
||||||
var absolutePath:String,
|
var absolutePath: String,
|
||||||
var simplePath:String,
|
var mimeType: String?,
|
||||||
var mimeType:String?,
|
var size: Long
|
||||||
var size:Long
|
|
||||||
) {
|
) {
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun isAudioFile():Boolean {
|
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
|
if (mimeType == "application/octet-stream") return true
|
||||||
if (mimeType == "video/mp4") return true
|
if (mimeType == "video/mp4") return true
|
||||||
return mimeType?.startsWith("audio") == true
|
return mimeType?.startsWith("audio") == true
|
||||||
}
|
}
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun isEBookFile():Boolean {
|
fun isEBookFile(): Boolean {
|
||||||
return getEBookFormat() != null
|
return getEBookFormat() != null
|
||||||
}
|
}
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun getEBookFormat():String? {
|
fun getEBookFormat(): String? {
|
||||||
if (mimeType == "application/epub+zip") return "epub"
|
if (mimeType == "application/epub+zip") return "epub"
|
||||||
if (mimeType == "application/pdf") return "pdf"
|
if (mimeType == "application/pdf") return "pdf"
|
||||||
if (mimeType == "application/x-mobipocket-ebook") return "mobi"
|
if (mimeType == "application/x-mobipocket-ebook") return "mobi"
|
||||||
@@ -81,52 +109,53 @@ data class LocalFile(
|
|||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
data class LocalFolder(
|
data class LocalFolder(
|
||||||
var id:String,
|
var id: String,
|
||||||
var name:String,
|
var name: String,
|
||||||
var contentUrl:String,
|
var contentUrl: String,
|
||||||
var basePath:String,
|
var basePath: String,
|
||||||
var absolutePath:String,
|
var absolutePath: String,
|
||||||
var simplePath:String,
|
var storageType: String,
|
||||||
var storageType:String,
|
var mediaType: String
|
||||||
var mediaType:String
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@JsonTypeInfo(use= JsonTypeInfo.Id.DEDUCTION)
|
@JsonTypeInfo(use = JsonTypeInfo.Id.DEDUCTION)
|
||||||
@JsonSubTypes(
|
@JsonSubTypes(JsonSubTypes.Type(LibraryItem::class), JsonSubTypes.Type(LocalLibraryItem::class))
|
||||||
JsonSubTypes.Type(LibraryItem::class),
|
open class LibraryItemWrapper(var id: String) {
|
||||||
JsonSubTypes.Type(LocalLibraryItem::class)
|
|
||||||
)
|
|
||||||
open class LibraryItemWrapper(var id:String) {
|
|
||||||
@JsonIgnore
|
@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)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
data class DeviceInfo(
|
data class DeviceInfo(
|
||||||
var deviceId:String,
|
var deviceId: String,
|
||||||
var manufacturer:String,
|
var manufacturer: String,
|
||||||
var model:String,
|
var model: String,
|
||||||
var sdkVersion:Int,
|
var sdkVersion: Int,
|
||||||
var clientVersion: String
|
var clientVersion: String
|
||||||
)
|
)
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
data class PlayItemRequestPayload(
|
data class PlayItemRequestPayload(
|
||||||
var mediaPlayer:String,
|
var mediaPlayer: String,
|
||||||
var forceDirectPlay:Boolean,
|
var forceDirectPlay: Boolean,
|
||||||
var forceTranscode:Boolean,
|
var forceTranscode: Boolean,
|
||||||
var deviceInfo:DeviceInfo
|
var deviceInfo: DeviceInfo
|
||||||
)
|
)
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
data class DeviceSettings(
|
data class DeviceSettings(
|
||||||
var disableAutoRewind:Boolean,
|
var disableAutoRewind: Boolean,
|
||||||
var enableAltView:Boolean,
|
var enableAltView: Boolean,
|
||||||
var allowSeekingOnMediaControls:Boolean,
|
var allowSeekingOnMediaControls: Boolean,
|
||||||
var jumpBackwardsTime:Int,
|
var jumpBackwardsTime: Int,
|
||||||
var jumpForwardTime:Int,
|
var jumpForwardTime: Int,
|
||||||
var enableMp3IndexSeeking:Boolean,
|
var enableMp3IndexSeeking: Boolean,
|
||||||
var disableShakeToResetSleepTimer:Boolean,
|
var disableShakeToResetSleepTimer: Boolean,
|
||||||
var shakeSensitivity: ShakeSensitivitySetting,
|
var shakeSensitivity: ShakeSensitivitySetting,
|
||||||
var lockOrientation: LockOrientationSetting,
|
var lockOrientation: LockOrientationSetting,
|
||||||
var hapticFeedback: HapticFeedbackSetting,
|
var hapticFeedback: HapticFeedbackSetting,
|
||||||
@@ -134,7 +163,7 @@ data class DeviceSettings(
|
|||||||
var autoSleepTimerStartTime: String,
|
var autoSleepTimerStartTime: String,
|
||||||
var autoSleepTimerEndTime: String,
|
var autoSleepTimerEndTime: String,
|
||||||
var autoSleepTimerAutoRewind: Boolean,
|
var autoSleepTimerAutoRewind: Boolean,
|
||||||
var autoSleepTimerAutoRewindTime: Long, //Time in milliseconds
|
var autoSleepTimerAutoRewindTime: Long, // Time in milliseconds
|
||||||
var sleepTimerLength: Long, // Time in milliseconds
|
var sleepTimerLength: Long, // Time in milliseconds
|
||||||
var disableSleepTimerFadeOut: Boolean,
|
var disableSleepTimerFadeOut: Boolean,
|
||||||
var disableSleepTimerResetFeedback: Boolean,
|
var disableSleepTimerResetFeedback: Boolean,
|
||||||
@@ -147,7 +176,7 @@ data class DeviceSettings(
|
|||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
// Static method to get default device settings
|
// Static method to get default device settings
|
||||||
fun default():DeviceSettings {
|
fun default(): DeviceSettings {
|
||||||
return DeviceSettings(
|
return DeviceSettings(
|
||||||
disableAutoRewind = false,
|
disableAutoRewind = false,
|
||||||
enableAltView = true,
|
enableAltView = true,
|
||||||
@@ -178,21 +207,26 @@ data class DeviceSettings(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@get:JsonIgnore
|
@get:JsonIgnore
|
||||||
val jumpBackwardsTimeMs get() = jumpBackwardsTime * 1000L
|
val jumpBackwardsTimeMs
|
||||||
|
get() = jumpBackwardsTime * 1000L
|
||||||
@get:JsonIgnore
|
@get:JsonIgnore
|
||||||
val jumpForwardTimeMs get() = jumpForwardTime * 1000L
|
val jumpForwardTimeMs
|
||||||
|
get() = jumpForwardTime * 1000L
|
||||||
@get:JsonIgnore
|
@get:JsonIgnore
|
||||||
val autoSleepTimerStartHour get() = autoSleepTimerStartTime.split(":")[0].toInt()
|
val autoSleepTimerStartHour
|
||||||
|
get() = autoSleepTimerStartTime.split(":")[0].toInt()
|
||||||
@get:JsonIgnore
|
@get:JsonIgnore
|
||||||
val autoSleepTimerStartMinute get() = autoSleepTimerStartTime.split(":")[1].toInt()
|
val autoSleepTimerStartMinute
|
||||||
|
get() = autoSleepTimerStartTime.split(":")[1].toInt()
|
||||||
@get:JsonIgnore
|
@get:JsonIgnore
|
||||||
val autoSleepTimerEndHour get() = autoSleepTimerEndTime.split(":")[0].toInt()
|
val autoSleepTimerEndHour
|
||||||
|
get() = autoSleepTimerEndTime.split(":")[0].toInt()
|
||||||
@get:JsonIgnore
|
@get:JsonIgnore
|
||||||
val autoSleepTimerEndMinute get() = autoSleepTimerEndTime.split(":")[1].toInt()
|
val autoSleepTimerEndMinute
|
||||||
|
get() = autoSleepTimerEndTime.split(":")[1].toInt()
|
||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun getShakeThresholdGravity() : Float { // Used in ShakeDetector
|
fun getShakeThresholdGravity(): Float { // Used in ShakeDetector
|
||||||
return if (shakeSensitivity == ShakeSensitivitySetting.VERY_HIGH) 1.1f
|
return if (shakeSensitivity == ShakeSensitivitySetting.VERY_HIGH) 1.1f
|
||||||
else if (shakeSensitivity == ShakeSensitivitySetting.HIGH) 1.3f
|
else if (shakeSensitivity == ShakeSensitivitySetting.HIGH) 1.3f
|
||||||
else if (shakeSensitivity == ShakeSensitivitySetting.MEDIUM) 1.5f
|
else if (shakeSensitivity == ShakeSensitivitySetting.MEDIUM) 1.5f
|
||||||
@@ -206,8 +240,8 @@ data class DeviceSettings(
|
|||||||
}
|
}
|
||||||
|
|
||||||
data class DeviceData(
|
data class DeviceData(
|
||||||
var serverConnectionConfigs:MutableList<ServerConnectionConfig>,
|
var serverConnectionConfigs: MutableList<ServerConnectionConfig>,
|
||||||
var lastServerConnectionConfigId:String?,
|
var lastServerConnectionConfigId: String?,
|
||||||
var deviceSettings: DeviceSettings?,
|
var deviceSettings: DeviceSettings?,
|
||||||
var lastPlaybackSession: PlaybackSession?
|
var lastPlaybackSession: PlaybackSession?
|
||||||
) {
|
) {
|
||||||
@@ -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
|
@JsonIgnore
|
||||||
fun hasTracks(episode:PodcastEpisode?): Boolean {
|
fun hasTracks(ctx: Context, episode:PodcastEpisode?): Boolean {
|
||||||
var audioTracks = media.getAudioTracks() as MutableList<AudioTrack>
|
var audioTracks = media.getAudioTracks() as MutableList<AudioTrack>
|
||||||
if (episode != null) { // Get podcast episode audio track
|
if (episode != null) { // Get podcast episode audio track
|
||||||
episode.audioTrack?.let { at -> mutableListOf(at) }?.let { tracks -> audioTracks = tracks }
|
episode.audioTrack?.let { at -> mutableListOf(at) }?.let { tracks -> audioTracks = tracks }
|
||||||
@@ -91,15 +91,18 @@ class LocalLibraryItem(
|
|||||||
if (it.metadata === null) {
|
if (it.metadata === null) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
// Check that file exists
|
if (!trackExists(ctx, it.contentUrl, it.metadata!!.path)) {
|
||||||
val file = File(it.metadata!!.path)
|
|
||||||
if (!file.exists()) {
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
private fun trackExists(ctx: Context, contentUrl: String?, path: String): Boolean {
|
||||||
|
return LocalFile("", null, contentUrl ?: "", "", path, null, 0).exists(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun getPlaybackSession(episode:PodcastEpisode?, deviceInfo:DeviceInfo):PlaybackSession {
|
fun getPlaybackSession(episode:PodcastEpisode?, deviceInfo:DeviceInfo):PlaybackSession {
|
||||||
val localEpisodeId = episode?.id
|
val localEpisodeId = episode?.id
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ data class LocalMediaItem(
|
|||||||
var mediaType: String,
|
var mediaType: String,
|
||||||
var folderId: String,
|
var folderId: String,
|
||||||
var contentUrl: String,
|
var contentUrl: String,
|
||||||
var simplePath: String,
|
|
||||||
var basePath: String,
|
var basePath: String,
|
||||||
var absolutePath: String,
|
var absolutePath: String,
|
||||||
var audioTracks: MutableList<AudioTrack>,
|
var audioTracks: MutableList<AudioTrack>,
|
||||||
|
|||||||
@@ -7,570 +7,306 @@ import androidx.documentfile.provider.DocumentFile
|
|||||||
import com.anggrayudi.storage.file.*
|
import com.anggrayudi.storage.file.*
|
||||||
import com.audiobookshelf.app.data.*
|
import com.audiobookshelf.app.data.*
|
||||||
import com.audiobookshelf.app.models.DownloadItem
|
import com.audiobookshelf.app.models.DownloadItem
|
||||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
import com.audiobookshelf.app.models.DownloadItemPart
|
||||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
|
||||||
import java.io.File
|
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 val tag = "FolderScanner"
|
||||||
private var jacksonMapper =
|
|
||||||
jacksonObjectMapper()
|
|
||||||
.enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
|
||||||
|
|
||||||
data class DownloadItemScanResult(
|
data class DownloadItemScanResult(
|
||||||
val localLibraryItem: LocalLibraryItem,
|
val localLibraryItem: LocalLibraryItem,
|
||||||
var localMediaProgress: LocalMediaProgress?
|
var localMediaProgress: LocalMediaProgress?
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun getLocalLibraryItemId(mediaItemId: String): String {
|
private fun localLibraryItemId(mediaItemId: String) =
|
||||||
return "local_" + DeviceManager.getBase64Id(mediaItemId)
|
"local_${DeviceManager.getBase64Id(mediaItemId)}"
|
||||||
}
|
|
||||||
|
|
||||||
private fun scanInternalDownloadItem(
|
private fun createLocalFile(
|
||||||
downloadItem: DownloadItem,
|
part: DownloadItemPart,
|
||||||
cb: (DownloadItemScanResult?) -> Unit
|
externalFile: DocumentFile? = null
|
||||||
) {
|
): LocalFile? {
|
||||||
val localLibraryItemId = "local_${downloadItem.libraryItemId}"
|
if (part.isInternalStorage) {
|
||||||
|
val file = File(part.finalDestinationPath)
|
||||||
var localEpisodeId: String? = null
|
if (!file.exists()) return null
|
||||||
var localLibraryItem: LocalLibraryItem?
|
return LocalFile(
|
||||||
if (downloadItem.mediaType == "book") {
|
DeviceManager.getBase64Id(file.name),
|
||||||
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,
|
|
||||||
file.name,
|
file.name,
|
||||||
downloadItemPart.finalDestinationUri.toString(),
|
Uri.fromFile(file).toString(),
|
||||||
file.getBasePath(ctx),
|
file.getBasePath(ctx),
|
||||||
file.absolutePath,
|
file.absolutePath,
|
||||||
file.getSimplePath(ctx),
|
|
||||||
file.mimeType,
|
file.mimeType,
|
||||||
file.length()
|
file.length()
|
||||||
)
|
)
|
||||||
localLibraryItem.localFiles.add(localFile)
|
}
|
||||||
|
|
||||||
val trackFileMetadata =
|
part.completedDestinationUri?.let { contentUrl ->
|
||||||
FileMetadata(
|
val uri = Uri.parse(contentUrl)
|
||||||
file.name,
|
val size =
|
||||||
file.extension,
|
try {
|
||||||
file.absolutePath,
|
ctx.contentResolver.openFileDescriptor(uri, "r")?.use { descriptor ->
|
||||||
file.getBasePath(ctx),
|
descriptor.statSize.coerceAtLeast(0L)
|
||||||
file.length()
|
}
|
||||||
|
?: 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 =
|
val track =
|
||||||
AudioTrack(
|
AudioTrack(
|
||||||
audioTrackFromServer.index,
|
serverTrack.index,
|
||||||
audioTrackFromServer.startOffset,
|
serverTrack.startOffset,
|
||||||
audioTrackFromServer.duration,
|
serverTrack.duration,
|
||||||
localFile.filename ?: "",
|
localFile.filename ?: "",
|
||||||
localFile.contentUrl,
|
localFile.contentUrl,
|
||||||
localFile.mimeType ?: "",
|
localFile.mimeType ?: "",
|
||||||
trackFileMetadata,
|
metadata,
|
||||||
true,
|
true,
|
||||||
localFileId,
|
localFile.id,
|
||||||
audioTrackFromServer.index
|
serverTrack.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}"
|
|
||||||
)
|
)
|
||||||
|
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
|
part.ebookFile != null -> {
|
||||||
Log.d(tag, "scanInternalDownloadItem: Ebook file found with mimetype=${file.mimeType}")
|
foundEbook = true
|
||||||
val localFileId = DeviceManager.getBase64Id(file.name)
|
localItem.localFiles.removeAll { it.id == localFile.id }
|
||||||
val localFile =
|
localItem.localFiles.add(localFile)
|
||||||
LocalFile(
|
(localItem.media as Book).ebookFile =
|
||||||
localFileId,
|
|
||||||
file.name,
|
|
||||||
Uri.fromFile(file).toString(),
|
|
||||||
file.getBasePath(ctx),
|
|
||||||
file.absolutePath,
|
|
||||||
file.getSimplePath(ctx),
|
|
||||||
file.mimeType,
|
|
||||||
file.length()
|
|
||||||
)
|
|
||||||
localLibraryItem.localFiles.add(localFile)
|
|
||||||
|
|
||||||
val ebookFile =
|
|
||||||
EBookFile(
|
EBookFile(
|
||||||
downloadItemPart.ebookFile.ino,
|
part.ebookFile.ino,
|
||||||
downloadItemPart.ebookFile.metadata,
|
part.ebookFile.metadata,
|
||||||
downloadItemPart.ebookFile.ebookFormat,
|
part.ebookFile.ebookFormat,
|
||||||
true,
|
true,
|
||||||
localFileId,
|
localFile.id,
|
||||||
localFile.contentUrl
|
localFile.contentUrl
|
||||||
)
|
)
|
||||||
(localLibraryItem.media as Book).ebookFile = ebookFile
|
}
|
||||||
Log.d(tag, "scanInternalDownloadItem: Ebook file added to lli ${localFile.contentUrl}")
|
else -> {
|
||||||
} else {
|
localItem.coverAbsolutePath = localFile.absolutePath
|
||||||
val localFileId = DeviceManager.getBase64Id(file.name)
|
localItem.coverContentUrl = localFile.contentUrl
|
||||||
val localFile =
|
localItem.localFiles.removeAll { it.id == localFile.id }
|
||||||
LocalFile(
|
localItem.localFiles.add(localFile)
|
||||||
localFileId,
|
|
||||||
file.name,
|
|
||||||
Uri.fromFile(file).toString(),
|
|
||||||
file.getBasePath(ctx),
|
|
||||||
file.absolutePath,
|
|
||||||
file.getSimplePath(ctx),
|
|
||||||
file.mimeType,
|
|
||||||
file.length()
|
|
||||||
)
|
|
||||||
|
|
||||||
localLibraryItem.coverAbsolutePath = localFile.absolutePath
|
|
||||||
localLibraryItem.coverContentUrl = localFile.contentUrl
|
|
||||||
localLibraryItem.localFiles.add(localFile)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (audioTracks.isEmpty() && !foundEBookFile) {
|
if (tracks.isEmpty() && !foundEbook) {
|
||||||
Log.d(
|
callback(null)
|
||||||
tag,
|
return
|
||||||
"scanDownloadItem did not find any audio tracks or ebook file in folder for ${downloadItem.itemFolderPath}"
|
}
|
||||||
)
|
if (item.mediaType == "book") {
|
||||||
return cb(null)
|
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
|
val result = DownloadItemScanResult(localItem, null)
|
||||||
if (downloadItem.mediaType == "book") {
|
item.userMediaProgress?.let { progress ->
|
||||||
audioTracks.sortBy { it.index }
|
val progressId =
|
||||||
|
if (item.episodeId.isNullOrEmpty()) localItem.id
|
||||||
var indexCheck = 1
|
else "${localItem.id}-$localEpisodeId"
|
||||||
var startOffset = 0.0
|
result.localMediaProgress =
|
||||||
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(
|
LocalMediaProgress(
|
||||||
id = localMediaProgressId,
|
progressId,
|
||||||
localLibraryItemId = localLibraryItemId,
|
localItem.id,
|
||||||
localEpisodeId = localEpisodeId,
|
localEpisodeId,
|
||||||
duration = mediaProgress.duration,
|
progress.duration,
|
||||||
progress = mediaProgress.progress,
|
progress.progress,
|
||||||
currentTime = mediaProgress.currentTime,
|
progress.currentTime,
|
||||||
isFinished = mediaProgress.isFinished,
|
progress.isFinished,
|
||||||
ebookLocation = mediaProgress.ebookLocation,
|
progress.ebookLocation,
|
||||||
ebookProgress = mediaProgress.ebookProgress,
|
progress.ebookProgress,
|
||||||
lastUpdate = mediaProgress.lastUpdate,
|
progress.lastUpdate,
|
||||||
startedAt = mediaProgress.startedAt,
|
progress.startedAt,
|
||||||
finishedAt = mediaProgress.finishedAt,
|
progress.finishedAt,
|
||||||
serverConnectionConfigId = downloadItem.serverConnectionConfigId,
|
item.serverConnectionConfigId,
|
||||||
serverAddress = downloadItem.serverAddress,
|
item.serverAddress,
|
||||||
serverUserId = downloadItem.serverUserId,
|
item.serverUserId,
|
||||||
libraryItemId = downloadItem.libraryItemId,
|
item.libraryItemId,
|
||||||
episodeId = downloadItem.episodeId
|
item.episodeId
|
||||||
)
|
)
|
||||||
Log.d(
|
DeviceManager.dbManager.saveLocalMediaProgress(result.localMediaProgress!!)
|
||||||
tag,
|
}
|
||||||
"scanLibraryItemFolder: Saving local media progress ${newLocalMediaProgress.id} at progress ${newLocalMediaProgress.progress}"
|
DeviceManager.dbManager.saveLocalLibraryItem(localItem)
|
||||||
)
|
callback(result)
|
||||||
DeviceManager.dbManager.saveLocalMediaProgress(newLocalMediaProgress)
|
|
||||||
|
|
||||||
downloadItemScanResult.localMediaProgress = newLocalMediaProgress
|
|
||||||
}
|
}
|
||||||
|
|
||||||
DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem)
|
private fun findFolderByPath(root: DocumentFile, subPath: String): DocumentFile? {
|
||||||
|
if (subPath.isBlank()) return root
|
||||||
cb(downloadItemScanResult)
|
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) {
|
* DownloadsProvider on Android 10 may expose an audio document without its extension through
|
||||||
// If downloading to internal storage handle separately
|
* DocumentFile.findFile(). Match the manifest first, then match the provider-normalized base
|
||||||
if (downloadItem.isInternalStorage) {
|
* filename. MIME type and server-reported size are unreliable for Opus on this platform.
|
||||||
scanInternalDownloadItem(downloadItem, cb)
|
*/
|
||||||
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val folderDf = DocumentFileCompat.fromUri(ctx, Uri.parse(downloadItem.localFolder.contentUrl))
|
val root = DocumentFileCompat.fromUri(ctx, Uri.parse(item.localFolder.contentUrl))
|
||||||
val foldersFound = folderDf?.search(true, DocumentFileType.FOLDER) ?: mutableListOf()
|
if (root == null) {
|
||||||
|
Log.e(tag, "Invalid SAF root: ${item.localFolder.contentUrl}")
|
||||||
var itemFolderId = ""
|
callback(null)
|
||||||
var itemFolderUrl = ""
|
return
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
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)
|
||||||
if (itemFolderUrl == "") {
|
val localItem =
|
||||||
Log.d(tag, "scanDownloadItem failed to find media folder")
|
DeviceManager.dbManager.getLocalLibraryItem(id)
|
||||||
return cb(null)
|
?: newLocalLibraryItem(
|
||||||
}
|
id,
|
||||||
val df: DocumentFile? = DocumentFileCompat.fromUri(ctx, Uri.parse(itemFolderUrl))
|
item,
|
||||||
|
itemFolder.getBasePath(ctx),
|
||||||
if (df == null) {
|
itemFolder.getAbsolutePath(ctx),
|
||||||
Log.e(tag, "Folder Doc File Invalid ${downloadItem.itemFolderPath}")
|
itemFolder.uri.toString()
|
||||||
return cb(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
val localLibraryItemId = getLocalLibraryItemId(itemFolderId)
|
|
||||||
Log.d(
|
|
||||||
tag,
|
|
||||||
"scanDownloadItem starting for ${downloadItem.itemFolderPath} | ${df.uri} | Item Folder Id:$itemFolderId | LLI Id:$localLibraryItemId"
|
|
||||||
)
|
)
|
||||||
|
scanParts(item, localItem, itemFolder, callback)
|
||||||
// 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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import com.audiobookshelf.app.models.DownloadItem
|
|||||||
import com.audiobookshelf.app.plugins.AbsLog
|
import com.audiobookshelf.app.plugins.AbsLog
|
||||||
import com.audiobookshelf.app.plugins.AbsLogger
|
import com.audiobookshelf.app.plugins.AbsLogger
|
||||||
import io.paperdb.Paper
|
import io.paperdb.Paper
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
class DbManager {
|
class DbManager {
|
||||||
val tag = "DbManager"
|
val tag = "DbManager"
|
||||||
@@ -148,7 +147,7 @@ class DbManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Make sure all local file ids still exist
|
// Make sure all local file ids still exist
|
||||||
fun cleanLocalLibraryItems() {
|
fun cleanLocalLibraryItems(context: Context) {
|
||||||
val localLibraryItems = getLocalLibraryItems()
|
val localLibraryItems = getLocalLibraryItems()
|
||||||
|
|
||||||
localLibraryItems.forEach { lli ->
|
localLibraryItems.forEach { lli ->
|
||||||
@@ -157,15 +156,15 @@ class DbManager {
|
|||||||
// Check local files
|
// Check local files
|
||||||
lli.localFiles =
|
lli.localFiles =
|
||||||
lli.localFiles.filter { localFile ->
|
lli.localFiles.filter { localFile ->
|
||||||
val file = File(localFile.absolutePath)
|
val exists = localFile.exists(context)
|
||||||
if (!file.exists()) {
|
if (!exists) {
|
||||||
Log.d(
|
Log.d(
|
||||||
tag,
|
tag,
|
||||||
"cleanLocalLibraryItems: Local file ${localFile.absolutePath} was removed from library item ${lli.media.metadata.title}"
|
"cleanLocalLibraryItems: Local file ${localFile.absolutePath} was removed from library item ${lli.media.metadata.title}"
|
||||||
)
|
)
|
||||||
hasUpdates = true
|
hasUpdates = true
|
||||||
}
|
}
|
||||||
file.exists()
|
exists
|
||||||
} as
|
} as
|
||||||
MutableList<LocalFile>
|
MutableList<LocalFile>
|
||||||
|
|
||||||
@@ -203,9 +202,11 @@ class DbManager {
|
|||||||
|
|
||||||
// Check cover still there
|
// Check cover still there
|
||||||
lli.coverAbsolutePath?.let {
|
lli.coverAbsolutePath?.let {
|
||||||
val coverFile = File(it)
|
val coverExists =
|
||||||
|
lli.localFiles.any { localFile ->
|
||||||
if (!coverFile.exists()) {
|
localFile.absolutePath == it && localFile.exists(context)
|
||||||
|
}
|
||||||
|
if (!coverExists) {
|
||||||
Log.d(
|
Log.d(
|
||||||
tag,
|
tag,
|
||||||
"cleanLocalLibraryItems: Cover $it was removed from library item ${lli.media.metadata.title}"
|
"cleanLocalLibraryItems: Cover $it was removed from library item ${lli.media.metadata.title}"
|
||||||
@@ -290,15 +291,13 @@ class DbManager {
|
|||||||
return sessions
|
return sessions
|
||||||
}
|
}
|
||||||
|
|
||||||
fun saveLog(log:AbsLog) {
|
fun saveLog(log: AbsLog) {
|
||||||
Paper.book("log").write(log.id, log)
|
Paper.book("log").write(log.id, log)
|
||||||
}
|
}
|
||||||
fun getAllLogs() : List<AbsLog> {
|
fun getAllLogs(): List<AbsLog> {
|
||||||
val logs:MutableList<AbsLog> = mutableListOf()
|
val logs: MutableList<AbsLog> = mutableListOf()
|
||||||
Paper.book("log").allKeys.forEach { logId ->
|
Paper.book("log").allKeys.forEach { logId ->
|
||||||
Paper.book("log").read<AbsLog>(logId)?.let {
|
Paper.book("log").read<AbsLog>(logId)?.let { logs.add(it) }
|
||||||
logs.add(it)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return logs.sortedBy { it.timestamp }
|
return logs.sortedBy { it.timestamp }
|
||||||
}
|
}
|
||||||
@@ -317,7 +316,10 @@ class DbManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (logsRemoved > 0) {
|
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"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+426
-297
@@ -1,16 +1,10 @@
|
|||||||
package com.audiobookshelf.app.managers
|
package com.audiobookshelf.app.managers
|
||||||
|
|
||||||
import android.app.DownloadManager
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.os.StatFs
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.documentfile.provider.DocumentFile
|
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.DeviceManager
|
||||||
import com.audiobookshelf.app.device.FolderScanner
|
import com.audiobookshelf.app.device.FolderScanner
|
||||||
import com.audiobookshelf.app.models.DownloadItem
|
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.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||||
import com.getcapacitor.JSObject
|
import com.getcapacitor.JSObject
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileOutputStream
|
import java.io.FileInputStream
|
||||||
import java.util.*
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import kotlin.math.max
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.GlobalScope
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import okhttp3.Call
|
||||||
|
|
||||||
/** Manages download items and their parts. */
|
/** Manages the process-owned queue for app-managed downloads. */
|
||||||
class DownloadItemManager(
|
class DownloadItemManager(
|
||||||
var downloadManager: DownloadManager,
|
private val folderScanner: FolderScanner,
|
||||||
private var folderScanner: FolderScanner,
|
private val context: Context,
|
||||||
var mainActivity: MainActivity,
|
|
||||||
private var clientEventEmitter: DownloadEventEmitter
|
private var clientEventEmitter: DownloadEventEmitter
|
||||||
) {
|
) {
|
||||||
val tag = "DownloadItemManager"
|
private val tag = "DownloadItemManager"
|
||||||
private val maxSimultaneousDownloads = 3
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
private var jacksonMapper =
|
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()
|
jacksonObjectMapper()
|
||||||
.enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
.enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||||
|
|
||||||
enum class DownloadCheckStatus {
|
var downloadItemQueue: MutableList<DownloadItem> = mutableListOf()
|
||||||
InProgress,
|
private set
|
||||||
Successful,
|
var currentDownloadItemParts: MutableList<DownloadItemPart> = mutableListOf()
|
||||||
Failed
|
private set
|
||||||
}
|
|
||||||
|
|
||||||
var downloadItemQueue: MutableList<DownloadItem> =
|
|
||||||
mutableListOf() // All pending and downloading items
|
|
||||||
var currentDownloadItemParts: MutableList<DownloadItemPart> =
|
|
||||||
mutableListOf() // Item parts currently being downloaded
|
|
||||||
|
|
||||||
interface DownloadEventEmitter {
|
interface DownloadEventEmitter {
|
||||||
fun onDownloadItem(downloadItem: DownloadItem)
|
fun onDownloadItem(downloadItem: DownloadItem)
|
||||||
fun onDownloadItemPartUpdate(downloadItemPart: DownloadItemPart)
|
fun onDownloadItemPartUpdate(downloadItemPart: DownloadItemPart)
|
||||||
fun onDownloadItemComplete(jsobj: JSObject)
|
fun onDownloadItemComplete(jsobj: JSObject)
|
||||||
|
fun onQueueChanged(hasWork: Boolean)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface InternalProgressCallback {
|
interface InternalProgressCallback {
|
||||||
@@ -61,323 +58,455 @@ class DownloadItemManager(
|
|||||||
fun onComplete(failed: Boolean)
|
fun onComplete(failed: Boolean)
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
init {
|
||||||
var isDownloading: Boolean = false
|
IncompleteDownloadCleanup.cleanupExpired(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Adds a download item to the queue and starts processing the queue. */
|
@Synchronized
|
||||||
fun addDownloadItem(downloadItem: DownloadItem) {
|
fun setEventEmitter(eventEmitter: DownloadEventEmitter) {
|
||||||
DeviceManager.dbManager.saveDownloadItem(downloadItem)
|
clientEventEmitter = eventEmitter
|
||||||
Log.i(tag, "Add download item ${downloadItem.media.metadata.title}")
|
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)
|
downloadItemQueue.add(downloadItem)
|
||||||
clientEventEmitter.onDownloadItem(downloadItem)
|
clientEventEmitter.onDownloadItem(downloadItem)
|
||||||
checkUpdateDownloadQueue()
|
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() {
|
private fun checkUpdateDownloadQueue() {
|
||||||
for (downloadItem in downloadItemQueue) {
|
downloadItemQueue.toList().forEach { item ->
|
||||||
val numPartsToGet = maxSimultaneousDownloads - currentDownloadItemParts.size
|
val slots = MAX_SIMULTANEOUS_DOWNLOADS - currentDownloadItemParts.size
|
||||||
val nextDownloadItemParts = downloadItem.getNextDownloadItemParts(numPartsToGet)
|
if (slots <= 0) return@forEach
|
||||||
Log.d(
|
item.getNextDownloadItemParts(slots).forEach { part ->
|
||||||
tag,
|
val existingFile = findSharedStorageFile(part)
|
||||||
"checkUpdateDownloadQueue: numPartsToGet=$numPartsToGet, nextDownloadItemParts=${nextDownloadItemParts.size}"
|
if (existingFile != null) {
|
||||||
)
|
part.bytesDownloaded = existingFile.length()
|
||||||
|
part.progress = 100L
|
||||||
if (nextDownloadItemParts.isNotEmpty()) {
|
part.completedDestinationUri = existingFile.uri.toString()
|
||||||
processDownloadItemParts(nextDownloadItemParts)
|
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) {
|
private fun startDownload(item: DownloadItem, part: DownloadItemPart) {
|
||||||
break
|
val stagingFile = File(part.destinationPath)
|
||||||
}
|
stagingFile.parentFile?.mkdirs()
|
||||||
}
|
part.downloadId = APP_MANAGED_DOWNLOAD_ID
|
||||||
|
part.waitingForSpace = false
|
||||||
if (currentDownloadItemParts.isNotEmpty()) startWatchingDownloads()
|
part.lastUpdateTime = System.currentTimeMillis()
|
||||||
}
|
currentDownloadItemParts.add(part)
|
||||||
|
persist(item, force = true)
|
||||||
/** Processes the download item parts. */
|
val activeConfig = DeviceManager.serverConnectionConfig
|
||||||
private fun processDownloadItemParts(nextDownloadItemParts: List<DownloadItemPart>) {
|
val token =
|
||||||
nextDownloadItemParts.forEach {
|
if (activeConfig?.id == item.serverConnectionConfigId) activeConfig.token
|
||||||
if (it.isInternalStorage) {
|
else
|
||||||
startInternalDownload(it)
|
DeviceManager.getServerConnectionConfig(item.serverConnectionConfigId)?.token
|
||||||
} else {
|
?: DeviceManager.token
|
||||||
startExternalDownload(it)
|
activeCalls[part.id] =
|
||||||
}
|
InternalDownloadManager(
|
||||||
}
|
stagingFile,
|
||||||
}
|
part.fileSize,
|
||||||
|
|
||||||
/** Starts an internal download. */
|
|
||||||
private fun startInternalDownload(downloadItemPart: DownloadItemPart) {
|
|
||||||
val file = File(downloadItemPart.finalDestinationPath)
|
|
||||||
file.parentFile?.mkdirs()
|
|
||||||
|
|
||||||
val fileOutputStream = FileOutputStream(downloadItemPart.finalDestinationPath)
|
|
||||||
val internalProgressCallback =
|
|
||||||
object : InternalProgressCallback {
|
object : InternalProgressCallback {
|
||||||
override fun onProgress(totalBytesWritten: Long, progress: Long) {
|
override fun onProgress(totalBytesWritten: Long, progress: Long) {
|
||||||
downloadItemPart.bytesDownloaded = totalBytesWritten
|
synchronized(this@DownloadItemManager) {
|
||||||
downloadItemPart.progress = progress
|
if (part !in currentDownloadItemParts) return
|
||||||
|
part.bytesDownloaded = totalBytesWritten
|
||||||
|
part.progress = progress
|
||||||
|
part.lastUpdateTime = System.currentTimeMillis()
|
||||||
|
persist(item)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onComplete(failed: Boolean) {
|
override fun onComplete(failed: Boolean) {
|
||||||
downloadItemPart.failed = failed
|
synchronized(this@DownloadItemManager) {
|
||||||
downloadItemPart.completed = true
|
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(
|
{ hasAvailableSpace(part) }
|
||||||
tag,
|
|
||||||
"Start internal download to destination path ${downloadItemPart.finalDestinationPath} from ${downloadItemPart.serverUrl}"
|
|
||||||
)
|
)
|
||||||
InternalDownloadManager(fileOutputStream, internalProgressCallback)
|
.download(serverUrl(item, part), token)
|
||||||
.download(downloadItemPart.serverUrl)
|
|
||||||
downloadItemPart.downloadId = 1
|
|
||||||
currentDownloadItemParts.add(downloadItemPart)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Starts an external download. */
|
@Synchronized
|
||||||
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. */
|
|
||||||
private fun startWatchingDownloads() {
|
private fun startWatchingDownloads() {
|
||||||
if (isDownloading) return // Already watching
|
if (watcherRunning) return
|
||||||
|
watcherRunning = true
|
||||||
GlobalScope.launch(Dispatchers.IO) {
|
scope.launch {
|
||||||
Log.d(tag, "Starting watching downloads")
|
while (true) {
|
||||||
isDownloading = true
|
val activeParts =
|
||||||
|
synchronized(this@DownloadItemManager) { currentDownloadItemParts.toList() }
|
||||||
while (currentDownloadItemParts.isNotEmpty()) {
|
activeParts.forEach(::handlePartUpdate)
|
||||||
val itemParts = currentDownloadItemParts.filter { !it.isMoving }
|
synchronized(this@DownloadItemManager) {
|
||||||
for (downloadItemPart in itemParts) {
|
|
||||||
if (downloadItemPart.isInternalStorage) {
|
|
||||||
handleInternalDownloadPart(downloadItemPart)
|
|
||||||
} else {
|
|
||||||
handleExternalDownloadPart(downloadItemPart)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
delay(500)
|
|
||||||
|
|
||||||
if (currentDownloadItemParts.size < maxSimultaneousDownloads) {
|
|
||||||
checkUpdateDownloadQueue()
|
checkUpdateDownloadQueue()
|
||||||
|
if (!hasWork()) {
|
||||||
|
watcherRunning = false
|
||||||
|
notifyQueueChanged()
|
||||||
|
return@launch
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
delay(WATCH_INTERVAL_MS)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Handles the result of a download item part check. */
|
private fun handlePartUpdate(part: DownloadItemPart) {
|
||||||
private fun handleDownloadItemPartCheck(
|
clientEventEmitter.onDownloadItemPartUpdate(part)
|
||||||
downloadCheckStatus: DownloadCheckStatus,
|
val item =
|
||||||
downloadItemPart: DownloadItemPart
|
synchronized(this) { downloadItemQueue.find { it.id == part.downloadItemId } }
|
||||||
) {
|
?: run {
|
||||||
val downloadItem = downloadItemQueue.find { it.id == downloadItemPart.downloadItemId }
|
removeActivePart(part)
|
||||||
if (downloadItem == null) {
|
return
|
||||||
Log.e(
|
}
|
||||||
tag,
|
if (!part.completed && !part.failed) {
|
||||||
"Download item part finished but download item not found ${downloadItemPart.filename}"
|
val lastUpdate = part.lastUpdateTime ?: return
|
||||||
)
|
if (System.currentTimeMillis() - lastUpdate > STALL_TIMEOUT_MS) {
|
||||||
currentDownloadItemParts.remove(downloadItemPart)
|
Log.w(tag, "Download stalled: ${part.filename}")
|
||||||
} else if (downloadCheckStatus == DownloadCheckStatus.Successful) {
|
activeCalls.remove(part.id)?.cancel()
|
||||||
moveDownloadedFile(downloadItem, downloadItemPart)
|
failOrRetry(item, part, "Download stalled")
|
||||||
} else if (downloadCheckStatus != DownloadCheckStatus.InProgress) {
|
}
|
||||||
checkDownloadItemFinished(downloadItem)
|
return
|
||||||
currentDownloadItemParts.remove(downloadItemPart)
|
}
|
||||||
|
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(item: DownloadItem, part: DownloadItemPart) {
|
||||||
private fun moveDownloadedFile(downloadItem: DownloadItem, downloadItemPart: DownloadItemPart) {
|
if (part.moved || part.isMoving) return
|
||||||
val file = DocumentFileCompat.fromUri(mainActivity, downloadItemPart.destinationUri)
|
val root =
|
||||||
Log.d(tag, "DOWNLOAD: DESTINATION URI ${downloadItemPart.destinationUri}")
|
DocumentFile.fromTreeUri(context, Uri.parse(part.localFolderUrl))
|
||||||
|
?: return failFinalization(item, part, "Could not resolve SAF destination")
|
||||||
val fcb =
|
part.isMoving = true
|
||||||
object : FileCallback() {
|
persist(item, force = true)
|
||||||
override fun onPrepare() {
|
scope.launch {
|
||||||
Log.d(tag, "DOWNLOAD: PREPARING MOVE FILE")
|
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")
|
||||||
}
|
}
|
||||||
|
if (temporary.length() != staging.length())
|
||||||
override fun onFailed(errorCode: ErrorCode) {
|
throw IllegalStateException("SAF copy size mismatch")
|
||||||
Log.e(tag, "DOWNLOAD: FAILED TO MOVE FILE $errorCode")
|
val existing = folder.findFile(part.filename)
|
||||||
downloadItemPart.failed = true
|
if (existing != null && !existing.delete())
|
||||||
downloadItemPart.isMoving = false
|
throw IllegalStateException("Could not replace existing file")
|
||||||
file?.delete()
|
if (!temporary.renameTo(part.filename))
|
||||||
checkDownloadItemFinished(downloadItem)
|
throw IllegalStateException("Could not finalize SAF temporary file")
|
||||||
currentDownloadItemParts.remove(downloadItemPart)
|
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 =
|
@Synchronized
|
||||||
DocumentFileCompat.fromUri(mainActivity, Uri.parse(downloadItemPart.localFolderUrl))
|
private fun failFinalization(item: DownloadItem, part: DownloadItemPart, message: String) {
|
||||||
if (localFolderFile == null) {
|
Log.e(tag, message)
|
||||||
// Failed
|
part.isMoving = false
|
||||||
downloadItemPart.failed = true
|
part.failed = true
|
||||||
Log.e(tag, "Local Folder File from uri is null")
|
failOrRetry(item, part, message)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Checks if a download item is finished and processes it. */
|
@Synchronized
|
||||||
private fun checkDownloadItemFinished(downloadItem: DownloadItem) {
|
private fun completePart(item: DownloadItem, part: DownloadItemPart) {
|
||||||
if (downloadItem.isDownloadFinished) {
|
part.moved = true
|
||||||
Log.i(tag, "Download Item finished ${downloadItem.media.metadata.title}")
|
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) {
|
private fun checkDownloadItemFinished(item: DownloadItem) {
|
||||||
folderScanner.scanDownloadItem(downloadItem) { downloadItemScanResult ->
|
if (!item.isDownloadFinished) return
|
||||||
Log.d(
|
scope.launch {
|
||||||
tag,
|
folderScanner.scanDownloadItem(item) { scanResult ->
|
||||||
"Item download complete ${downloadItem.itemTitle} | local library item id: ${downloadItemScanResult?.localLibraryItem?.id}"
|
val event =
|
||||||
)
|
|
||||||
|
|
||||||
val jsobj =
|
|
||||||
JSObject().apply {
|
JSObject().apply {
|
||||||
put("libraryItemId", downloadItem.id)
|
put("libraryItemId", item.id)
|
||||||
put("localFolderId", downloadItem.localFolder.id)
|
put("localFolderId", item.localFolder.id)
|
||||||
|
scanResult?.localLibraryItem?.let {
|
||||||
downloadItemScanResult?.localLibraryItem?.let { localLibraryItem ->
|
put("localLibraryItem", JSObject(jacksonMapper.writeValueAsString(it)))
|
||||||
put(
|
}
|
||||||
"localLibraryItem",
|
scanResult?.localMediaProgress?.let {
|
||||||
JSObject(jacksonMapper.writeValueAsString(localLibraryItem))
|
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) {
|
private fun tryReserve(part: DownloadItemPart): Boolean {
|
||||||
clientEventEmitter.onDownloadItemComplete(jsobj)
|
if (part.fileSize <= 0L && currentDownloadItemParts.any { it.fileSize <= 0L }) return false
|
||||||
downloadItemQueue.remove(downloadItem)
|
val staging = File(part.destinationPath)
|
||||||
DeviceManager.dbManager.removeDownloadItem(downloadItem.id)
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
+109
-88
@@ -1,114 +1,135 @@
|
|||||||
package com.audiobookshelf.app.managers
|
package com.audiobookshelf.app.managers
|
||||||
|
|
||||||
import android.util.Log
|
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 java.util.concurrent.TimeUnit
|
||||||
import okhttp3.*
|
import okhttp3.Call
|
||||||
|
import okhttp3.Callback
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
|
||||||
/**
|
/** Streams a download into an app-owned staging file. */
|
||||||
* Manages the internal download process.
|
|
||||||
*
|
|
||||||
* @property outputStream The output stream to write the downloaded data.
|
|
||||||
* @property progressCallback The callback to report download progress.
|
|
||||||
*/
|
|
||||||
class InternalDownloadManager(
|
class InternalDownloadManager(
|
||||||
private val outputStream: FileOutputStream,
|
private val destinationFile: File,
|
||||||
private val progressCallback: DownloadItemManager.InternalProgressCallback
|
private val expectedSize: Long,
|
||||||
) : AutoCloseable {
|
private val progressCallback: DownloadItemManager.InternalProgressCallback,
|
||||||
|
private val hasAvailableSpace: () -> Boolean
|
||||||
|
) {
|
||||||
private val tag = "InternalDownloadManager"
|
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.
|
* @param url download URL
|
||||||
* @throws IOException If an I/O error occurs.
|
* @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, token: String): Call {
|
||||||
fun download(url: String) {
|
destinationFile.parentFile?.mkdirs()
|
||||||
val request: Request = Request.Builder().url(url).addHeader("Accept-Encoding", "identity").build()
|
val existingBytes = destinationFile.takeIf { it.exists() }?.length() ?: 0L
|
||||||
client.newCall(request)
|
val request =
|
||||||
.enqueue(
|
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 {
|
object : Callback {
|
||||||
override fun onFailure(call: Call, e: IOException) {
|
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)
|
progressCallback.onComplete(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResponse(call: Call, response: Response) {
|
override fun onResponse(call: Call, response: Response) {
|
||||||
response.body?.let { responseBody ->
|
response.use {
|
||||||
val length: Long = response.header("Content-Length")?.toLongOrNull() ?: 0L
|
try {
|
||||||
writer.write(responseBody.byteStream(), length)
|
if (response.code == 416 && expectedSize > 0L && existingBytes == expectedSize
|
||||||
|
) {
|
||||||
|
progressCallback.onProgress(existingBytes, 100L)
|
||||||
|
progressCallback.onComplete(false)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
?: run {
|
val append =
|
||||||
Log.e(tag, "Response doesn't contain a file")
|
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)
|
progressCallback.onComplete(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
return call
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private fun hasExpectedRange(response: Response, offset: Long): Boolean {
|
||||||
* Closes the download manager and releases resources.
|
val range = response.header("Content-Range") ?: return false
|
||||||
*
|
val match = CONTENT_RANGE.matchEntire(range) ?: return false
|
||||||
* @throws Exception If an error occurs during closing.
|
return match.groupValues[1].toLongOrNull() == offset &&
|
||||||
*/
|
match.groupValues[2].toLongOrNull()?.let { it >= offset } == true
|
||||||
@Throws(Exception::class)
|
}
|
||||||
override fun close() {
|
|
||||||
writer.close()
|
private companion object {
|
||||||
}
|
const val CHUNK_SIZE = 512 * 1024 // 512 KB
|
||||||
}
|
val CONTENT_RANGE = Regex("bytes (\\d+)-(\\d+)/(?:\\d+|\\*)")
|
||||||
|
val client =
|
||||||
/**
|
OkHttpClient.Builder()
|
||||||
* Writes binary data to an output stream.
|
.connectTimeout(30, TimeUnit.SECONDS)
|
||||||
*
|
.readTimeout(60, TimeUnit.SECONDS)
|
||||||
* @property outputStream The output stream to write the data to.
|
.writeTimeout(60, TimeUnit.SECONDS)
|
||||||
* @property progressCallback The callback to report write progress.
|
.build()
|
||||||
*/
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,33 +7,36 @@ import com.fasterxml.jackson.annotation.JsonIgnore
|
|||||||
|
|
||||||
data class DownloadItem(
|
data class DownloadItem(
|
||||||
val id: String,
|
val id: String,
|
||||||
val libraryItemId:String,
|
val libraryItemId: String,
|
||||||
val episodeId:String?,
|
val episodeId: String?,
|
||||||
val userMediaProgress: MediaProgress?,
|
val userMediaProgress: MediaProgress?,
|
||||||
val serverConnectionConfigId:String,
|
val serverConnectionConfigId: String,
|
||||||
val serverAddress:String,
|
val serverAddress: String,
|
||||||
val serverUserId:String,
|
val serverUserId: String,
|
||||||
val mediaType: String,
|
val mediaType: String,
|
||||||
val itemFolderPath:String,
|
val itemFolderPath: String,
|
||||||
val localFolder: LocalFolder,
|
val localFolder: LocalFolder,
|
||||||
val itemTitle: String,
|
val itemTitle: String,
|
||||||
val itemSubfolder: String,
|
val itemSubfolder: String,
|
||||||
val media: MediaType,
|
val media: MediaType,
|
||||||
val downloadItemParts: MutableList<DownloadItemPart>
|
val downloadItemParts: MutableList<DownloadItemPart>,
|
||||||
|
@JsonIgnore var terminalFailureAt: Long? = null
|
||||||
) {
|
) {
|
||||||
@get:JsonIgnore
|
@get:JsonIgnore
|
||||||
val isInternalStorage get() = localFolder.id.startsWith("internal-")
|
val isInternalStorage
|
||||||
|
get() = localFolder.id.startsWith("internal-")
|
||||||
|
|
||||||
@get:JsonIgnore
|
@get:JsonIgnore
|
||||||
val isDownloadFinished get() = !downloadItemParts.any { !it.completed || it.isMoving }
|
val isDownloadFinished
|
||||||
|
get() = !downloadItemParts.any { !it.completed || it.isMoving || it.failed }
|
||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun getNextDownloadItemParts(limit:Int): MutableList<DownloadItemPart> {
|
fun getNextDownloadItemParts(limit: Int): MutableList<DownloadItemPart> {
|
||||||
val itemParts = mutableListOf<DownloadItemPart>()
|
val itemParts = mutableListOf<DownloadItemPart>()
|
||||||
if (limit == 0) return itemParts
|
if (limit == 0) return itemParts
|
||||||
|
|
||||||
for (it in downloadItemParts) {
|
for (it in downloadItemParts) {
|
||||||
if (!it.completed && it.downloadId == null) {
|
if (!it.completed && !it.failed && it.downloadId == null) {
|
||||||
itemParts.add(it)
|
itemParts.add(it)
|
||||||
if (itemParts.size >= limit) break
|
if (itemParts.size >= limit) break
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package com.audiobookshelf.app.models
|
package com.audiobookshelf.app.models
|
||||||
|
|
||||||
import android.app.DownloadManager
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.audiobookshelf.app.data.AudioTrack
|
import com.audiobookshelf.app.data.AudioTrack
|
||||||
@@ -16,6 +15,7 @@ data class DownloadItemPart(
|
|||||||
val downloadItemId: String,
|
val downloadItemId: String,
|
||||||
val filename: String,
|
val filename: String,
|
||||||
val fileSize: Long,
|
val fileSize: Long,
|
||||||
|
@JsonIgnore val destinationPath: String,
|
||||||
val finalDestinationPath:String,
|
val finalDestinationPath:String,
|
||||||
val serverPath: String,
|
val serverPath: String,
|
||||||
val localFolderName: String,
|
val localFolderName: String,
|
||||||
@@ -31,28 +31,29 @@ data class DownloadItemPart(
|
|||||||
@JsonIgnore val uri: Uri,
|
@JsonIgnore val uri: Uri,
|
||||||
@JsonIgnore val destinationUri: Uri,
|
@JsonIgnore val destinationUri: Uri,
|
||||||
@JsonIgnore val finalDestinationUri: Uri,
|
@JsonIgnore val finalDestinationUri: Uri,
|
||||||
|
@JsonIgnore var completedDestinationUri: String?,
|
||||||
val finalDestinationSubfolder: String,
|
val finalDestinationSubfolder: String,
|
||||||
var downloadId: Long?,
|
var downloadId: Long?,
|
||||||
|
@JsonIgnore var lastUpdateTime: Long?,
|
||||||
var progress: Long,
|
var progress: Long,
|
||||||
var bytesDownloaded: Long
|
var bytesDownloaded: Long,
|
||||||
|
@JsonIgnore var retryCount: Int = 0,
|
||||||
|
@JsonIgnore var waitingForSpace: Boolean = false
|
||||||
) {
|
) {
|
||||||
companion object {
|
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 {
|
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 destinationUri = Uri.fromFile(destinationFile)
|
||||||
val finalDestinationUri = Uri.fromFile(finalDestinationFile)
|
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}"
|
Log.d("DownloadItemPart", "Audio File Destination Uri: $destinationUri | Final Destination Uri: $finalDestinationUri | Server Path $serverPath")
|
||||||
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")
|
|
||||||
return DownloadItemPart(
|
return DownloadItemPart(
|
||||||
id = DeviceManager.getBase64Id(finalDestinationFile.absolutePath),
|
id = DeviceManager.getBase64Id(finalDestinationFile.absolutePath),
|
||||||
downloadItemId,
|
downloadItemId,
|
||||||
filename = filename,
|
filename = filename,
|
||||||
fileSize = fileSize,
|
fileSize = fileSize,
|
||||||
|
destinationPath = destinationFile.absolutePath,
|
||||||
finalDestinationPath = finalDestinationFile.absolutePath,
|
finalDestinationPath = finalDestinationFile.absolutePath,
|
||||||
serverPath = serverPath,
|
serverPath = serverPath,
|
||||||
localFolderName = localFolder.name,
|
localFolderName = localFolder.name,
|
||||||
@@ -68,8 +69,10 @@ data class DownloadItemPart(
|
|||||||
uri = downloadUri,
|
uri = downloadUri,
|
||||||
destinationUri = destinationUri,
|
destinationUri = destinationUri,
|
||||||
finalDestinationUri = finalDestinationUri,
|
finalDestinationUri = finalDestinationUri,
|
||||||
|
completedDestinationUri = null,
|
||||||
finalDestinationSubfolder = subfolder,
|
finalDestinationSubfolder = subfolder,
|
||||||
downloadId = null,
|
downloadId = null,
|
||||||
|
lastUpdateTime = null,
|
||||||
progress = 0,
|
progress = 0,
|
||||||
bytesDownloaded = 0
|
bytesDownloaded = 0
|
||||||
)
|
)
|
||||||
@@ -79,16 +82,4 @@ data class DownloadItemPart(
|
|||||||
@get:JsonIgnore
|
@get:JsonIgnore
|
||||||
val isInternalStorage get() = localFolderId.startsWith("internal-")
|
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ class AbsAudioPlayer : Plugin() {
|
|||||||
return call.resolve(JSObject("{\"error\":\"Podcast episode not found\"}"))
|
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.\"}"))
|
return call.resolve(JSObject("{\"error\":\"No audio files found on device. Download book again to fix.\"}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -325,10 +325,10 @@ class AbsAudioPlayer : Plugin() {
|
|||||||
|
|
||||||
@PluginMethod
|
@PluginMethod
|
||||||
fun seek(call: PluginCall) {
|
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")
|
Log.d(tag, "seek action to $time")
|
||||||
Handler(Looper.getMainLooper()).post {
|
Handler(Looper.getMainLooper()).post {
|
||||||
playerNotificationService.seekPlayer(time * 1000L) // convert to ms
|
playerNotificationService.seekPlayer((time * 1000L).toLong())
|
||||||
call.resolve()
|
call.resolve()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ class AbsDatabase : Plugin() {
|
|||||||
secureStorage = SecureStorage(mainActivity)
|
secureStorage = SecureStorage(mainActivity)
|
||||||
|
|
||||||
DeviceManager.dbManager.cleanLocalMediaProgress()
|
DeviceManager.dbManager.cleanLocalMediaProgress()
|
||||||
DeviceManager.dbManager.cleanLocalLibraryItems()
|
DeviceManager.dbManager.cleanLocalLibraryItems(mainActivity)
|
||||||
DeviceManager.dbManager.cleanLogs()
|
DeviceManager.dbManager.cleanLogs()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,7 +119,7 @@ class AbsDatabase : Plugin() {
|
|||||||
|
|
||||||
@PluginMethod
|
@PluginMethod
|
||||||
fun setCurrentServerConnectionConfig(call:PluginCall) {
|
fun setCurrentServerConnectionConfig(call:PluginCall) {
|
||||||
Log.d(tag, "setCurrentServerConnectionConfig ${call.data}")
|
Log.d(tag, "setCurrentServerConnectionConfig called")
|
||||||
val serverConfigPayload = jacksonMapper.readValue<ServerConnConfigPayload>(call.data.toString())
|
val serverConfigPayload = jacksonMapper.readValue<ServerConnConfigPayload>(call.data.toString())
|
||||||
var serverConnectionConfig = DeviceManager.deviceData.serverConnectionConfigs.find { it.id == serverConfigPayload.id }
|
var serverConnectionConfig = DeviceManager.deviceData.serverConnectionConfigs.find { it.id == serverConfigPayload.id }
|
||||||
|
|
||||||
@@ -558,7 +558,7 @@ class AbsDatabase : Plugin() {
|
|||||||
|
|
||||||
@PluginMethod
|
@PluginMethod
|
||||||
fun updateDeviceSettings(call:PluginCall) { // Returns device data
|
fun updateDeviceSettings(call:PluginCall) { // Returns device data
|
||||||
Log.d(tag, "updateDeviceSettings ${call.data}")
|
Log.d(tag, "updateDeviceSettings called")
|
||||||
val newDeviceSettings = jacksonMapper.readValue<DeviceSettings>(call.data.toString())
|
val newDeviceSettings = jacksonMapper.readValue<DeviceSettings>(call.data.toString())
|
||||||
|
|
||||||
Handler(Looper.getMainLooper()).post {
|
Handler(Looper.getMainLooper()).post {
|
||||||
@@ -576,7 +576,7 @@ class AbsDatabase : Plugin() {
|
|||||||
|
|
||||||
@PluginMethod
|
@PluginMethod
|
||||||
fun getMediaItemHistory(call:PluginCall) { // Returns device data
|
fun getMediaItemHistory(call:PluginCall) { // Returns device data
|
||||||
Log.d(tag, "getMediaItemHistory ${call.data}")
|
Log.d(tag, "getMediaItemHistory called")
|
||||||
val mediaId = call.getString("mediaId") ?: ""
|
val mediaId = call.getString("mediaId") ?: ""
|
||||||
|
|
||||||
GlobalScope.launch(Dispatchers.IO) {
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
package com.audiobookshelf.app.plugins
|
package com.audiobookshelf.app.plugins
|
||||||
|
|
||||||
import android.app.DownloadManager
|
|
||||||
import android.content.Context
|
|
||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.audiobookshelf.app.MainActivity
|
import com.audiobookshelf.app.MainActivity
|
||||||
import com.audiobookshelf.app.data.*
|
import com.audiobookshelf.app.data.*
|
||||||
import com.audiobookshelf.app.device.DeviceManager
|
import com.audiobookshelf.app.device.DeviceManager
|
||||||
import com.audiobookshelf.app.device.FolderScanner
|
|
||||||
import com.audiobookshelf.app.models.DownloadItem
|
import com.audiobookshelf.app.models.DownloadItem
|
||||||
import com.audiobookshelf.app.models.DownloadItemPart
|
import com.audiobookshelf.app.models.DownloadItemPart
|
||||||
import com.audiobookshelf.app.server.ApiHandler
|
import com.audiobookshelf.app.server.ApiHandler
|
||||||
import com.audiobookshelf.app.managers.DownloadItemManager
|
import com.audiobookshelf.app.managers.DownloadItemManager
|
||||||
|
import com.audiobookshelf.app.services.DownloadServiceHost
|
||||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||||
import com.getcapacitor.JSObject
|
import com.getcapacitor.JSObject
|
||||||
@@ -27,9 +25,7 @@ class AbsDownloader : Plugin() {
|
|||||||
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
|
lateinit var mainActivity: MainActivity
|
||||||
lateinit var downloadManager: DownloadManager
|
|
||||||
lateinit var apiHandler: ApiHandler
|
lateinit var apiHandler: ApiHandler
|
||||||
lateinit var folderScanner: FolderScanner
|
|
||||||
lateinit var downloadItemManager: DownloadItemManager
|
lateinit var downloadItemManager: DownloadItemManager
|
||||||
|
|
||||||
private val clientEventEmitter = (object : DownloadItemManager.DownloadEventEmitter {
|
private val clientEventEmitter = (object : DownloadItemManager.DownloadEventEmitter {
|
||||||
@@ -42,14 +38,44 @@ class AbsDownloader : Plugin() {
|
|||||||
override fun onDownloadItemComplete(jsobj:JSObject) {
|
override fun onDownloadItemComplete(jsobj:JSObject) {
|
||||||
notifyListeners("onItemDownloadComplete", jsobj)
|
notifyListeners("onItemDownloadComplete", jsobj)
|
||||||
}
|
}
|
||||||
|
override fun onQueueChanged(hasWork: Boolean) {
|
||||||
|
notifyListeners("onQueueChanged", JSObject().put("hasWork", hasWork))
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
override fun load() {
|
override fun load() {
|
||||||
mainActivity = (activity as MainActivity)
|
mainActivity = (activity as MainActivity)
|
||||||
downloadManager = activity.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
|
|
||||||
folderScanner = FolderScanner(mainActivity)
|
|
||||||
apiHandler = ApiHandler(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
|
@PluginMethod
|
||||||
@@ -79,7 +105,7 @@ class AbsDownloader : Plugin() {
|
|||||||
|
|
||||||
if (localFolder == null && localFolderId.startsWith("internal-")) {
|
if (localFolder == null && localFolderId.startsWith("internal-")) {
|
||||||
Log.d(tag, "Creating new App Storage internal LocalFolder $localFolderId")
|
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)
|
DeviceManager.dbManager.saveLocalFolder(localFolder)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,7 +158,13 @@ class AbsDownloader : Plugin() {
|
|||||||
private fun startLibraryItemDownload(libraryItem: LibraryItem, localFolder: LocalFolder, episode:PodcastEpisode?) {
|
private fun startLibraryItemDownload(libraryItem: LibraryItem, localFolder: LocalFolder, episode:PodcastEpisode?) {
|
||||||
val isInternal = localFolder.id.startsWith("internal-")
|
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")
|
Log.d(tag, "downloadCacheDirectory=$tempFolderPath")
|
||||||
|
|
||||||
@@ -143,7 +175,7 @@ class AbsDownloader : Plugin() {
|
|||||||
val tracks = libraryItem.media.getAudioTracks()
|
val tracks = libraryItem.media.getAudioTracks()
|
||||||
Log.d(tag, "Starting library item download with ${tracks.size} tracks")
|
Log.d(tag, "Starting library item download with ${tracks.size} tracks")
|
||||||
val itemSubfolder = "$bookAuthor/$bookTitle"
|
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 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
|
val book = libraryItem.media as Book
|
||||||
@@ -152,17 +184,7 @@ class AbsDownloader : Plugin() {
|
|||||||
val serverPath = "/api/items/${libraryItem.id}/file/${ebookFile.ino}/download"
|
val serverPath = "/api/items/${libraryItem.id}/file/${ebookFile.ino}/download"
|
||||||
val destinationFilename = getFilenameFromRelPath(ebookFile.metadata?.relPath ?: "")
|
val destinationFilename = getFilenameFromRelPath(ebookFile.metadata?.relPath ?: "")
|
||||||
val finalDestinationFile = File("$itemFolderPath/$destinationFilename")
|
val finalDestinationFile = File("$itemFolderPath/$destinationFilename")
|
||||||
val destinationFile = File("$tempFolderPath/$destinationFilename")
|
val destinationFile = File("$tempFolderPath/$destinationFilename.part")
|
||||||
|
|
||||||
if (destinationFile.exists()) {
|
|
||||||
Log.d(tag, "TEMP ebook file already exists, removing it from ${destinationFile.absolutePath}")
|
|
||||||
destinationFile.delete()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (finalDestinationFile.exists()) {
|
|
||||||
Log.d(tag, "ebook file already exists, removing it from ${finalDestinationFile.absolutePath}")
|
|
||||||
finalDestinationFile.delete()
|
|
||||||
}
|
|
||||||
|
|
||||||
val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, fileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,ebookFile,null,null)
|
val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, fileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,ebookFile,null,null)
|
||||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
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")
|
Log.d(tag, "Audio File Server Path $serverPath | AF RelPath ${audioTrack.relPath} | LocalFolder Path ${localFolder.absolutePath} | DestName $destinationFilename")
|
||||||
|
|
||||||
val finalDestinationFile = File("$itemFolderPath/$destinationFilename")
|
val finalDestinationFile = File("$itemFolderPath/$destinationFilename")
|
||||||
val destinationFile = File("$tempFolderPath/$destinationFilename")
|
val destinationFile = File("$tempFolderPath/$destinationFilename.part")
|
||||||
|
|
||||||
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 downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, fileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,null,audioTrack,null)
|
val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, fileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,null,audioTrack,null)
|
||||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||||
@@ -205,24 +217,14 @@ class AbsDownloader : Plugin() {
|
|||||||
|
|
||||||
val serverPath = "/api/items/${libraryItem.id}/cover"
|
val serverPath = "/api/items/${libraryItem.id}/cover"
|
||||||
val destinationFilename = "cover-${libraryItem.id}.jpg"
|
val destinationFilename = "cover-${libraryItem.id}.jpg"
|
||||||
val destinationFile = File("$tempFolderPath/$destinationFilename")
|
val destinationFile = File("$tempFolderPath/$destinationFilename.part")
|
||||||
val finalDestinationFile = File("$itemFolderPath/$destinationFilename")
|
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)
|
val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, coverFileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,null,null,null)
|
||||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadItemManager.addDownloadItem(downloadItem)
|
DownloadServiceHost.enqueue(mainActivity, downloadItem)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Podcast episode download
|
// Podcast episode download
|
||||||
@@ -233,7 +235,7 @@ class AbsDownloader : Plugin() {
|
|||||||
val fileSize = audioTrack?.metadata?.size ?: 0
|
val fileSize = audioTrack?.metadata?.size ?: 0
|
||||||
|
|
||||||
Log.d(tag, "Starting podcast episode download")
|
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 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())
|
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 ?: "")
|
var destinationFilename = getFilenameFromRelPath(audioTrack?.relPath ?: "")
|
||||||
Log.d(tag, "Audio File Server Path $serverPath | AF RelPath ${audioTrack?.relPath} | LocalFolder Path ${localFolder.absolutePath} | DestName $destinationFilename")
|
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")
|
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)
|
var downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,fileSize, destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,null,audioTrack,episode)
|
||||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||||
|
|
||||||
@@ -258,18 +255,14 @@ class AbsDownloader : Plugin() {
|
|||||||
serverPath = "/api/items/${libraryItem.id}/cover"
|
serverPath = "/api/items/${libraryItem.id}/cover"
|
||||||
destinationFilename = "cover.jpg"
|
destinationFilename = "cover.jpg"
|
||||||
|
|
||||||
destinationFile = File("$tempFolderPath/$destinationFilename")
|
destinationFile = File("$tempFolderPath/$destinationFilename.part")
|
||||||
finalDestinationFile = File("$itemFolderPath/$destinationFilename")
|
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)
|
downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,coverFileSize,destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,null,null,null)
|
||||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
downloadItemManager.addDownloadItem(downloadItem)
|
DownloadServiceHost.enqueue(mainActivity, downloadItem)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.audiobookshelf.app.plugins
|
package com.audiobookshelf.app.plugins
|
||||||
|
|
||||||
import android.app.AlertDialog
|
import android.app.AlertDialog
|
||||||
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
@@ -23,14 +24,17 @@ import java.io.File
|
|||||||
class AbsFileSystem : Plugin() {
|
class AbsFileSystem : Plugin() {
|
||||||
private val TAG = "AbsFileSystem"
|
private val TAG = "AbsFileSystem"
|
||||||
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
|
lateinit var mainActivity: MainActivity
|
||||||
|
|
||||||
override fun load() {
|
override fun load() {
|
||||||
mainActivity = (activity as MainActivity)
|
mainActivity = (activity as MainActivity)
|
||||||
|
|
||||||
mainActivity.storage.storageAccessCallback = object : StorageAccessCallback {
|
mainActivity.storage.storageAccessCallback =
|
||||||
|
object : StorageAccessCallback {
|
||||||
override fun onRootPathNotSelected(
|
override fun onRootPathNotSelected(
|
||||||
requestCode: Int,
|
requestCode: Int,
|
||||||
rootPath: String,
|
rootPath: String,
|
||||||
@@ -45,7 +49,13 @@ class AbsFileSystem : Plugin() {
|
|||||||
Log.d(TAG, "STORAGE ACCESS CALLBACK")
|
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")
|
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
|
@PluginMethod
|
||||||
fun selectFolder(call: PluginCall) {
|
fun selectFolder(call: PluginCall) {
|
||||||
val mediaType = call.data.getString("mediaType", "book").toString()
|
val mediaType = call.data.getString("mediaType", "book").toString()
|
||||||
val REQUEST_CODE_SELECT_FOLDER = 6
|
val REQUEST_CODE_SELECT_FOLDER = 6
|
||||||
val REQUEST_CODE_SDCARD_ACCESS = 7
|
val REQUEST_CODE_SDCARD_ACCESS = 7
|
||||||
|
|
||||||
mainActivity.storage.folderPickerCallback = object : FolderPickerCallback {
|
mainActivity.storage.folderPickerCallback =
|
||||||
|
object : FolderPickerCallback {
|
||||||
override fun onFolderSelected(requestCode: Int, folder: DocumentFile) {
|
override fun onFolderSelected(requestCode: Int, folder: DocumentFile) {
|
||||||
Log.d(TAG, "ON FOLDER SELECTED ${folder.uri} ${folder.name}")
|
Log.d(TAG, "ON FOLDER SELECTED ${folder.uri} ${folder.name}")
|
||||||
val absolutePath = folder.getAbsolutePath(activity)
|
val absolutePath = folder.getAbsolutePath(activity)
|
||||||
val storageType = folder.getStorageType(activity)
|
val storageType = folder.getStorageType(activity)
|
||||||
val simplePath = folder.getSimplePath(activity)
|
|
||||||
val basePath = folder.getBasePath(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)
|
DeviceManager.dbManager.saveLocalFolder(localFolder)
|
||||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(localFolder)))
|
call.resolve(JSObject(jacksonMapper.writeValueAsString(localFolder)))
|
||||||
@@ -92,32 +128,33 @@ class AbsFileSystem : Plugin() {
|
|||||||
if (requestCode == REQUEST_CODE_SELECT_FOLDER) {
|
if (requestCode == REQUEST_CODE_SELECT_FOLDER) {
|
||||||
|
|
||||||
val builder: AlertDialog.Builder = AlertDialog.Builder(mainActivity)
|
val builder: AlertDialog.Builder = AlertDialog.Builder(mainActivity)
|
||||||
builder.setMessage(
|
builder.setMessage(folderPickerString(KEY_WRITE_ACCESS_REQUIRED, DEFAULT_WRITE_ACCESS_REQUIRED))
|
||||||
"You have no write access to this storage, thus selecting this folder is useless." +
|
builder.setNegativeButton(folderPickerString(KEY_CANCEL, DEFAULT_CANCEL)) { _, _ ->
|
||||||
"\nWould you like to grant access to this folder?")
|
|
||||||
builder.setNegativeButton("Dont Allow") { _, _ ->
|
|
||||||
run {
|
run {
|
||||||
jsobj.put("error", "User Canceled, Access Denied")
|
jsobj.put("error", folderPickerString(KEY_ACCESS_DENIED, DEFAULT_ACCESS_DENIED))
|
||||||
call.resolve(jsobj)
|
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()
|
builder.show()
|
||||||
} else {
|
} else {
|
||||||
Log.d(TAG, "STORAGE ACCESS DENIED $requestCode")
|
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)
|
call.resolve(jsobj)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
override fun onStoragePermissionDenied(requestCode: Int) {
|
override fun onStoragePermissionDenied(requestCode: Int) {
|
||||||
Log.d(TAG, "STORAGE PERMISSION DENIED $requestCode")
|
Log.d(TAG, "STORAGE PERMISSION DENIED $requestCode")
|
||||||
val jsobj = JSObject()
|
val jsobj = JSObject()
|
||||||
jsobj.put("error", "Permission Denied")
|
jsobj.put("error", folderPickerString(KEY_PERMISSION_DENIED, DEFAULT_PERMISSION_DENIED))
|
||||||
call.resolve(jsobj)
|
call.resolve(jsobj)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
mainActivity.storage.openFolderPicker(REQUEST_CODE_SELECT_FOLDER)
|
mainActivity.storage.openFolderPicker(REQUEST_CODE_SELECT_FOLDER)
|
||||||
@@ -152,7 +189,7 @@ class AbsFileSystem : Plugin() {
|
|||||||
val folderUrl = call.data.getString("folderUrl", "").toString()
|
val folderUrl = call.data.getString("folderUrl", "").toString()
|
||||||
Log.d(TAG, "Check Folder Permissions for $folderUrl")
|
Log.d(TAG, "Check Folder Permissions for $folderUrl")
|
||||||
|
|
||||||
val hasAccess = SimpleStorage.hasStorageAccess(context,folderUrl,true)
|
val hasAccess = SimpleStorage.hasStorageAccess(context, folderUrl, true)
|
||||||
|
|
||||||
val jsobj = JSObject()
|
val jsobj = JSObject()
|
||||||
jsobj.put("value", hasAccess)
|
jsobj.put("value", hasAccess)
|
||||||
@@ -196,7 +233,8 @@ class AbsFileSystem : Plugin() {
|
|||||||
if (localLibraryItem?.folderId?.startsWith("internal-") == true) {
|
if (localLibraryItem?.folderId?.startsWith("internal-") == true) {
|
||||||
Log.d(tag, "Deleting internal library item at absolutePath $absolutePath")
|
Log.d(tag, "Deleting internal library item at absolutePath $absolutePath")
|
||||||
val file = File(absolutePath)
|
val file = File(absolutePath)
|
||||||
success = if (file.exists()) {
|
success =
|
||||||
|
if (file.exists()) {
|
||||||
file.deleteRecursively()
|
file.deleteRecursively()
|
||||||
} else {
|
} else {
|
||||||
true
|
true
|
||||||
@@ -207,9 +245,11 @@ class AbsFileSystem : Plugin() {
|
|||||||
val folder = DeviceManager.dbManager.getLocalFolder(folderId)
|
val folder = DeviceManager.dbManager.getLocalFolder(folderId)
|
||||||
folder?.absolutePath?.let { folderPath ->
|
folder?.absolutePath?.let { folderPath ->
|
||||||
val splitAbsolutePath = absolutePath.split("/")
|
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) {
|
if (fullSubDir != folderPath) {
|
||||||
val subdirHasAnItem = DeviceManager.dbManager.getLocalLibraryItems().any { _localLibraryItem ->
|
val subdirHasAnItem =
|
||||||
|
DeviceManager.dbManager.getLocalLibraryItems().any { _localLibraryItem ->
|
||||||
if (_localLibraryItem.id == localLibraryItemId) {
|
if (_localLibraryItem.id == localLibraryItemId) {
|
||||||
false
|
false
|
||||||
} else {
|
} else {
|
||||||
@@ -266,4 +306,23 @@ class AbsFileSystem : Plugin() {
|
|||||||
call.resolve(JSObject("{\"success\":false}"))
|
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.audiobookshelf.app.managers.SecureStorage
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||||
|
import com.fasterxml.jackson.databind.node.ObjectNode
|
||||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||||
import com.fasterxml.jackson.module.kotlin.readValue
|
import com.fasterxml.jackson.module.kotlin.readValue
|
||||||
import com.getcapacitor.JSArray
|
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 jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||||
private var secureStorage = SecureStorage(ctx)
|
private var secureStorage = SecureStorage(ctx)
|
||||||
|
|
||||||
data class LocalSessionsSyncRequestPayload(val sessions:List<PlaybackSession>, val deviceInfo:DeviceInfo)
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
data class LocalSessionSyncResult(val id:String, val success:Boolean, val progressSynced:Boolean?, val error:String?)
|
data class LocalSessionSyncResult(val id:String, val success:Boolean, val progressSynced:Boolean?, val error:String?)
|
||||||
data class LocalSessionsSyncResponsePayload(val results:List<LocalSessionSyncResult>)
|
data class LocalSessionsSyncResponsePayload(val results:List<LocalSessionSyncResult>)
|
||||||
@@ -652,10 +652,29 @@ class ApiHandler(var ctx:Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sendLocalProgressSync(playbackSession:PlaybackSession, cb: (Boolean, String?) -> Unit) {
|
private fun createPartialPlaybackSession(playbackSession: PlaybackSession): ObjectNode {
|
||||||
val payload = JSObject(jacksonMapper.writeValueAsString(playbackSession))
|
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()) {
|
if (!it.getString("error").isNullOrEmpty()) {
|
||||||
cb(false, it.getString("error"))
|
cb(false, it.getString("error"))
|
||||||
} else {
|
} else {
|
||||||
@@ -748,10 +767,12 @@ class ApiHandler(var ctx:Context) {
|
|||||||
val deviceId = Settings.Secure.getString(ctx.contentResolver, Settings.Secure.ANDROID_ID)
|
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 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})")
|
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()) {
|
if (!it.getString("error").isNullOrEmpty()) {
|
||||||
AbsLogger.error("ApiHandler", "sendSyncLocalSessions: Failed to sync local sessions. (${it.getString("error")})")
|
AbsLogger.error("ApiHandler", "sendSyncLocalSessions: Failed to sync local sessions. (${it.getString("error")})")
|
||||||
cb(false, 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"
|
||||||
|
}
|
||||||
+120
-42
@@ -1,20 +1,31 @@
|
|||||||
<template>
|
<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' }">
|
<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 }">
|
<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)" />
|
<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">
|
<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>
|
<span class="material-symbols text-5xl" :class="{ 'text-black text-opacity-75': coverBgIsLight && theme !== 'black' }" @click="collapseFullscreen">keyboard_arrow_down</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Cast button - Chromecast toggle -->
|
||||||
<div v-show="showCastBtn" class="top-6 right-16 absolute cursor-pointer">
|
<div v-show="showCastBtn" class="top-6 right-16 absolute cursor-pointer">
|
||||||
<span class="material-symbols text-3xl" :class="coverBgIsLight && theme !== 'black' ? 'text-black' : ''" @click="castClick">{{ isCasting ? 'cast_connected' : 'cast' }}</span>
|
<span class="material-symbols text-3xl" :class="coverBgIsLight && theme !== 'black' ? 'text-black' : ''" @click="castClick">{{ isCasting ? 'cast_connected' : 'cast' }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Player options menu button -->
|
||||||
<div class="top-6 right-4 absolute cursor-pointer">
|
<div class="top-6 right-4 absolute cursor-pointer">
|
||||||
<span class="material-symbols text-3xl" :class="{ 'text-black text-opacity-75': coverBgIsLight && theme !== 'black' }" @click="showMoreMenuDialog = true">more_vert</span>
|
<span class="material-symbols text-3xl" :class="{ 'text-black text-opacity-75': coverBgIsLight && theme !== 'black' }" @click="showMoreMenuDialog = true">more_vert</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="top-4 absolute left-0 right-0 mx-auto text-center uppercase tracking-widest text-opacity-75" :class="{ 'text-black text-opacity-75': coverBgIsLight && 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>
|
</div>
|
||||||
|
|
||||||
|
<!-- Overall book progress bar -->
|
||||||
<div v-if="playerSettings.useChapterTrack && playerSettings.useTotalTrack && showFullscreen" class="absolute total-track w-full z-30 px-6">
|
<div v-if="playerSettings.useChapterTrack && playerSettings.useTotalTrack && showFullscreen" class="absolute total-track w-full z-30 px-6">
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<p class="font-mono text-fg" style="font-size: 0.8rem">{{ currentTimePretty }}</p>
|
<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>
|
<p class="font-mono text-fg" style="font-size: 0.8rem">{{ totalTimeRemainingPretty }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full">
|
<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="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="totalBufferedTrack" class="h-full bg-track absolute top-0 left-0 pointer-events-none rounded-full" />
|
||||||
<div ref="totalPlayedTrack" class="h-full bg-track-cursor absolute top-0 left-0 pointer-events-none rounded-full" />
|
<div ref="totalPlayedTrack" class="h-full bg-track-cursor absolute top-0 left-0 pointer-events-none rounded-full" />
|
||||||
@@ -30,16 +41,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Book cover wrapper -->
|
||||||
<div class="cover-wrapper absolute z-30 pointer-events-auto" @click="clickContainer">
|
<div class="cover-wrapper absolute z-30 pointer-events-auto" @click="clickContainer">
|
||||||
<div class="w-full h-full flex justify-center">
|
<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" />
|
<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>
|
</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">
|
<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>
|
<span class="material-symbols text-error text-3xl">error</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Title and author text -->
|
||||||
<div class="title-author-texts absolute z-30 left-0 right-0 overflow-hidden" @click="clickTitleAndAuthor">
|
<div class="title-author-texts absolute z-30 left-0 right-0 overflow-hidden" @click="clickTitleAndAuthor">
|
||||||
<div ref="titlewrapper" class="overflow-hidden relative">
|
<div ref="titlewrapper" class="overflow-hidden relative">
|
||||||
<p class="title-text whitespace-nowrap"></p>
|
<p class="title-text whitespace-nowrap"></p>
|
||||||
@@ -48,6 +62,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="playerContent" class="playerContainer w-full z-20 absolute bottom-0 left-0 right-0 p-2 pointer-events-auto transition-all" :style="{ backgroundColor: showFullscreen ? '' : coverRgb }" @click="clickContainer">
|
<div 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 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">
|
<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>
|
<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,6 +82,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-else class="w-full h-full absolute top-0 left-0 pointer-events-none" style="background: var(--gradient-minimized-audio-player)" />
|
<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 id="playerControls" class="absolute right-0 bottom-0 mx-auto" style="max-width: 414px">
|
||||||
<div class="flex items-center max-w-full" :class="playerSettings.lockUi ? 'justify-center' : 'justify-between'">
|
<div class="flex items-center max-w-full" :class="playerSettings.lockUi ? 'justify-center' : 'justify-between'">
|
||||||
<span v-show="showFullscreen && !playerSettings.lockUi" class="material-symbols next-icon text-fg cursor-pointer" :class="showLoadingState ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpChapterStart">first_page</span>
|
<span v-show="showFullscreen && !playerSettings.lockUi" class="material-symbols next-icon text-fg cursor-pointer" :class="showLoadingState ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpChapterStart">first_page</span>
|
||||||
@@ -88,13 +104,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Chapter progress bar -->
|
||||||
<div id="playerTrack" class="absolute left-0 w-full px-6">
|
<div id="playerTrack" class="absolute left-0 w-full px-6">
|
||||||
<div class="flex pointer-events-none">
|
<div class="flex pointer-events-none">
|
||||||
<p class="font-mono text-fg" style="font-size: 0.8rem" ref="currentTimestamp">0:00</p>
|
<p class="font-mono text-fg" style="font-size: 0.8rem" ref="currentTimestamp">0:00</p>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<p class="font-mono text-fg" style="font-size: 0.8rem">{{ timeRemainingPretty }}</p>
|
<p class="font-mono text-fg" style="font-size: 0.8rem">{{ timeRemainingPretty }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div ref="track" class="h-1.5 w-full bg-track/50 relative rounded-full" :class="{ 'animate-pulse': showLoadingState }" @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="readyTrack" class="h-full bg-track-buffered absolute top-0 left-0 rounded-full pointer-events-none" />
|
||||||
<div ref="bufferedTrack" class="h-full bg-track absolute top-0 left-0 rounded-full pointer-events-none" />
|
<div ref="bufferedTrack" class="h-full bg-track absolute top-0 left-0 rounded-full pointer-events-none" />
|
||||||
<div ref="playedTrack" class="h-full bg-track-cursor absolute top-0 left-0 rounded-full pointer-events-none" />
|
<div ref="playedTrack" class="h-full bg-track-cursor absolute top-0 left-0 rounded-full pointer-events-none" />
|
||||||
@@ -105,7 +122,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Chapters modal - lists chapters for navigation -->
|
||||||
<modals-chapters-modal v-model="showChapterModal" :current-chapter="currentChapter" :chapters="chapters" :playback-rate="currentPlaybackRate" @select="selectChapter" />
|
<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" />
|
<modals-dialog v-model="showMoreMenuDialog" :items="menuItems" width="80vw" @action="clickMenuAction" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -114,7 +133,7 @@
|
|||||||
import { Capacitor } from '@capacitor/core'
|
import { Capacitor } from '@capacitor/core'
|
||||||
import { AbsAudioPlayer } from '@/plugins/capacitor'
|
import { AbsAudioPlayer } from '@/plugins/capacitor'
|
||||||
import { Dialog } from '@capacitor/dialog'
|
import { Dialog } from '@capacitor/dialog'
|
||||||
import { FastAverageColor } from 'fast-average-color'
|
import { getAverageColorFromCoverUrl } from '@/utils/coverAverageColor'
|
||||||
import WrappingMarquee from '@/assets/WrappingMarquee.js'
|
import WrappingMarquee from '@/assets/WrappingMarquee.js'
|
||||||
import jumpLabelMixin from '@/mixins/jumpLabel'
|
import jumpLabelMixin from '@/mixins/jumpLabel'
|
||||||
|
|
||||||
@@ -412,17 +431,14 @@ export default {
|
|||||||
},
|
},
|
||||||
async coverImageLoaded(fullCoverUrl) {
|
async coverImageLoaded(fullCoverUrl) {
|
||||||
if (!fullCoverUrl) return
|
if (!fullCoverUrl) return
|
||||||
|
const avg = await getAverageColorFromCoverUrl(this, fullCoverUrl)
|
||||||
const fac = new FastAverageColor()
|
if (!avg) {
|
||||||
fac
|
this.coverRgb = 'rgb(55, 56, 56)'
|
||||||
.getColorAsync(fullCoverUrl)
|
this.coverBgIsLight = false
|
||||||
.then((color) => {
|
} else {
|
||||||
this.coverRgb = color.rgba
|
this.coverRgb = avg.rgba
|
||||||
this.coverBgIsLight = color.isLight
|
this.coverBgIsLight = avg.isLight
|
||||||
})
|
}
|
||||||
.catch((e) => {
|
|
||||||
console.log(e)
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
clickTitleAndAuthor() {
|
clickTitleAndAuthor() {
|
||||||
if (!this.showFullscreen) return
|
if (!this.showFullscreen) return
|
||||||
@@ -452,9 +468,8 @@ export default {
|
|||||||
this.showFullscreen = true
|
this.showFullscreen = true
|
||||||
if (this.titleMarquee) this.titleMarquee.reset()
|
if (this.titleMarquee) this.titleMarquee.reset()
|
||||||
|
|
||||||
// Update track for total time bar if useChapterTrack is set
|
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
this.updateTrack()
|
this.measureAndUpdateTrackWidth()
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
collapseFullscreen() {
|
collapseFullscreen() {
|
||||||
@@ -534,13 +549,17 @@ export default {
|
|||||||
this.updateReadyTrack()
|
this.updateReadyTrack()
|
||||||
},
|
},
|
||||||
updateReadyTrack() {
|
updateReadyTrack() {
|
||||||
|
if (!this.$refs.readyTrack) return
|
||||||
|
|
||||||
if (this.playerSettings.useChapterTrack) {
|
if (this.playerSettings.useChapterTrack) {
|
||||||
if (this.$refs.totalReadyTrack) {
|
if (this.$refs.totalReadyTrack && this.trackWidth) {
|
||||||
this.$refs.totalReadyTrack.style.width = this.readyTrackWidth + 'px'
|
const readyPercent = (this.readyTrackWidth / this.trackWidth) * 100
|
||||||
|
this.$refs.totalReadyTrack.style.width = readyPercent + '%'
|
||||||
}
|
}
|
||||||
this.$refs.readyTrack.style.width = this.trackWidth + 'px'
|
this.$refs.readyTrack.style.width = '100%'
|
||||||
} else {
|
} else if (this.trackWidth) {
|
||||||
this.$refs.readyTrack.style.width = this.readyTrackWidth + 'px'
|
const readyPercent = (this.readyTrackWidth / this.trackWidth) * 100
|
||||||
|
this.$refs.readyTrack.style.width = readyPercent + '%'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
updateTimestamp() {
|
updateTimestamp() {
|
||||||
@@ -592,21 +611,23 @@ export default {
|
|||||||
bufferedPercent = Math.max(0, Math.min(1, (this.bufferedTime - this.currentChapter.start) / this.currentChapterDuration))
|
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) {
|
if (this.$refs.playedTrack) {
|
||||||
this.$refs.playedTrack.style.width = ptWidth + 'px'
|
this.$refs.playedTrack.style.width = playedPercent + '%'
|
||||||
}
|
}
|
||||||
if (this.$refs.bufferedTrack) {
|
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) {
|
if (this.$refs.trackCursor && this.$refs.track) {
|
||||||
this.$refs.trackCursor.style.left = ptWidth - 14 + 'px'
|
const trackWidth = this.$refs.track.clientWidth
|
||||||
|
this.$refs.trackCursor.style.left = Math.round(percentDone * trackWidth) - 14 + 'px'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.playerSettings.useChapterTrack) {
|
if (this.playerSettings.useChapterTrack) {
|
||||||
if (this.$refs.totalPlayedTrack) this.$refs.totalPlayedTrack.style.width = Math.round(totalPercentDone * this.trackWidth) + 'px'
|
if (this.$refs.totalPlayedTrack) this.$refs.totalPlayedTrack.style.width = totalPercentDone * 100 + '%'
|
||||||
if (this.$refs.totalBufferedTrack) this.$refs.totalBufferedTrack.style.width = Math.round(totalBufferedPercent * this.trackWidth) + 'px'
|
if (this.$refs.totalBufferedTrack) this.$refs.totalBufferedTrack.style.width = totalBufferedPercent * 100 + '%'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
seek(time) {
|
seek(time) {
|
||||||
@@ -619,12 +640,12 @@ export default {
|
|||||||
this.seekedTime = time
|
this.seekedTime = time
|
||||||
this.seekLoading = true
|
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) {
|
if (this.$refs.playedTrack) {
|
||||||
const perc = time / this.totalDuration
|
const perc = time / this.totalDuration
|
||||||
const ptWidth = Math.round(perc * this.trackWidth)
|
this.$refs.playedTrack.style.width = perc * 100 + '%'
|
||||||
this.$refs.playedTrack.style.width = ptWidth + 'px'
|
|
||||||
|
|
||||||
this.$refs.playedTrack.classList.remove('bg-gray-200')
|
this.$refs.playedTrack.classList.remove('bg-gray-200')
|
||||||
this.$refs.playedTrack.classList.add('bg-yellow-300')
|
this.$refs.playedTrack.classList.add('bg-yellow-300')
|
||||||
@@ -733,7 +754,8 @@ export default {
|
|||||||
maxTime = minTime + duration
|
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
|
const newTime = this.draggingTouchStartTime + timePerPixel * distanceMoved
|
||||||
this.draggingCurrentTime = Math.min(maxTime, Math.max(minTime, newTime))
|
this.draggingCurrentTime = Math.min(maxTime, Math.max(minTime, newTime))
|
||||||
|
|
||||||
@@ -871,11 +893,7 @@ export default {
|
|||||||
this.titleMarquee = new WrappingMarquee(this.$refs.titlewrapper)
|
this.titleMarquee = new WrappingMarquee(this.$refs.titlewrapper)
|
||||||
this.titleMarquee.init(this.title)
|
this.titleMarquee.init(this.title)
|
||||||
|
|
||||||
if (this.$refs.track) {
|
this.measureAndUpdateTrackWidth()
|
||||||
this.trackWidth = this.$refs.track.clientWidth
|
|
||||||
} else {
|
|
||||||
console.error('Track not loaded', this.$refs)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
onPlaybackClosed() {
|
onPlaybackClosed() {
|
||||||
@@ -921,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
|
this.isRefreshingUI = false
|
||||||
},
|
},
|
||||||
refreshUI() {
|
measureAndUpdateTrackWidth() {
|
||||||
this.updateScreenSize()
|
if (!this.$refs.track) {
|
||||||
if (this.$refs.track) {
|
console.error('Track not loaded', this.$refs)
|
||||||
|
return
|
||||||
|
}
|
||||||
this.trackWidth = this.$refs.track.clientWidth
|
this.trackWidth = this.$refs.track.clientWidth
|
||||||
this.updateTrack()
|
this.updateTrack()
|
||||||
this.updateReadyTrack()
|
this.updateReadyTrack()
|
||||||
}
|
},
|
||||||
|
refreshUI() {
|
||||||
|
this.updateScreenSize()
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.measureAndUpdateTrackWidth()
|
||||||
|
})
|
||||||
},
|
},
|
||||||
updateScreenSize() {
|
updateScreenSize() {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -1020,6 +1049,12 @@ export default {
|
|||||||
.fullscreen .playerContainer {
|
.fullscreen .playerContainer {
|
||||||
height: 200px;
|
height: 200px;
|
||||||
}
|
}
|
||||||
|
@media (orientation: landscape) {
|
||||||
|
.fullscreen .playerContainer {
|
||||||
|
width: 50%;
|
||||||
|
left: 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
#playerContent {
|
#playerContent {
|
||||||
box-shadow: 0px -8px 8px #11111155;
|
box-shadow: 0px -8px 8px #11111155;
|
||||||
}
|
}
|
||||||
@@ -1031,6 +1066,7 @@ export default {
|
|||||||
transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1);
|
transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1);
|
||||||
transition-property: margin;
|
transition-property: margin;
|
||||||
bottom: 35px;
|
bottom: 35px;
|
||||||
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
.fullscreen #playerTrack {
|
.fullscreen #playerTrack {
|
||||||
bottom: unset;
|
bottom: unset;
|
||||||
@@ -1047,13 +1083,45 @@ export default {
|
|||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
overflow: hidden;
|
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 {
|
.total-track {
|
||||||
bottom: 215px;
|
bottom: 215px;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 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 {
|
.title-author-texts {
|
||||||
transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1);
|
transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1);
|
||||||
transition-property: left, bottom, width, height;
|
transition-property: left, bottom, width, height;
|
||||||
@@ -1070,12 +1138,22 @@ export default {
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
@media (orientation: landscape) {
|
||||||
|
.fullscreen .title-author-texts .title-text {
|
||||||
|
font-size: larger !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
.title-author-texts .author-text {
|
.title-author-texts .author-text {
|
||||||
transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1);
|
transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1);
|
||||||
transition-property: font-size;
|
transition-property: font-size;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
@media (orientation: landscape) {
|
||||||
|
.fullscreen .title-author-texts .author-text {
|
||||||
|
font-size: inherit !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.fullscreen .title-author-texts {
|
.fullscreen .title-author-texts {
|
||||||
bottom: calc(50% - var(--cover-image-height) / 2 + 50px);
|
bottom: calc(50% - var(--cover-image-height) / 2 + 50px);
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ export default {
|
|||||||
console.log('Already streaming item', startTime)
|
console.log('Already streaming item', startTime)
|
||||||
if (startTime !== undefined && startTime !== null) {
|
if (startTime !== undefined && startTime !== null) {
|
||||||
// seek to start time
|
// seek to start time
|
||||||
AbsAudioPlayer.seek({ value: Math.floor(startTime) })
|
AbsAudioPlayer.seek({ value: startTime })
|
||||||
} else if (this.$refs.audioPlayer) {
|
} else if (this.$refs.audioPlayer) {
|
||||||
this.$refs.audioPlayer.play()
|
this.$refs.audioPlayer.play()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -484,7 +484,7 @@ export default {
|
|||||||
|
|
||||||
const { value } = await Dialog.confirm({
|
const { value } = await Dialog.confirm({
|
||||||
title: this.$strings.HeaderConfirm,
|
title: this.$strings.HeaderConfirm,
|
||||||
message: this.$strings.MessageConfirmDeleteServerConfig,
|
message: this.$strings.MessageConfirmDeleteServerConfig
|
||||||
})
|
})
|
||||||
if (value) {
|
if (value) {
|
||||||
this.processing = true
|
this.processing = true
|
||||||
@@ -843,7 +843,7 @@ export default {
|
|||||||
async setUserAndConnection({ user, userDefaultLibraryId, serverSettings, ereaderDevices }) {
|
async setUserAndConnection({ user, userDefaultLibraryId, serverSettings, ereaderDevices }) {
|
||||||
if (!user) return
|
if (!user) return
|
||||||
|
|
||||||
console.log('Successfully logged in', JSON.stringify(user))
|
console.log('Successfully logged in: ' + user.username)
|
||||||
|
|
||||||
this.$store.commit('setServerSettings', serverSettings)
|
this.$store.commit('setServerSettings', serverSettings)
|
||||||
this.$store.commit('libraries/setEReaderDevices', ereaderDevices)
|
this.$store.commit('libraries/setEReaderDevices', ereaderDevices)
|
||||||
|
|||||||
@@ -79,9 +79,6 @@ export default {
|
|||||||
episodesCopy: [],
|
episodesCopy: [],
|
||||||
showFiltersModal: false,
|
showFiltersModal: false,
|
||||||
showSortModal: false,
|
showSortModal: false,
|
||||||
sortKey: 'publishedAt',
|
|
||||||
sortDesc: true,
|
|
||||||
filterKey: 'incomplete',
|
|
||||||
fetchingRSSFeed: false,
|
fetchingRSSFeed: false,
|
||||||
podcastFeedEpisodes: [],
|
podcastFeedEpisodes: [],
|
||||||
showPodcastEpisodeFeed: false,
|
showPodcastEpisodeFeed: false,
|
||||||
@@ -223,6 +220,27 @@ export default {
|
|||||||
if (!this.sortKey) return ''
|
if (!this.sortKey) return ''
|
||||||
const _sel = this.episodeSortItems.find((i) => i.value === this.sortKey)
|
const _sel = this.episodeSortItems.find((i) => i.value === this.sortKey)
|
||||||
return _sel?.text || ''
|
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: {
|
methods: {
|
||||||
@@ -277,8 +295,8 @@ export default {
|
|||||||
this.$store.commit('globals/setShowPlaylistsAddCreateModal', true)
|
this.$store.commit('globals/setShowPlaylistsAddCreateModal', true)
|
||||||
},
|
},
|
||||||
setFilter(filter) {
|
setFilter(filter) {
|
||||||
this.filterKey = filter
|
|
||||||
this.showFiltersModal = false
|
this.showFiltersModal = false
|
||||||
|
this.$store.dispatch('user/updateUserSettings', { podcastEpisodesFilterBy: filter })
|
||||||
},
|
},
|
||||||
showFilters() {
|
showFilters() {
|
||||||
this.showFiltersModal = true
|
this.showFiltersModal = true
|
||||||
@@ -291,7 +309,6 @@ export default {
|
|||||||
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId, episode.id)
|
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId, episode.id)
|
||||||
},
|
},
|
||||||
init() {
|
init() {
|
||||||
this.sortDesc = this.mediaMetadata.type === 'episodic'
|
|
||||||
this.episodesCopy = this.episodes.map((ep) => {
|
this.episodesCopy = this.episodes.map((ep) => {
|
||||||
return { ...ep }
|
return { ...ep }
|
||||||
})
|
})
|
||||||
@@ -316,7 +333,7 @@ export default {
|
|||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
if (this.$route.query['episodefilter'] === 'downloaded') {
|
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_queued', this.episodeDownloadQueued)
|
||||||
this.$socket.$on('episode_download_started', this.episodeDownloadStarted)
|
this.$socket.$on('episode_download_started', this.episodeDownloadStarted)
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ export default {
|
|||||||
return {
|
return {
|
||||||
downloadItemListener: null,
|
downloadItemListener: null,
|
||||||
completeListener: null,
|
completeListener: null,
|
||||||
itemPartUpdateListener: null
|
itemPartUpdateListener: null,
|
||||||
|
queueChangedListener: null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -76,17 +77,22 @@ export default {
|
|||||||
},
|
},
|
||||||
onDownloadItemPartUpdate(itemPart) {
|
onDownloadItemPartUpdate(itemPart) {
|
||||||
this.$store.commit('globals/updateDownloadItemPart', itemPart)
|
this.$store.commit('globals/updateDownloadItemPart', itemPart)
|
||||||
|
},
|
||||||
|
onQueueChanged(data) {
|
||||||
|
if (!data.hasWork) this.$store.commit('globals/clearItemDownloads')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async mounted() {
|
async mounted() {
|
||||||
this.downloadItemListener = await AbsDownloader.addListener('onDownloadItem', (data) => this.onDownloadItem(data))
|
this.downloadItemListener = await AbsDownloader.addListener('onDownloadItem', (data) => this.onDownloadItem(data))
|
||||||
this.itemPartUpdateListener = await AbsDownloader.addListener('onDownloadItemPartUpdate', (data) => this.onDownloadItemPartUpdate(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))
|
this.completeListener = await AbsDownloader.addListener('onItemDownloadComplete', (data) => this.onItemDownloadComplete(data))
|
||||||
},
|
},
|
||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
this.downloadItemListener?.remove()
|
this.downloadItemListener?.remove()
|
||||||
this.completeListener?.remove()
|
this.completeListener?.remove()
|
||||||
this.itemPartUpdateListener?.remove()
|
this.itemPartUpdateListener?.remove()
|
||||||
|
this.queueChangedListener?.remove()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -740,12 +740,12 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 41;
|
CURRENT_PROJECT_VERSION = 43;
|
||||||
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
|
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
|
||||||
INFOPLIST_FILE = App/Info.plist;
|
INFOPLIST_FILE = App/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||||
MARKETING_VERSION = 0.11.0;
|
MARKETING_VERSION = 0.13.0;
|
||||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app.dev;
|
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app.dev;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
@@ -764,12 +764,12 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 41;
|
CURRENT_PROJECT_VERSION = 43;
|
||||||
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
|
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
|
||||||
INFOPLIST_FILE = App/Info.plist;
|
INFOPLIST_FILE = App/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||||
MARKETING_VERSION = 0.11.0;
|
MARKETING_VERSION = 0.13.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app;
|
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||||
|
|||||||
+20
-20
@@ -25,11 +25,11 @@ PODS:
|
|||||||
- Capacitor
|
- Capacitor
|
||||||
- CordovaPlugins (6.2.1):
|
- CordovaPlugins (6.2.1):
|
||||||
- CapacitorCordova
|
- CapacitorCordova
|
||||||
- Realm (10.54.4):
|
- Realm (10.54.6):
|
||||||
- Realm/Headers (= 10.54.4)
|
- Realm/Headers (= 10.54.6)
|
||||||
- Realm/Headers (10.54.4)
|
- Realm/Headers (10.54.6)
|
||||||
- RealmSwift (10.54.4):
|
- RealmSwift (10.54.6):
|
||||||
- Realm (= 10.54.4)
|
- Realm (= 10.54.6)
|
||||||
- WebnativellcCapacitorFilesharer (7.0.4):
|
- WebnativellcCapacitorFilesharer (7.0.4):
|
||||||
- Capacitor
|
- Capacitor
|
||||||
|
|
||||||
@@ -89,22 +89,22 @@ EXTERNAL SOURCES:
|
|||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
Alamofire: 7193b3b92c74a07f85569e1a6c4f4237291e7496
|
Alamofire: 7193b3b92c74a07f85569e1a6c4f4237291e7496
|
||||||
Capacitor: 106e7a4205f4618d582b886a975657c61179138d
|
Capacitor: 03bc7cbdde6a629a8b910a9d7d78c3cc7ed09ea7
|
||||||
CapacitorApp: d63334c052278caf5d81585d80b21905c6f93f39
|
CapacitorApp: febecbb9582cb353aed037e18ec765141f880fe9
|
||||||
CapacitorBrowser: 081852cf532acf77b9d2953f3a88fe5b9711fb06
|
CapacitorBrowser: 6299776d496e968505464884d565992faa20444a
|
||||||
CapacitorClipboard: b98aead5dc7ec595547fc2c5d75bacd2ae3338bc
|
CapacitorClipboard: 70bfdb42b877b320a6e511ab94fa7a6a55d57ecb
|
||||||
CapacitorCommunityKeepAwake: 00dfd8fa3cca0df003c9a3e2cd7bee678aeec68b
|
CapacitorCommunityKeepAwake: ae762ce29b53147d28cfcaae5273cd1db0c38fc4
|
||||||
CapacitorCommunityVolumeButtons: 8a0443a202ed659688d85f4d44d66f42f62f2b56
|
CapacitorCommunityVolumeButtons: 1b84f7abf29cd9476cef9e8979b2854a64d2eed5
|
||||||
CapacitorCordova: 5967b9ba03915ef1d585469d6e31f31dc49be96f
|
CapacitorCordova: 5967b9ba03915ef1d585469d6e31f31dc49be96f
|
||||||
CapacitorDialog: 9b934329026b2b0ffa56939bb06df3c67541a2ab
|
CapacitorDialog: 0e09f242f6c3f5e82e4dc76b20f2a056be57a579
|
||||||
CapacitorHaptics: 70e47470fa1a6bd6338cd102552e3846b7f9a1b3
|
CapacitorHaptics: 1f1e17041f435d8ead9ff2a34edd592c6aa6a8d6
|
||||||
CapacitorNetwork: 07ec4c69c1bb696f41c23e00d31bda1bbb221bba
|
CapacitorNetwork: 15cb4385f0913a8ceb5e9a4d7af1ec554bdb8de8
|
||||||
CapacitorPreferences: cbf154e5e5519b7f5ab33817a334dda1e98387f9
|
CapacitorPreferences: 6c98117d4d7508034a4af9db64d6b26fc75d7b94
|
||||||
CapacitorStatusBar: 275cbf2f4dfc00388f519ef80c7ec22edda342c9
|
CapacitorStatusBar: 6e7af040d8fc4dd655999819625cae9c2d74c36f
|
||||||
CordovaPlugins: 5a72a85b45469e68556bb172409f1b6d57b27236
|
CordovaPlugins: 2ecbba09775516c41764dbf78ade612427311b7e
|
||||||
Realm: 8b5cda39a41f17a1734da2f39c6004eb8745587a
|
Realm: b1b3bc68162fa242132eb7eefbf91d7c40f36a85
|
||||||
RealmSwift: 0b4f808fed6898f1f6c26f501f740efd80dff0b4
|
RealmSwift: 456cfd82a4f23dff8e3456980999331ab69bbf3e
|
||||||
WebnativellcCapacitorFilesharer: 10b111373d4dc49608935600dcbcc14605258c73
|
WebnativellcCapacitorFilesharer: e3a5930240633db3335040251d66aac6762ff111
|
||||||
|
|
||||||
PODFILE CHECKSUM: 498821c0cfa2508609567fa95d7244c01cbef538
|
PODFILE CHECKSUM: 498821c0cfa2508609567fa95d7244c01cbef538
|
||||||
|
|
||||||
|
|||||||
@@ -511,7 +511,8 @@ class ApiClient {
|
|||||||
|
|
||||||
public static func reportLocalPlaybackProgress(_ session: PlaybackSession) async -> Bool {
|
public static func reportLocalPlaybackProgress(_ session: PlaybackSession) async -> Bool {
|
||||||
return await withCheckedContinuation { continuation in
|
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)
|
continuation.resume(returning: success)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -519,7 +520,10 @@ class ApiClient {
|
|||||||
|
|
||||||
public static func reportAllLocalPlaybackSessions(_ sessions: [PlaybackSession]) async -> Bool {
|
public static func reportAllLocalPlaybackSessions(_ sessions: [PlaybackSession]) async -> Bool {
|
||||||
return await withCheckedContinuation { continuation in
|
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
|
postResourceWithTokenRefresh(endpoint: "api/session/local-all", parameters: payload) { success in
|
||||||
continuation.resume(returning: success)
|
continuation.resume(returning: success)
|
||||||
}
|
}
|
||||||
@@ -668,8 +672,44 @@ struct LocalMediaProgressSyncResultsPayload: Codable {
|
|||||||
var numLocalProgressUpdates: Int?
|
var numLocalProgressUpdates: Int?
|
||||||
}
|
}
|
||||||
|
|
||||||
struct LocalPlaybackSessionSyncAllPayload: Codable {
|
struct PartialPlaybackSessionSyncPayload: Encodable {
|
||||||
var sessions: [PlaybackSession]
|
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?]?
|
var deviceInfo: [String: String?]?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+14
-12
@@ -244,9 +244,9 @@ export default {
|
|||||||
AbsLogger.info({ tag: 'default', message: 'Calling syncLocalSessions' })
|
AbsLogger.info({ tag: 'default', message: 'Calling syncLocalSessions' })
|
||||||
const response = await this.$db.syncLocalSessionsWithServer(isFirstSync)
|
const response = await this.$db.syncLocalSessionsWithServer(isFirstSync)
|
||||||
if (response?.error) {
|
if (response?.error) {
|
||||||
console.error('[default] Failed to sync local sessions', response.error)
|
await AbsLogger.error({ tag: 'default', message: `syncLocalSessions: Failed to sync local sessions: ${response.error}` })
|
||||||
} else {
|
} else {
|
||||||
console.log('[default] Successfully synced local sessions')
|
await AbsLogger.info({ tag: 'default', message: 'syncLocalSessions: Successfully synced local sessions' })
|
||||||
// Reload local media progresses
|
// Reload local media progresses
|
||||||
await this.$store.dispatch('globals/loadLocalMediaProgress')
|
await this.$store.dispatch('globals/loadLocalMediaProgress')
|
||||||
}
|
}
|
||||||
@@ -259,11 +259,13 @@ export default {
|
|||||||
async userMediaProgressUpdated(payload) {
|
async userMediaProgressUpdated(payload) {
|
||||||
const prog = payload.data // MediaProgress
|
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}` })
|
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
|
// Check if this media item is currently open in the player, paused, and this progress update is coming from a different session
|
||||||
const isMediaOpenInPlayer = this.$store.getters['getIsMediaStreaming'](prog.libraryItemId, prog.episodeId)
|
const isMediaOpenInPlayer = this.$store.getters['getIsMediaStreaming'](prog.libraryItemId, prog.episodeId)
|
||||||
if (isMediaOpenInPlayer && this.$store.getters['getCurrentPlaybackSessionId'] !== payload.sessionId && !this.$store.state.playerIsPlaying) {
|
if (isMediaOpenInPlayer && this.$store.getters['getCurrentPlaybackSessionId'] !== payload.sessionId && !this.$store.state.playerIsPlaying) {
|
||||||
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)
|
this.$eventBus.$emit('playback-time-update', payload.data.currentTime)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,17 +276,17 @@ export default {
|
|||||||
// Progress update is more recent then local progress
|
// Progress update is more recent then local progress
|
||||||
if (localProg && localProg.lastUpdate < prog.lastUpdate) {
|
if (localProg && localProg.lastUpdate < prog.lastUpdate) {
|
||||||
if (localProg.currentTime == prog.currentTime && localProg.isFinished == prog.isFinished) {
|
if (localProg.currentTime == prog.currentTime && localProg.isFinished == prog.isFinished) {
|
||||||
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server progress is more up-to-date
|
// 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}` })
|
await AbsLogger.info({ tag: 'default', message: `userMediaProgressUpdate: Syncing server progress to local (${itemLabel}, mediaProgressId: ${mediaProgressId}, server lastUpdate=${prog.lastUpdate} > local lastUpdate=${localProg.lastUpdate})` })
|
||||||
const payload = {
|
const syncPayload = {
|
||||||
localMediaProgressId: localProg.id,
|
localMediaProgressId: localProg.id,
|
||||||
mediaProgress: prog
|
mediaProgress: prog
|
||||||
}
|
}
|
||||||
newLocalMediaProgress = await this.$db.syncServerMediaProgressWithLocalMediaProgress(payload)
|
newLocalMediaProgress = await this.$db.syncServerMediaProgressWithLocalMediaProgress(syncPayload)
|
||||||
} else if (!localProg) {
|
} else if (!localProg) {
|
||||||
// Check if local library item exists
|
// Check if local library item exists
|
||||||
// local media progress may not exist yet if it hasn't been played
|
// local media progress may not exist yet if it hasn't been played
|
||||||
@@ -296,20 +298,20 @@ export default {
|
|||||||
const localEpisode = lliEpisodes.find((ep) => ep.serverEpisodeId === prog.episodeId)
|
const localEpisode = lliEpisodes.find((ep) => ep.serverEpisodeId === prog.episodeId)
|
||||||
if (localEpisode) {
|
if (localEpisode) {
|
||||||
// Add new local media progress
|
// Add new local media progress
|
||||||
const payload = {
|
const syncPayload = {
|
||||||
localLibraryItemId: localLibraryItem.id,
|
localLibraryItemId: localLibraryItem.id,
|
||||||
localEpisodeId: localEpisode.id,
|
localEpisodeId: localEpisode.id,
|
||||||
mediaProgress: prog
|
mediaProgress: prog
|
||||||
}
|
}
|
||||||
newLocalMediaProgress = await this.$db.syncServerMediaProgressWithLocalMediaProgress(payload)
|
newLocalMediaProgress = await this.$db.syncServerMediaProgressWithLocalMediaProgress(syncPayload)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Add new local media progress
|
// Add new local media progress
|
||||||
const payload = {
|
const syncPayload = {
|
||||||
localLibraryItemId: localLibraryItem.id,
|
localLibraryItemId: localLibraryItem.id,
|
||||||
mediaProgress: prog
|
mediaProgress: prog
|
||||||
}
|
}
|
||||||
newLocalMediaProgress = await this.$db.syncServerMediaProgressWithLocalMediaProgress(payload)
|
newLocalMediaProgress = await this.$db.syncServerMediaProgressWithLocalMediaProgress(syncPayload)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log(`[default] userMediaProgressUpdate no local media progress or lli found for this server item ${prog.id}`)
|
console.log(`[default] userMediaProgressUpdate no local media progress or lli found for this server item ${prog.id}`)
|
||||||
@@ -317,7 +319,7 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (newLocalMediaProgress?.id) {
|
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)
|
this.$store.commit('globals/updateLocalMediaProgress', newLocalMediaProgress)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf-app",
|
"name": "audiobookshelf-app",
|
||||||
"version": "0.12.0-beta",
|
"version": "0.13.0-beta",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "audiobookshelf-app",
|
"name": "audiobookshelf-app",
|
||||||
"version": "0.12.0-beta",
|
"version": "0.13.0-beta",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@capacitor-community/keep-awake": "^7.0.0",
|
"@capacitor-community/keep-awake": "^7.0.0",
|
||||||
"@capacitor-community/volume-buttons": "^7.0.0",
|
"@capacitor-community/volume-buttons": "^7.0.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf-app",
|
"name": "audiobookshelf-app",
|
||||||
"version": "0.12.0-beta",
|
"version": "0.13.0-beta",
|
||||||
"author": "advplyr",
|
"author": "advplyr",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "nuxt --hostname 0.0.0.0 --port 1337",
|
"dev": "nuxt --hostname 0.0.0.0 --port 1337",
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="relative" @click="showFullscreenCover = true">
|
<div class="relative" @click="showFullscreenCover = true">
|
||||||
<covers-book-cover :library-item="libraryItem" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" no-bg raw @imageLoaded="coverImageLoaded" />
|
<covers-book-cover :library-item="libraryItem" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" no-bg raw />
|
||||||
<div v-if="!isPodcast" class="absolute bottom-0 left-0 h-1 z-10 box-shadow-progressbar" :class="userIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: coverWidth * progressPercent + 'px' }"></div>
|
<div v-if="!isPodcast" class="absolute bottom-0 left-0 h-1 z-10 box-shadow-progressbar" :class="userIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: coverWidth * progressPercent + 'px' }"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -172,7 +172,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import { Dialog } from '@capacitor/dialog'
|
import { Dialog } from '@capacitor/dialog'
|
||||||
import { AbsFileSystem, AbsDownloader } from '@/plugins/capacitor'
|
import { AbsFileSystem, AbsDownloader } from '@/plugins/capacitor'
|
||||||
import { FastAverageColor } from 'fast-average-color'
|
import { getAverageColorFromCoverUrl } from '@/utils/coverAverageColor'
|
||||||
import cellularPermissionHelpers from '@/mixins/cellularPermissionHelpers'
|
import cellularPermissionHelpers from '@/mixins/cellularPermissionHelpers'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@@ -496,17 +496,10 @@ export default {
|
|||||||
},
|
},
|
||||||
async coverImageLoaded(fullCoverUrl) {
|
async coverImageLoaded(fullCoverUrl) {
|
||||||
if (!fullCoverUrl) return
|
if (!fullCoverUrl) return
|
||||||
|
const avg = await getAverageColorFromCoverUrl(this, fullCoverUrl)
|
||||||
const fac = new FastAverageColor()
|
if (!avg) return
|
||||||
fac
|
this.coverRgb = avg.rgba
|
||||||
.getColorAsync(fullCoverUrl)
|
this.coverBgIsLight = avg.isLight
|
||||||
.then((color) => {
|
|
||||||
this.coverRgb = color.rgba
|
|
||||||
this.coverBgIsLight = color.isLight
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
console.log(e)
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
moreButtonPress() {
|
moreButtonPress() {
|
||||||
this.showMoreMenu = true
|
this.showMoreMenu = true
|
||||||
|
|||||||
+1
-1
@@ -122,7 +122,7 @@ export default {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.isFetching = true
|
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)
|
console.error('Search error', error)
|
||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ class AbsDatabaseWeb extends WebPlugin {
|
|||||||
ssc.customHeaders = serverConnectionConfig.customHeaders || {}
|
ssc.customHeaders = serverConnectionConfig.customHeaders || {}
|
||||||
|
|
||||||
if (serverConnectionConfig.refreshToken) {
|
if (serverConnectionConfig.refreshToken) {
|
||||||
console.log('[AbsDatabase] Updating refresh token...', serverConnectionConfig.refreshToken)
|
console.log('[AbsDatabase] Updating refresh token...')
|
||||||
// Only using local storage for web version that is only used for testing
|
// Only using local storage for web version that is only used for testing
|
||||||
localStorage.setItem(`refresh_token_${ssc.id}`, serverConnectionConfig.refreshToken)
|
localStorage.setItem(`refresh_token_${ssc.id}`, serverConnectionConfig.refreshToken)
|
||||||
}
|
}
|
||||||
@@ -71,7 +71,7 @@ class AbsDatabaseWeb extends WebPlugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (serverConnectionConfig.refreshToken) {
|
if (serverConnectionConfig.refreshToken) {
|
||||||
console.log('[AbsDatabase] Setting refresh token...', serverConnectionConfig.refreshToken)
|
console.log('[AbsDatabase] Setting refresh token...')
|
||||||
// Only using local storage for web version that is only used for testing
|
// Only using local storage for web version that is only used for testing
|
||||||
localStorage.setItem(`refresh_token_${ssc.id}`, serverConnectionConfig.refreshToken)
|
localStorage.setItem(`refresh_token_${ssc.id}`, serverConnectionConfig.refreshToken)
|
||||||
}
|
}
|
||||||
@@ -119,7 +119,6 @@ class AbsDatabaseWeb extends WebPlugin {
|
|||||||
name: 'Audiobooks',
|
name: 'Audiobooks',
|
||||||
contentUrl: 'test',
|
contentUrl: 'test',
|
||||||
absolutePath: '/audiobooks',
|
absolutePath: '/audiobooks',
|
||||||
simplePath: 'audiobooks',
|
|
||||||
storageType: 'primary',
|
storageType: 'primary',
|
||||||
mediaType: 'book'
|
mediaType: 'book'
|
||||||
}
|
}
|
||||||
@@ -196,7 +195,6 @@ class AbsDatabaseWeb extends WebPlugin {
|
|||||||
filename: 'lf1.mp3',
|
filename: 'lf1.mp3',
|
||||||
contentUrl: 'test',
|
contentUrl: 'test',
|
||||||
absolutePath: 'test',
|
absolutePath: 'test',
|
||||||
simplePath: 'test',
|
|
||||||
mimeType: 'audio/mpeg',
|
mimeType: 'audio/mpeg',
|
||||||
size: 39048290
|
size: 39048290
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-2
@@ -128,13 +128,12 @@ export default ({ app, store }, inject) => {
|
|||||||
|
|
||||||
// Listen for token refresh events from native app
|
// Listen for token refresh events from native app
|
||||||
AbsDatabase.addListener('onTokenRefresh', (data) => {
|
AbsDatabase.addListener('onTokenRefresh', (data) => {
|
||||||
console.log('[db] onTokenRefresh', data)
|
|
||||||
store.commit('user/setAccessToken', data.accessToken)
|
store.commit('user/setAccessToken', data.accessToken)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Listen for token refresh failure events from native app
|
// Listen for token refresh failure events from native app
|
||||||
AbsDatabase.addListener('onTokenRefreshFailure', async (data) => {
|
AbsDatabase.addListener('onTokenRefreshFailure', async (data) => {
|
||||||
console.log('[db] onTokenRefreshFailure', data)
|
console.log('[db] onTokenRefreshFailure')
|
||||||
// Clear store and redirect to login page
|
// Clear store and redirect to login page
|
||||||
await store.dispatch('user/logout')
|
await store.dispatch('user/logout')
|
||||||
if (window.location.pathname !== '/connect') {
|
if (window.location.pathname !== '/connect') {
|
||||||
|
|||||||
+23
-1
@@ -1,10 +1,13 @@
|
|||||||
import Vue from 'vue'
|
import Vue from 'vue'
|
||||||
|
import { Capacitor } from '@capacitor/core'
|
||||||
|
import { AbsDownloader, AbsFileSystem } from '@/plugins/capacitor'
|
||||||
import enUsStrings from '../strings/en-us.json'
|
import enUsStrings from '../strings/en-us.json'
|
||||||
|
|
||||||
const defaultCode = 'en-us'
|
const defaultCode = 'en-us'
|
||||||
let $localStore = null
|
let $localStore = null
|
||||||
|
|
||||||
const languageCodeMap = {
|
const languageCodeMap = {
|
||||||
|
be: { label: 'Беларуская', dateFnsLocale: 'be' },
|
||||||
bn: { label: 'বাংলা', dateFnsLocale: 'bn' },
|
bn: { label: 'বাংলা', dateFnsLocale: 'bn' },
|
||||||
bg: { label: 'Български', dateFnsLocale: 'bg' },
|
bg: { label: 'Български', dateFnsLocale: 'bg' },
|
||||||
ca: { label: 'Català', dateFnsLocale: 'ca' },
|
ca: { label: 'Català', dateFnsLocale: 'ca' },
|
||||||
@@ -40,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) => {
|
Vue.prototype.$languageCodeOptions = Object.keys(languageCodeMap).map((code) => {
|
||||||
return {
|
return {
|
||||||
text: languageCodeMap[code].label,
|
text: languageCodeMap[code].label,
|
||||||
@@ -107,6 +128,7 @@ async function loadi18n(code) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Vue.prototype.$setDateFnsLocale(languageCodeMap[code].dateFnsLocale)
|
Vue.prototype.$setDateFnsLocale(languageCodeMap[code].dateFnsLocale)
|
||||||
|
syncDownloadNotificationStrings()
|
||||||
|
|
||||||
this.$eventBus.$emit('change-lang', code)
|
this.$eventBus.$emit('change-lang', code)
|
||||||
return true
|
return true
|
||||||
@@ -144,5 +166,5 @@ async function initialize() {
|
|||||||
|
|
||||||
export default ({ app, store }, inject) => {
|
export default ({ app, store }, inject) => {
|
||||||
$localStore = app.$localStore
|
$localStore = app.$localStore
|
||||||
initialize()
|
initialize().finally(syncDownloadNotificationStrings)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,8 @@ export default function ({ store, $db, $socket }, inject) {
|
|||||||
}
|
}
|
||||||
if (res.status >= 400) {
|
if (res.status >= 400) {
|
||||||
console.error(`[nativeHttp] ${res.status} status for url "${url}"`)
|
console.error(`[nativeHttp] ${res.status} status for url "${url}"`)
|
||||||
throw new Error(res.data)
|
const message = typeof res.data === 'string' ? res.data : `HTTP ${res.status}`
|
||||||
|
throw new Error(message)
|
||||||
}
|
}
|
||||||
return res.data
|
return res.data
|
||||||
})
|
})
|
||||||
@@ -100,7 +101,8 @@ export default function ({ store, $db, $socket }, inject) {
|
|||||||
|
|
||||||
if (retryResponse.status >= 400) {
|
if (retryResponse.status >= 400) {
|
||||||
console.error(`[nativeHttp] Retry request failed with status ${retryResponse.status}`)
|
console.error(`[nativeHttp] Retry request failed with status ${retryResponse.status}`)
|
||||||
throw new Error(retryResponse.data)
|
const message = typeof retryResponse.data === 'string' ? retryResponse.data : `HTTP ${retryResponse.status}`
|
||||||
|
throw new Error(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
return retryResponse.data
|
return retryResponse.data
|
||||||
|
|||||||
@@ -135,6 +135,9 @@ export const mutations = {
|
|||||||
removeItemDownload(state, id) {
|
removeItemDownload(state, id) {
|
||||||
state.itemDownloads = state.itemDownloads.filter((i) => i.id != id)
|
state.itemDownloads = state.itemDownloads.filter((i) => i.id != id)
|
||||||
},
|
},
|
||||||
|
clearItemDownloads(state) {
|
||||||
|
state.itemDownloads = []
|
||||||
|
},
|
||||||
setBookshelfListView(state, val) {
|
setBookshelfListView(state, val) {
|
||||||
state.bookshelfListView = val
|
state.bookshelfListView = val
|
||||||
},
|
},
|
||||||
|
|||||||
+5
-3
@@ -12,7 +12,10 @@ export const state = () => ({
|
|||||||
mobileFilterBy: 'all',
|
mobileFilterBy: 'all',
|
||||||
playbackRate: 1,
|
playbackRate: 1,
|
||||||
collapseSeries: false,
|
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)
|
return state.user.bookmarks.filter((bm) => bm.libraryItemId === libraryItemId)
|
||||||
},
|
},
|
||||||
getUserSetting: (state) => (key) => {
|
getUserSetting: (state) => (key) => {
|
||||||
return state.settings?.[key] || null
|
return state.settings?.[key] ?? null
|
||||||
},
|
},
|
||||||
getUserCanUpdate: (state) => {
|
getUserCanUpdate: (state) => {
|
||||||
return !!state.user?.permissions?.update
|
return !!state.user?.permissions?.update
|
||||||
@@ -231,7 +234,6 @@ export const mutations = {
|
|||||||
state.user = user
|
state.user = user
|
||||||
},
|
},
|
||||||
setAccessToken(state, accessToken) {
|
setAccessToken(state, accessToken) {
|
||||||
console.log('[user] setAccessToken', accessToken)
|
|
||||||
state.accessToken = accessToken
|
state.accessToken = accessToken
|
||||||
},
|
},
|
||||||
removeMediaProgress(state, id) {
|
removeMediaProgress(state, id) {
|
||||||
|
|||||||
+14
-1
@@ -115,6 +115,7 @@
|
|||||||
"LabelAutoSleepTimerAutoRewindHelp": "عندما ينتهي مؤقت النوم التلقائي، فإن تشغيل العنصر مرة أخرى سيؤدي إلى الإرجاع التلقائي.",
|
"LabelAutoSleepTimerAutoRewindHelp": "عندما ينتهي مؤقت النوم التلقائي، فإن تشغيل العنصر مرة أخرى سيؤدي إلى الإرجاع التلقائي.",
|
||||||
"LabelAutoSleepTimerHelp": "عند تشغيل الوسائط بين أوقات البداية والنهاية المحددة، سيبدأ مؤقت النوم تلقائيًا.",
|
"LabelAutoSleepTimerHelp": "عند تشغيل الوسائط بين أوقات البداية والنهاية المحددة، سيبدأ مؤقت النوم تلقائيًا.",
|
||||||
"LabelBooks": "الكتب",
|
"LabelBooks": "الكتب",
|
||||||
|
"LabelByAuthor": "بواسطة {0}",
|
||||||
"LabelChapterTrack": "مسار الفصل",
|
"LabelChapterTrack": "مسار الفصل",
|
||||||
"LabelChapters": "الفصول",
|
"LabelChapters": "الفصول",
|
||||||
"LabelClosePlayer": "إغلاق المشغل",
|
"LabelClosePlayer": "إغلاق المشغل",
|
||||||
@@ -179,6 +180,9 @@
|
|||||||
"LabelLayout": "التنسيق",
|
"LabelLayout": "التنسيق",
|
||||||
"LabelLayoutAuto": "تلقائي",
|
"LabelLayoutAuto": "تلقائي",
|
||||||
"LabelLayoutSinglePage": "صفحة واحدة",
|
"LabelLayoutSinglePage": "صفحة واحدة",
|
||||||
|
"LabelLibrarySortByProgress": "المرحلة: الأحدث",
|
||||||
|
"LabelLibrarySortByProgressFinished": "المرحلة: تم الانتهاء",
|
||||||
|
"LabelLibrarySortByProgressStarted": "المرحلة: تم البدء",
|
||||||
"LabelLight": "فاتح",
|
"LabelLight": "فاتح",
|
||||||
"LabelLineSpacing": "تباعد الأسطر",
|
"LabelLineSpacing": "تباعد الأسطر",
|
||||||
"LabelListenAgain": "الاستماع مجدداً",
|
"LabelListenAgain": "الاستماع مجدداً",
|
||||||
@@ -238,6 +242,7 @@
|
|||||||
"LabelScaleElapsedTimeBySpeed": "تعديل الوقت المنصرم حسب السرعة",
|
"LabelScaleElapsedTimeBySpeed": "تعديل الوقت المنصرم حسب السرعة",
|
||||||
"LabelSeason": "الموسم",
|
"LabelSeason": "الموسم",
|
||||||
"LabelSelectADevice": "اختيار جهاز",
|
"LabelSelectADevice": "اختيار جهاز",
|
||||||
|
"LabelSelectMediaType": "اختيار نوع الوسائط",
|
||||||
"LabelSequenceAscending": "تسلسل تصاعدي",
|
"LabelSequenceAscending": "تسلسل تصاعدي",
|
||||||
"LabelSequenceDescending": "تسلسل تنازلي",
|
"LabelSequenceDescending": "تسلسل تنازلي",
|
||||||
"LabelSeries": "المسلسلات",
|
"LabelSeries": "المسلسلات",
|
||||||
@@ -290,12 +295,17 @@
|
|||||||
"MessageAudiobookshelfServerNotConnected": "خادم Audiobookshelf غير متصل",
|
"MessageAudiobookshelfServerNotConnected": "خادم Audiobookshelf غير متصل",
|
||||||
"MessageAudiobookshelfServerRequired": "<strong>مهم!</strong> هذا التطبيق مصمم للعمل مع خادم Audiobookshelf تستضيفه أنت أو شخص تعرفه. هذا التطبيق لا يقدم أي محتوى.",
|
"MessageAudiobookshelfServerRequired": "<strong>مهم!</strong> هذا التطبيق مصمم للعمل مع خادم Audiobookshelf تستضيفه أنت أو شخص تعرفه. هذا التطبيق لا يقدم أي محتوى.",
|
||||||
"MessageBookshelfEmpty": "الرف فارغ",
|
"MessageBookshelfEmpty": "الرف فارغ",
|
||||||
|
"MessageConfirmAppExit": "هل تريد الخروج من التطبيق؟",
|
||||||
|
"MessageConfirmDeleteEpisodeDownloadQueue": "هل أنت متأكد من تصفية قائمة انتظار تنزيل الحلقة؟",
|
||||||
"MessageConfirmDeleteLocalEpisode": "هل تريد بالتأكيد إزالة الحلقة المحلية \"{0}\" من جهازك؟ لن يتأثر الملف الموجود على الخادم.",
|
"MessageConfirmDeleteLocalEpisode": "هل تريد بالتأكيد إزالة الحلقة المحلية \"{0}\" من جهازك؟ لن يتأثر الملف الموجود على الخادم.",
|
||||||
"MessageConfirmDeleteLocalFiles": "هل تريد بالتأكيد إزالة الملفات المحلية لهذا العنصر من جهازك؟ الملفات الموجودة على الخادم وتقدمك لن يتأثرا.",
|
"MessageConfirmDeleteLocalFiles": "هل تريد بالتأكيد إزالة الملفات المحلية لهذا العنصر من جهازك؟ الملفات الموجودة على الخادم وتقدمك لن يتأثرا.",
|
||||||
|
"MessageConfirmDeleteServerConfig": "إزالة إعدادات هذا الخادم؟",
|
||||||
|
"MessageConfirmDeleteServerEpisode": "هل أنت متأكد من حذف الحلقة \"{0}\" من الخادم؟\nتحذير: سيتم حذف الملف الصوتي.",
|
||||||
"MessageConfirmDisableAutoTimer": "هل أنت متأكد أنك تريد تعطيل المؤقت التلقائي لبقية اليوم؟ سيتم إعادة تفعيل المؤقت في نهاية فترة مؤقت النوم التلقائي هذه، أو إذا قمت بإعادة تشغيل التطبيق.",
|
"MessageConfirmDisableAutoTimer": "هل أنت متأكد أنك تريد تعطيل المؤقت التلقائي لبقية اليوم؟ سيتم إعادة تفعيل المؤقت في نهاية فترة مؤقت النوم التلقائي هذه، أو إذا قمت بإعادة تشغيل التطبيق.",
|
||||||
"MessageConfirmDiscardProgress": "هل أنت متأكد أنك تريد إعادة تعيين تقدمك؟",
|
"MessageConfirmDiscardProgress": "هل أنت متأكد أنك تريد إعادة تعيين تقدمك؟",
|
||||||
"MessageConfirmDownloadUsingCellular": "أنت على وشك التنزيل باستخدام بيانات الجوال. قد يشمل ذلك رسوم استخدام بيانات من شركة الاتصالات. هل ترغب في المتابعة؟",
|
"MessageConfirmDownloadUsingCellular": "أنت على وشك التنزيل باستخدام بيانات الجوال. قد يشمل ذلك رسوم استخدام بيانات من شركة الاتصالات. هل ترغب في المتابعة؟",
|
||||||
"MessageConfirmMarkAsFinished": "هل أنت متأكد أنك تريد وضع علامة \"مكتمل\" على هذا العنصر؟",
|
"MessageConfirmMarkAsFinished": "هل أنت متأكد أنك تريد وضع علامة \"مكتمل\" على هذا العنصر؟",
|
||||||
|
"MessageConfirmPlaybackTime": "هل تريد بدء التشغيل لـ \"{0}\" في الساعة {1}؟",
|
||||||
"MessageConfirmRemoveBookmark": "هل أنت متأكد أنك تريد إزالة العلامة المرجعية؟",
|
"MessageConfirmRemoveBookmark": "هل أنت متأكد أنك تريد إزالة العلامة المرجعية؟",
|
||||||
"MessageConfirmStreamingUsingCellular": "أنت على وشك البث باستخدام بيانات الجوال. قد يشمل ذلك رسوم استخدام بيانات من شركة الاتصالات. هل ترغب في المتابعة؟",
|
"MessageConfirmStreamingUsingCellular": "أنت على وشك البث باستخدام بيانات الجوال. قد يشمل ذلك رسوم استخدام بيانات من شركة الاتصالات. هل ترغب في المتابعة؟",
|
||||||
"MessageDiscardProgress": "إلغاء التقدم",
|
"MessageDiscardProgress": "إلغاء التقدم",
|
||||||
@@ -333,6 +343,7 @@
|
|||||||
"MessageNoUserPlaylists": "ليس لديك أي قوائم تشغيل",
|
"MessageNoUserPlaylists": "ليس لديك أي قوائم تشغيل",
|
||||||
"MessageOldServerAuthReLoginRequired": "تم تحسين المصادقة في الخادم v2.26.0 لضمان الأمان. يُطلب من جميع المستخدمين إعادة تسجيل الدخول.",
|
"MessageOldServerAuthReLoginRequired": "تم تحسين المصادقة في الخادم v2.26.0 لضمان الأمان. يُطلب من جميع المستخدمين إعادة تسجيل الدخول.",
|
||||||
"MessageOldServerAuthWarning": "الخادم يستخدم مصادقة قديمة",
|
"MessageOldServerAuthWarning": "الخادم يستخدم مصادقة قديمة",
|
||||||
|
"MessageOldServerAuthWarningHelp": "الخادم يعمل على إصدار أقدم من النسخة (2.26.0)، نظام مصادقة أكثر أماناً أضيف في النسخة (2.26.0). يوصى بشدة تحديث الخادم إلى آخر نسخة. إذا قمت بالفعل بتحديث الخادم قم بإعادة تسجيل الدخول لاستخدام المصادقة الجديدة.",
|
||||||
"MessageOldServerConnectionWarning": "تكوين اتصال الخادم يستخدم معرّف مستخدم قديم. يرجى حذف اتصال الخادم هذا وإعادة إضافته.",
|
"MessageOldServerConnectionWarning": "تكوين اتصال الخادم يستخدم معرّف مستخدم قديم. يرجى حذف اتصال الخادم هذا وإعادة إضافته.",
|
||||||
"MessageOldServerConnectionWarningHelp": "لقد قمتَ بإعداد الاتصال بهذا الخادم قبل ترحيل قاعدة البيانات في الإصدار 2.3.0، الصادر في يونيو 2023. سيؤدي تحديث الخادم المستقبلي إلى إلغاء إمكانية تسجيل الدخول باستخدام هذا الاتصال القديم. يُرجى حذف اتصال الخادم الحالي والاتصال مرة أخرى (باستخدام نفس عنوان الخادم وبيانات الاعتماد). إذا كانت لديك أي وسائط مُنزّلة على هذا الجهاز، فيجب تنزيلها مرة أخرى لمزامنتها مع الخادم.",
|
"MessageOldServerConnectionWarningHelp": "لقد قمتَ بإعداد الاتصال بهذا الخادم قبل ترحيل قاعدة البيانات في الإصدار 2.3.0، الصادر في يونيو 2023. سيؤدي تحديث الخادم المستقبلي إلى إلغاء إمكانية تسجيل الدخول باستخدام هذا الاتصال القديم. يُرجى حذف اتصال الخادم الحالي والاتصال مرة أخرى (باستخدام نفس عنوان الخادم وبيانات الاعتماد). إذا كانت لديك أي وسائط مُنزّلة على هذا الجهاز، فيجب تنزيلها مرة أخرى لمزامنتها مع الخادم.",
|
||||||
"MessagePodcastSearchField": "أدخل مصطلح البحث أو عنوان URL الخاص بتغذية RSS",
|
"MessagePodcastSearchField": "أدخل مصطلح البحث أو عنوان URL الخاص بتغذية RSS",
|
||||||
@@ -359,5 +370,7 @@
|
|||||||
"ToastPodcastCreateSuccess": "تم إنشاء البودكاست بنجاح",
|
"ToastPodcastCreateSuccess": "تم إنشاء البودكاست بنجاح",
|
||||||
"ToastRSSFeedCloseFailed": "فشل إغلاق مغذّي RSS",
|
"ToastRSSFeedCloseFailed": "فشل إغلاق مغذّي RSS",
|
||||||
"ToastRSSFeedCloseSuccess": "تم إغلاق مغذّي RSS",
|
"ToastRSSFeedCloseSuccess": "تم إغلاق مغذّي RSS",
|
||||||
"ToastStreamingNotAllowedOnCellular": "لا يُسمح بالبث عبر البيانات الخلوية"
|
"ToastStreamingNotAllowedOnCellular": "لا يُسمح بالبث عبر البيانات الخلوية",
|
||||||
|
"UnitMinutesShort": "{0}د",
|
||||||
|
"UnitSecondsShort": "{0}ث"
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-25
@@ -50,9 +50,9 @@
|
|||||||
"ButtonSendEbookToDevice": "Адправіць электронную кнігу на прыладу",
|
"ButtonSendEbookToDevice": "Адправіць электронную кнігу на прыладу",
|
||||||
"ButtonSeries": "Серыі",
|
"ButtonSeries": "Серыі",
|
||||||
"ButtonSetTimer": "Устанавіць таймер",
|
"ButtonSetTimer": "Устанавіць таймер",
|
||||||
"ButtonStream": "Трансляцыя",
|
"ButtonStream": "Трансліраваць",
|
||||||
"ButtonSubmit": "Адправіць",
|
"ButtonSubmit": "Адправіць",
|
||||||
"ButtonSwitchServerUser": "Змяніць Сервер/Карыстальніка",
|
"ButtonSwitchServerUser": "Змяніць сервер/карыстальніка",
|
||||||
"ButtonUnmaskServerAddress": "Паказаць адрас сервера",
|
"ButtonUnmaskServerAddress": "Паказаць адрас сервера",
|
||||||
"ButtonUserStats": "Статыстыка карыстальніка",
|
"ButtonUserStats": "Статыстыка карыстальніка",
|
||||||
"ButtonYes": "Так",
|
"ButtonYes": "Так",
|
||||||
@@ -103,7 +103,7 @@
|
|||||||
"LabelAndroidAutoBrowseLimitForGrouping": "Ліміт алфавітнага выпадальнага спісу",
|
"LabelAndroidAutoBrowseLimitForGrouping": "Ліміт алфавітнага выпадальнага спісу",
|
||||||
"LabelAndroidAutoBrowseLimitForGroupingHelp": "Не выкарыстоўваць алфавітны выпадальны спіс, калі элементаў для паказу менш за гэтую колькасць",
|
"LabelAndroidAutoBrowseLimitForGroupingHelp": "Не выкарыстоўваць алфавітны выпадальны спіс, калі элементаў для паказу менш за гэтую колькасць",
|
||||||
"LabelAndroidAutoBrowseSeriesSequenceOrder": "Парадак кніг у серыі",
|
"LabelAndroidAutoBrowseSeriesSequenceOrder": "Парадак кніг у серыі",
|
||||||
"LabelAskConfirmation": "Запытваць пацверджанне",
|
"LabelAskConfirmation": "Запытваць пацвярджэнне",
|
||||||
"LabelAuthor": "Аўтар",
|
"LabelAuthor": "Аўтар",
|
||||||
"LabelAuthorFirstLast": "Аўтар (імя, прозвішча)",
|
"LabelAuthorFirstLast": "Аўтар (імя, прозвішча)",
|
||||||
"LabelAuthorLastFirst": "Аўтар (прозвішча, імя)",
|
"LabelAuthorLastFirst": "Аўтар (прозвішча, імя)",
|
||||||
@@ -118,7 +118,7 @@
|
|||||||
"LabelByAuthor": "– {0}",
|
"LabelByAuthor": "– {0}",
|
||||||
"LabelChapterTrack": "Трэк раздзела",
|
"LabelChapterTrack": "Трэк раздзела",
|
||||||
"LabelChapters": "Раздзелы",
|
"LabelChapters": "Раздзелы",
|
||||||
"LabelClosePlayer": "Зачыніць прайгравальнік",
|
"LabelClosePlayer": "Закрыць прайгравальнік",
|
||||||
"LabelCollapseSeries": "Згарнуць серыі",
|
"LabelCollapseSeries": "Згарнуць серыі",
|
||||||
"LabelComplete": "Завяршыць",
|
"LabelComplete": "Завяршыць",
|
||||||
"LabelContinueBooks": "Працягнуць кнігі",
|
"LabelContinueBooks": "Працягнуць кнігі",
|
||||||
@@ -131,8 +131,8 @@
|
|||||||
"LabelDisableAudioFadeOut": "Адключыць змяншэнне гучнасці",
|
"LabelDisableAudioFadeOut": "Адключыць змяншэнне гучнасці",
|
||||||
"LabelDisableAudioFadeOutHelp": "Гучнасць гуку пачне змяншацца, калі на таймеры сну застанецца менш за 1 хвіліну. Уключыце гэтую наладу, каб пазбегнуць змяншэння гучнасці.",
|
"LabelDisableAudioFadeOutHelp": "Гучнасць гуку пачне змяншацца, калі на таймеры сну застанецца менш за 1 хвіліну. Уключыце гэтую наладу, каб пазбегнуць змяншэння гучнасці.",
|
||||||
"LabelDisableAutoRewind": "Адключыць аўтаматычную перамотку назад",
|
"LabelDisableAutoRewind": "Адключыць аўтаматычную перамотку назад",
|
||||||
"LabelDisableShakeToReset": "Адключыць скід страсеннем",
|
"LabelDisableShakeToReset": "Адключыць скід устрэсваннем",
|
||||||
"LabelDisableShakeToResetHelp": "Калі патрасці прыладу падчас працы таймера АБО на працягу 2 хвілін пасля заканчэння таймера, таймер сну скідваецца. Уключыце гэтую наладу, каб адключыць скід страсеннем.",
|
"LabelDisableShakeToResetHelp": "Калі патрэсці прыладу падчас працы таймера АБО на працягу 2 хвілін пасля заканчэння таймера, таймер сну скідваецца. Уключыце гэтую наладу, каб адключыць скід устрэсваннем.",
|
||||||
"LabelDisableVibrateOnReset": "Адключыць вібрацыю пры скідзе",
|
"LabelDisableVibrateOnReset": "Адключыць вібрацыю пры скідзе",
|
||||||
"LabelDisableVibrateOnResetHelp": "Калі таймер сну скідваецца, ваша прылада будзе вібраваць. Уключыце гэтую наладу, каб адключыць вібрацыю пры скідзе таймера сну.",
|
"LabelDisableVibrateOnResetHelp": "Калі таймер сну скідваецца, ваша прылада будзе вібраваць. Уключыце гэтую наладу, каб адключыць вібрацыю пры скідзе таймера сну.",
|
||||||
"LabelDiscover": "Знаходкі",
|
"LabelDiscover": "Знаходкі",
|
||||||
@@ -144,7 +144,7 @@
|
|||||||
"LabelEbooks": "Электронныя кнігі",
|
"LabelEbooks": "Электронныя кнігі",
|
||||||
"LabelEnable": "Уключыць",
|
"LabelEnable": "Уключыць",
|
||||||
"LabelEnableMp3IndexSeeking": "Уключыць пошук па індэксе mp3",
|
"LabelEnableMp3IndexSeeking": "Уключыць пошук па індэксе mp3",
|
||||||
"LabelEnableMp3IndexSeekingHelp": "Гэтую наладу варта ўключаць толькі, калі ў вас ёсць MP3-файлы, якія няправільна перамотваюцца. Хутчэй за ўсё, гэта выклікана файламі з зменным бітрэйтам (VBR). Гэтая налада прымусова ўключае індэксаванне для перамоткі, пры якім падчас чытання файла ствараецца адпаведнасць паміж часам і байтамі. У некаторых выпадках з вялікімі MP3-файламі можа быць затрымка пры перемотцы да канца файла.",
|
"LabelEnableMp3IndexSeekingHelp": "Гэтую наладу варта ўключаць толькі, калі ў вас ёсць MP3-файлы, якія няправільна перамотваюцца. Хутчэй за ўсё, гэта выклікана файламі са зменным бітрэйтам (VBR). Гэтая налада прымусова ўключае індэксаванне для перамоткі, пры якім падчас чытання файла ствараецца адпаведнасць паміж часам і байтамі. У некаторых выпадках з вялікімі MP3-файламі можа быць затрымка пры перемотцы да канца файла.",
|
||||||
"LabelEnd": "Канец",
|
"LabelEnd": "Канец",
|
||||||
"LabelEndOfChapter": "Канец раздзела",
|
"LabelEndOfChapter": "Канец раздзела",
|
||||||
"LabelEndTime": "Час заканчэння",
|
"LabelEndTime": "Час заканчэння",
|
||||||
@@ -167,8 +167,8 @@
|
|||||||
"LabelHapticFeedback": "Тактыльная зваротная сувязь",
|
"LabelHapticFeedback": "Тактыльная зваротная сувязь",
|
||||||
"LabelHasEbook": "Мае электронную кнігу",
|
"LabelHasEbook": "Мае электронную кнігу",
|
||||||
"LabelHasSupplementaryEbook": "Мае дадатковую электронную кнігу",
|
"LabelHasSupplementaryEbook": "Мае дадатковую электронную кнігу",
|
||||||
"LabelHeavy": "Моцна",
|
"LabelHeavy": "Моцная",
|
||||||
"LabelHigh": "Высокі",
|
"LabelHigh": "Высокая",
|
||||||
"LabelHost": "Хост",
|
"LabelHost": "Хост",
|
||||||
"LabelInProgress": "У працэсе",
|
"LabelInProgress": "У працэсе",
|
||||||
"LabelIncomplete": "Незавершана",
|
"LabelIncomplete": "Незавершана",
|
||||||
@@ -183,16 +183,16 @@
|
|||||||
"LabelLibrarySortByProgress": "Прагрэс: апошняе абнаўленне",
|
"LabelLibrarySortByProgress": "Прагрэс: апошняе абнаўленне",
|
||||||
"LabelLibrarySortByProgressFinished": "Прагрэс: завершана",
|
"LabelLibrarySortByProgressFinished": "Прагрэс: завершана",
|
||||||
"LabelLibrarySortByProgressStarted": "Прагрэс: пачата",
|
"LabelLibrarySortByProgressStarted": "Прагрэс: пачата",
|
||||||
"LabelLight": "Святло",
|
"LabelLight": "Лёгкая",
|
||||||
"LabelLineSpacing": "Міжрадковы інтэрвал",
|
"LabelLineSpacing": "Міжрадковы інтэрвал",
|
||||||
"LabelListenAgain": "Паслухаць зноў",
|
"LabelListenAgain": "Паслухаць зноў",
|
||||||
"LabelLocalBooks": "Лакальныя кнігі",
|
"LabelLocalBooks": "Лакальныя кнігі",
|
||||||
"LabelLocalPodcasts": "Лакальныя падкасты",
|
"LabelLocalPodcasts": "Лакальныя падкасты",
|
||||||
"LabelLockOrientation": "Зафіксаваць арыентацыю",
|
"LabelLockOrientation": "Зафіксаваць арыентацыю",
|
||||||
"LabelLockPlayer": "Заблакаваць прайгравальнік",
|
"LabelLockPlayer": "Заблакіраваць прайгравальнік",
|
||||||
"LabelLow": "Нізкі",
|
"LabelLow": "Нізкая",
|
||||||
"LabelMediaType": "Тып медыя",
|
"LabelMediaType": "Тып медыя",
|
||||||
"LabelMedium": "Сярэдні",
|
"LabelMedium": "Сярэдняя",
|
||||||
"LabelMissing": "Адсутнічае",
|
"LabelMissing": "Адсутнічае",
|
||||||
"LabelMore": "Больш",
|
"LabelMore": "Больш",
|
||||||
"LabelMoreInfo": "Больш інфармацыі",
|
"LabelMoreInfo": "Больш інфармацыі",
|
||||||
@@ -239,22 +239,22 @@
|
|||||||
"LabelRecentSeries": "Апошнія серыі",
|
"LabelRecentSeries": "Апошнія серыі",
|
||||||
"LabelRecentlyAdded": "Нядаўна дададзеныя",
|
"LabelRecentlyAdded": "Нядаўна дададзеныя",
|
||||||
"LabelRemoveFromPlaylist": "Выдаліць з плэй-ліста",
|
"LabelRemoveFromPlaylist": "Выдаліць з плэй-ліста",
|
||||||
"LabelScaleElapsedTimeBySpeed": "Прыстасаваць мінулы час да хуткасці",
|
"LabelScaleElapsedTimeBySpeed": "Маштабаваць мінулы час па хуткасці",
|
||||||
"LabelSeason": "Сезон",
|
"LabelSeason": "Сезон",
|
||||||
"LabelSelectADevice": "Выбраць прыладу",
|
"LabelSelectADevice": "Выбраць прыладу",
|
||||||
"LabelSelectMediaType": "Выберыце тып медыя",
|
"LabelSelectMediaType": "Выберыце тып медыя",
|
||||||
"LabelSequenceAscending": "Паслядоўнасць па ўзрастанні",
|
"LabelSequenceAscending": "Па ўзрастанні",
|
||||||
"LabelSequenceDescending": "Паслядоўнасць па змяншэнні",
|
"LabelSequenceDescending": "Па ўбыванні",
|
||||||
"LabelSeries": "Серыі",
|
"LabelSeries": "Серыі",
|
||||||
"LabelServerAddress": "Адрас сервера",
|
"LabelServerAddress": "Адрас сервера",
|
||||||
"LabelSetEbookAsPrimary": "Зрабіць асноўным",
|
"LabelSetEbookAsPrimary": "Зрабіць асноўным",
|
||||||
"LabelSetEbookAsSupplementary": "Зрабіць дадатковым",
|
"LabelSetEbookAsSupplementary": "Зрабіць дадатковым",
|
||||||
"LabelShakeSensitivity": "Чуйнасць да трасення",
|
"LabelShakeSensitivity": "Адчувальнасць да ўстрэсвання",
|
||||||
"LabelShowAll": "Паказаць усё",
|
"LabelShowAll": "Паказаць усё",
|
||||||
"LabelSize": "Памер",
|
"LabelSize": "Памер",
|
||||||
"LabelSleepTimer": "Таймер сну",
|
"LabelSleepTimer": "Таймер сну",
|
||||||
"LabelSleepTimerAlmostDoneChime": "Гукавы сігнал, калі амаль звершаны",
|
"LabelSleepTimerAlmostDoneChime": "Гукавы сігнал, калі амаль завершаны",
|
||||||
"LabelSleepTimerAlmostDoneChimeHelp": "Памякчыць звонак, калі на таймеры сну застанецца 30 секунд",
|
"LabelSleepTimerAlmostDoneChimeHelp": "Прайграць званок, калі да канца таймера сну застанецца 30 секунд",
|
||||||
"LabelStart": "Пачатак",
|
"LabelStart": "Пачатак",
|
||||||
"LabelStartTime": "Час пачатку",
|
"LabelStartTime": "Час пачатку",
|
||||||
"LabelStatsBestDay": "Найлепшы дзень",
|
"LabelStatsBestDay": "Найлепшы дзень",
|
||||||
@@ -270,7 +270,7 @@
|
|||||||
"LabelTag": "Метка",
|
"LabelTag": "Метка",
|
||||||
"LabelTags": "Меткі",
|
"LabelTags": "Меткі",
|
||||||
"LabelTheme": "Тэма",
|
"LabelTheme": "Тэма",
|
||||||
"LabelThemeBlack": "Чорны",
|
"LabelThemeBlack": "Чорная",
|
||||||
"LabelThemeDark": "Цёмная",
|
"LabelThemeDark": "Цёмная",
|
||||||
"LabelThemeLight": "Светлая",
|
"LabelThemeLight": "Светлая",
|
||||||
"LabelTimeRemaining": "Засталося {0}",
|
"LabelTimeRemaining": "Засталося {0}",
|
||||||
@@ -280,12 +280,12 @@
|
|||||||
"LabelTracks": "Трэкі",
|
"LabelTracks": "Трэкі",
|
||||||
"LabelType": "Тып",
|
"LabelType": "Тып",
|
||||||
"LabelUnknown": "Невядома",
|
"LabelUnknown": "Невядома",
|
||||||
"LabelUnlockPlayer": "Разблакаваць прайгравальнік",
|
"LabelUnlockPlayer": "Разблакіраваць прайгравальнік",
|
||||||
"LabelUseBookshelfView": "Паказваць кніжныя паліцы",
|
"LabelUseBookshelfView": "Паказваць кніжныя паліцы",
|
||||||
"LabelUser": "Карыстальнік",
|
"LabelUser": "Карыстальнік",
|
||||||
"LabelUsername": "Імя карыстальніка",
|
"LabelUsername": "Імя карыстальніка",
|
||||||
"LabelVeryHigh": "Вельмі высокі",
|
"LabelVeryHigh": "Вельмі высокая",
|
||||||
"LabelVeryLow": "Вельмі нізкі",
|
"LabelVeryLow": "Вельмі нізкая",
|
||||||
"LabelYearReviewHide": "Схаваць вынікі года",
|
"LabelYearReviewHide": "Схаваць вынікі года",
|
||||||
"LabelYearReviewShow": "Азнаёміцца з вынікамі года",
|
"LabelYearReviewShow": "Азнаёміцца з вынікамі года",
|
||||||
"LabelYourBookmarks": "Вашы закладкі",
|
"LabelYourBookmarks": "Вашы закладкі",
|
||||||
@@ -297,8 +297,8 @@
|
|||||||
"MessageBookshelfEmpty": "Кніжная паліца пустая",
|
"MessageBookshelfEmpty": "Кніжная паліца пустая",
|
||||||
"MessageConfirmAppExit": "Вы хацелі выйсці з праграмы?",
|
"MessageConfirmAppExit": "Вы хацелі выйсці з праграмы?",
|
||||||
"MessageConfirmDeleteEpisodeDownloadQueue": "Вы ўпэўнены, што хочаце ачысціць чаргу спампоўвання выпускаў?",
|
"MessageConfirmDeleteEpisodeDownloadQueue": "Вы ўпэўнены, што хочаце ачысціць чаргу спампоўвання выпускаў?",
|
||||||
"MessageConfirmDeleteLocalEpisode": "Выдаліць лакальны выпуск \"{0}\" з вашай прылады? Файл на серверы застанецца нязменным.",
|
"MessageConfirmDeleteLocalEpisode": "Выдаліць лакальны выпуск \"{0}\" з прылады? Файл на серверы гэта не закране.",
|
||||||
"MessageConfirmDeleteLocalFiles": "Выдаліць лакальныя файлы гэтага элемента з вашай прылады? Файлы на серверы і прагрэс застануцца нязменнымі.",
|
"MessageConfirmDeleteLocalFiles": "Выдаліць лакальныя файлы гэтага элемента з прылады? Файлы на серверы і прагрэс застануцца нязменнымі.",
|
||||||
"MessageConfirmDeleteServerConfig": "Выдаліць гэту канфігурацыю сервера?",
|
"MessageConfirmDeleteServerConfig": "Выдаліць гэту канфігурацыю сервера?",
|
||||||
"MessageConfirmDeleteServerEpisode": "Вы ўпэўнены, што хочаце выдаліць выпуск \"{0}\" з сервера?\nУвага: будзе выдалены аўдыяфайл.",
|
"MessageConfirmDeleteServerEpisode": "Вы ўпэўнены, што хочаце выдаліць выпуск \"{0}\" з сервера?\nУвага: будзе выдалены аўдыяфайл.",
|
||||||
"MessageConfirmDisableAutoTimer": "Вы ўпэўнены, што хочаце адключыць аўтаматычны таймер на сённяшні дзень? Таймер будзе зноў уключаны па заканчэнні перыяду гэтага аўтаматычнага таймера сну або пры перазапуску праграмы.",
|
"MessageConfirmDisableAutoTimer": "Вы ўпэўнены, што хочаце адключыць аўтаматычны таймер на сённяшні дзень? Таймер будзе зноў уключаны па заканчэнні перыяду гэтага аўтаматычнага таймера сну або пры перазапуску праграмы.",
|
||||||
|
|||||||
+36
-34
@@ -62,41 +62,41 @@
|
|||||||
"HeaderAudioTracks": "Lydspor",
|
"HeaderAudioTracks": "Lydspor",
|
||||||
"HeaderChapters": "Kapitler",
|
"HeaderChapters": "Kapitler",
|
||||||
"HeaderCollection": "Samling",
|
"HeaderCollection": "Samling",
|
||||||
"HeaderCollectionItems": "Samlingselementer",
|
"HeaderCollectionItems": "Bøger i samlingen",
|
||||||
"HeaderConfirm": "Bekræft",
|
"HeaderConfirm": "Bekræft",
|
||||||
"HeaderConnectionStatus": "Forbindelsesstatus",
|
"HeaderConnectionStatus": "Forbindelsesstatus",
|
||||||
"HeaderDataSettings": "Data indstillinger",
|
"HeaderDataSettings": "Data indstillinger",
|
||||||
"HeaderDetails": "Detaljer",
|
"HeaderDetails": "Detaljer",
|
||||||
"HeaderDownloads": "Downloads",
|
"HeaderDownloads": "Downloads",
|
||||||
"HeaderEbookFiles": "E-bogsfiler",
|
"HeaderEbookFiles": "E-bogsfiler",
|
||||||
"HeaderEpisodes": "Episoder",
|
"HeaderEpisodes": "Afsnit",
|
||||||
"HeaderEreaderSettings": "E-læser Indstillinger",
|
"HeaderEreaderSettings": "Indstillinger for e-bogslæsere",
|
||||||
"HeaderLatestEpisodes": "Seneste episoder",
|
"HeaderLatestEpisodes": "Nyeste afsnit",
|
||||||
"HeaderLibraries": "Biblioteker",
|
"HeaderLibraries": "Biblioteker",
|
||||||
"HeaderLocalFolders": "Lokale mapper",
|
"HeaderLocalFolders": "Lokale mapper",
|
||||||
"HeaderLocalLibraryItems": "Lokale biblioteks elementer",
|
"HeaderLocalLibraryItems": "Lokale biblioteks elementer",
|
||||||
"HeaderNewPlaylist": "Ny afspilningsliste",
|
"HeaderNewPlaylist": "Ny afspilningsliste",
|
||||||
"HeaderOpenRSSFeed": "Åbn RSS Feed",
|
"HeaderOpenRSSFeed": "Åbn RSS-feed",
|
||||||
"HeaderPlaybackSettings": "Afspilnings indstillinger",
|
"HeaderPlaybackSettings": "Afspilnings indstillinger",
|
||||||
"HeaderPlaylist": "Afspilningsliste",
|
"HeaderPlaylist": "Afspilningsliste",
|
||||||
"HeaderPlaylistItems": "Afspilningsliste Elementer",
|
"HeaderPlaylistItems": "Elementer i afspilningslisten",
|
||||||
"HeaderProgressSyncFailed": "Fremskridt synkronisering fejlede",
|
"HeaderProgressSyncFailed": "Fremskridt synkronisering fejlede",
|
||||||
"HeaderRSSFeed": "RSS Feed",
|
"HeaderRSSFeed": "RSS Feed",
|
||||||
"HeaderRSSFeedGeneral": "RSS Detaljer",
|
"HeaderRSSFeedGeneral": "RSS-detaljer",
|
||||||
"HeaderRSSFeedIsOpen": "RSS Feed er Åben",
|
"HeaderRSSFeedIsOpen": "RSS-feedet er åbent",
|
||||||
"HeaderSelectDownloadLocation": "Vælg Download Placering",
|
"HeaderSelectDownloadLocation": "Vælg Download Placering",
|
||||||
"HeaderSettings": "Indstillinger",
|
"HeaderSettings": "Indstillinger",
|
||||||
"HeaderSleepTimer": "Søvntimer",
|
"HeaderSleepTimer": "Sovefunktion",
|
||||||
"HeaderSleepTimerSettings": "Sleep Timer instillinger",
|
"HeaderSleepTimerSettings": "Sleep Timer instillinger",
|
||||||
"HeaderStatsMinutesListeningChart": "Minutter Lyttet (sidste 7 dage)",
|
"HeaderStatsMinutesListeningChart": "Minutter lyttet (sidste 7 dage)",
|
||||||
"HeaderStatsRecentSessions": "Seneste Sessions",
|
"HeaderStatsRecentSessions": "Seneste sessioner",
|
||||||
"HeaderTableOfContents": "Indholdsfortegnelse",
|
"HeaderTableOfContents": "Indholdsfortegnelse",
|
||||||
"HeaderUserInterfaceSettings": "Indstillinger for brugergrænseflade",
|
"HeaderUserInterfaceSettings": "Indstillinger for brugergrænseflade",
|
||||||
"HeaderWelcome": "Velkommen, <strong>{0}</strong>",
|
"HeaderWelcome": "Velkommen, <strong>{0}</strong>",
|
||||||
"HeaderYourStats": "Dine Statistikker",
|
"HeaderYourStats": "Din statistik",
|
||||||
"LabelAddToPlaylist": "Tilføj til Afspilningsliste",
|
"LabelAddToPlaylist": "Tilføj til afspilningsliste",
|
||||||
"LabelAddedAt": "Tilføjet",
|
"LabelAddedAt": "Tilføjet",
|
||||||
"LabelAddedDate": "Tilføjet {0}",
|
"LabelAddedDate": "Tilføjede {0}",
|
||||||
"LabelAll": "Alle",
|
"LabelAll": "Alle",
|
||||||
"LabelAllowSeekingOnMediaControls": "Tillad positionssøgning på mediemeddelelseskontroller",
|
"LabelAllowSeekingOnMediaControls": "Tillad positionssøgning på mediemeddelelseskontroller",
|
||||||
"LabelAlways": "Altid",
|
"LabelAlways": "Altid",
|
||||||
@@ -108,24 +108,24 @@
|
|||||||
"LabelAuthorFirstLast": "Forfatter (Fornavn Efternavn)",
|
"LabelAuthorFirstLast": "Forfatter (Fornavn Efternavn)",
|
||||||
"LabelAuthorLastFirst": "Forfatter (Efternavn, Fornavn)",
|
"LabelAuthorLastFirst": "Forfatter (Efternavn, Fornavn)",
|
||||||
"LabelAuthors": "Forfattere",
|
"LabelAuthors": "Forfattere",
|
||||||
"LabelAutoDownloadEpisodes": "Auto Download Episoder",
|
"LabelAutoDownloadEpisodes": "Hent automatisk afsnit",
|
||||||
"LabelAutoRewindTime": "Automatisk tilbagespolingstid",
|
"LabelAutoRewindTime": "Automatisk tilbagespolingstid",
|
||||||
"LabelAutoSleepTimer": "Auto søvn-timer",
|
"LabelAutoSleepTimer": "Auto søvn-timer",
|
||||||
"LabelAutoSleepTimerAutoRewind": "Automatisk sleep timer automatisk tilbagespoling",
|
"LabelAutoSleepTimerAutoRewind": "Automatisk sleep timer automatisk tilbagespoling",
|
||||||
"LabelAutoSleepTimerAutoRewindHelp": "Når den automatiske sleep-timer er færdig, vil genafspilning af elementet automatisk spole din position tilbage.",
|
"LabelAutoSleepTimerAutoRewindHelp": "Når den automatiske sleep-timer er færdig, vil genafspilning af elementet automatisk spole din position tilbage.",
|
||||||
"LabelAutoSleepTimerHelp": "Når der afspilles medie mellem de angivne start- og sluttidspunkter, starter en sleep-timer automatisk.",
|
"LabelAutoSleepTimerHelp": "Når der afspilles medie mellem de angivne start- og sluttidspunkter, starter en sleep-timer automatisk.",
|
||||||
"LabelBooks": "Bøger",
|
"LabelBooks": "Bøger",
|
||||||
"LabelByAuthor": "Efter Forfatter",
|
"LabelByAuthor": "af {0}",
|
||||||
"LabelChapterTrack": "Kapitel spor",
|
"LabelChapterTrack": "Kapitel spor",
|
||||||
"LabelChapters": "Kapitler",
|
"LabelChapters": "Kapitler",
|
||||||
"LabelClosePlayer": "Luk afspiller",
|
"LabelClosePlayer": "Luk afspiller",
|
||||||
"LabelCollapseSeries": "Fold Serier Sammen",
|
"LabelCollapseSeries": "Fold serier sammen",
|
||||||
"LabelComplete": "Fuldfør",
|
"LabelComplete": "Afsluttet",
|
||||||
"LabelContinueBooks": "Fortsæt Bøger",
|
"LabelContinueBooks": "Fortsæt Bøger",
|
||||||
"LabelContinueEpisodes": "Fortsæt Episoder",
|
"LabelContinueEpisodes": "Fortsæt Episoder",
|
||||||
"LabelContinueListening": "Fortsæt med at lytte",
|
"LabelContinueListening": "Fortsæt med at lytte",
|
||||||
"LabelContinueReading": "Fortsæt med at læse",
|
"LabelContinueReading": "Fortsæt med at læse",
|
||||||
"LabelContinueSeries": "Fortsæt Serien",
|
"LabelContinueSeries": "Fortsæt serien",
|
||||||
"LabelCustomTime": "Brugerdefineret tid",
|
"LabelCustomTime": "Brugerdefineret tid",
|
||||||
"LabelDescription": "Beskrivelse",
|
"LabelDescription": "Beskrivelse",
|
||||||
"LabelDisableAudioFadeOut": "Deaktiver lydudtoning",
|
"LabelDisableAudioFadeOut": "Deaktiver lydudtoning",
|
||||||
@@ -136,7 +136,7 @@
|
|||||||
"LabelDisableVibrateOnReset": "Deaktiver vibrering ved nulstilling",
|
"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.",
|
"LabelDisableVibrateOnResetHelp": "Når sleep-timeren nulstilles, vil din enhed vibrere. Aktiver denne indstilling for ikke at vibrere, når sleep-timeren nulstilles.",
|
||||||
"LabelDiscover": "Opdag",
|
"LabelDiscover": "Opdag",
|
||||||
"LabelDownload": "Download",
|
"LabelDownload": "Hent",
|
||||||
"LabelDownloadUsingCellular": "Download med mobildata",
|
"LabelDownloadUsingCellular": "Download med mobildata",
|
||||||
"LabelDownloaded": "Downloadet",
|
"LabelDownloaded": "Downloadet",
|
||||||
"LabelDuration": "Varighed",
|
"LabelDuration": "Varighed",
|
||||||
@@ -149,8 +149,8 @@
|
|||||||
"LabelEndOfChapter": "Slutningen af kapitel",
|
"LabelEndOfChapter": "Slutningen af kapitel",
|
||||||
"LabelEndTime": "Sluttidspunkt",
|
"LabelEndTime": "Sluttidspunkt",
|
||||||
"LabelEpisode": "Afsnit",
|
"LabelEpisode": "Afsnit",
|
||||||
"LabelExplicit": "Eksplisit",
|
"LabelExplicit": "Voksenindhold",
|
||||||
"LabelFeedURL": "Feed URL",
|
"LabelFeedURL": "Feed-URL",
|
||||||
"LabelFile": "Fil",
|
"LabelFile": "Fil",
|
||||||
"LabelFileBirthtime": "Oprettelsestidspunkt for fil",
|
"LabelFileBirthtime": "Oprettelsestidspunkt for fil",
|
||||||
"LabelFileModified": "Fil ændret",
|
"LabelFileModified": "Fil ændret",
|
||||||
@@ -159,8 +159,8 @@
|
|||||||
"LabelFolder": "Mappe",
|
"LabelFolder": "Mappe",
|
||||||
"LabelFontBoldness": "Skrift tykkelse",
|
"LabelFontBoldness": "Skrift tykkelse",
|
||||||
"LabelFontFamily": "Skrifttypefamilie",
|
"LabelFontFamily": "Skrifttypefamilie",
|
||||||
"LabelFontFamilySans": "Font Sans",
|
"LabelFontFamilySans": "Sans",
|
||||||
"LabelFontFamilySerif": "Font Serif",
|
"LabelFontFamilySerif": "Serif",
|
||||||
"LabelFontScale": "Skriftstørrelse",
|
"LabelFontScale": "Skriftstørrelse",
|
||||||
"LabelGenre": "Genre",
|
"LabelGenre": "Genre",
|
||||||
"LabelGenres": "Genrer",
|
"LabelGenres": "Genrer",
|
||||||
@@ -177,7 +177,7 @@
|
|||||||
"LabelJumpForwardsTime": "Spring frem tid",
|
"LabelJumpForwardsTime": "Spring frem tid",
|
||||||
"LabelKeepScreenAwake": "Hold skærm aktiv",
|
"LabelKeepScreenAwake": "Hold skærm aktiv",
|
||||||
"LabelLanguage": "Sprog",
|
"LabelLanguage": "Sprog",
|
||||||
"LabelLayout": "Layout",
|
"LabelLayout": "Udseende",
|
||||||
"LabelLayoutAuto": "Automatisk",
|
"LabelLayoutAuto": "Automatisk",
|
||||||
"LabelLayoutSinglePage": "Enkel Side",
|
"LabelLayoutSinglePage": "Enkel Side",
|
||||||
"LabelLibrarySortByProgress": "Fremgang: Sidst opdateret",
|
"LabelLibrarySortByProgress": "Fremgang: Sidst opdateret",
|
||||||
@@ -185,7 +185,7 @@
|
|||||||
"LabelLibrarySortByProgressStarted": "Fremgang: Startet",
|
"LabelLibrarySortByProgressStarted": "Fremgang: Startet",
|
||||||
"LabelLight": "Lys",
|
"LabelLight": "Lys",
|
||||||
"LabelLineSpacing": "Linjeafstand",
|
"LabelLineSpacing": "Linjeafstand",
|
||||||
"LabelListenAgain": "Lyt Igen",
|
"LabelListenAgain": "Lyt igen",
|
||||||
"LabelLocalBooks": "Lokale Bøger",
|
"LabelLocalBooks": "Lokale Bøger",
|
||||||
"LabelLocalPodcasts": "Lokale Podcasts",
|
"LabelLocalPodcasts": "Lokale Podcasts",
|
||||||
"LabelLockOrientation": "Lås Orientering",
|
"LabelLockOrientation": "Lås Orientering",
|
||||||
@@ -197,8 +197,8 @@
|
|||||||
"LabelMore": "Mere",
|
"LabelMore": "Mere",
|
||||||
"LabelMoreInfo": "Mere info",
|
"LabelMoreInfo": "Mere info",
|
||||||
"LabelName": "Navn",
|
"LabelName": "Navn",
|
||||||
"LabelNarrator": "Fortæller",
|
"LabelNarrator": "Oplæser",
|
||||||
"LabelNarrators": "Fortællere",
|
"LabelNarrators": "Oplæsere",
|
||||||
"LabelNavigateWithVolume": "Naviger med lydstyrketasterne",
|
"LabelNavigateWithVolume": "Naviger med lydstyrketasterne",
|
||||||
"LabelNavigateWithVolumeMirrored": "Spejlet",
|
"LabelNavigateWithVolumeMirrored": "Spejlet",
|
||||||
"LabelNavigateWithVolumeWhilePlaying": "Tillad navigering med lydstyrketasterne , mens du spiller",
|
"LabelNavigateWithVolumeWhilePlaying": "Tillad navigering med lydstyrketasterne , mens du spiller",
|
||||||
@@ -206,7 +206,7 @@
|
|||||||
"LabelNavigateWithVolumeWhilePlayingEnabled": "Tændt",
|
"LabelNavigateWithVolumeWhilePlayingEnabled": "Tændt",
|
||||||
"LabelNever": "Aldrig",
|
"LabelNever": "Aldrig",
|
||||||
"LabelNewestAuthors": "Nyeste Forfattere",
|
"LabelNewestAuthors": "Nyeste Forfattere",
|
||||||
"LabelNewestEpisodes": "Nyeste Episoder",
|
"LabelNewestEpisodes": "Nyeste afsnit",
|
||||||
"LabelNo": "Nej",
|
"LabelNo": "Nej",
|
||||||
"LabelNotFinished": "Ikke færdig",
|
"LabelNotFinished": "Ikke færdig",
|
||||||
"LabelNotStarted": "Ikke påbegyndt",
|
"LabelNotStarted": "Ikke påbegyndt",
|
||||||
@@ -223,19 +223,19 @@
|
|||||||
"LabelPlaybackTranscode": "Transcode",
|
"LabelPlaybackTranscode": "Transcode",
|
||||||
"LabelPodcast": "Podcast",
|
"LabelPodcast": "Podcast",
|
||||||
"LabelPodcasts": "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",
|
"LabelProgress": "Fremskridt",
|
||||||
"LabelPubDate": "Udgivelsesdato",
|
"LabelPubDate": "Udgivelsesdato",
|
||||||
"LabelPublishYear": "Udgivelsesår",
|
"LabelPublishYear": "Udgivelsesår",
|
||||||
"LabelPublishedDate": "Publiceret {0}",
|
"LabelPublishedDate": "Udgivet {0}",
|
||||||
"LabelRSSFeedCustomOwnerEmail": "Brugerdefineret ejerens e-mail",
|
"LabelRSSFeedCustomOwnerEmail": "Brugerdefineret ejerens e-mail",
|
||||||
"LabelRSSFeedCustomOwnerName": "Brugerdefineret ejerens navn",
|
"LabelRSSFeedCustomOwnerName": "Brugerdefineret ejerens navn",
|
||||||
"LabelRSSFeedOpen": "RSS-feed åbent",
|
"LabelRSSFeedOpen": "RSS-feed åbent",
|
||||||
"LabelRSSFeedPreventIndexing": "Forhindrer indeksering",
|
"LabelRSSFeedPreventIndexing": "Forhindr indeksering",
|
||||||
"LabelRSSFeedSlug": "RSS-feed-slug",
|
"LabelRSSFeedSlug": "RSS-feed-slug",
|
||||||
"LabelRandomly": "Tilfældigt",
|
"LabelRandomly": "Tilfældigt",
|
||||||
"LabelRead": "Læst",
|
"LabelRead": "Læst",
|
||||||
"LabelReadAgain": "Læs Igen",
|
"LabelReadAgain": "Læs igen",
|
||||||
"LabelRecentSeries": "Seneste serier",
|
"LabelRecentSeries": "Seneste serier",
|
||||||
"LabelRecentlyAdded": "Nyligt Tilføjet",
|
"LabelRecentlyAdded": "Nyligt Tilføjet",
|
||||||
"LabelRemoveFromPlaylist": "Fjern fra Playliste",
|
"LabelRemoveFromPlaylist": "Fjern fra Playliste",
|
||||||
@@ -262,7 +262,7 @@
|
|||||||
"LabelStatsDays": "Dage",
|
"LabelStatsDays": "Dage",
|
||||||
"LabelStatsDaysListened": "Dage hørt",
|
"LabelStatsDaysListened": "Dage hørt",
|
||||||
"LabelStatsInARow": "i træk",
|
"LabelStatsInARow": "i træk",
|
||||||
"LabelStatsItemsFinished": "Elementer færdige",
|
"LabelStatsItemsFinished": "færdiglæste bøger",
|
||||||
"LabelStatsMinutes": "minutter",
|
"LabelStatsMinutes": "minutter",
|
||||||
"LabelStatsMinutesListening": "Minutter hørt",
|
"LabelStatsMinutesListening": "Minutter hørt",
|
||||||
"LabelStatsWeekListening": "Ugens lytning",
|
"LabelStatsWeekListening": "Ugens lytning",
|
||||||
@@ -300,10 +300,12 @@
|
|||||||
"MessageConfirmDeleteLocalEpisode": "Slet lokal episode \"{0}\" fra din enhed? Filen på serveren vil ikke blive påvirket.",
|
"MessageConfirmDeleteLocalEpisode": "Slet lokal episode \"{0}\" fra din enhed? Filen på serveren vil ikke blive påvirket.",
|
||||||
"MessageConfirmDeleteLocalFiles": "Slet lokale filer af denne type fra din enhed? Filerne på serveren og din fremgang vil ikke blive påvirket.",
|
"MessageConfirmDeleteLocalFiles": "Slet lokale filer af denne type fra din enhed? Filerne på serveren og din fremgang vil ikke blive påvirket.",
|
||||||
"MessageConfirmDeleteServerConfig": "Fjern denne serverkonfiguration?",
|
"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.",
|
"MessageConfirmDisableAutoTimer": "Er du sikker på at du vil slå auto-timer fra resten af dagen? Timeren vil blive slået til ved slutningen af auto-sleep timer perioden eller appen genstartes.",
|
||||||
"MessageConfirmDiscardProgress": "Er du sikker på at du vil nulstille din fremgang?",
|
"MessageConfirmDiscardProgress": "Er du sikker på at du vil nulstille din fremgang?",
|
||||||
"MessageConfirmDownloadUsingCellular": "Du er ved at downloade via mobildata. Dette kan medføre ekstra omkostninger fra din teleoperatør. Vil du fortsætte?",
|
"MessageConfirmDownloadUsingCellular": "Du er ved at downloade via mobildata. Dette kan medføre ekstra omkostninger fra din teleoperatør. Vil du fortsætte?",
|
||||||
"MessageConfirmMarkAsFinished": "Er du sikker på at du vil markere dette element færdig?",
|
"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?",
|
"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?",
|
"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",
|
"MessageDiscardProgress": "Kassér fremgang",
|
||||||
|
|||||||
+1
-1
@@ -143,7 +143,7 @@
|
|||||||
"LabelEbook": "E-Buch",
|
"LabelEbook": "E-Buch",
|
||||||
"LabelEbooks": "E-Bücher",
|
"LabelEbooks": "E-Bücher",
|
||||||
"LabelEnable": "Aktivieren",
|
"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.",
|
"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",
|
"LabelEnd": "Ende",
|
||||||
"LabelEndOfChapter": "Ende des Kapitels",
|
"LabelEndOfChapter": "Ende des Kapitels",
|
||||||
|
|||||||
+44
-5
@@ -131,56 +131,95 @@
|
|||||||
"LabelDisableAudioFadeOut": "Απενεργοποίηση σταδιακής μείωσης ήχου",
|
"LabelDisableAudioFadeOut": "Απενεργοποίηση σταδιακής μείωσης ήχου",
|
||||||
"LabelDisableAudioFadeOutHelp": "Η ένταση του ήχου θα αρχίσει να μειώνεται όταν απομένει λιγότερο από 1 λεπτό στον χρονοδιακόπτη ύπνου. Ενεργοποιήστε αυτή τη ρύθμιση για να μην γίνεται σταδιακή μείωση.",
|
"LabelDisableAudioFadeOutHelp": "Η ένταση του ήχου θα αρχίσει να μειώνεται όταν απομένει λιγότερο από 1 λεπτό στον χρονοδιακόπτη ύπνου. Ενεργοποιήστε αυτή τη ρύθμιση για να μην γίνεται σταδιακή μείωση.",
|
||||||
"LabelDisableAutoRewind": "Απενεργοποίηση αυτόματης επαναφοράς",
|
"LabelDisableAutoRewind": "Απενεργοποίηση αυτόματης επαναφοράς",
|
||||||
|
"LabelDisableShakeToReset": "Απενεργοποιήστε το κούνημα για επαναφορά",
|
||||||
|
"LabelDisableShakeToResetHelp": "Αν κουνήσετε τη συσκευή σας ενώ λειτουργεί ο χρονοδιακόπτης Ή εντός 2 λεπτών από τη λήξη του χρονοδιακόπτη, θα επαναφέρετε τον χρονοδιακόπτη ύπνου. Ενεργοποιήστε αυτήν τη ρύθμιση για να απενεργοποιήσετε το κούνημα για επαναφορά.",
|
||||||
|
"LabelDisableVibrateOnReset": "Απενεργοποίηση δόνησης κατά την επαναφορά",
|
||||||
|
"LabelDisableVibrateOnResetHelp": "Όταν γίνει επαναφορά του χρονοδιακόπτη ύπνου, η συσκευή σας θα δονηθεί. Ενεργοποιήστε αυτήν τη ρύθμιση για να μην δονείται κατά την επαναφορά του χρονοδιακόπτη ύπνου.",
|
||||||
|
"LabelDiscover": "Ανακάλυψη",
|
||||||
"LabelDownload": "Λήψη",
|
"LabelDownload": "Λήψη",
|
||||||
"LabelDownloadUsingCellular": "Λήψη με χρήση δεδομένων",
|
"LabelDownloadUsingCellular": "Λήψη με χρήση δεδομένων",
|
||||||
"LabelDownloaded": "Ληφθέντα",
|
"LabelDownloaded": "Ληφθέντα",
|
||||||
"LabelDuration": "Διάρκεια",
|
"LabelDuration": "Διάρκεια",
|
||||||
"LabelEbook": "Ebook",
|
"LabelEbook": "Ηλεκτρονικό Βιβλίο/ Ebook",
|
||||||
"LabelEbooks": "Ebooks",
|
"LabelEbooks": "Ηλεκτρονικά Βιβλία / Ebooks",
|
||||||
"LabelEnable": "Ενεργοποίηση",
|
"LabelEnable": "Ενεργοποίηση",
|
||||||
|
"LabelEnableMp3IndexSeeking": "Ενεργοποίηση αναζήτησης σε mp3 ευρετήριο",
|
||||||
|
"LabelEnableMp3IndexSeekingHelp": "Αυτή η ρύθμιση θα πρέπει να είναι ενεργοποιημένη μόνο εάν έχετε αρχεία mp3 που δεν έχει γίνει σωστή αναζήτηση. Η ανακριβής αναζήτηση πιθανότατα οφείλεται σε αρχεία MP3 με μεταβλητό ρυθμό μετάδοσης bit (VBR). Αυτή η ρύθμιση θα επιβάλει την αναζήτηση ευρετηρίου, στην οποία δημιουργείται μια αντιστοίχιση χρόνου-byte καθώς διαβάζεται το αρχείο. Σε ορισμένες περιπτώσεις, με μεγάλα αρχεία MP3, θα υπάρχει καθυστέρηση κατά την αναζήτηση προς το τέλος του αρχείου.",
|
||||||
"LabelEnd": "Τέλος",
|
"LabelEnd": "Τέλος",
|
||||||
"LabelEndOfChapter": "Τέλος Κεφαλαίου",
|
"LabelEndOfChapter": "Τέλος Κεφαλαίου",
|
||||||
|
"LabelEndTime": "Ώρα λήξης",
|
||||||
"LabelEpisode": "Επεισόδιο",
|
"LabelEpisode": "Επεισόδιο",
|
||||||
|
"LabelExplicit": "Άσεμνο περιεχόμενο",
|
||||||
|
"LabelFeedURL": "URL ροής",
|
||||||
"LabelFile": "Αρχείο",
|
"LabelFile": "Αρχείο",
|
||||||
|
"LabelFileBirthtime": "Ώρα δημιουργίας αρχείου",
|
||||||
|
"LabelFileModified": "Τροποποιημένο αρχείο",
|
||||||
"LabelFilename": "Όνομα Αρχείου",
|
"LabelFilename": "Όνομα Αρχείου",
|
||||||
"LabelFinished": "Ολοκληρώθηκε",
|
"LabelFinished": "Ολοκληρώθηκε",
|
||||||
"LabelFolder": "Φάκελος",
|
"LabelFolder": "Φάκελος",
|
||||||
|
"LabelFontBoldness": "Ένταση γραμματοσειράς",
|
||||||
"LabelFontFamily": "Οικογένεια Γραμματοσειράς",
|
"LabelFontFamily": "Οικογένεια Γραμματοσειράς",
|
||||||
"LabelGenre": "Είδος",
|
"LabelFontScale": "Μέγεθος γραμματοσειράς",
|
||||||
"LabelGenres": "Είδη",
|
"LabelGenre": "Λογοτεχνικό Είδος",
|
||||||
|
"LabelGenres": "Λογοτεχνικά Είδη",
|
||||||
|
"LabelHapticFeedback": "Απτική ανατροφοδότηση",
|
||||||
|
"LabelHasEbook": "Έχει ebook",
|
||||||
|
"LabelHasSupplementaryEbook": "Διαθέτει συμπληρωματικό ηλεκτρονικό βιβλίο",
|
||||||
|
"LabelHeavy": "Έντονη",
|
||||||
|
"LabelHigh": "Υψηλή",
|
||||||
"LabelHost": "Διακομιστής",
|
"LabelHost": "Διακομιστής",
|
||||||
"LabelInProgress": "Σε Εξέλιξη",
|
"LabelInProgress": "Σε Εξέλιξη",
|
||||||
|
"LabelIncomplete": "Ελειπής",
|
||||||
|
"LabelInternalAppStorage": "Εσωτερικός χώρος αποθήκευσης εφαρμογών",
|
||||||
|
"LabelJumpBackwardsTime": "Πήγαινε πίσω το χρόνο",
|
||||||
|
"LabelJumpForwardsTime": "Πήγαινε μπρος το χρόνο",
|
||||||
|
"LabelKeepScreenAwake": "Να παραμείνει η οθόνη ενεργή",
|
||||||
"LabelLanguage": "Γλώσσα",
|
"LabelLanguage": "Γλώσσα",
|
||||||
|
"LabelLayout": "Διάταξη",
|
||||||
"LabelLayoutAuto": "Αυτόματα",
|
"LabelLayoutAuto": "Αυτόματα",
|
||||||
"LabelLayoutSinglePage": "Μονή Σελίδα",
|
"LabelLayoutSinglePage": "Μονή Σελίδα",
|
||||||
|
"LabelLibrarySortByProgress": "Πρόοδος: Τελευταία ενημέρωση",
|
||||||
|
"LabelLibrarySortByProgressFinished": "Πρόοδος: Ολοκληρώθηκε",
|
||||||
|
"LabelLibrarySortByProgressStarted": "Πρόοδος: Έναρξη",
|
||||||
"LabelLight": "Ανοιχτό",
|
"LabelLight": "Ανοιχτό",
|
||||||
|
"LabelLineSpacing": "Διάστημα γραμμών",
|
||||||
"LabelListenAgain": "Επανάληψη Ακρόασης",
|
"LabelListenAgain": "Επανάληψη Ακρόασης",
|
||||||
"LabelLocalBooks": "Τοπικά Βιβλία",
|
"LabelLocalBooks": "Τοπικά Βιβλία",
|
||||||
"LabelLocalPodcasts": "Τοπικά Podcasts",
|
"LabelLocalPodcasts": "Τοπικά Podcasts",
|
||||||
|
"LabelLockOrientation": "Κλείδωμα Προσανατολισμού",
|
||||||
|
"LabelLockPlayer": "Κλείδωμα Αναπαραγωγής",
|
||||||
"LabelLow": "Χαμηλά",
|
"LabelLow": "Χαμηλά",
|
||||||
"LabelMediaType": "Τύπος Πολυμέσων",
|
"LabelMediaType": "Τύπος Πολυμέσων",
|
||||||
|
"LabelMedium": "Μεσαίο",
|
||||||
|
"LabelMissing": "Λείπει η ετικέτα",
|
||||||
"LabelMore": "Περισσότερα",
|
"LabelMore": "Περισσότερα",
|
||||||
"LabelMoreInfo": "Περισσότερες Πληροφορίες",
|
"LabelMoreInfo": "Περισσότερες Πληροφορίες",
|
||||||
"LabelName": "Όνομα",
|
"LabelName": "Όνομα",
|
||||||
"LabelNarrator": "Αφηγητής",
|
"LabelNarrator": "Αφηγητής",
|
||||||
"LabelNarrators": "Αφηγητές",
|
"LabelNarrators": "Αφηγητές",
|
||||||
|
"LabelNavigateWithVolume": "Περιήγηση με τα πλήκτρα έντασης",
|
||||||
"LabelNavigateWithVolumeMirrored": "Καθρεπτισμός",
|
"LabelNavigateWithVolumeMirrored": "Καθρεπτισμός",
|
||||||
|
"LabelNavigateWithVolumeWhilePlaying": "Να επιτρέπεται η περιήγησηστα πλήκτρα έντασης ήχου κατά την αναπαραγωγή",
|
||||||
"LabelNavigateWithVolumeWhilePlayingDisabled": "Ανενεργό",
|
"LabelNavigateWithVolumeWhilePlayingDisabled": "Ανενεργό",
|
||||||
"LabelNavigateWithVolumeWhilePlayingEnabled": "Ενεργό",
|
"LabelNavigateWithVolumeWhilePlayingEnabled": "Ενεργό",
|
||||||
"LabelNever": "Ποτέ",
|
"LabelNever": "Ποτέ",
|
||||||
"LabelNewestAuthors": "Πρόσφατοι Συγγραφείς",
|
"LabelNewestAuthors": "Πρόσφατοι Συγγραφείς",
|
||||||
"LabelNewestEpisodes": "Πρόσφατα Επεισόδια",
|
"LabelNewestEpisodes": "Πρόσφατα Επεισόδια",
|
||||||
"LabelNo": "Όχι",
|
"LabelNo": "Όχι",
|
||||||
|
"LabelNotFinished": "Δεν έχει ολοκληρωθεί",
|
||||||
"LabelNotStarted": "Δεν Έχει Ξεκινήσει",
|
"LabelNotStarted": "Δεν Έχει Ξεκινήσει",
|
||||||
"LabelNumEpisodes": "{0} επεισόδια",
|
"LabelNumEpisodes": "{0} επεισόδια",
|
||||||
|
"LabelNumEpisodesIncomplete": "{0} επεισόδια, {1} μη ολοκληρωμένα",
|
||||||
"LabelNumberOfEpisodes": "# Επεισοδίων",
|
"LabelNumberOfEpisodes": "# Επεισοδίων",
|
||||||
"LabelOff": "Ανενεργό",
|
"LabelOff": "Ανενεργό",
|
||||||
"LabelOn": "Ενεργό",
|
"LabelOn": "Ενεργό",
|
||||||
"LabelPassword": "Κωδικός Πρόσβασης",
|
"LabelPassword": "Κωδικός Πρόσβασης",
|
||||||
"LabelPath": "Διαδρομή",
|
"LabelPath": "Διαδρομή",
|
||||||
"LabelPlaybackLocal": "Τοπικά",
|
"LabelPlaybackDirect": "Άμεση αναπαραγωγή",
|
||||||
|
"LabelPlaybackLocal": "Αναπαραγωγή τοπικού αρχείου",
|
||||||
"LabelPlaybackSpeed": "Ταχύτητα Αναπαραγωγής",
|
"LabelPlaybackSpeed": "Ταχύτητα Αναπαραγωγής",
|
||||||
"LabelProgress": "Πρόοδος",
|
"LabelProgress": "Πρόοδος",
|
||||||
|
"LabelPubDate": "Ημερομηνία έκδοσης",
|
||||||
"LabelPublishYear": "Χρονολογία Έκδοσης",
|
"LabelPublishYear": "Χρονολογία Έκδοσης",
|
||||||
"LabelPublishedDate": "Εκδόθηκε {0}",
|
"LabelPublishedDate": "Εκδόθηκε {0}",
|
||||||
"LabelRandomly": "Τυχαία",
|
"LabelRandomly": "Τυχαία",
|
||||||
|
|||||||
+9
-2
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"ButtonAdd": "Add",
|
"ButtonAdd": "Add",
|
||||||
"ButtonAddNewServer": "Add New Server",
|
"ButtonAddNewServer": "Add New Server",
|
||||||
|
"ButtonAllow": "Allow",
|
||||||
"ButtonAuthors": "Authors",
|
"ButtonAuthors": "Authors",
|
||||||
"ButtonBack": "Back",
|
"ButtonBack": "Back",
|
||||||
"ButtonCancel": "Cancel",
|
"ButtonCancel": "Cancel",
|
||||||
@@ -295,7 +296,7 @@
|
|||||||
"MessageAudiobookshelfServerNotConnected": "Audiobookshelf server not connected",
|
"MessageAudiobookshelfServerNotConnected": "Audiobookshelf server not connected",
|
||||||
"MessageAudiobookshelfServerRequired": "<strong>Important!</strong> This app is designed to work with an Audiobookshelf server that you or someone you know is hosting. This app does not provide any content.",
|
"MessageAudiobookshelfServerRequired": "<strong>Important!</strong> This app is designed to work with an Audiobookshelf server that you or someone you know is hosting. This app does not provide any content.",
|
||||||
"MessageBookshelfEmpty": "Bookshelf empty",
|
"MessageBookshelfEmpty": "Bookshelf empty",
|
||||||
"MessageConfirmAppExit":"Did you want to exit the app?",
|
"MessageConfirmAppExit": "Did you want to exit the app?",
|
||||||
"MessageConfirmDeleteEpisodeDownloadQueue": "Are you sure you want to clear episode download queue?",
|
"MessageConfirmDeleteEpisodeDownloadQueue": "Are you sure you want to clear episode download queue?",
|
||||||
"MessageConfirmDeleteLocalEpisode": "Remove local episode \"{0}\" from your device? The file on the server will be unaffected.",
|
"MessageConfirmDeleteLocalEpisode": "Remove local episode \"{0}\" from your device? The file on the server will be unaffected.",
|
||||||
"MessageConfirmDeleteLocalFiles": "Remove local files of this item from your device? The files on the server and your progress will be unaffected.",
|
"MessageConfirmDeleteLocalFiles": "Remove local files of this item from your device? The files on the server and your progress will be unaffected.",
|
||||||
@@ -305,13 +306,14 @@
|
|||||||
"MessageConfirmDiscardProgress": "Are you sure you want to reset your progress?",
|
"MessageConfirmDiscardProgress": "Are you sure you want to reset your progress?",
|
||||||
"MessageConfirmDownloadUsingCellular": "You are about to download using cellular data. This may include carrier data charges. Do you wish to continue?",
|
"MessageConfirmDownloadUsingCellular": "You are about to download using cellular data. This may include carrier data charges. Do you wish to continue?",
|
||||||
"MessageConfirmMarkAsFinished": "Are you sure you want to mark this item as finished?",
|
"MessageConfirmMarkAsFinished": "Are you sure you want to mark this item as finished?",
|
||||||
"MessageConfirmPlaybackTime":"Start playback for \"{0}\" at {1}?",
|
"MessageConfirmPlaybackTime": "Start playback for \"{0}\" at {1}?",
|
||||||
"MessageConfirmRemoveBookmark": "Are you sure you want to remove bookmark?",
|
"MessageConfirmRemoveBookmark": "Are you sure you want to remove bookmark?",
|
||||||
"MessageConfirmStreamingUsingCellular": "You are about to stream using cellular data. This may include carrier data charges. Do you wish to continue?",
|
"MessageConfirmStreamingUsingCellular": "You are about to stream using cellular data. This may include carrier data charges. Do you wish to continue?",
|
||||||
"MessageDiscardProgress": "Discard Progress",
|
"MessageDiscardProgress": "Discard Progress",
|
||||||
"MessageDownloadCompleteProcessing": "Download complete. Processing...",
|
"MessageDownloadCompleteProcessing": "Download complete. Processing...",
|
||||||
"MessageDownloading": "Downloading...",
|
"MessageDownloading": "Downloading...",
|
||||||
"MessageDownloadingEpisode": "Downloading episode",
|
"MessageDownloadingEpisode": "Downloading episode",
|
||||||
|
"MessageDownloadingFile": "Downloading {0}",
|
||||||
"MessageEpisodesQueuedForDownload": "{0} Episode(s) queued for download",
|
"MessageEpisodesQueuedForDownload": "{0} Episode(s) queued for download",
|
||||||
"MessageFailedToRefreshToken": "Failed to refresh token, re-login required",
|
"MessageFailedToRefreshToken": "Failed to refresh token, re-login required",
|
||||||
"MessageFeedURLWillBe": "Feed URL will be {0}",
|
"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.",
|
"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.",
|
"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",
|
"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.",
|
"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",
|
"MessageReportBugsAndContribute": "Report bugs, request features, and contribute on",
|
||||||
"MessageSeriesAlreadyDownloaded": "You have already downloaded all books in this series.",
|
"MessageSeriesAlreadyDownloaded": "You have already downloaded all books in this series.",
|
||||||
@@ -357,6 +360,10 @@
|
|||||||
"MessageSocketConnectedOverUnmeteredCellular": "Socket connected over unmetered cellular",
|
"MessageSocketConnectedOverUnmeteredCellular": "Socket connected over unmetered cellular",
|
||||||
"MessageSocketConnectedOverUnmeteredWifi": "Socket connected over unmetered wifi",
|
"MessageSocketConnectedOverUnmeteredWifi": "Socket connected over unmetered wifi",
|
||||||
"MessageSocketNotConnected": "Socket not connected",
|
"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",
|
"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.",
|
"NoteRSSFeedPodcastAppsPubDate": "Warning: 1 or more of your episodes do not have a Pub Date. Some podcast apps require this.",
|
||||||
"ToastBookmarkCreateFailed": "Failed to create bookmark",
|
"ToastBookmarkCreateFailed": "Failed to create bookmark",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"ButtonAdd": "Dodaj",
|
"ButtonAdd": "Dodaj",
|
||||||
"ButtonAddNewServer": "Dodaj novi poslužitelj",
|
"ButtonAddNewServer": "Dodaj novi poslužitelj",
|
||||||
|
"ButtonAllow": "Dopusti",
|
||||||
"ButtonAuthors": "Autori",
|
"ButtonAuthors": "Autori",
|
||||||
"ButtonBack": "Natrag",
|
"ButtonBack": "Natrag",
|
||||||
"ButtonCancel": "Odustani",
|
"ButtonCancel": "Odustani",
|
||||||
@@ -312,6 +313,7 @@
|
|||||||
"MessageDownloadCompleteProcessing": "Preuzimanje dovršeno. Obrađujem...",
|
"MessageDownloadCompleteProcessing": "Preuzimanje dovršeno. Obrađujem...",
|
||||||
"MessageDownloading": "Preuzimanje...",
|
"MessageDownloading": "Preuzimanje...",
|
||||||
"MessageDownloadingEpisode": "Preuzimam nastavak",
|
"MessageDownloadingEpisode": "Preuzimam nastavak",
|
||||||
|
"MessageDownloadingFile": "Preuzimanje {0}",
|
||||||
"MessageEpisodesQueuedForDownload": "{0} nastavak(a) u redu za preuzimanje",
|
"MessageEpisodesQueuedForDownload": "{0} nastavak(a) u redu za preuzimanje",
|
||||||
"MessageFailedToRefreshToken": "Osvježavanje tokena nije uspjelo, potrebna je ponovna prijava",
|
"MessageFailedToRefreshToken": "Osvježavanje tokena nije uspjelo, potrebna je ponovna prijava",
|
||||||
"MessageFeedURLWillBe": "URL izvora bit će {0}",
|
"MessageFeedURLWillBe": "URL izvora bit će {0}",
|
||||||
@@ -347,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.",
|
"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.",
|
"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",
|
"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.",
|
"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",
|
"MessageReportBugsAndContribute": "Prijavite pogreške, zatražite funkcije i doprinesite na",
|
||||||
"MessageSeriesAlreadyDownloaded": "Već ste preuzeli sve knjige ovog serijala.",
|
"MessageSeriesAlreadyDownloaded": "Već ste preuzeli sve knjige ovog serijala.",
|
||||||
@@ -357,6 +360,10 @@
|
|||||||
"MessageSocketConnectedOverUnmeteredCellular": "Socket je povezan preko mobilne mreže kod koje se ne prati potrošnja",
|
"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",
|
"MessageSocketConnectedOverUnmeteredWifi": "Socket je povezan preko Wi-Fi mreže na kojoj se ne prati potrošnja",
|
||||||
"MessageSocketNotConnected": "Socket nije povezan",
|
"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",
|
"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.",
|
"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",
|
"ToastBookmarkCreateFailed": "Izrada knjižne oznake nije uspjela",
|
||||||
|
|||||||
+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"
|
||||||
|
}
|
||||||
|
|||||||
+1
-1
@@ -50,7 +50,7 @@
|
|||||||
"ButtonSendEbookToDevice": "Invia il libro al dispositivo",
|
"ButtonSendEbookToDevice": "Invia il libro al dispositivo",
|
||||||
"ButtonSeries": "Serie",
|
"ButtonSeries": "Serie",
|
||||||
"ButtonSetTimer": "Imposta Timer",
|
"ButtonSetTimer": "Imposta Timer",
|
||||||
"ButtonStream": "Flusso",
|
"ButtonStream": "Riproduci",
|
||||||
"ButtonSubmit": "Invia",
|
"ButtonSubmit": "Invia",
|
||||||
"ButtonSwitchServerUser": "Cambia Server/Utente",
|
"ButtonSwitchServerUser": "Cambia Server/Utente",
|
||||||
"ButtonUnmaskServerAddress": "Mostra l'indirizzo server",
|
"ButtonUnmaskServerAddress": "Mostra l'indirizzo server",
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
+13
-6
@@ -27,7 +27,7 @@
|
|||||||
"ButtonLatest": "Mais Recentes",
|
"ButtonLatest": "Mais Recentes",
|
||||||
"ButtonLibrary": "Biblioteca",
|
"ButtonLibrary": "Biblioteca",
|
||||||
"ButtonLocalMedia": "Mídia Local",
|
"ButtonLocalMedia": "Mídia Local",
|
||||||
"ButtonLogs": "Logs",
|
"ButtonLogs": "Registros",
|
||||||
"ButtonManageLocalFiles": "Gerenciar Arquivos Locais",
|
"ButtonManageLocalFiles": "Gerenciar Arquivos Locais",
|
||||||
"ButtonMaskServerAddress": "Mascarar endereço do servidor",
|
"ButtonMaskServerAddress": "Mascarar endereço do servidor",
|
||||||
"ButtonNewFolder": "Nova Pasta",
|
"ButtonNewFolder": "Nova Pasta",
|
||||||
@@ -50,7 +50,7 @@
|
|||||||
"ButtonSendEbookToDevice": "Enviar Ebook para Dispositivo",
|
"ButtonSendEbookToDevice": "Enviar Ebook para Dispositivo",
|
||||||
"ButtonSeries": "Série",
|
"ButtonSeries": "Série",
|
||||||
"ButtonSetTimer": "Definir Timer",
|
"ButtonSetTimer": "Definir Timer",
|
||||||
"ButtonStream": "Stream",
|
"ButtonStream": "Transmissão",
|
||||||
"ButtonSubmit": "Enviar",
|
"ButtonSubmit": "Enviar",
|
||||||
"ButtonSwitchServerUser": "Trocar Servidor/Usuário",
|
"ButtonSwitchServerUser": "Trocar Servidor/Usuário",
|
||||||
"ButtonUnmaskServerAddress": "Revelar endereço do servidor",
|
"ButtonUnmaskServerAddress": "Revelar endereço do servidor",
|
||||||
@@ -140,8 +140,8 @@
|
|||||||
"LabelDownloadUsingCellular": "Download usando o celular",
|
"LabelDownloadUsingCellular": "Download usando o celular",
|
||||||
"LabelDownloaded": "Download realizado",
|
"LabelDownloaded": "Download realizado",
|
||||||
"LabelDuration": "Duração",
|
"LabelDuration": "Duração",
|
||||||
"LabelEbook": "Ebook",
|
"LabelEbook": "E-book",
|
||||||
"LabelEbooks": "Ebooks",
|
"LabelEbooks": "E-books",
|
||||||
"LabelEnable": "Habilitar",
|
"LabelEnable": "Habilitar",
|
||||||
"LabelEnableMp3IndexSeeking": "Ativar busca por índice mp3",
|
"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.",
|
"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",
|
"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.",
|
"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",
|
"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.",
|
"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.",
|
"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.",
|
"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?",
|
"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?",
|
"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?",
|
"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?",
|
"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?",
|
"MessageConfirmStreamingUsingCellular": "Você irá fazer o streaming usando dados do celular. Isso pode incidir em cobrança por sua operadora. Deseja continuar?",
|
||||||
"MessageDiscardProgress": "Descartar Progresso",
|
"MessageDiscardProgress": "Descartar Progresso",
|
||||||
@@ -308,7 +313,7 @@
|
|||||||
"MessageDownloading": "Realizando o download...",
|
"MessageDownloading": "Realizando o download...",
|
||||||
"MessageDownloadingEpisode": "Realizando o downloading do episódio",
|
"MessageDownloadingEpisode": "Realizando o downloading do episódio",
|
||||||
"MessageEpisodesQueuedForDownload": "{0} Episódio(s) na fila de download",
|
"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}",
|
"MessageFeedURLWillBe": "URL do Feed será {0}",
|
||||||
"MessageFetching": "Buscando...",
|
"MessageFetching": "Buscando...",
|
||||||
"MessageFollowTheProjectOnGithub": "Siga este projeto no GitHub",
|
"MessageFollowTheProjectOnGithub": "Siga este projeto no GitHub",
|
||||||
@@ -365,5 +370,7 @@
|
|||||||
"ToastPodcastCreateSuccess": "Podcast criado",
|
"ToastPodcastCreateSuccess": "Podcast criado",
|
||||||
"ToastRSSFeedCloseFailed": "Falha ao fechar feed RSS",
|
"ToastRSSFeedCloseFailed": "Falha ao fechar feed RSS",
|
||||||
"ToastRSSFeedCloseSuccess": "Feed RSS fechado",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"ButtonAdd": "Добавить",
|
"ButtonAdd": "Добавить",
|
||||||
"ButtonAddNewServer": "Добавить новый сервер",
|
"ButtonAddNewServer": "Добавить новый сервер",
|
||||||
|
"ButtonAllow": "Разрешить",
|
||||||
"ButtonAuthors": "Авторы",
|
"ButtonAuthors": "Авторы",
|
||||||
"ButtonBack": "Назад",
|
"ButtonBack": "Назад",
|
||||||
"ButtonCancel": "Отмена",
|
"ButtonCancel": "Отмена",
|
||||||
@@ -312,6 +313,7 @@
|
|||||||
"MessageDownloadCompleteProcessing": "Загрузка завершена. Обработка...",
|
"MessageDownloadCompleteProcessing": "Загрузка завершена. Обработка...",
|
||||||
"MessageDownloading": "Загрузка...",
|
"MessageDownloading": "Загрузка...",
|
||||||
"MessageDownloadingEpisode": "Скачивание выпуска",
|
"MessageDownloadingEpisode": "Скачивание выпуска",
|
||||||
|
"MessageDownloadingFile": "Загрузка {0}",
|
||||||
"MessageEpisodesQueuedForDownload": "{0} выпуск(ов) запланировано для скачивания",
|
"MessageEpisodesQueuedForDownload": "{0} выпуск(ов) запланировано для скачивания",
|
||||||
"MessageFailedToRefreshToken": "Не удалось обновить токен, требуется повторный вход в систему",
|
"MessageFailedToRefreshToken": "Не удалось обновить токен, требуется повторный вход в систему",
|
||||||
"MessageFeedURLWillBe": "URL-адрес ленты будет {0}",
|
"MessageFeedURLWillBe": "URL-адрес ленты будет {0}",
|
||||||
@@ -347,6 +349,7 @@
|
|||||||
"MessageOldServerConnectionWarning": "Конфигурация подключения к серверу использует старый идентификатор пользователя. Удалите и повторно добавьте это подключение к серверу.",
|
"MessageOldServerConnectionWarning": "Конфигурация подключения к серверу использует старый идентификатор пользователя. Удалите и повторно добавьте это подключение к серверу.",
|
||||||
"MessageOldServerConnectionWarningHelp": "Первоначально вы настроили подключение к этому серверу до миграции базы данных в версии 2.3.0, выпущенной в июне 2023 года. Будущее обновление сервера уберет возможность входа с помощью этого старого подключения. Удалите существующее подключение к серверу и подключитесь снова (используя тот же адрес сервера и учетные данные). Если на этом устройстве есть загруженные медиафайлы, их необходимо будет загрузить снова для синхронизации с сервером.",
|
"MessageOldServerConnectionWarningHelp": "Первоначально вы настроили подключение к этому серверу до миграции базы данных в версии 2.3.0, выпущенной в июне 2023 года. Будущее обновление сервера уберет возможность входа с помощью этого старого подключения. Удалите существующее подключение к серверу и подключитесь снова (используя тот же адрес сервера и учетные данные). Если на этом устройстве есть загруженные медиафайлы, их необходимо будет загрузить снова для синхронизации с сервером.",
|
||||||
"MessagePodcastSearchField": "Введите поисковый запрос или URL-адрес RSS-ленты",
|
"MessagePodcastSearchField": "Введите поисковый запрос или URL-адрес RSS-ленты",
|
||||||
|
"MessagePreparingDownloads": "Подготовка загрузок",
|
||||||
"MessageProgressSyncFailed": "Последняя попытка сообщить о вашем прогрессе прослушивания на сервер не удалась. Запросы на синхронизацию прогресса будут продолжаться каждые 15 секунд в течении 1 минуты во время воспроизведения медиа.",
|
"MessageProgressSyncFailed": "Последняя попытка сообщить о вашем прогрессе прослушивания на сервер не удалась. Запросы на синхронизацию прогресса будут продолжаться каждые 15 секунд в течении 1 минуты во время воспроизведения медиа.",
|
||||||
"MessageReportBugsAndContribute": "Сообщайте об ошибках, запрашивайте функции и вносите свой вклад на",
|
"MessageReportBugsAndContribute": "Сообщайте об ошибках, запрашивайте функции и вносите свой вклад на",
|
||||||
"MessageSeriesAlreadyDownloaded": "Вы уже скачали все книги из этой серии.",
|
"MessageSeriesAlreadyDownloaded": "Вы уже скачали все книги из этой серии.",
|
||||||
@@ -357,6 +360,10 @@
|
|||||||
"MessageSocketConnectedOverUnmeteredCellular": "Сокет подключен по безлимитной сотовой связи",
|
"MessageSocketConnectedOverUnmeteredCellular": "Сокет подключен по безлимитной сотовой связи",
|
||||||
"MessageSocketConnectedOverUnmeteredWifi": "Сокет подключен через безлимитный Wi-Fi",
|
"MessageSocketConnectedOverUnmeteredWifi": "Сокет подключен через безлимитный Wi-Fi",
|
||||||
"MessageSocketNotConnected": "Сокет не подключен",
|
"MessageSocketNotConnected": "Сокет не подключен",
|
||||||
|
"MessageStorageAccessDenied": "Доступ запрещен",
|
||||||
|
"MessageStoragePermissionDenied": "Недостаточно прав",
|
||||||
|
"MessageStorageWriteAccessRequired": "У вас нет доступа на запись в эту папку. Хотите предоставить доступ?",
|
||||||
|
"MessageWaitingForAvailableStorage": "Ожидание доступного хранилища",
|
||||||
"NoteRSSFeedPodcastAppsHttps": "Предупреждение: Большинству приложений подкастов потребуется, чтобы URL-адрес RSS-ленты использовал HTTPS",
|
"NoteRSSFeedPodcastAppsHttps": "Предупреждение: Большинству приложений подкастов потребуется, чтобы URL-адрес RSS-ленты использовал HTTPS",
|
||||||
"NoteRSSFeedPodcastAppsPubDate": "Предупреждение: 1 или более выпусков не имеют даты публикации. Некоторые приложения для подкастов требуют этого.",
|
"NoteRSSFeedPodcastAppsPubDate": "Предупреждение: 1 или более выпусков не имеют даты публикации. Некоторые приложения для подкастов требуют этого.",
|
||||||
"ToastBookmarkCreateFailed": "Не удалось создать закладку",
|
"ToastBookmarkCreateFailed": "Не удалось создать закладку",
|
||||||
|
|||||||
+179
-179
@@ -4,164 +4,164 @@
|
|||||||
"ButtonAuthors": "Författare",
|
"ButtonAuthors": "Författare",
|
||||||
"ButtonBack": "Tillbaka",
|
"ButtonBack": "Tillbaka",
|
||||||
"ButtonCancel": "Avbryt",
|
"ButtonCancel": "Avbryt",
|
||||||
"ButtonCancelTimer": "Avbryt Timer",
|
"ButtonCancelTimer": "Avbryt timern",
|
||||||
"ButtonClearFilter": "Rensa filter",
|
"ButtonClearFilter": "Rensa filter",
|
||||||
"ButtonClearLogs": "Rensa Loggar",
|
"ButtonClearLogs": "Rensa loggar",
|
||||||
"ButtonCloseFeed": "Stäng flöde",
|
"ButtonCloseFeed": "Stäng flödet",
|
||||||
"ButtonCollections": "Samlingar",
|
"ButtonCollections": "Samlingar",
|
||||||
"ButtonConnect": "Anslut",
|
"ButtonConnect": "Anslut",
|
||||||
"ButtonConnectToServer": "Anslut till Server",
|
"ButtonConnectToServer": "Anslut till server",
|
||||||
"ButtonCreate": "Skapa",
|
"ButtonCreate": "Skapa",
|
||||||
"ButtonCreateBookmark": "Skapa Bokmärke",
|
"ButtonCreateBookmark": "Skapa bokmärke",
|
||||||
"ButtonCreateNewPlaylist": "Skapa Ny Spellista",
|
"ButtonCreateNewPlaylist": "Skapa ny spellista",
|
||||||
"ButtonDelete": "Radera",
|
"ButtonDelete": "Ta bort",
|
||||||
"ButtonDeleteLocalEpisode": "Radera lokal episod",
|
"ButtonDeleteLocalEpisode": "Ta bort lokalt avsnitt",
|
||||||
"ButtonDeleteLocalFile": "Radera lokal fil",
|
"ButtonDeleteLocalFile": "Ta bort lokal fil",
|
||||||
"ButtonDeleteLocalItem": "Radera lokalt objekt",
|
"ButtonDeleteLocalItem": "Ta bort lokalt objekt",
|
||||||
"ButtonDisableAutoTimer": "Inaktivera Automatisk Timer",
|
"ButtonDisableAutoTimer": "Inaktivera automatisk insomningstimer",
|
||||||
"ButtonDisconnect": "Koppla ur",
|
"ButtonDisconnect": "Koppla från",
|
||||||
"ButtonGoToWebClient": "Öppna i webbläsare",
|
"ButtonGoToWebClient": "Öppna i webbläsare",
|
||||||
"ButtonHistory": "Historik",
|
"ButtonHistory": "Historik",
|
||||||
"ButtonHome": "Hem",
|
"ButtonHome": "Startsida",
|
||||||
"ButtonIssues": "Objekt med problem",
|
"ButtonIssues": "Objekt med problem",
|
||||||
"ButtonLatest": "Senaste",
|
"ButtonLatest": "Senaste",
|
||||||
"ButtonLibrary": "Bibliotek",
|
"ButtonLibrary": "Bibliotek",
|
||||||
"ButtonLocalMedia": "Lokal media",
|
"ButtonLocalMedia": "Lokala medier",
|
||||||
"ButtonLogs": "Loggar",
|
"ButtonLogs": "Loggar",
|
||||||
"ButtonManageLocalFiles": "Hantera Lokal Filer",
|
"ButtonManageLocalFiles": "Hantera lokala filer",
|
||||||
"ButtonMaskServerAddress": "Dölj serveradress",
|
"ButtonMaskServerAddress": "Dölj serveradress",
|
||||||
"ButtonNewFolder": "Ny Katalog",
|
"ButtonNewFolder": "Ny mapp",
|
||||||
"ButtonNextEpisode": "Nästa avsnitt",
|
"ButtonNextEpisode": "Nästa avsnitt",
|
||||||
"ButtonOk": "OK",
|
"ButtonOk": "OK",
|
||||||
"ButtonOpenFeed": "Öppna flöde",
|
"ButtonOpenFeed": "Öppna flödet",
|
||||||
"ButtonOverride": "Åsidosätt",
|
"ButtonOverride": "Åsidosätt",
|
||||||
"ButtonPause": "Pausa",
|
"ButtonPause": "Pausa",
|
||||||
"ButtonPlay": "Spela",
|
"ButtonPlay": "Spela upp",
|
||||||
"ButtonPlayEpisode": "Spela Avsnitt",
|
"ButtonPlayEpisode": "Spela upp avsnitt",
|
||||||
"ButtonPlaylists": "Spellistor",
|
"ButtonPlaylists": "Spellistor",
|
||||||
"ButtonRead": "Läs",
|
"ButtonRead": "Läs",
|
||||||
"ButtonReadLess": "Visa mindre",
|
"ButtonReadLess": "Visa mindre",
|
||||||
"ButtonReadMore": "Visa mer",
|
"ButtonReadMore": "Visa mer",
|
||||||
"ButtonRemove": "Ta bort",
|
"ButtonRemove": "Ta bort",
|
||||||
"ButtonRemoveFromServer": "Ta bort från Server",
|
"ButtonRemoveFromServer": "Ta bort från servern",
|
||||||
"ButtonSave": "Spara",
|
"ButtonSave": "Spara",
|
||||||
"ButtonSaveOrder": "Spara Ordning",
|
"ButtonSaveOrder": "Spara ordning",
|
||||||
"ButtonSearch": "Sök",
|
"ButtonSearch": "Sök",
|
||||||
"ButtonSendEbookToDevice": "Skicka E-bok till Enhet",
|
"ButtonSendEbookToDevice": "Skicka e-bok till enhet",
|
||||||
"ButtonSeries": "Serie",
|
"ButtonSeries": "Serier",
|
||||||
"ButtonSetTimer": "Ställ in timer",
|
"ButtonSetTimer": "Ställ in insomningstimer",
|
||||||
"ButtonStream": "Ström",
|
"ButtonStream": "Strömma",
|
||||||
"ButtonSubmit": "Skicka",
|
"ButtonSubmit": "Skicka",
|
||||||
"ButtonSwitchServerUser": "Byt Server/Användare",
|
"ButtonSwitchServerUser": "Byt server/användare",
|
||||||
"ButtonUnmaskServerAddress": "Visa serveradress",
|
"ButtonUnmaskServerAddress": "Visa serveradress",
|
||||||
"ButtonUserStats": "Användarstatistik",
|
"ButtonUserStats": "Användarstatistik",
|
||||||
"ButtonYes": "Ja",
|
"ButtonYes": "Ja",
|
||||||
"HeaderAccount": "Konto",
|
"HeaderAccount": "Konto",
|
||||||
"HeaderAdvanced": "Avancerad",
|
"HeaderAdvanced": "Avancerat",
|
||||||
"HeaderAndroidAutoSettings": "Android Auto Inställningar",
|
"HeaderAndroidAutoSettings": "Inställningar för Android Auto",
|
||||||
"HeaderAudioTracks": "Ljudspår",
|
"HeaderAudioTracks": "Ljudspår",
|
||||||
"HeaderChapters": "Kapitel",
|
"HeaderChapters": "Kapitel",
|
||||||
"HeaderCollection": "Samling",
|
"HeaderCollection": "Samling",
|
||||||
"HeaderCollectionItems": "Böcker i samlingen",
|
"HeaderCollectionItems": "Objekt i samlingen",
|
||||||
"HeaderConfirm": "Bekräfta",
|
"HeaderConfirm": "Bekräfta",
|
||||||
"HeaderConnectionStatus": "Anslutningsstatus",
|
"HeaderConnectionStatus": "Anslutningsstatus",
|
||||||
"HeaderDataSettings": "Datainställningar",
|
"HeaderDataSettings": "Datainställningar",
|
||||||
"HeaderDetails": "Detaljer",
|
"HeaderDetails": "Detaljer",
|
||||||
"HeaderDownloads": "Nedladdningar",
|
"HeaderDownloads": "Hämtningar",
|
||||||
"HeaderEbookFiles": "E-boksfiler",
|
"HeaderEbookFiles": "E-boksfiler",
|
||||||
"HeaderEpisodes": "Avsnitt",
|
"HeaderEpisodes": "Avsnitt",
|
||||||
"HeaderEreaderSettings": "E-boksinställningar",
|
"HeaderEreaderSettings": "Inställningar för e-boksläsare",
|
||||||
"HeaderLatestEpisodes": "Senaste avsnitten",
|
"HeaderLatestEpisodes": "Senaste avsnitten",
|
||||||
"HeaderLibraries": "Bibliotek",
|
"HeaderLibraries": "Bibliotek",
|
||||||
"HeaderLocalFolders": "Lokala Kataloger",
|
"HeaderLocalFolders": "Lokala mappar",
|
||||||
"HeaderLocalLibraryItems": "Lokala Biblioteksfiler",
|
"HeaderLocalLibraryItems": "Lokala biblioteksobjekt",
|
||||||
"HeaderNewPlaylist": "Ny Spellista",
|
"HeaderNewPlaylist": "Ny spellista",
|
||||||
"HeaderOpenRSSFeed": "Öppna RSS-flöde",
|
"HeaderOpenRSSFeed": "Öppna RSS-flödet",
|
||||||
"HeaderPlaybackSettings": "Uppspelningsinställningar",
|
"HeaderPlaybackSettings": "Uppspelningsinställningar",
|
||||||
"HeaderPlaylist": "Spellista",
|
"HeaderPlaylist": "Spellista",
|
||||||
"HeaderPlaylistItems": "Böcker i spellistan",
|
"HeaderPlaylistItems": "Objekt i spellistan",
|
||||||
"HeaderProgressSyncFailed": "Positionssynk misslyckades",
|
"HeaderProgressSyncFailed": "Synkroniseringen av uppspelningspositionen misslyckades",
|
||||||
"HeaderRSSFeed": "RSS-flöde",
|
"HeaderRSSFeed": "RSS-flöde",
|
||||||
"HeaderRSSFeedGeneral": "RSS-information",
|
"HeaderRSSFeedGeneral": "RSS-information",
|
||||||
"HeaderRSSFeedIsOpen": "RSS-flödet är öppet",
|
"HeaderRSSFeedIsOpen": "RSS-flödet är öppet",
|
||||||
"HeaderSelectDownloadLocation": "Välj Nedladdningsplats",
|
"HeaderSelectDownloadLocation": "Välj hämtningsplats",
|
||||||
"HeaderSettings": "Inställningar",
|
"HeaderSettings": "Inställningar",
|
||||||
"HeaderSleepTimer": "Insomningstimer",
|
"HeaderSleepTimer": "Insomningstimer",
|
||||||
"HeaderSleepTimerSettings": "Insomningstimer-inställningar",
|
"HeaderSleepTimerSettings": "Inställningar för insomningstimer",
|
||||||
"HeaderStatsMinutesListeningChart": "Minuters lyssning (senaste 7 dagarna)",
|
"HeaderStatsMinutesListeningChart": "Lyssnade minuter (senaste 7 dagarna)",
|
||||||
"HeaderStatsRecentSessions": "Senaste tillfällena",
|
"HeaderStatsRecentSessions": "Senaste lyssningstillfällena",
|
||||||
"HeaderTableOfContents": "Innehållsförteckning",
|
"HeaderTableOfContents": "Innehållsförteckning",
|
||||||
"HeaderUserInterfaceSettings": "Användargränssnittsinställningar",
|
"HeaderUserInterfaceSettings": "Användargränssnittsinställningar",
|
||||||
"HeaderWelcome": "Välkommen, <strong>{0}</strong>",
|
"HeaderWelcome": "Välkommen, <strong>{0}</strong>",
|
||||||
"HeaderYourStats": "Din statistik",
|
"HeaderYourStats": "Din statistik",
|
||||||
"LabelAddToPlaylist": "Lägg till i spellista",
|
"LabelAddToPlaylist": "Lägg till i spellista",
|
||||||
"LabelAddedAt": "Tillagd vid",
|
"LabelAddedAt": "Tillagd",
|
||||||
"LabelAddedDate": "Tillagd {0}",
|
"LabelAddedDate": "Tillagd {0}",
|
||||||
"LabelAll": "Alla",
|
"LabelAll": "Alla",
|
||||||
"LabelAllowSeekingOnMediaControls": "Tillåt positionssökning på medieaviseringskontroller",
|
"LabelAllowSeekingOnMediaControls": "Tillåt spolning med mediekontrollerna i aviseringen",
|
||||||
"LabelAlways": "Alltid",
|
"LabelAlways": "Alltid",
|
||||||
"LabelAndroidAutoBrowseLimitForGrouping": "Alfabetisk neddragningsgräns",
|
"LabelAndroidAutoBrowseLimitForGrouping": "Gräns för alfabetisk gruppering",
|
||||||
"LabelAndroidAutoBrowseLimitForGroupingHelp": "Använd inte alfabetisk neddragning när det finns mindre än denna mängd objekt att visa",
|
"LabelAndroidAutoBrowseLimitForGroupingHelp": "Gruppera inte alfabetiskt när färre än detta antal objekt visas",
|
||||||
"LabelAndroidAutoBrowseSeriesSequenceOrder": "Seriebokföljd",
|
"LabelAndroidAutoBrowseSeriesSequenceOrder": "Seriernas ordningsföljd",
|
||||||
"LabelAskConfirmation": "Fråga efter bekräftelse",
|
"LabelAskConfirmation": "Begär bekräftelse",
|
||||||
"LabelAuthor": "Författare",
|
"LabelAuthor": "Författare",
|
||||||
"LabelAuthorFirstLast": "Författare (Förnamn Efternamn)",
|
"LabelAuthorFirstLast": "Författare (förnamn efternamn)",
|
||||||
"LabelAuthorLastFirst": "Författare (Efternamn, Förnamn)",
|
"LabelAuthorLastFirst": "Författare (efternamn, förnamn)",
|
||||||
"LabelAuthors": "Författare",
|
"LabelAuthors": "Författare",
|
||||||
"LabelAutoDownloadEpisodes": "Automatisk nedladdning av avsnitt",
|
"LabelAutoDownloadEpisodes": "Automatisk hämtning av avsnitt",
|
||||||
"LabelAutoRewindTime": "Automatisk tillbakaspolningstid",
|
"LabelAutoRewindTime": "Automatisk återspolningstid",
|
||||||
"LabelAutoSleepTimer": "Automatisk insomningstimer",
|
"LabelAutoSleepTimer": "Automatisk insomningstimer",
|
||||||
"LabelAutoSleepTimerAutoRewind": "Automatisk sovtimer automatisk bakåtspolning",
|
"LabelAutoSleepTimerAutoRewind": "Automatisk återspolning efter insomningstimern",
|
||||||
"LabelAutoSleepTimerAutoRewindHelp": "När den automatiska insomningstimern är klar spolas din position automatiskt tillbaka.",
|
"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.",
|
"LabelAutoSleepTimerHelp": "Vid uppspelning av media mellan de angivna start- och sluttiderna startar en insomningstimer automatiskt.",
|
||||||
"LabelBooks": "Böcker",
|
"LabelBooks": "Böcker",
|
||||||
"LabelByAuthor": "av {0}",
|
"LabelByAuthor": "av {0}",
|
||||||
"LabelChapterTrack": "Kapitel Spår",
|
"LabelChapterTrack": "Kapitelspår",
|
||||||
"LabelChapters": "Kapitel",
|
"LabelChapters": "Kapitel",
|
||||||
"LabelClosePlayer": "Stäng spelaren",
|
"LabelClosePlayer": "Stäng spelaren",
|
||||||
"LabelCollapseSeries": "Komprimera serier",
|
"LabelCollapseSeries": "Fäll ihop serier",
|
||||||
"LabelComplete": "Komplett",
|
"LabelComplete": "Slutförd",
|
||||||
"LabelContinueBooks": "Fortsätt Böcker",
|
"LabelContinueBooks": "Fortsätt med böcker",
|
||||||
"LabelContinueEpisodes": "Fortsätt Episoder",
|
"LabelContinueEpisodes": "Fortsätt med avsnitt",
|
||||||
"LabelContinueListening": "Fortsätt att lyssna",
|
"LabelContinueListening": "Fortsätt att lyssna",
|
||||||
"LabelContinueReading": "Fortsätt att läsa",
|
"LabelContinueReading": "Fortsätt att läsa",
|
||||||
"LabelContinueSeries": "Forsätt Serie",
|
"LabelContinueSeries": "Fortsätt med serien",
|
||||||
"LabelCustomTime": "Anpassad tid",
|
"LabelCustomTime": "Anpassad tid",
|
||||||
"LabelDescription": "Beskrivning",
|
"LabelDescription": "Beskrivning",
|
||||||
"LabelDisableAudioFadeOut": "Inaktivera att ljudet tonas ut",
|
"LabelDisableAudioFadeOut": "Inaktivera uttoning av ljudet",
|
||||||
"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.",
|
"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 tillbakaspolning",
|
"LabelDisableAutoRewind": "Inaktivera automatisk återspolning",
|
||||||
"LabelDisableShakeToReset": "Inaktivera skakning för att återställa",
|
"LabelDisableShakeToReset": "Inaktivera återställning genom skakning",
|
||||||
"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.",
|
"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",
|
"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.",
|
"LabelDisableVibrateOnResetHelp": "Enheten vibrerar när insomningstimern återställs. Aktivera alternativet för att inaktivera vibrationen.",
|
||||||
"LabelDiscover": "Några förslag",
|
"LabelDiscover": "Upptäck",
|
||||||
"LabelDownload": "Ladda ner",
|
"LabelDownload": "Hämta",
|
||||||
"LabelDownloadUsingCellular": "Ladda ner med mobildata",
|
"LabelDownloadUsingCellular": "Hämta med mobildata",
|
||||||
"LabelDownloaded": "Nedladdat",
|
"LabelDownloaded": "Hämtad",
|
||||||
"LabelDuration": "Längd",
|
"LabelDuration": "Längd",
|
||||||
"LabelEbook": "E-bok",
|
"LabelEbook": "E-bok",
|
||||||
"LabelEbooks": "E-böcker",
|
"LabelEbooks": "E-böcker",
|
||||||
"LabelEnable": "Aktivera",
|
"LabelEnable": "Aktivera",
|
||||||
"LabelEnableMp3IndexSeeking": "Aktivera mp3 index sökning",
|
"LabelEnableMp3IndexSeeking": "Aktivera indexbaserad spolning i MP3-filer",
|
||||||
"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.",
|
"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",
|
"LabelEnd": "Slut",
|
||||||
"LabelEndOfChapter": "Slut av kapitel",
|
"LabelEndOfChapter": "Kapitlets slut",
|
||||||
"LabelEndTime": "Sluttid",
|
"LabelEndTime": "Sluttid",
|
||||||
"LabelEpisode": "Avsnitt",
|
"LabelEpisode": "Avsnitt",
|
||||||
"LabelExplicit": "Vuxeninnehåll",
|
"LabelExplicit": "Vuxeninnehåll",
|
||||||
"LabelFeedURL": "URL-adress för flödet",
|
"LabelFeedURL": "Flödets URL",
|
||||||
"LabelFile": "Fil",
|
"LabelFile": "Fil",
|
||||||
"LabelFileBirthtime": "Tidpunkt, tillagd",
|
"LabelFileBirthtime": "Skapad",
|
||||||
"LabelFileModified": "Tidpunkt, ändrad",
|
"LabelFileModified": "Ändrad",
|
||||||
"LabelFilename": "Filnamn",
|
"LabelFilename": "Filnamn",
|
||||||
"LabelFinished": "Avslutad",
|
"LabelFinished": "Avslutad",
|
||||||
"LabelFolder": "Mapp",
|
"LabelFolder": "Mapp",
|
||||||
"LabelFontBoldness": "Fetstil",
|
"LabelFontBoldness": "Fetstil",
|
||||||
"LabelFontFamily": "Typsnittsfamilj",
|
"LabelFontFamily": "Teckensnittsfamilj",
|
||||||
"LabelFontFamilySans": "Utan klackar",
|
"LabelFontFamilySans": "Sans serif",
|
||||||
"LabelFontFamilySerif": "Med klackar",
|
"LabelFontFamilySerif": "Serif",
|
||||||
"LabelFontScale": "Storlek på typsnitt",
|
"LabelFontScale": "Teckenstorlek",
|
||||||
"LabelGenre": "Genre",
|
"LabelGenre": "Genre",
|
||||||
"LabelGenres": "Genrer",
|
"LabelGenres": "Genrer",
|
||||||
"LabelHapticFeedback": "Haptisk återkoppling",
|
"LabelHapticFeedback": "Haptisk återkoppling",
|
||||||
@@ -172,89 +172,89 @@
|
|||||||
"LabelHost": "Värd",
|
"LabelHost": "Värd",
|
||||||
"LabelInProgress": "Pågående",
|
"LabelInProgress": "Pågående",
|
||||||
"LabelIncomplete": "Ofullständigt",
|
"LabelIncomplete": "Ofullständigt",
|
||||||
"LabelInternalAppStorage": "Intern App Lagring",
|
"LabelInternalAppStorage": "Appens interna lagring",
|
||||||
"LabelJumpBackwardsTime": "hoppa-bakåt-tid",
|
"LabelJumpBackwardsTime": "Hoppintervall bakåt",
|
||||||
"LabelJumpForwardsTime": "hoppa-framåt-tid",
|
"LabelJumpForwardsTime": "Hoppintervall framåt",
|
||||||
"LabelKeepScreenAwake": "Håll skärmen vaken",
|
"LabelKeepScreenAwake": "Håll skärmen vaken",
|
||||||
"LabelLanguage": "Språk",
|
"LabelLanguage": "Språk",
|
||||||
"LabelLayout": "Layout",
|
"LabelLayout": "Layout",
|
||||||
"LabelLayoutAuto": "Automatisk",
|
"LabelLayoutAuto": "Automatisk",
|
||||||
"LabelLayoutSinglePage": "En sida",
|
"LabelLayoutSinglePage": "En sida",
|
||||||
"LabelLibrarySortByProgress": "Status: Senast uppdaterad",
|
"LabelLibrarySortByProgress": "Förlopp: senast uppdaterat",
|
||||||
"LabelLibrarySortByProgressFinished": "Status: Avslutad",
|
"LabelLibrarySortByProgressFinished": "Förlopp: avslutat",
|
||||||
"LabelLibrarySortByProgressStarted": "Status: Startad",
|
"LabelLibrarySortByProgressStarted": "Förlopp: påbörjat",
|
||||||
"LabelLight": "Lätt",
|
"LabelLight": "Lätt",
|
||||||
"LabelLineSpacing": "Radavstånd",
|
"LabelLineSpacing": "Radavstånd",
|
||||||
"LabelListenAgain": "Lyssna igen",
|
"LabelListenAgain": "Lyssna igen",
|
||||||
"LabelLocalBooks": "Lokala böcker",
|
"LabelLocalBooks": "Lokala böcker",
|
||||||
"LabelLocalPodcasts": "Lokala podcasts",
|
"LabelLocalPodcasts": "Lokala poddar",
|
||||||
"LabelLockOrientation": "Lås skärmorientering",
|
"LabelLockOrientation": "Lås skärmorientering",
|
||||||
"LabelLockPlayer": "Lås spelare",
|
"LabelLockPlayer": "Lås spelaren",
|
||||||
"LabelLow": "Låg",
|
"LabelLow": "Låg",
|
||||||
"LabelMediaType": "Mediatyp",
|
"LabelMediaType": "Mediatyp",
|
||||||
"LabelMedium": "Medel",
|
"LabelMedium": "Medel",
|
||||||
"LabelMissing": "Saknar",
|
"LabelMissing": "Saknas",
|
||||||
"LabelMore": "Mer",
|
"LabelMore": "Mer",
|
||||||
"LabelMoreInfo": "Mer information",
|
"LabelMoreInfo": "Mer information",
|
||||||
"LabelName": "Namn",
|
"LabelName": "Namn",
|
||||||
"LabelNarrator": "Berättare",
|
"LabelNarrator": "Uppläsare",
|
||||||
"LabelNarrators": "Berättare",
|
"LabelNarrators": "Uppläsare",
|
||||||
"LabelNavigateWithVolume": "Navigera med volymknapparna",
|
"LabelNavigateWithVolume": "Navigera med volymknapparna",
|
||||||
"LabelNavigateWithVolumeMirrored": "Speglad",
|
"LabelNavigateWithVolumeMirrored": "Speglad",
|
||||||
"LabelNavigateWithVolumeWhilePlaying": "Tillåt volymknappar att navigera medan du spelar",
|
"LabelNavigateWithVolumeWhilePlaying": "Använd volymknapparna för navigering under uppspelning",
|
||||||
"LabelNavigateWithVolumeWhilePlayingDisabled": "Av",
|
"LabelNavigateWithVolumeWhilePlayingDisabled": "Av",
|
||||||
"LabelNavigateWithVolumeWhilePlayingEnabled": "På",
|
"LabelNavigateWithVolumeWhilePlayingEnabled": "På",
|
||||||
"LabelNever": "Aldrig",
|
"LabelNever": "Aldrig",
|
||||||
"LabelNewestAuthors": "Senaste författarna",
|
"LabelNewestAuthors": "Senast tillagda författare",
|
||||||
"LabelNewestEpisodes": "Senaste avsnitten",
|
"LabelNewestEpisodes": "Senast tillagda avsnitt",
|
||||||
"LabelNo": "Nej",
|
"LabelNo": "Nej",
|
||||||
"LabelNotFinished": "Ej avslutad",
|
"LabelNotFinished": "Inte avslutad",
|
||||||
"LabelNotStarted": "Ej påbörjad",
|
"LabelNotStarted": "Inte påbörjad",
|
||||||
"LabelNumEpisodes": "{0} avsnitt",
|
"LabelNumEpisodes": "{0} avsnitt",
|
||||||
"LabelNumEpisodesIncomplete": "{0} avsnitt, {1} ofärdiga",
|
"LabelNumEpisodesIncomplete": "{0} avsnitt, {1} inte slutförda",
|
||||||
"LabelNumberOfEpisodes": "# av Avsnitt",
|
"LabelNumberOfEpisodes": "Antal avsnitt",
|
||||||
"LabelOff": "Av",
|
"LabelOff": "Av",
|
||||||
"LabelOn": "På",
|
"LabelOn": "På",
|
||||||
"LabelPassword": "Lösenord",
|
"LabelPassword": "Lösenord",
|
||||||
"LabelPath": "Sökväg",
|
"LabelPath": "Sökväg",
|
||||||
"LabelPlaybackDirect": "Direct",
|
"LabelPlaybackDirect": "Direkt",
|
||||||
"LabelPlaybackLocal": "Lokal",
|
"LabelPlaybackLocal": "Lokal",
|
||||||
"LabelPlaybackSpeed": "Uppspelningshastighet",
|
"LabelPlaybackSpeed": "Uppspelningshastighet",
|
||||||
"LabelPlaybackTranscode": "Transkodning",
|
"LabelPlaybackTranscode": "Omkodning",
|
||||||
"LabelPodcast": "Podcast",
|
"LabelPodcast": "Podd",
|
||||||
"LabelPodcasts": "Podcasts",
|
"LabelPodcasts": "Poddar",
|
||||||
"LabelPreventIndexing": "Förhindra att ditt flöde indexeras av sökmotorer från iTunes och Google",
|
"LabelPreventIndexing": "Förhindra att flödet indexeras av poddkatalogerna iTunes och Google",
|
||||||
"LabelProgress": "Framsteg",
|
"LabelProgress": "Förlopp",
|
||||||
"LabelPubDate": "Publiceringsdatum",
|
"LabelPubDate": "Publiceringsdatum",
|
||||||
"LabelPublishYear": "Utgivningsår",
|
"LabelPublishYear": "Utgivningsår",
|
||||||
"LabelPublishedDate": "Publicerad {0}",
|
"LabelPublishedDate": "Publicerad {0}",
|
||||||
"LabelRSSFeedCustomOwnerEmail": "Anpassad ägarens e-post",
|
"LabelRSSFeedCustomOwnerEmail": "Anpassad e-postadress för ägaren",
|
||||||
"LabelRSSFeedCustomOwnerName": "Anpassat ägarnamn",
|
"LabelRSSFeedCustomOwnerName": "Anpassat ägarnamn",
|
||||||
"LabelRSSFeedOpen": "RSS-flöde öppet",
|
"LabelRSSFeedOpen": "RSS-flödet är öppet",
|
||||||
"LabelRSSFeedPreventIndexing": "Förhindra indexering",
|
"LabelRSSFeedPreventIndexing": "Förhindra indexering",
|
||||||
"LabelRSSFeedSlug": "RSS-flödesslag",
|
"LabelRSSFeedSlug": "Slug för RSS-flödet",
|
||||||
"LabelRandomly": "Slumpartat",
|
"LabelRandomly": "Slumpmässigt",
|
||||||
"LabelRead": "Läs",
|
"LabelRead": "Läs",
|
||||||
"LabelReadAgain": "Läs igen",
|
"LabelReadAgain": "Läs igen",
|
||||||
"LabelRecentSeries": "Senaste serierna",
|
"LabelRecentSeries": "Senaste serierna",
|
||||||
"LabelRecentlyAdded": "Nyligen adderade",
|
"LabelRecentlyAdded": "Nyligen tillagda",
|
||||||
"LabelRemoveFromPlaylist": "Ta bort från spellista",
|
"LabelRemoveFromPlaylist": "Ta bort från spellista",
|
||||||
"LabelScaleElapsedTimeBySpeed": "Skaländra uppspelningsposition efter uppspelningshastighet.",
|
"LabelScaleElapsedTimeBySpeed": "Justera den visade uppspelningstiden efter uppspelningshastigheten.",
|
||||||
"LabelSeason": "Säsong",
|
"LabelSeason": "Säsong",
|
||||||
"LabelSelectADevice": "Välj en enhet",
|
"LabelSelectADevice": "Välj en enhet",
|
||||||
"LabelSelectMediaType": "Ange mediatyp",
|
"LabelSelectMediaType": "Välj mediatyp",
|
||||||
"LabelSequenceAscending": "Stigande",
|
"LabelSequenceAscending": "Stigande",
|
||||||
"LabelSequenceDescending": "Fallande",
|
"LabelSequenceDescending": "Fallande",
|
||||||
"LabelSeries": "Serie",
|
"LabelSeries": "Serie",
|
||||||
"LabelServerAddress": "Server-adress",
|
"LabelServerAddress": "Serveradress",
|
||||||
"LabelSetEbookAsPrimary": "Ange som primär",
|
"LabelSetEbookAsPrimary": "Ange som primär",
|
||||||
"LabelSetEbookAsSupplementary": "Ange som kompletterande",
|
"LabelSetEbookAsSupplementary": "Ange som kompletterande",
|
||||||
"LabelShakeSensitivity": "Skakkänslighet",
|
"LabelShakeSensitivity": "Skakkänslighet",
|
||||||
"LabelShowAll": "Visa alla",
|
"LabelShowAll": "Visa alla",
|
||||||
"LabelSize": "Storlek",
|
"LabelSize": "Storlek",
|
||||||
"LabelSleepTimer": "Insomningstimer",
|
"LabelSleepTimer": "Insomningstimer",
|
||||||
"LabelSleepTimerAlmostDoneChime": "Spela ett ljud när det är nästan klart",
|
"LabelSleepTimerAlmostDoneChime": "Spela upp ett ljud när timern nästan har löpt ut",
|
||||||
"LabelSleepTimerAlmostDoneChimeHelp": "Spela ett ljud när insomningstimern har 30 sekunder kvar",
|
"LabelSleepTimerAlmostDoneChimeHelp": "Spela upp ett ljud när 30 sekunder återstår av insomningstimern",
|
||||||
"LabelStart": "Start",
|
"LabelStart": "Start",
|
||||||
"LabelStartTime": "Starttid",
|
"LabelStartTime": "Starttid",
|
||||||
"LabelStatsBestDay": "Bästa dag",
|
"LabelStatsBestDay": "Bästa dag",
|
||||||
@@ -275,8 +275,8 @@
|
|||||||
"LabelThemeLight": "Ljust",
|
"LabelThemeLight": "Ljust",
|
||||||
"LabelTimeRemaining": "{0} kvar",
|
"LabelTimeRemaining": "{0} kvar",
|
||||||
"LabelTitle": "Titel",
|
"LabelTitle": "Titel",
|
||||||
"LabelTotalSize": "Total Storlek",
|
"LabelTotalSize": "Total storlek",
|
||||||
"LabelTotalTrack": "Total Spår",
|
"LabelTotalTrack": "Totalt antal spår",
|
||||||
"LabelTracks": "Ljudspår",
|
"LabelTracks": "Ljudspår",
|
||||||
"LabelType": "Typ",
|
"LabelType": "Typ",
|
||||||
"LabelUnknown": "Okänd",
|
"LabelUnknown": "Okänd",
|
||||||
@@ -284,93 +284,93 @@
|
|||||||
"LabelUseBookshelfView": "Använd bokhyllevyn",
|
"LabelUseBookshelfView": "Använd bokhyllevyn",
|
||||||
"LabelUser": "Användare",
|
"LabelUser": "Användare",
|
||||||
"LabelUsername": "Användarnamn",
|
"LabelUsername": "Användarnamn",
|
||||||
"LabelVeryHigh": "Väldigt hög",
|
"LabelVeryHigh": "Mycket hög",
|
||||||
"LabelVeryLow": "Väldigt låg",
|
"LabelVeryLow": "Mycket låg",
|
||||||
"LabelYearReviewHide": "Dölj årets sammanställning",
|
"LabelYearReviewHide": "Dölj årets sammanställning",
|
||||||
"LabelYearReviewShow": "Visa årets sammanställning",
|
"LabelYearReviewShow": "Visa årets sammanställning",
|
||||||
"LabelYourBookmarks": "Dina bokmärken",
|
"LabelYourBookmarks": "Dina bokmärken",
|
||||||
"LabelYourProgress": "Framsteg",
|
"LabelYourProgress": "Ditt förlopp",
|
||||||
"MessageAndroid10Downloads": "Android 10 och lägre använder intern applagring för nedladdningar.",
|
"MessageAndroid10Downloads": "Android 10 och lägre använder intern applagring för hämtningar.",
|
||||||
"MessageAttemptingServerConnection": "Försöker ansluta till server...",
|
"MessageAttemptingServerConnection": "Försöker ansluta till servern…",
|
||||||
"MessageAudiobookshelfServerNotConnected": "Audiobookshelf-server är ej ansluten",
|
"MessageAudiobookshelfServerNotConnected": "Audiobookshelf-servern är inte 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.",
|
"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",
|
"MessageBookshelfEmpty": "Bokhyllan är tom",
|
||||||
"MessageConfirmAppExit": "Vill du avsluta appen?",
|
"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.",
|
"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.",
|
"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 radera serverkonfigurationen?",
|
"MessageConfirmDeleteServerConfig": "Vill du ta bort serverkonfigurationen?",
|
||||||
"MessageConfirmDeleteServerEpisode": "Är du säkert på att du vill radera avsnitt \"{0}\" från servern?\nOBSERVERA: Ljudfilen kommer då att raderas.",
|
"MessageConfirmDeleteServerEpisode": "Vill du verkligen ta bort avsnittet ”{0}” från servern?\nOBSERVERA: Ljudfilen tas då bort.",
|
||||||
"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.",
|
"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?",
|
"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?",
|
"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": "Är du säker på att du vill färdigmarkera det här objektet.",
|
"MessageConfirmMarkAsFinished": "Vill du markera objektet som avslutat?",
|
||||||
"MessageConfirmPlaybackTime": "Vill du starta uppspelning av \"{0}\" på {1}?",
|
"MessageConfirmPlaybackTime": "Vill du starta uppspelning av \"{0}\" på {1}?",
|
||||||
"MessageConfirmRemoveBookmark": "Är du säker på att du vill ta bort bokmärket?",
|
"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?",
|
"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": "Avfärda uppspelningsposition",
|
"MessageDiscardProgress": "Återställ uppspelningspositionen",
|
||||||
"MessageDownloadCompleteProcessing": "Nedladdning klar. Behandlar...",
|
"MessageDownloadCompleteProcessing": "Hämtningen är klar. Bearbetar…",
|
||||||
"MessageDownloading": "Laddar ned...",
|
"MessageDownloading": "Hämtar…",
|
||||||
"MessageDownloadingEpisode": "Laddar ner avsnitt",
|
"MessageDownloadingEpisode": "Hämtar avsnitt",
|
||||||
"MessageEpisodesQueuedForDownload": "{0} avsnitt i kö för nedladdning",
|
"MessageEpisodesQueuedForDownload": "{0} avsnitt har lagts i hämtningskön",
|
||||||
"MessageFailedToRefreshToken": "Misslyckades med att uppdatera token, nytt inloggningsförsök krävs",
|
"MessageFailedToRefreshToken": "Det gick inte att förnya token. Du måste logga in igen.",
|
||||||
"MessageFeedURLWillBe": "Flödes-URL kommer att vara {0}",
|
"MessageFeedURLWillBe": "Flödets URL blir {0}",
|
||||||
"MessageFetching": "Hämtar...",
|
"MessageFetching": "Hämtar…",
|
||||||
"MessageFollowTheProjectOnGithub": "Följ projektet på Github",
|
"MessageFollowTheProjectOnGithub": "Följ projektet på GitHub",
|
||||||
"MessageItemDownloadCompleteFailedToCreate": "Objektet laddades ned men det gick inte att skapa ett biblioteksobjekt",
|
"MessageItemDownloadCompleteFailedToCreate": "Objektet hämtades 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.",
|
"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": "Laddar...",
|
"MessageLoading": "Läser in…",
|
||||||
"MessageLoadingServerData": "Läser in serverdata...",
|
"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.",
|
"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",
|
"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.",
|
"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": "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.",
|
"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 server {0}",
|
"MessageMediaLinkedToServer": "Länkad till servern {0}",
|
||||||
"MessageMediaLinkedToThisServer": "Nedladdad media är länkad till denna server",
|
"MessageMediaLinkedToThisServer": "Det hämtade mediet är länkat till den här servern",
|
||||||
"MessageMediaNotLinkedToServer": "Detta media är inte länkat till en Audiobookshelf-server. Inga uppspelningspositioner kommer synkas.",
|
"MessageMediaNotLinkedToServer": "Det här mediet är inte länkat till en Audiobookshelf-server. Uppspelningspositionen synkroniseras inte.",
|
||||||
"MessageNoBookmarks": "Inga bokmärken",
|
"MessageNoBookmarks": "Inga bokmärken",
|
||||||
"MessageNoChapters": "Inga kapitel",
|
"MessageNoChapters": "Inga kapitel",
|
||||||
"MessageNoCollections": "Inga Samlingar",
|
"MessageNoCollections": "Inga samlingar",
|
||||||
"MessageNoItems": "Inga objekt",
|
"MessageNoItems": "Inga objekt",
|
||||||
"MessageNoItemsFound": "Inga objekt hittades",
|
"MessageNoItemsFound": "Inga objekt hittades",
|
||||||
"MessageNoListeningSessions": "Inga lyssningstillfällen",
|
"MessageNoListeningSessions": "Inga lyssningstillfällen",
|
||||||
"MessageNoLogs": "Inga loggar",
|
"MessageNoLogs": "Inga loggar",
|
||||||
"MessageNoMediaFolders": "Inga Mediamappar",
|
"MessageNoMediaFolders": "Inga mediemappar",
|
||||||
"MessageNoNetworkConnection": "Ingen nätverksanslutning",
|
"MessageNoNetworkConnection": "Ingen nätverksanslutning",
|
||||||
"MessageNoPodcastsFound": "Inga podcasts hittade",
|
"MessageNoPodcastsFound": "Inga poddar hittades",
|
||||||
"MessageNoSeries": "Inga serier",
|
"MessageNoSeries": "Inga serier",
|
||||||
"MessageNoUpdatesWereNecessary": "Inga uppdateringar var nödvändiga",
|
"MessageNoUpdatesWereNecessary": "Inga uppdateringar behövdes",
|
||||||
"MessageNoUserPlaylists": "Du har inga spellistor",
|
"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",
|
"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.",
|
"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.",
|
"MessageOldServerConnectionWarning": "Serveranslutningen använder ett äldre användar-ID. Ta bort anslutningen och lägg till den 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.",
|
"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": "Skriv sökfrågan eller URL-adressen för RSS-flödet",
|
"MessagePodcastSearchField": "Ange en sökfras eller RSS-flödets URL",
|
||||||
"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.",
|
"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 buggar, begär funktioner och bidra på",
|
"MessageReportBugsAndContribute": "Rapportera fel, föreslå funktioner och bidra på",
|
||||||
"MessageSeriesAlreadyDownloaded": "Du har redan laddat ner alla böcker i den här serien.",
|
"MessageSeriesAlreadyDownloaded": "Du har redan hämtat alla böcker i serien.",
|
||||||
"MessageSeriesDownloadConfirm": "Vill du ladda ner de saknade {0} boken/böckerna med {1} fil(er), totalt {2}, till mappen {3}?",
|
"MessageSeriesDownloadConfirm": "Vill du hämta de {0} saknade böckerna med sammanlagt {1} filer ({2}) till mappen {3}?",
|
||||||
"MessageSeriesDownloadConfirmIos": "Vill du ladda ner de saknade {0} boken/böckerna med {1} fil(er), totalt {2}?",
|
"MessageSeriesDownloadConfirmIos": "Vill du hämta de {0} saknade böckerna med sammanlagt {1} filer ({2})?",
|
||||||
"MessageSocketConnectedOverMeteredCellular": "Socket ansluten via mätad mobilanslutning",
|
"MessageSocketConnectedOverMeteredCellular": "Socketanslutning via mobilnät med datagräns",
|
||||||
"MessageSocketConnectedOverMeteredWifi": "Socket connected over metered Wi-Fi",
|
"MessageSocketConnectedOverMeteredWifi": "Socketanslutning via Wi-Fi med datagräns",
|
||||||
"MessageSocketConnectedOverUnmeteredCellular": "Socket ansluten via omättad mobilanslutning",
|
"MessageSocketConnectedOverUnmeteredCellular": "Socketanslutning via mobilnät utan datagräns",
|
||||||
"MessageSocketConnectedOverUnmeteredWifi": "Socket ansluten via omättat Wi-Fi",
|
"MessageSocketConnectedOverUnmeteredWifi": "Socketanslutning via Wi-Fi utan datagräns",
|
||||||
"MessageSocketNotConnected": "Socket inte ansluten",
|
"MessageSocketNotConnected": "Socketanslutning saknas",
|
||||||
"NoteRSSFeedPodcastAppsHttps": "VARNING: De flesta applikationer för podcasts kräver att URL:en för RSS-flödet använder HTTPS",
|
"NoteRSSFeedPodcastAppsHttps": "VARNING: De flesta poddappar kräver att RSS-flödets URL använder HTTPS",
|
||||||
"NoteRSSFeedPodcastAppsPubDate": "VARNING: Ett eller flera av dina avsnitt saknar publiceringsdatum. Vissa applikationer för podcasts kräver detta.",
|
"NoteRSSFeedPodcastAppsPubDate": "VARNING: Ett eller flera avsnitt saknar publiceringsdatum. Vissa poddappar kräver ett sådant datum.",
|
||||||
"ToastBookmarkCreateFailed": "Det gick inte att skapa bokmärket",
|
"ToastBookmarkCreateFailed": "Det gick inte att skapa bokmärket",
|
||||||
"ToastBookmarkRemoveFailed": "Det gick inte att ta bort bokmärket",
|
"ToastBookmarkRemoveFailed": "Det gick inte att ta bort bokmärket",
|
||||||
"ToastBookmarkUpdateFailed": "Det gick inte att uppdatera bokmärket",
|
"ToastBookmarkUpdateFailed": "Det gick inte att uppdatera bokmärket",
|
||||||
"ToastDownloadNotAllowedOnCellular": "Nedladdning tillåts inte över mobildata",
|
"ToastDownloadNotAllowedOnCellular": "Hämtning tillåts inte över mobildata",
|
||||||
"ToastItemMarkedAsFinishedFailed": "Misslyckades med att markera den som avslutad",
|
"ToastItemMarkedAsFinishedFailed": "Det gick inte att markera objektet som avslutat",
|
||||||
"ToastItemMarkedAsNotFinishedFailed": "Misslyckades med att markera den som ej avslutad",
|
"ToastItemMarkedAsNotFinishedFailed": "Det gick inte att markera objektet som inte avslutat",
|
||||||
"ToastPlaylistCreateFailed": "Det gick inte att skapa spellistan",
|
"ToastPlaylistCreateFailed": "Det gick inte att skapa spellistan",
|
||||||
"ToastPodcastCreateFailed": "Misslyckades med att skapa podcasten",
|
"ToastPodcastCreateFailed": "Det gick inte att skapa podden",
|
||||||
"ToastPodcastCreateSuccess": "Podcasten skapades framgångsrikt",
|
"ToastPodcastCreateSuccess": "Podden skapades",
|
||||||
"ToastRSSFeedCloseFailed": "Misslyckades med att stänga RSS-flödet",
|
"ToastRSSFeedCloseFailed": "Det gick inte att stänga RSS-flödet",
|
||||||
"ToastRSSFeedCloseSuccess": "RSS-flödet stängt",
|
"ToastRSSFeedCloseSuccess": "RSS-flödet har stängts",
|
||||||
"ToastStreamingNotAllowedOnCellular": "Strömning tillåts inte över mobildata.",
|
"ToastStreamingNotAllowedOnCellular": "Strömning tillåts inte över mobildata",
|
||||||
"UnitMinutesShort": "{0}m",
|
"UnitMinutesShort": "{0} min",
|
||||||
"UnitSecondsShort": "{0}s"
|
"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}வி"
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
+65
-65
@@ -39,7 +39,7 @@
|
|||||||
"ButtonPlay": "播放",
|
"ButtonPlay": "播放",
|
||||||
"ButtonPlayEpisode": "播放剧集",
|
"ButtonPlayEpisode": "播放剧集",
|
||||||
"ButtonPlaylists": "播放列表",
|
"ButtonPlaylists": "播放列表",
|
||||||
"ButtonRead": "读取",
|
"ButtonRead": "阅读",
|
||||||
"ButtonReadLess": "收起",
|
"ButtonReadLess": "收起",
|
||||||
"ButtonReadMore": "阅读更多",
|
"ButtonReadMore": "阅读更多",
|
||||||
"ButtonRemove": "移除",
|
"ButtonRemove": "移除",
|
||||||
@@ -88,53 +88,53 @@
|
|||||||
"HeaderSettings": "设置",
|
"HeaderSettings": "设置",
|
||||||
"HeaderSleepTimer": "睡眠计时",
|
"HeaderSleepTimer": "睡眠计时",
|
||||||
"HeaderSleepTimerSettings": "睡眠计时器设置",
|
"HeaderSleepTimerSettings": "睡眠计时器设置",
|
||||||
"HeaderStatsMinutesListeningChart": "收听分钟数 (最近7天)",
|
"HeaderStatsMinutesListeningChart": "收听分钟数(最近7天)",
|
||||||
"HeaderStatsRecentSessions": "历史会话",
|
"HeaderStatsRecentSessions": "历史会话",
|
||||||
"HeaderTableOfContents": "目录",
|
"HeaderTableOfContents": "目录",
|
||||||
"HeaderUserInterfaceSettings": "用户界面设置",
|
"HeaderUserInterfaceSettings": "用户界面设置",
|
||||||
"HeaderWelcome": "欢迎, <strong>{0}</strong>",
|
"HeaderWelcome": "欢迎,<strong>{0}</strong>",
|
||||||
"HeaderYourStats": "你的统计数据",
|
"HeaderYourStats": "你的统计数据",
|
||||||
"LabelAddToPlaylist": "添加到播放列表",
|
"LabelAddToPlaylist": "添加到播放列表",
|
||||||
"LabelAddedAt": "添加于",
|
"LabelAddedAt": "添加于",
|
||||||
"LabelAddedDate": "已添加 {0}",
|
"LabelAddedDate": "已添加 {0}",
|
||||||
"LabelAll": "全部",
|
"LabelAll": "全部",
|
||||||
"LabelAllowSeekingOnMediaControls": "允许在媒体通知控件上查找位置",
|
"LabelAllowSeekingOnMediaControls": "允许在媒体通知控件上拖动进度",
|
||||||
"LabelAlways": "总是",
|
"LabelAlways": "总是",
|
||||||
"LabelAndroidAutoBrowseLimitForGrouping": "按字母顺序排列的下限",
|
"LabelAndroidAutoBrowseLimitForGrouping": "按字母顺序排列的下限",
|
||||||
"LabelAndroidAutoBrowseLimitForGroupingHelp": "当要显示的项目数量少于此数量时, 不要使用按字母顺序排列",
|
"LabelAndroidAutoBrowseLimitForGroupingHelp": "当要显示的项目数量少于此数量时,不要使用按字母顺序排列",
|
||||||
"LabelAndroidAutoBrowseSeriesSequenceOrder": "系列图书订单",
|
"LabelAndroidAutoBrowseSeriesSequenceOrder": "系列图书顺序",
|
||||||
"LabelAskConfirmation": "要求确认",
|
"LabelAskConfirmation": "要求确认",
|
||||||
"LabelAuthor": "作者",
|
"LabelAuthor": "作者",
|
||||||
"LabelAuthorFirstLast": "作者 (姓 名)",
|
"LabelAuthorFirstLast": "作者(名 姓)",
|
||||||
"LabelAuthorLastFirst": "作者 (名, 姓)",
|
"LabelAuthorLastFirst": "作者(姓,名)",
|
||||||
"LabelAuthors": "作者",
|
"LabelAuthors": "作者",
|
||||||
"LabelAutoDownloadEpisodes": "自动下载剧集",
|
"LabelAutoDownloadEpisodes": "自动下载剧集",
|
||||||
"LabelAutoRewindTime": "自动倒带时间",
|
"LabelAutoRewindTime": "自动倒带时间",
|
||||||
"LabelAutoSleepTimer": "自动睡眠定时器",
|
"LabelAutoSleepTimer": "自动睡眠定时器",
|
||||||
"LabelAutoSleepTimerAutoRewind": "自动睡眠定时器自动倒带",
|
"LabelAutoSleepTimerAutoRewind": "自动睡眠定时器自动倒带",
|
||||||
"LabelAutoSleepTimerAutoRewindHelp": "当自动睡眠计时器结束时, 再次播放该项目将自动倒带你之前的位置.",
|
"LabelAutoSleepTimerAutoRewindHelp": "当自动睡眠计时器结束时,再次播放该项目将自动倒带你之前的位置。",
|
||||||
"LabelAutoSleepTimerHelp": "当在指定的时间范围内播放媒体时, 睡眠计时器将自动启动.",
|
"LabelAutoSleepTimerHelp": "当在指定的时间范围内播放媒体时,睡眠计时器将自动启动。",
|
||||||
"LabelBooks": "图书",
|
"LabelBooks": "图书",
|
||||||
"LabelByAuthor": "作者: {0}",
|
"LabelByAuthor": "作者:{0}",
|
||||||
"LabelChapterTrack": "章节音轨",
|
"LabelChapterTrack": "章节音轨",
|
||||||
"LabelChapters": "章节",
|
"LabelChapters": "章节",
|
||||||
"LabelClosePlayer": "关闭播放器",
|
"LabelClosePlayer": "关闭播放器",
|
||||||
"LabelCollapseSeries": "折叠系列",
|
"LabelCollapseSeries": "折叠系列",
|
||||||
"LabelComplete": "已完成",
|
"LabelComplete": "已完成",
|
||||||
"LabelContinueBooks": "继续看书",
|
"LabelContinueBooks": "继续阅读",
|
||||||
"LabelContinueEpisodes": "继续剧集",
|
"LabelContinueEpisodes": "继续剧集",
|
||||||
"LabelContinueListening": "继续收听",
|
"LabelContinueListening": "继续收听",
|
||||||
"LabelContinueReading": "继续阅读",
|
"LabelContinueReading": "继续阅读",
|
||||||
"LabelContinueSeries": "继续收听系列",
|
"LabelContinueSeries": "在追系列",
|
||||||
"LabelCustomTime": "自定义时间",
|
"LabelCustomTime": "自定义时间",
|
||||||
"LabelDescription": "描述",
|
"LabelDescription": "描述",
|
||||||
"LabelDisableAudioFadeOut": "禁用音频淡出",
|
"LabelDisableAudioFadeOut": "禁用音频淡出",
|
||||||
"LabelDisableAudioFadeOutHelp": "当睡眠计时器的剩余时间不到1分钟时, 音频音量将开始降低.启用此设置不淡出.",
|
"LabelDisableAudioFadeOutHelp": "当睡眠计时器的剩余时间不到1分钟时,音频音量将开始降低。启用此设置可禁用淡出。",
|
||||||
"LabelDisableAutoRewind": "禁用自动倒带",
|
"LabelDisableAutoRewind": "禁用自动倒带",
|
||||||
"LabelDisableShakeToReset": "禁用抖动设备重置",
|
"LabelDisableShakeToReset": "禁用摇晃设备重置",
|
||||||
"LabelDisableShakeToResetHelp": "在计时器运行时摇晃设备, 或者在计时器到期后 2 分钟内摇晃设备将重置睡眠计时器. 启用此设置可禁用抖动以重置.",
|
"LabelDisableShakeToResetHelp": "在计时器运行时摇晃设备,或者在计时器到期后 2 分钟内摇晃设备将重置睡眠计时器。启用此设置禁用摇晃设备重置。",
|
||||||
"LabelDisableVibrateOnReset": "重置时禁用振动",
|
"LabelDisableVibrateOnReset": "重置时禁用振动",
|
||||||
"LabelDisableVibrateOnResetHelp": "当睡眠计时器重置时, 你的设备会振动. 启用此设置以在睡眠计时器重置时不振动.",
|
"LabelDisableVibrateOnResetHelp": "当睡眠计时器重置时,你的设备会振动。启用此设置以在睡眠计时器重置时不振动。",
|
||||||
"LabelDiscover": "发现",
|
"LabelDiscover": "发现",
|
||||||
"LabelDownload": "下载",
|
"LabelDownload": "下载",
|
||||||
"LabelDownloadUsingCellular": "使用移动数据网络进行下载",
|
"LabelDownloadUsingCellular": "使用移动数据网络进行下载",
|
||||||
@@ -143,8 +143,8 @@
|
|||||||
"LabelEbook": "电子书",
|
"LabelEbook": "电子书",
|
||||||
"LabelEbooks": "电子书",
|
"LabelEbooks": "电子书",
|
||||||
"LabelEnable": "启用",
|
"LabelEnable": "启用",
|
||||||
"LabelEnableMp3IndexSeeking": "启用 mp3 索引搜索",
|
"LabelEnableMp3IndexSeeking": "启用 mp3 索引定位",
|
||||||
"LabelEnableMp3IndexSeekingHelp": "只有当你的 mp3 文件未正确查找时, 才应启用此设置. 不准确的搜索很可能是由于可变二进制 (VBR) MP3 文件. 此设置将强制进行索引查找, 即在读取文件时构建时间到字节的映射. 在某些情况下, 对于大型MP3文件, 在查找文件末尾时会有延迟.",
|
"LabelEnableMp3IndexSeekingHelp": "仅在 MP3 文件无法正常快进/倒退(定位)时开启此设置。定位不准确通常是由可变码率(VBR)MP3 文件引起的。开启后将强制执行“索引定位”——即在读取文件时建立时间到字节的映射关系。对于体积较大的 MP3 文件,在快进到接近尾部的位置时,可能会出现短暂的加载延迟。",
|
||||||
"LabelEnd": "结束",
|
"LabelEnd": "结束",
|
||||||
"LabelEndOfChapter": "章节结束",
|
"LabelEndOfChapter": "章节结束",
|
||||||
"LabelEndTime": "结束时间",
|
"LabelEndTime": "结束时间",
|
||||||
@@ -180,9 +180,9 @@
|
|||||||
"LabelLayout": "布局",
|
"LabelLayout": "布局",
|
||||||
"LabelLayoutAuto": "自动",
|
"LabelLayoutAuto": "自动",
|
||||||
"LabelLayoutSinglePage": "单页",
|
"LabelLayoutSinglePage": "单页",
|
||||||
"LabelLibrarySortByProgress": "进度: 上次更新",
|
"LabelLibrarySortByProgress": "进度:最近更新",
|
||||||
"LabelLibrarySortByProgressFinished": "进度: 已完成",
|
"LabelLibrarySortByProgressFinished": "进度:已完成",
|
||||||
"LabelLibrarySortByProgressStarted": "进度: 已开始",
|
"LabelLibrarySortByProgressStarted": "进度:已开始",
|
||||||
"LabelLight": "轻",
|
"LabelLight": "轻",
|
||||||
"LabelLineSpacing": "行间距",
|
"LabelLineSpacing": "行间距",
|
||||||
"LabelListenAgain": "再次收听",
|
"LabelListenAgain": "再次收听",
|
||||||
@@ -207,12 +207,12 @@
|
|||||||
"LabelNever": "从不",
|
"LabelNever": "从不",
|
||||||
"LabelNewestAuthors": "最新作者",
|
"LabelNewestAuthors": "最新作者",
|
||||||
"LabelNewestEpisodes": "最新剧集",
|
"LabelNewestEpisodes": "最新剧集",
|
||||||
"LabelNo": "取消",
|
"LabelNo": "否",
|
||||||
"LabelNotFinished": "未听完",
|
"LabelNotFinished": "未听完",
|
||||||
"LabelNotStarted": "未开始",
|
"LabelNotStarted": "未开始",
|
||||||
"LabelNumEpisodes": "{0} 个剧集",
|
"LabelNumEpisodes": "{0} 个剧集",
|
||||||
"LabelNumEpisodesIncomplete": "共 {0} 集, {1} 集未完成",
|
"LabelNumEpisodesIncomplete": "共 {0} 集,{1} 集未完成",
|
||||||
"LabelNumberOfEpisodes": "# 集数",
|
"LabelNumberOfEpisodes": "集数",
|
||||||
"LabelOff": "关闭",
|
"LabelOff": "关闭",
|
||||||
"LabelOn": "打开",
|
"LabelOn": "打开",
|
||||||
"LabelPassword": "密码",
|
"LabelPassword": "密码",
|
||||||
@@ -232,7 +232,7 @@
|
|||||||
"LabelRSSFeedCustomOwnerName": "自定义所有者名称",
|
"LabelRSSFeedCustomOwnerName": "自定义所有者名称",
|
||||||
"LabelRSSFeedOpen": "打开 RSS 源",
|
"LabelRSSFeedOpen": "打开 RSS 源",
|
||||||
"LabelRSSFeedPreventIndexing": "防止索引",
|
"LabelRSSFeedPreventIndexing": "防止索引",
|
||||||
"LabelRSSFeedSlug": "RSS 源段",
|
"LabelRSSFeedSlug": "RSS 源别名(Slug)",
|
||||||
"LabelRandomly": "随机",
|
"LabelRandomly": "随机",
|
||||||
"LabelRead": "阅读",
|
"LabelRead": "阅读",
|
||||||
"LabelReadAgain": "再次阅读",
|
"LabelReadAgain": "再次阅读",
|
||||||
@@ -249,7 +249,7 @@
|
|||||||
"LabelServerAddress": "服务器地址",
|
"LabelServerAddress": "服务器地址",
|
||||||
"LabelSetEbookAsPrimary": "设置为主",
|
"LabelSetEbookAsPrimary": "设置为主",
|
||||||
"LabelSetEbookAsSupplementary": "设置为补充",
|
"LabelSetEbookAsSupplementary": "设置为补充",
|
||||||
"LabelShakeSensitivity": "抖动灵敏度",
|
"LabelShakeSensitivity": "摇晃灵敏度",
|
||||||
"LabelShowAll": "全部显示",
|
"LabelShowAll": "全部显示",
|
||||||
"LabelSize": "文件大小",
|
"LabelSize": "文件大小",
|
||||||
"LabelSleepTimer": "睡眠定时",
|
"LabelSleepTimer": "睡眠定时",
|
||||||
@@ -290,44 +290,44 @@
|
|||||||
"LabelYearReviewShow": "查看年度回顾",
|
"LabelYearReviewShow": "查看年度回顾",
|
||||||
"LabelYourBookmarks": "你的书签",
|
"LabelYourBookmarks": "你的书签",
|
||||||
"LabelYourProgress": "你的进度",
|
"LabelYourProgress": "你的进度",
|
||||||
"MessageAndroid10Downloads": "Android 10 及以下版本将使用内部应用程序存储进行下载.",
|
"MessageAndroid10Downloads": "Android 10 及以下版本将使用内部应用程序存储进行下载。",
|
||||||
"MessageAttemptingServerConnection": "正在尝试连接服务器...",
|
"MessageAttemptingServerConnection": "正在尝试连接服务器…",
|
||||||
"MessageAudiobookshelfServerNotConnected": "Audiobookshelf 服务器未连接",
|
"MessageAudiobookshelfServerNotConnected": "Audiobookshelf 服务器未连接",
|
||||||
"MessageAudiobookshelfServerRequired": "<strong>重要提示!</strong>此应用旨在与你或你认识的人托管的 Audiobookshelf 服务器配合使用. 此应用不提供任何内容.",
|
"MessageAudiobookshelfServerRequired": "<strong>重要提示!</strong>此应用旨在与你或你认识的人托管的 Audiobookshelf 服务器配合使用。此应用不提供任何内容。",
|
||||||
"MessageBookshelfEmpty": "书架是空的",
|
"MessageBookshelfEmpty": "书架是空的",
|
||||||
"MessageConfirmAppExit": "确定要退出应用吗?",
|
"MessageConfirmAppExit": "确定要退出应用吗?",
|
||||||
"MessageConfirmDeleteEpisodeDownloadQueue": "确定要清空剧集下载队列吗?",
|
"MessageConfirmDeleteEpisodeDownloadQueue": "确定要清空剧集下载队列吗?",
|
||||||
"MessageConfirmDeleteLocalEpisode": "要从设备中删除本地剧集 \"{0}\" 吗? 服务器上的文件将不受影响.",
|
"MessageConfirmDeleteLocalEpisode": "要从设备中删除本地剧集 “{0}” 吗?服务器上的文件将不受影响。",
|
||||||
"MessageConfirmDeleteLocalFiles": "要从设备中删除此项目的本地文件吗? 服务器上的文件和你的进度将不受影响.",
|
"MessageConfirmDeleteLocalFiles": "要从设备中删除此项目的本地文件吗?服务器上的文件和你的进度将不受影响。",
|
||||||
"MessageConfirmDeleteServerConfig": "确定要移除此服务器配置吗?",
|
"MessageConfirmDeleteServerConfig": "确定要移除此服务器配置吗?",
|
||||||
"MessageConfirmDeleteServerEpisode": "确定要从服务器删除剧集 \"{0}\" 吗?\n警告: 这将会删除音频文件.",
|
"MessageConfirmDeleteServerEpisode": "确定要从服务器删除剧集 “{0}” 吗?\n警告:这将会删除音频文件。",
|
||||||
"MessageConfirmDisableAutoTimer": "你确定要在今天剩余时间内禁用自动定时器吗? 定时器将在自动睡眠定时器周期结束时或你重新启动应用时重新启用.",
|
"MessageConfirmDisableAutoTimer": "你确定要在今天剩余时间内禁用自动定时器吗?定时器将在自动睡眠定时器周期结束时或你重新启动应用时重新启用。",
|
||||||
"MessageConfirmDiscardProgress": "你确定要重置进度吗?",
|
"MessageConfirmDiscardProgress": "你确定要重置进度吗?",
|
||||||
"MessageConfirmDownloadUsingCellular": "你即将使用移动数据进行下载. 这可能包括运营商数据费用. 是否要继续?",
|
"MessageConfirmDownloadUsingCellular": "你即将使用移动数据进行下载。这可能包括运营商数据费用。是否要继续?",
|
||||||
"MessageConfirmMarkAsFinished": "你确定要要将此项目标记为已完成吗?",
|
"MessageConfirmMarkAsFinished": "你确定要将此项目标记为已完成吗?",
|
||||||
"MessageConfirmPlaybackTime": "确定从 {1} 开始播放 \"{0}\" 吗?",
|
"MessageConfirmPlaybackTime": "确定从 {1} 开始播放 “{0}” 吗?",
|
||||||
"MessageConfirmRemoveBookmark": "你确定要删除书签吗?",
|
"MessageConfirmRemoveBookmark": "你确定要删除书签吗?",
|
||||||
"MessageConfirmStreamingUsingCellular": "你将使用移动数据进行流媒体播放. 这可能包括运营商数据费用. 是否要继续?",
|
"MessageConfirmStreamingUsingCellular": "你将使用移动数据进行流媒体播放。这可能包括运营商数据费用。是否要继续?",
|
||||||
"MessageDiscardProgress": "放弃进度",
|
"MessageDiscardProgress": "放弃进度",
|
||||||
"MessageDownloadCompleteProcessing": "下载完成. 正在处理...",
|
"MessageDownloadCompleteProcessing": "下载完成。正在处理…",
|
||||||
"MessageDownloading": "下载中...",
|
"MessageDownloading": "下载中…",
|
||||||
"MessageDownloadingEpisode": "正在下载剧集",
|
"MessageDownloadingEpisode": "正在下载剧集",
|
||||||
"MessageEpisodesQueuedForDownload": "{0} 个剧集排队等待下载",
|
"MessageEpisodesQueuedForDownload": "{0} 个剧集排队等待下载",
|
||||||
"MessageFailedToRefreshToken": "无法刷新令牌,需要重新登录",
|
"MessageFailedToRefreshToken": "无法刷新令牌,需要重新登录",
|
||||||
"MessageFeedURLWillBe": "源 URL 将改为 {0}",
|
"MessageFeedURLWillBe": "源 URL 将改为 {0}",
|
||||||
"MessageFetching": "正在获取...",
|
"MessageFetching": "正在获取…",
|
||||||
"MessageFollowTheProjectOnGithub": "在 GitHub 上关注此项目",
|
"MessageFollowTheProjectOnGithub": "在 GitHub 上关注此项目",
|
||||||
"MessageItemDownloadCompleteFailedToCreate": "项目下载完成, 但未能创建库项目",
|
"MessageItemDownloadCompleteFailedToCreate": "项目下载完成,但未能创建库项目",
|
||||||
"MessageItemMissing": "项目丢失, 必须在服务器上修复. 项目被标记为丢失通常是因为文件路径无法访问.",
|
"MessageItemMissing": "项目丢失,必须在服务器上修复。项目被标记为丢失通常是因为文件路径无法访问。",
|
||||||
"MessageLoading": "正在加载...",
|
"MessageLoading": "正在加载…",
|
||||||
"MessageLoadingServerData": "正在加载服务器数据...",
|
"MessageLoadingServerData": "正在加载服务器数据…",
|
||||||
"MessageLocalFolderDescription": "'内部应用存储' 仅可由此应用程序访问. 此应用程序仅支持直接通过此应用程序下载的媒体. 共享存储文件夹可用于允许其他应用程序访问此应用程序下载的媒体.",
|
"MessageLocalFolderDescription": "“内部应用存储” 仅可由此应用程序访问。此应用程序仅支持直接通过此应用程序下载的媒体。共享存储文件夹可用于允许其他应用程序访问此应用程序下载的媒体。",
|
||||||
"MessageMarkAsFinished": "标记为已听完",
|
"MessageMarkAsFinished": "标记为已完成",
|
||||||
"MessageMediaLinkedToADifferentServer": "媒体链接到了一个地址不同 ({0}) 的 Audiobookshelf 服务器上. 连接到此服务器地址时将同步进度.",
|
"MessageMediaLinkedToADifferentServer": "媒体链接到了一个地址不同({0})的 Audiobookshelf 服务器上。连接到此服务器地址时将同步进度。",
|
||||||
"MessageMediaLinkedToADifferentUser": "媒体链接到此服务器, 但是由其他用户下载. 进度将只同步到下载的用户.",
|
"MessageMediaLinkedToADifferentUser": "媒体链接到此服务器,但是由其他用户下载。进度将只同步到下载的用户。",
|
||||||
"MessageMediaLinkedToServer": "链接到服务器 {0}",
|
"MessageMediaLinkedToServer": "链接到服务器 {0}",
|
||||||
"MessageMediaLinkedToThisServer": "下载的媒体链接到此服务器",
|
"MessageMediaLinkedToThisServer": "下载的媒体链接到此服务器",
|
||||||
"MessageMediaNotLinkedToServer": "媒体未链接到 Audiobookshelf 服务器. 不会同步任何进度.",
|
"MessageMediaNotLinkedToServer": "媒体未链接到 Audiobookshelf 服务器。不会同步任何进度。",
|
||||||
"MessageNoBookmarks": "没有书签",
|
"MessageNoBookmarks": "没有书签",
|
||||||
"MessageNoChapters": "没有章节",
|
"MessageNoChapters": "没有章节",
|
||||||
"MessageNoCollections": "没有收藏",
|
"MessageNoCollections": "没有收藏",
|
||||||
@@ -341,24 +341,24 @@
|
|||||||
"MessageNoSeries": "没有系列",
|
"MessageNoSeries": "没有系列",
|
||||||
"MessageNoUpdatesWereNecessary": "无需更新",
|
"MessageNoUpdatesWereNecessary": "无需更新",
|
||||||
"MessageNoUserPlaylists": "你没有播放列表",
|
"MessageNoUserPlaylists": "你没有播放列表",
|
||||||
"MessageOldServerAuthReLoginRequired": "服务器 v2.26.0 中的身份验证已得到改进, 以提高安全性. 所有用户都需要重新登录.",
|
"MessageOldServerAuthReLoginRequired": "服务器 v2.26.0 中的身份验证已得到改进,以提高安全性。所有用户都需要重新登录。",
|
||||||
"MessageOldServerAuthWarning": "服务器正在使用过时的身份验证",
|
"MessageOldServerAuthWarning": "服务器正在使用过时的身份验证",
|
||||||
"MessageOldServerAuthWarningHelp": "此服务器运行的版本早于 v2.26.0. 在 v2.26.0 中添加了更安全的身份验证系统. 强烈建议将服务器更新到最新版本. 如果您已经更新了服务器, 请重新登录以使用新的身份验证.",
|
"MessageOldServerAuthWarningHelp": "此服务器运行的版本早于 v2.26.0。在 v2.26.0 中添加了更安全的身份验证系统。强烈建议将服务器更新到最新版本。如果您已经更新了服务器,请重新登录以使用新的身份验证。",
|
||||||
"MessageOldServerConnectionWarning": "服务器连接配置正在使用旧的用户 ID. 请删除并重新添加此服务器连接.",
|
"MessageOldServerConnectionWarning": "服务器连接配置正在使用旧的用户 ID。请删除并重新添加此服务器连接。",
|
||||||
"MessageOldServerConnectionWarningHelp": "您最初在 2023 年 6 月发布的 2.3.0 版本中进行数据库迁移之前设置了与此服务器的连接. 未来的服务器更新将取消使用此旧连接登录的功能. 请删除现有的服务器连接并重新连接 (使用相同的服务器地址和凭据). 如果您在此设备上有任何下载的媒体, 则需要再次下载该媒体才能与服务器同步.",
|
"MessageOldServerConnectionWarningHelp": "您最初在 2023 年 6 月发布的 2.3.0 版本中进行数据库迁移之前设置了与此服务器的连接。未来的服务器更新将取消使用此旧连接登录的功能。请删除现有的服务器连接并重新连接(使用相同的服务器地址和凭据)。如果您在此设备上有任何下载的媒体,则需要再次下载该媒体才能与服务器同步。",
|
||||||
"MessagePodcastSearchField": "输入搜索词或 RSS 源 URL",
|
"MessagePodcastSearchField": "输入搜索词或 RSS 源 URL",
|
||||||
"MessageProgressSyncFailed": "向服务器报告收听进度的最新尝试失败. 播放媒体时, 将继续每 15 秒至 1 分钟尝试一次进度同步请求.",
|
"MessageProgressSyncFailed": "向服务器报告收听进度的最新尝试失败。播放媒体时,将继续每 15 秒至 1 分钟尝试一次进度同步请求。",
|
||||||
"MessageReportBugsAndContribute": "反馈问题, 建议功能或参与贡献, 请访问",
|
"MessageReportBugsAndContribute": "反馈问题,建议功能或参与贡献,请访问",
|
||||||
"MessageSeriesAlreadyDownloaded": "你已下载此系列的所有书籍.",
|
"MessageSeriesAlreadyDownloaded": "你已下载此系列的所有书籍。",
|
||||||
"MessageSeriesDownloadConfirm": "将缺失的 {0} 本图书和 {1} 个文件 (共 {2} 个) 下载至文件夹 {3}?",
|
"MessageSeriesDownloadConfirm": "将缺失的 {0} 本图书和 {1} 个文件(共 {2} 个)下载至文件夹 {3}?",
|
||||||
"MessageSeriesDownloadConfirmIos": "是否下载缺失的 {0} 本书和 {1} 个文件,总计 {2} 个?",
|
"MessageSeriesDownloadConfirmIos": "是否下载缺失的 {0} 本书和 {1} 个文件,总计 {2} 个?",
|
||||||
"MessageSocketConnectedOverMeteredCellular": "通过按流量计费的移动数据网络完成连接",
|
"MessageSocketConnectedOverMeteredCellular": "通过按流量计费的移动数据网络完成连接",
|
||||||
"MessageSocketConnectedOverMeteredWifi": "通过按流量计费的无线网络完成连接",
|
"MessageSocketConnectedOverMeteredWifi": "通过按流量计费的无线网络完成连接",
|
||||||
"MessageSocketConnectedOverUnmeteredCellular": "通过不计量的移动数据网络完成连接",
|
"MessageSocketConnectedOverUnmeteredCellular": "通过不计量的移动数据网络完成连接",
|
||||||
"MessageSocketConnectedOverUnmeteredWifi": "通过不计量的无线网络完成连接",
|
"MessageSocketConnectedOverUnmeteredWifi": "通过不计量的无线网络完成连接",
|
||||||
"MessageSocketNotConnected": "未连接",
|
"MessageSocketNotConnected": "未连接",
|
||||||
"NoteRSSFeedPodcastAppsHttps": "警告: 大多数播客应用程序都需要 RSS 源 URL 使用 HTTPS",
|
"NoteRSSFeedPodcastAppsHttps": "警告:大多数播客应用程序都需要 RSS 源 URL 使用 HTTPS",
|
||||||
"NoteRSSFeedPodcastAppsPubDate": "警告: 你的一集或多集没有发布日期. 一些播客应用程序要求这样做.",
|
"NoteRSSFeedPodcastAppsPubDate": "警告:你的一集或多集没有发布日期。一些播客应用程序要求这样做。",
|
||||||
"ToastBookmarkCreateFailed": "创建书签失败",
|
"ToastBookmarkCreateFailed": "创建书签失败",
|
||||||
"ToastBookmarkRemoveFailed": "书签删除失败",
|
"ToastBookmarkRemoveFailed": "书签删除失败",
|
||||||
"ToastBookmarkUpdateFailed": "书签更新失败",
|
"ToastBookmarkUpdateFailed": "书签更新失败",
|
||||||
|
|||||||
@@ -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