mirror of
https://github.com/advplyr/audiobookshelf-app.git
synced 2026-08-06 20:08:48 +02:00
Merge branch 'master' into landscape-player
This commit is contained in:
@@ -72,10 +72,10 @@ body:
|
||||
description: Please ensure your app is up to date. *If you are using a 3rd-party app, please reach out to them directly.*
|
||||
multiple: true
|
||||
options:
|
||||
- 'Android App - 0.13.0'
|
||||
- 'iOS App - 0.13.0'
|
||||
- 'Android App - 0.12.0'
|
||||
- 'iOS App - 0.12.0'
|
||||
- 'Android App - 0.11.0'
|
||||
- 'iOS App - 0.11.0'
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
@@ -43,10 +43,10 @@ body:
|
||||
description: Please ensure your app is up to date. *If you are using a 3rd-party app, please reach out to them directly.*
|
||||
multiple: true
|
||||
options:
|
||||
- 'Android App - 0.13.0'
|
||||
- 'iOS App - 0.13.0'
|
||||
- 'Android App - 0.12.0'
|
||||
- 'iOS App - 0.12.0'
|
||||
- 'Android App - 0.11.0'
|
||||
- 'iOS App - 0.11.0'
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
|
||||
@@ -36,8 +36,8 @@ android {
|
||||
applicationId "com.audiobookshelf.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 116
|
||||
versionName "0.12.0-beta"
|
||||
versionCode 117
|
||||
versionName "0.13.0-beta"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
manifestPlaceholders = [
|
||||
"appAuthRedirectScheme": "com.audiobookshelf.app"
|
||||
@@ -92,6 +92,7 @@ dependencies {
|
||||
implementation project(':capacitor-cordova-android-plugins')
|
||||
|
||||
implementation "androidx.core:core-ktx:$androidx_core_ktx_version"
|
||||
implementation "androidx.work:work-runtime-ktx:2.9.1"
|
||||
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlin_coroutines_version"
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version"
|
||||
|
||||
@@ -6,12 +6,14 @@
|
||||
|
||||
<!-- Permissions -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission
|
||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="28" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
@@ -98,6 +100,12 @@
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".services.DownloadService"
|
||||
android:enabled="true"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
|
||||
@@ -40,9 +40,6 @@ class MainActivity : BridgeActivity() {
|
||||
val storage = SimpleStorage(this)
|
||||
|
||||
val REQUEST_PERMISSIONS = 1
|
||||
var PERMISSIONS_ALL = arrayOf(
|
||||
Manifest.permission.READ_EXTERNAL_STORAGE
|
||||
)
|
||||
|
||||
public override fun onCreate(savedInstanceState: Bundle?) {
|
||||
DbManager.initialize(applicationContext)
|
||||
@@ -98,11 +95,20 @@ class MainActivity : BridgeActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
val permission = ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE)
|
||||
if (permission != PackageManager.PERMISSION_GRANTED) {
|
||||
ActivityCompat.requestPermissions(this,
|
||||
PERMISSIONS_ALL,
|
||||
REQUEST_PERMISSIONS)
|
||||
requestNeededPermissions()
|
||||
}
|
||||
|
||||
private fun requestNeededPermissions() {
|
||||
val needed = mutableListOf<String>()
|
||||
if (ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
|
||||
needed.add(Manifest.permission.READ_EXTERNAL_STORAGE)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||
ActivityCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||
needed.add(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
if (needed.isNotEmpty()) {
|
||||
ActivityCompat.requestPermissions(this, needed.toTypedArray(), REQUEST_PERMISSIONS)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,74 +1,102 @@
|
||||
package com.audiobookshelf.app.data
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.support.v4.media.MediaDescriptionCompat
|
||||
import android.util.Log
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
import com.fasterxml.jackson.annotation.JsonSubTypes
|
||||
import com.fasterxml.jackson.annotation.JsonTypeInfo
|
||||
import java.io.File
|
||||
|
||||
enum class LockOrientationSetting {
|
||||
NONE, PORTRAIT, LANDSCAPE
|
||||
NONE,
|
||||
PORTRAIT,
|
||||
LANDSCAPE
|
||||
}
|
||||
|
||||
enum class HapticFeedbackSetting {
|
||||
OFF, LIGHT, MEDIUM, HEAVY
|
||||
OFF,
|
||||
LIGHT,
|
||||
MEDIUM,
|
||||
HEAVY
|
||||
}
|
||||
|
||||
enum class ShakeSensitivitySetting {
|
||||
VERY_LOW, LOW, MEDIUM, HIGH, VERY_HIGH
|
||||
VERY_LOW,
|
||||
LOW,
|
||||
MEDIUM,
|
||||
HIGH,
|
||||
VERY_HIGH
|
||||
}
|
||||
|
||||
enum class DownloadUsingCellularSetting {
|
||||
ASK, ALWAYS, NEVER
|
||||
ASK,
|
||||
ALWAYS,
|
||||
NEVER
|
||||
}
|
||||
|
||||
enum class StreamingUsingCellularSetting {
|
||||
ASK, ALWAYS, NEVER
|
||||
ASK,
|
||||
ALWAYS,
|
||||
NEVER
|
||||
}
|
||||
|
||||
enum class AndroidAutoBrowseSeriesSequenceOrderSetting {
|
||||
ASC, DESC
|
||||
ASC,
|
||||
DESC
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class ServerConnectionConfig(
|
||||
var id:String,
|
||||
var index:Int,
|
||||
var name:String,
|
||||
var address:String,
|
||||
// version added after 0.9.81-beta
|
||||
var version:String?,
|
||||
var userId:String,
|
||||
var username:String,
|
||||
var token:String,
|
||||
var customHeaders:Map<String, String>?
|
||||
var id: String,
|
||||
var index: Int,
|
||||
var name: String,
|
||||
var address: String,
|
||||
// version added after 0.9.81-beta
|
||||
var version: String?,
|
||||
var userId: String,
|
||||
var username: String,
|
||||
var token: String,
|
||||
var customHeaders: Map<String, String>?
|
||||
)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class LocalFile(
|
||||
var id:String,
|
||||
var filename:String?,
|
||||
var contentUrl:String,
|
||||
var basePath:String,
|
||||
var absolutePath:String,
|
||||
var simplePath:String,
|
||||
var mimeType:String?,
|
||||
var size:Long
|
||||
var id: String,
|
||||
var filename: String?,
|
||||
var contentUrl: String,
|
||||
var basePath: String,
|
||||
var absolutePath: String,
|
||||
var mimeType: String?,
|
||||
var size: Long
|
||||
) {
|
||||
@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 == "video/mp4") return true
|
||||
return mimeType?.startsWith("audio") == true
|
||||
}
|
||||
@JsonIgnore
|
||||
fun isEBookFile():Boolean {
|
||||
fun isEBookFile(): Boolean {
|
||||
return getEBookFormat() != null
|
||||
}
|
||||
@JsonIgnore
|
||||
fun getEBookFormat():String? {
|
||||
fun getEBookFormat(): String? {
|
||||
if (mimeType == "application/epub+zip") return "epub"
|
||||
if (mimeType == "application/pdf") return "pdf"
|
||||
if (mimeType == "application/x-mobipocket-ebook") return "mobi"
|
||||
@@ -81,118 +109,124 @@ data class LocalFile(
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class LocalFolder(
|
||||
var id:String,
|
||||
var name:String,
|
||||
var contentUrl:String,
|
||||
var basePath:String,
|
||||
var absolutePath:String,
|
||||
var simplePath:String,
|
||||
var storageType:String,
|
||||
var mediaType:String
|
||||
var id: String,
|
||||
var name: String,
|
||||
var contentUrl: String,
|
||||
var basePath: String,
|
||||
var absolutePath: String,
|
||||
var storageType: String,
|
||||
var mediaType: String
|
||||
)
|
||||
|
||||
@JsonTypeInfo(use= JsonTypeInfo.Id.DEDUCTION)
|
||||
@JsonSubTypes(
|
||||
JsonSubTypes.Type(LibraryItem::class),
|
||||
JsonSubTypes.Type(LocalLibraryItem::class)
|
||||
)
|
||||
open class LibraryItemWrapper(var id:String) {
|
||||
@JsonTypeInfo(use = JsonTypeInfo.Id.DEDUCTION)
|
||||
@JsonSubTypes(JsonSubTypes.Type(LibraryItem::class), JsonSubTypes.Type(LocalLibraryItem::class))
|
||||
open class LibraryItemWrapper(var id: String) {
|
||||
@JsonIgnore
|
||||
open fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context): MediaDescriptionCompat { return MediaDescriptionCompat.Builder().build() }
|
||||
open fun getMediaDescription(
|
||||
progress: MediaProgressWrapper?,
|
||||
ctx: Context
|
||||
): MediaDescriptionCompat {
|
||||
return MediaDescriptionCompat.Builder().build()
|
||||
}
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class DeviceInfo(
|
||||
var deviceId:String,
|
||||
var manufacturer:String,
|
||||
var model:String,
|
||||
var sdkVersion:Int,
|
||||
var clientVersion: String
|
||||
var deviceId: String,
|
||||
var manufacturer: String,
|
||||
var model: String,
|
||||
var sdkVersion: Int,
|
||||
var clientVersion: String
|
||||
)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class PlayItemRequestPayload(
|
||||
var mediaPlayer:String,
|
||||
var forceDirectPlay:Boolean,
|
||||
var forceTranscode:Boolean,
|
||||
var deviceInfo:DeviceInfo
|
||||
var mediaPlayer: String,
|
||||
var forceDirectPlay: Boolean,
|
||||
var forceTranscode: Boolean,
|
||||
var deviceInfo: DeviceInfo
|
||||
)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class DeviceSettings(
|
||||
var disableAutoRewind:Boolean,
|
||||
var enableAltView:Boolean,
|
||||
var allowSeekingOnMediaControls:Boolean,
|
||||
var jumpBackwardsTime:Int,
|
||||
var jumpForwardTime:Int,
|
||||
var enableMp3IndexSeeking:Boolean,
|
||||
var disableShakeToResetSleepTimer:Boolean,
|
||||
var shakeSensitivity: ShakeSensitivitySetting,
|
||||
var lockOrientation: LockOrientationSetting,
|
||||
var hapticFeedback: HapticFeedbackSetting,
|
||||
var autoSleepTimer: Boolean,
|
||||
var autoSleepTimerStartTime: String,
|
||||
var autoSleepTimerEndTime: String,
|
||||
var autoSleepTimerAutoRewind: Boolean,
|
||||
var autoSleepTimerAutoRewindTime: Long, //Time in milliseconds
|
||||
var sleepTimerLength: Long, // Time in milliseconds
|
||||
var disableSleepTimerFadeOut: Boolean,
|
||||
var disableSleepTimerResetFeedback: Boolean,
|
||||
var enableSleepTimerAlmostDoneChime: Boolean,
|
||||
var languageCode: String,
|
||||
var downloadUsingCellular: DownloadUsingCellularSetting,
|
||||
var streamingUsingCellular: StreamingUsingCellularSetting,
|
||||
var androidAutoBrowseLimitForGrouping: Int,
|
||||
var androidAutoBrowseSeriesSequenceOrder: AndroidAutoBrowseSeriesSequenceOrderSetting
|
||||
var disableAutoRewind: Boolean,
|
||||
var enableAltView: Boolean,
|
||||
var allowSeekingOnMediaControls: Boolean,
|
||||
var jumpBackwardsTime: Int,
|
||||
var jumpForwardTime: Int,
|
||||
var enableMp3IndexSeeking: Boolean,
|
||||
var disableShakeToResetSleepTimer: Boolean,
|
||||
var shakeSensitivity: ShakeSensitivitySetting,
|
||||
var lockOrientation: LockOrientationSetting,
|
||||
var hapticFeedback: HapticFeedbackSetting,
|
||||
var autoSleepTimer: Boolean,
|
||||
var autoSleepTimerStartTime: String,
|
||||
var autoSleepTimerEndTime: String,
|
||||
var autoSleepTimerAutoRewind: Boolean,
|
||||
var autoSleepTimerAutoRewindTime: Long, // Time in milliseconds
|
||||
var sleepTimerLength: Long, // Time in milliseconds
|
||||
var disableSleepTimerFadeOut: Boolean,
|
||||
var disableSleepTimerResetFeedback: Boolean,
|
||||
var enableSleepTimerAlmostDoneChime: Boolean,
|
||||
var languageCode: String,
|
||||
var downloadUsingCellular: DownloadUsingCellularSetting,
|
||||
var streamingUsingCellular: StreamingUsingCellularSetting,
|
||||
var androidAutoBrowseLimitForGrouping: Int,
|
||||
var androidAutoBrowseSeriesSequenceOrder: AndroidAutoBrowseSeriesSequenceOrderSetting
|
||||
) {
|
||||
companion object {
|
||||
// Static method to get default device settings
|
||||
fun default():DeviceSettings {
|
||||
fun default(): DeviceSettings {
|
||||
return DeviceSettings(
|
||||
disableAutoRewind = false,
|
||||
enableAltView = true,
|
||||
allowSeekingOnMediaControls = false,
|
||||
jumpBackwardsTime = 10,
|
||||
jumpForwardTime = 10,
|
||||
enableMp3IndexSeeking = false,
|
||||
disableShakeToResetSleepTimer = false,
|
||||
shakeSensitivity = ShakeSensitivitySetting.MEDIUM,
|
||||
lockOrientation = LockOrientationSetting.NONE,
|
||||
hapticFeedback = HapticFeedbackSetting.LIGHT,
|
||||
autoSleepTimer = false,
|
||||
autoSleepTimerStartTime = "22:00",
|
||||
autoSleepTimerEndTime = "06:00",
|
||||
sleepTimerLength = 900000L, // 15 minutes
|
||||
autoSleepTimerAutoRewind = false,
|
||||
autoSleepTimerAutoRewindTime = 300000L, // 5 minutes
|
||||
disableSleepTimerFadeOut = false,
|
||||
disableSleepTimerResetFeedback = false,
|
||||
enableSleepTimerAlmostDoneChime = false,
|
||||
languageCode = "en-us",
|
||||
downloadUsingCellular = DownloadUsingCellularSetting.ALWAYS,
|
||||
streamingUsingCellular = StreamingUsingCellularSetting.ALWAYS,
|
||||
androidAutoBrowseLimitForGrouping = 100,
|
||||
androidAutoBrowseSeriesSequenceOrder = AndroidAutoBrowseSeriesSequenceOrderSetting.ASC
|
||||
disableAutoRewind = false,
|
||||
enableAltView = true,
|
||||
allowSeekingOnMediaControls = false,
|
||||
jumpBackwardsTime = 10,
|
||||
jumpForwardTime = 10,
|
||||
enableMp3IndexSeeking = false,
|
||||
disableShakeToResetSleepTimer = false,
|
||||
shakeSensitivity = ShakeSensitivitySetting.MEDIUM,
|
||||
lockOrientation = LockOrientationSetting.NONE,
|
||||
hapticFeedback = HapticFeedbackSetting.LIGHT,
|
||||
autoSleepTimer = false,
|
||||
autoSleepTimerStartTime = "22:00",
|
||||
autoSleepTimerEndTime = "06:00",
|
||||
sleepTimerLength = 900000L, // 15 minutes
|
||||
autoSleepTimerAutoRewind = false,
|
||||
autoSleepTimerAutoRewindTime = 300000L, // 5 minutes
|
||||
disableSleepTimerFadeOut = false,
|
||||
disableSleepTimerResetFeedback = false,
|
||||
enableSleepTimerAlmostDoneChime = false,
|
||||
languageCode = "en-us",
|
||||
downloadUsingCellular = DownloadUsingCellularSetting.ALWAYS,
|
||||
streamingUsingCellular = StreamingUsingCellularSetting.ALWAYS,
|
||||
androidAutoBrowseLimitForGrouping = 100,
|
||||
androidAutoBrowseSeriesSequenceOrder = AndroidAutoBrowseSeriesSequenceOrderSetting.ASC
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@get:JsonIgnore
|
||||
val jumpBackwardsTimeMs get() = jumpBackwardsTime * 1000L
|
||||
val jumpBackwardsTimeMs
|
||||
get() = jumpBackwardsTime * 1000L
|
||||
@get:JsonIgnore
|
||||
val jumpForwardTimeMs get() = jumpForwardTime * 1000L
|
||||
val jumpForwardTimeMs
|
||||
get() = jumpForwardTime * 1000L
|
||||
@get:JsonIgnore
|
||||
val autoSleepTimerStartHour get() = autoSleepTimerStartTime.split(":")[0].toInt()
|
||||
val autoSleepTimerStartHour
|
||||
get() = autoSleepTimerStartTime.split(":")[0].toInt()
|
||||
@get:JsonIgnore
|
||||
val autoSleepTimerStartMinute get() = autoSleepTimerStartTime.split(":")[1].toInt()
|
||||
val autoSleepTimerStartMinute
|
||||
get() = autoSleepTimerStartTime.split(":")[1].toInt()
|
||||
@get:JsonIgnore
|
||||
val autoSleepTimerEndHour get() = autoSleepTimerEndTime.split(":")[0].toInt()
|
||||
val autoSleepTimerEndHour
|
||||
get() = autoSleepTimerEndTime.split(":")[0].toInt()
|
||||
@get:JsonIgnore
|
||||
val autoSleepTimerEndMinute get() = autoSleepTimerEndTime.split(":")[1].toInt()
|
||||
|
||||
val autoSleepTimerEndMinute
|
||||
get() = autoSleepTimerEndTime.split(":")[1].toInt()
|
||||
|
||||
@JsonIgnore
|
||||
fun getShakeThresholdGravity() : Float { // Used in ShakeDetector
|
||||
fun getShakeThresholdGravity(): Float { // Used in ShakeDetector
|
||||
return if (shakeSensitivity == ShakeSensitivitySetting.VERY_HIGH) 1.1f
|
||||
else if (shakeSensitivity == ShakeSensitivitySetting.HIGH) 1.3f
|
||||
else if (shakeSensitivity == ShakeSensitivitySetting.MEDIUM) 1.5f
|
||||
@@ -206,10 +240,10 @@ data class DeviceSettings(
|
||||
}
|
||||
|
||||
data class DeviceData(
|
||||
var serverConnectionConfigs:MutableList<ServerConnectionConfig>,
|
||||
var lastServerConnectionConfigId:String?,
|
||||
var deviceSettings: DeviceSettings?,
|
||||
var lastPlaybackSession: PlaybackSession?
|
||||
var serverConnectionConfigs: MutableList<ServerConnectionConfig>,
|
||||
var lastServerConnectionConfigId: String?,
|
||||
var deviceSettings: DeviceSettings?,
|
||||
var lastPlaybackSession: PlaybackSession?
|
||||
) {
|
||||
@JsonIgnore
|
||||
fun getLastServerConnectionConfig(): ServerConnectionConfig? {
|
||||
@@ -218,4 +252,3 @@ data class DeviceData(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
package com.audiobookshelf.app.data
|
||||
|
||||
data class FolderScanResult(
|
||||
var itemsAdded:Int,
|
||||
var itemsUpdated:Int,
|
||||
var itemsRemoved:Int,
|
||||
var itemsUpToDate:Int,
|
||||
val localFolder:LocalFolder,
|
||||
val localLibraryItems:List<LocalLibraryItem>,
|
||||
)
|
||||
|
||||
data class LocalLibraryItemScanResult(
|
||||
val updated:Boolean,
|
||||
val localLibraryItem:LocalLibraryItem,
|
||||
)
|
||||
@@ -80,7 +80,7 @@ class LocalLibraryItem(
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun hasTracks(episode:PodcastEpisode?): Boolean {
|
||||
fun hasTracks(ctx: Context, episode:PodcastEpisode?): Boolean {
|
||||
var audioTracks = media.getAudioTracks() as MutableList<AudioTrack>
|
||||
if (episode != null) { // Get podcast episode audio track
|
||||
episode.audioTrack?.let { at -> mutableListOf(at) }?.let { tracks -> audioTracks = tracks }
|
||||
@@ -91,15 +91,18 @@ class LocalLibraryItem(
|
||||
if (it.metadata === null) {
|
||||
return false
|
||||
}
|
||||
// Check that file exists
|
||||
val file = File(it.metadata!!.path)
|
||||
if (!file.exists()) {
|
||||
if (!trackExists(ctx, it.contentUrl, it.metadata!!.path)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
private fun trackExists(ctx: Context, contentUrl: String?, path: String): Boolean {
|
||||
return LocalFile("", null, contentUrl ?: "", "", path, null, 0).exists(ctx)
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getPlaybackSession(episode:PodcastEpisode?, deviceInfo:DeviceInfo):PlaybackSession {
|
||||
val localEpisodeId = episode?.id
|
||||
|
||||
@@ -14,7 +14,6 @@ data class LocalMediaItem(
|
||||
var mediaType: String,
|
||||
var folderId: String,
|
||||
var contentUrl: String,
|
||||
var simplePath: String,
|
||||
var basePath: String,
|
||||
var absolutePath: String,
|
||||
var audioTracks: MutableList<AudioTrack>,
|
||||
|
||||
@@ -7,570 +7,306 @@ import androidx.documentfile.provider.DocumentFile
|
||||
import com.anggrayudi.storage.file.*
|
||||
import com.audiobookshelf.app.data.*
|
||||
import com.audiobookshelf.app.models.DownloadItem
|
||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.audiobookshelf.app.models.DownloadItemPart
|
||||
import java.io.File
|
||||
|
||||
class FolderScanner(var ctx: Context) {
|
||||
/** Creates local-library records from a completed download manifest. */
|
||||
class FolderScanner(private val ctx: Context) {
|
||||
private val tag = "FolderScanner"
|
||||
private var jacksonMapper =
|
||||
jacksonObjectMapper()
|
||||
.enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||
|
||||
data class DownloadItemScanResult(
|
||||
val localLibraryItem: LocalLibraryItem,
|
||||
var localMediaProgress: LocalMediaProgress?
|
||||
)
|
||||
|
||||
private fun getLocalLibraryItemId(mediaItemId: String): String {
|
||||
return "local_" + DeviceManager.getBase64Id(mediaItemId)
|
||||
}
|
||||
private fun localLibraryItemId(mediaItemId: String) =
|
||||
"local_${DeviceManager.getBase64Id(mediaItemId)}"
|
||||
|
||||
private fun scanInternalDownloadItem(
|
||||
downloadItem: DownloadItem,
|
||||
cb: (DownloadItemScanResult?) -> Unit
|
||||
) {
|
||||
val localLibraryItemId = "local_${downloadItem.libraryItemId}"
|
||||
|
||||
var localEpisodeId: String? = null
|
||||
var localLibraryItem: LocalLibraryItem?
|
||||
if (downloadItem.mediaType == "book") {
|
||||
localLibraryItem =
|
||||
LocalLibraryItem(
|
||||
localLibraryItemId,
|
||||
downloadItem.localFolder.id,
|
||||
downloadItem.itemFolderPath,
|
||||
downloadItem.itemFolderPath,
|
||||
"",
|
||||
false,
|
||||
downloadItem.mediaType,
|
||||
downloadItem.media.getLocalCopy(),
|
||||
mutableListOf(),
|
||||
null,
|
||||
null,
|
||||
true,
|
||||
downloadItem.serverConnectionConfigId,
|
||||
downloadItem.serverAddress,
|
||||
downloadItem.serverUserId,
|
||||
downloadItem.libraryItemId
|
||||
)
|
||||
} else {
|
||||
// Lookup or create podcast local library item
|
||||
localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
|
||||
if (localLibraryItem == null) {
|
||||
Log.d(
|
||||
tag,
|
||||
"[FolderScanner] Podcast local library item not created yet for ${downloadItem.media.metadata.title}"
|
||||
)
|
||||
localLibraryItem =
|
||||
LocalLibraryItem(
|
||||
localLibraryItemId,
|
||||
downloadItem.localFolder.id,
|
||||
downloadItem.itemFolderPath,
|
||||
downloadItem.itemFolderPath,
|
||||
"",
|
||||
false,
|
||||
downloadItem.mediaType,
|
||||
downloadItem.media.getLocalCopy(),
|
||||
mutableListOf(),
|
||||
null,
|
||||
null,
|
||||
true,
|
||||
downloadItem.serverConnectionConfigId,
|
||||
downloadItem.serverAddress,
|
||||
downloadItem.serverUserId,
|
||||
downloadItem.libraryItemId
|
||||
)
|
||||
}
|
||||
private fun createLocalFile(
|
||||
part: DownloadItemPart,
|
||||
externalFile: DocumentFile? = null
|
||||
): LocalFile? {
|
||||
if (part.isInternalStorage) {
|
||||
val file = File(part.finalDestinationPath)
|
||||
if (!file.exists()) return null
|
||||
return LocalFile(
|
||||
DeviceManager.getBase64Id(file.name),
|
||||
file.name,
|
||||
Uri.fromFile(file).toString(),
|
||||
file.getBasePath(ctx),
|
||||
file.absolutePath,
|
||||
file.mimeType,
|
||||
file.length()
|
||||
)
|
||||
}
|
||||
|
||||
val audioTracks: MutableList<AudioTrack> = mutableListOf()
|
||||
var foundEBookFile = false
|
||||
|
||||
downloadItem.downloadItemParts.forEach { downloadItemPart ->
|
||||
Log.d(
|
||||
tag,
|
||||
"Scan internal storage item with finalDestinationUri=${downloadItemPart.finalDestinationUri}"
|
||||
part.completedDestinationUri?.let { contentUrl ->
|
||||
val uri = Uri.parse(contentUrl)
|
||||
val size =
|
||||
try {
|
||||
ctx.contentResolver.openFileDescriptor(uri, "r")?.use { descriptor ->
|
||||
descriptor.statSize.coerceAtLeast(0L)
|
||||
}
|
||||
?: 0L
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "Could not open completed SAF file: $contentUrl", e)
|
||||
return null
|
||||
}
|
||||
// Android 10 DownloadsProvider may not reconstruct a DocumentFile for an audio URI even
|
||||
// though the URI remains readable. Keep the URI as the authoritative local-file location.
|
||||
return LocalFile(
|
||||
DeviceManager.getBase64Id(contentUrl),
|
||||
part.filename,
|
||||
contentUrl,
|
||||
part.localFolderName,
|
||||
part.finalDestinationPath,
|
||||
mimeTypeFor(part),
|
||||
size
|
||||
)
|
||||
}
|
||||
|
||||
val file = File(downloadItemPart.finalDestinationPath)
|
||||
Log.d(tag, "Scan internal storage item created file ${file.name}")
|
||||
// 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()
|
||||
)
|
||||
}
|
||||
|
||||
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}"
|
||||
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
|
||||
)
|
||||
|
||||
val localFileId = DeviceManager.getBase64Id(file.name)
|
||||
Log.d(tag, "Scan internal file localFileId=$localFileId")
|
||||
val localFile =
|
||||
LocalFile(
|
||||
localFileId,
|
||||
file.name,
|
||||
downloadItemPart.finalDestinationUri.toString(),
|
||||
file.getBasePath(ctx),
|
||||
file.absolutePath,
|
||||
file.getSimplePath(ctx),
|
||||
file.mimeType,
|
||||
file.length()
|
||||
)
|
||||
localLibraryItem.localFiles.add(localFile)
|
||||
private fun scanParts(
|
||||
item: DownloadItem,
|
||||
localItem: LocalLibraryItem,
|
||||
externalFolder: DocumentFile? = null,
|
||||
callback: (DownloadItemScanResult?) -> Unit
|
||||
) {
|
||||
val tracks = mutableListOf<AudioTrack>()
|
||||
var foundEbook = false
|
||||
var localEpisodeId: String? = null
|
||||
|
||||
val trackFileMetadata =
|
||||
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(
|
||||
file.name,
|
||||
file.extension,
|
||||
file.absolutePath,
|
||||
file.getBasePath(ctx),
|
||||
file.length()
|
||||
localFile.filename ?: "",
|
||||
File(localFile.filename ?: "").extension,
|
||||
localFile.absolutePath,
|
||||
localFile.basePath,
|
||||
localFile.size
|
||||
)
|
||||
// Create new audio track
|
||||
val track =
|
||||
AudioTrack(
|
||||
audioTrackFromServer.index,
|
||||
audioTrackFromServer.startOffset,
|
||||
audioTrackFromServer.duration,
|
||||
serverTrack.index,
|
||||
serverTrack.startOffset,
|
||||
serverTrack.duration,
|
||||
localFile.filename ?: "",
|
||||
localFile.contentUrl,
|
||||
localFile.mimeType ?: "",
|
||||
trackFileMetadata,
|
||||
metadata,
|
||||
true,
|
||||
localFileId,
|
||||
audioTrackFromServer.index
|
||||
localFile.id,
|
||||
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
|
||||
Log.d(tag, "scanInternalDownloadItem: Ebook file found with mimetype=${file.mimeType}")
|
||||
val localFileId = DeviceManager.getBase64Id(file.name)
|
||||
val localFile =
|
||||
LocalFile(
|
||||
localFileId,
|
||||
file.name,
|
||||
Uri.fromFile(file).toString(),
|
||||
file.getBasePath(ctx),
|
||||
file.absolutePath,
|
||||
file.getSimplePath(ctx),
|
||||
file.mimeType,
|
||||
file.length()
|
||||
)
|
||||
localLibraryItem.localFiles.add(localFile)
|
||||
|
||||
val ebookFile =
|
||||
}
|
||||
part.ebookFile != null -> {
|
||||
foundEbook = true
|
||||
localItem.localFiles.removeAll { it.id == localFile.id }
|
||||
localItem.localFiles.add(localFile)
|
||||
(localItem.media as Book).ebookFile =
|
||||
EBookFile(
|
||||
downloadItemPart.ebookFile.ino,
|
||||
downloadItemPart.ebookFile.metadata,
|
||||
downloadItemPart.ebookFile.ebookFormat,
|
||||
part.ebookFile.ino,
|
||||
part.ebookFile.metadata,
|
||||
part.ebookFile.ebookFormat,
|
||||
true,
|
||||
localFileId,
|
||||
localFile.id,
|
||||
localFile.contentUrl
|
||||
)
|
||||
(localLibraryItem.media as Book).ebookFile = ebookFile
|
||||
Log.d(tag, "scanInternalDownloadItem: Ebook file added to lli ${localFile.contentUrl}")
|
||||
} else {
|
||||
val localFileId = DeviceManager.getBase64Id(file.name)
|
||||
val localFile =
|
||||
LocalFile(
|
||||
localFileId,
|
||||
file.name,
|
||||
Uri.fromFile(file).toString(),
|
||||
file.getBasePath(ctx),
|
||||
file.absolutePath,
|
||||
file.getSimplePath(ctx),
|
||||
file.mimeType,
|
||||
file.length()
|
||||
)
|
||||
|
||||
localLibraryItem.coverAbsolutePath = localFile.absolutePath
|
||||
localLibraryItem.coverContentUrl = localFile.contentUrl
|
||||
localLibraryItem.localFiles.add(localFile)
|
||||
}
|
||||
else -> {
|
||||
localItem.coverAbsolutePath = localFile.absolutePath
|
||||
localItem.coverContentUrl = localFile.contentUrl
|
||||
localItem.localFiles.removeAll { it.id == localFile.id }
|
||||
localItem.localFiles.add(localFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (audioTracks.isEmpty() && !foundEBookFile) {
|
||||
Log.d(
|
||||
tag,
|
||||
"scanDownloadItem did not find any audio tracks or ebook file in folder for ${downloadItem.itemFolderPath}"
|
||||
)
|
||||
return cb(null)
|
||||
if (tracks.isEmpty() && !foundEbook) {
|
||||
callback(null)
|
||||
return
|
||||
}
|
||||
|
||||
// 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
|
||||
if (item.mediaType == "book") {
|
||||
tracks.sortBy { it.index }
|
||||
var expectedIndex = 1
|
||||
var offset = 0.0
|
||||
tracks.forEach { track ->
|
||||
track.index = expectedIndex++
|
||||
track.startOffset = offset
|
||||
offset += track.duration
|
||||
}
|
||||
|
||||
localLibraryItem.media.setAudioTracks(audioTracks)
|
||||
localItem.media.setAudioTracks(tracks)
|
||||
}
|
||||
|
||||
val downloadItemScanResult = DownloadItemScanResult(localLibraryItem, null)
|
||||
|
||||
// If library item had media progress then make local media progress and save
|
||||
downloadItem.userMediaProgress?.let { mediaProgress ->
|
||||
val localMediaProgressId =
|
||||
if (downloadItem.episodeId.isNullOrEmpty()) localLibraryItemId
|
||||
else "$localLibraryItemId-$localEpisodeId"
|
||||
val newLocalMediaProgress =
|
||||
val result = DownloadItemScanResult(localItem, null)
|
||||
item.userMediaProgress?.let { progress ->
|
||||
val progressId =
|
||||
if (item.episodeId.isNullOrEmpty()) localItem.id
|
||||
else "${localItem.id}-$localEpisodeId"
|
||||
result.localMediaProgress =
|
||||
LocalMediaProgress(
|
||||
id = localMediaProgressId,
|
||||
localLibraryItemId = localLibraryItemId,
|
||||
localEpisodeId = localEpisodeId,
|
||||
duration = mediaProgress.duration,
|
||||
progress = mediaProgress.progress,
|
||||
currentTime = mediaProgress.currentTime,
|
||||
isFinished = mediaProgress.isFinished,
|
||||
ebookLocation = mediaProgress.ebookLocation,
|
||||
ebookProgress = mediaProgress.ebookProgress,
|
||||
lastUpdate = mediaProgress.lastUpdate,
|
||||
startedAt = mediaProgress.startedAt,
|
||||
finishedAt = mediaProgress.finishedAt,
|
||||
serverConnectionConfigId = downloadItem.serverConnectionConfigId,
|
||||
serverAddress = downloadItem.serverAddress,
|
||||
serverUserId = downloadItem.serverUserId,
|
||||
libraryItemId = downloadItem.libraryItemId,
|
||||
episodeId = downloadItem.episodeId
|
||||
progressId,
|
||||
localItem.id,
|
||||
localEpisodeId,
|
||||
progress.duration,
|
||||
progress.progress,
|
||||
progress.currentTime,
|
||||
progress.isFinished,
|
||||
progress.ebookLocation,
|
||||
progress.ebookProgress,
|
||||
progress.lastUpdate,
|
||||
progress.startedAt,
|
||||
progress.finishedAt,
|
||||
item.serverConnectionConfigId,
|
||||
item.serverAddress,
|
||||
item.serverUserId,
|
||||
item.libraryItemId,
|
||||
item.episodeId
|
||||
)
|
||||
Log.d(
|
||||
tag,
|
||||
"scanLibraryItemFolder: Saving local media progress ${newLocalMediaProgress.id} at progress ${newLocalMediaProgress.progress}"
|
||||
)
|
||||
DeviceManager.dbManager.saveLocalMediaProgress(newLocalMediaProgress)
|
||||
|
||||
downloadItemScanResult.localMediaProgress = newLocalMediaProgress
|
||||
DeviceManager.dbManager.saveLocalMediaProgress(result.localMediaProgress!!)
|
||||
}
|
||||
|
||||
DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem)
|
||||
|
||||
cb(downloadItemScanResult)
|
||||
DeviceManager.dbManager.saveLocalLibraryItem(localItem)
|
||||
callback(result)
|
||||
}
|
||||
|
||||
// Scan item after download and create local library item
|
||||
fun scanDownloadItem(downloadItem: DownloadItem, cb: (DownloadItemScanResult?) -> Unit) {
|
||||
// If downloading to internal storage handle separately
|
||||
if (downloadItem.isInternalStorage) {
|
||||
scanInternalDownloadItem(downloadItem, cb)
|
||||
private fun findFolderByPath(root: DocumentFile, subPath: String): DocumentFile? {
|
||||
if (subPath.isBlank()) return root
|
||||
var current = root
|
||||
subPath.split('/').filter { it.isNotBlank() }.forEach { segment ->
|
||||
if (segment == "." || segment == "..") return null
|
||||
current = current.findFile(segment) ?: return null
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
/**
|
||||
* DownloadsProvider on Android 10 may expose an audio document without its extension through
|
||||
* DocumentFile.findFile(). Match the manifest first, then match the provider-normalized base
|
||||
* filename. MIME type and server-reported size are unreliable for Opus on this platform.
|
||||
*/
|
||||
private fun resolveExternalFile(folder: DocumentFile?, part: DownloadItemPart): DocumentFile? {
|
||||
if (folder == null) return null
|
||||
folder.findFile(part.filename)?.let {
|
||||
return it
|
||||
}
|
||||
val expectedBaseName = part.filename.substringBeforeLast('.')
|
||||
return folder.listFiles().firstOrNull { document ->
|
||||
document.name == part.filename ||
|
||||
document.fullName == part.filename ||
|
||||
(part.audioTrack != null &&
|
||||
document.isFile &&
|
||||
(document.name ?: "").substringBeforeLast('.') == expectedBaseName) ||
|
||||
(part.audioTrack != null &&
|
||||
document.isFile &&
|
||||
document.fullName.substringBeforeLast('.') == expectedBaseName)
|
||||
}
|
||||
}
|
||||
|
||||
private fun mimeTypeFor(part: DownloadItemPart): String? {
|
||||
return part.audioTrack?.mimeType
|
||||
?: when (part.ebookFile?.ebookFormat?.lowercase()) {
|
||||
"epub" -> "application/epub+zip"
|
||||
"pdf" -> "application/pdf"
|
||||
else -> "image/jpeg"
|
||||
}
|
||||
}
|
||||
|
||||
fun scanDownloadItem(item: DownloadItem, callback: (DownloadItemScanResult?) -> Unit) {
|
||||
if (item.isInternalStorage) {
|
||||
val id = "local_${item.libraryItemId}"
|
||||
val localItem =
|
||||
DeviceManager.dbManager.getLocalLibraryItem(id)
|
||||
?: newLocalLibraryItem(id, item, item.itemFolderPath, item.itemFolderPath, "")
|
||||
scanParts(item, localItem, callback = callback)
|
||||
return
|
||||
}
|
||||
|
||||
val folderDf = DocumentFileCompat.fromUri(ctx, Uri.parse(downloadItem.localFolder.contentUrl))
|
||||
val foldersFound = folderDf?.search(true, DocumentFileType.FOLDER) ?: mutableListOf()
|
||||
|
||||
var itemFolderId = ""
|
||||
var itemFolderUrl = ""
|
||||
var itemFolderBasePath = ""
|
||||
var itemFolderAbsolutePath = ""
|
||||
foldersFound.forEach {
|
||||
// e.g. absolute path is "storage/emulated/0/Audiobooks/Orson Scott Card/Enders Game"
|
||||
// and itemSubfolder is "Orson Scott Card/Enders Game"
|
||||
if (it.getAbsolutePath(ctx).endsWith(downloadItem.itemSubfolder)) {
|
||||
itemFolderId = it.id
|
||||
itemFolderUrl = it.uri.toString()
|
||||
itemFolderBasePath = it.getBasePath(ctx)
|
||||
itemFolderAbsolutePath = it.getAbsolutePath(ctx)
|
||||
}
|
||||
val root = DocumentFileCompat.fromUri(ctx, Uri.parse(item.localFolder.contentUrl))
|
||||
if (root == null) {
|
||||
Log.e(tag, "Invalid SAF root: ${item.localFolder.contentUrl}")
|
||||
callback(null)
|
||||
return
|
||||
}
|
||||
|
||||
if (itemFolderUrl == "") {
|
||||
Log.d(tag, "scanDownloadItem failed to find media folder")
|
||||
return cb(null)
|
||||
val itemFolder = findFolderByPath(root, item.itemSubfolder)
|
||||
if (itemFolder == null) {
|
||||
Log.e(tag, "SAF item folder not found: ${item.itemSubfolder}")
|
||||
callback(null)
|
||||
return
|
||||
}
|
||||
val df: DocumentFile? = DocumentFileCompat.fromUri(ctx, Uri.parse(itemFolderUrl))
|
||||
|
||||
if (df == null) {
|
||||
Log.e(tag, "Folder Doc File Invalid ${downloadItem.itemFolderPath}")
|
||||
return cb(null)
|
||||
}
|
||||
|
||||
val localLibraryItemId = getLocalLibraryItemId(itemFolderId)
|
||||
Log.d(
|
||||
tag,
|
||||
"scanDownloadItem starting for ${downloadItem.itemFolderPath} | ${df.uri} | Item Folder Id:$itemFolderId | LLI Id:$localLibraryItemId"
|
||||
)
|
||||
|
||||
// Search for files in media item folder
|
||||
// m4b files showing as mimeType application/octet-stream on Android 10 and earlier see #154
|
||||
val filesFound =
|
||||
df.search(
|
||||
false,
|
||||
DocumentFileType.FILE,
|
||||
arrayOf("audio/*", "image/*", "video/mp4", "application/*")
|
||||
)
|
||||
Log.d(tag, "scanDownloadItem ${filesFound.size} files found in ${downloadItem.itemFolderPath}")
|
||||
|
||||
var localEpisodeId: String? = null
|
||||
var localLibraryItem: LocalLibraryItem?
|
||||
if (downloadItem.mediaType == "book") {
|
||||
localLibraryItem =
|
||||
LocalLibraryItem(
|
||||
localLibraryItemId,
|
||||
downloadItem.localFolder.id,
|
||||
itemFolderBasePath,
|
||||
itemFolderAbsolutePath,
|
||||
itemFolderUrl,
|
||||
false,
|
||||
downloadItem.mediaType,
|
||||
downloadItem.media.getLocalCopy(),
|
||||
mutableListOf(),
|
||||
null,
|
||||
null,
|
||||
true,
|
||||
downloadItem.serverConnectionConfigId,
|
||||
downloadItem.serverAddress,
|
||||
downloadItem.serverUserId,
|
||||
downloadItem.libraryItemId
|
||||
)
|
||||
} else {
|
||||
// Lookup or create podcast local library item
|
||||
localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
|
||||
if (localLibraryItem == null) {
|
||||
Log.d(
|
||||
tag,
|
||||
"[FolderScanner] Podcast local library item not created yet for ${downloadItem.media.metadata.title}"
|
||||
)
|
||||
localLibraryItem =
|
||||
LocalLibraryItem(
|
||||
localLibraryItemId,
|
||||
downloadItem.localFolder.id,
|
||||
itemFolderBasePath,
|
||||
itemFolderAbsolutePath,
|
||||
itemFolderUrl,
|
||||
false,
|
||||
downloadItem.mediaType,
|
||||
downloadItem.media.getLocalCopy(),
|
||||
mutableListOf(),
|
||||
null,
|
||||
null,
|
||||
true,
|
||||
downloadItem.serverConnectionConfigId,
|
||||
downloadItem.serverAddress,
|
||||
downloadItem.serverUserId,
|
||||
downloadItem.libraryItemId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val audioTracks: MutableList<AudioTrack> = mutableListOf()
|
||||
var foundEBookFile = false
|
||||
|
||||
filesFound.forEach { docFile ->
|
||||
val itemPart =
|
||||
downloadItem.downloadItemParts.find { itemPart -> itemPart.filename == docFile.name }
|
||||
if (itemPart == null) {
|
||||
if (downloadItem.mediaType == "book"
|
||||
) { // for books every download item should be a file found
|
||||
Log.e(
|
||||
tag,
|
||||
"scanDownloadItem: Item part not found for doc file ${docFile.name} | ${docFile.getAbsolutePath(ctx)} | ${docFile.uri}"
|
||||
)
|
||||
}
|
||||
} else if (itemPart.audioTrack != null) { // Is audio track
|
||||
val audioTrackFromServer = itemPart.audioTrack
|
||||
Log.d(
|
||||
tag,
|
||||
"scanDownloadItem: Audio Track from Server index = ${audioTrackFromServer.index}"
|
||||
)
|
||||
|
||||
val localFileId = DeviceManager.getBase64Id(docFile.id)
|
||||
val localFile =
|
||||
LocalFile(
|
||||
localFileId,
|
||||
docFile.name,
|
||||
docFile.uri.toString(),
|
||||
docFile.getBasePath(ctx),
|
||||
docFile.getAbsolutePath(ctx),
|
||||
docFile.getSimplePath(ctx),
|
||||
docFile.mimeType,
|
||||
docFile.length()
|
||||
)
|
||||
localLibraryItem.localFiles.add(localFile)
|
||||
|
||||
// Create new audio track
|
||||
val trackFileMetadata =
|
||||
FileMetadata(
|
||||
docFile.name ?: "",
|
||||
docFile.extension ?: "",
|
||||
docFile.getAbsolutePath(ctx),
|
||||
docFile.getBasePath(ctx),
|
||||
docFile.length()
|
||||
)
|
||||
val track =
|
||||
AudioTrack(
|
||||
audioTrackFromServer.index,
|
||||
audioTrackFromServer.startOffset,
|
||||
audioTrackFromServer.duration,
|
||||
localFile.filename ?: "",
|
||||
localFile.contentUrl,
|
||||
localFile.mimeType ?: "",
|
||||
trackFileMetadata,
|
||||
true,
|
||||
localFileId,
|
||||
audioTrackFromServer.index
|
||||
)
|
||||
audioTracks.add(track)
|
||||
|
||||
Log.d(
|
||||
tag,
|
||||
"scanDownloadItem: Created Audio Track with index ${track.index} from local file ${localFile.absolutePath}"
|
||||
)
|
||||
|
||||
// Add podcast episodes to library
|
||||
itemPart.episode?.let { podcastEpisode ->
|
||||
val podcast = localLibraryItem.media as Podcast
|
||||
val newEpisode = podcast.addEpisode(track, podcastEpisode)
|
||||
localEpisodeId = newEpisode.id
|
||||
Log.d(
|
||||
tag,
|
||||
"scanDownloadItem: Added episode to podcast ${podcastEpisode.title} ${track.title} | Track index: ${podcastEpisode.audioTrack?.index}"
|
||||
)
|
||||
}
|
||||
} else if (itemPart.ebookFile != null) { // Ebook
|
||||
foundEBookFile = true
|
||||
Log.d(tag, "scanDownloadItem: Ebook file found with mimetype=${docFile.mimeType}")
|
||||
val localFileId = DeviceManager.getBase64Id(docFile.id)
|
||||
val localFile =
|
||||
LocalFile(
|
||||
localFileId,
|
||||
docFile.name,
|
||||
docFile.uri.toString(),
|
||||
docFile.getBasePath(ctx),
|
||||
docFile.getAbsolutePath(ctx),
|
||||
docFile.getSimplePath(ctx),
|
||||
docFile.mimeType,
|
||||
docFile.length()
|
||||
)
|
||||
localLibraryItem.localFiles.add(localFile)
|
||||
|
||||
val ebookFile =
|
||||
EBookFile(
|
||||
itemPart.ebookFile.ino,
|
||||
itemPart.ebookFile.metadata,
|
||||
itemPart.ebookFile.ebookFormat,
|
||||
true,
|
||||
localFileId,
|
||||
localFile.contentUrl
|
||||
)
|
||||
(localLibraryItem.media as Book).ebookFile = ebookFile
|
||||
Log.d(tag, "scanDownloadItem: Ebook file added to lli ${localFile.contentUrl}")
|
||||
} else { // Cover image
|
||||
val localFileId = DeviceManager.getBase64Id(docFile.id)
|
||||
val localFile =
|
||||
LocalFile(
|
||||
localFileId,
|
||||
docFile.name,
|
||||
docFile.uri.toString(),
|
||||
docFile.getBasePath(ctx),
|
||||
docFile.getAbsolutePath(ctx),
|
||||
docFile.getSimplePath(ctx),
|
||||
docFile.mimeType,
|
||||
docFile.length()
|
||||
)
|
||||
|
||||
localLibraryItem.coverAbsolutePath = localFile.absolutePath
|
||||
localLibraryItem.coverContentUrl = localFile.contentUrl
|
||||
localLibraryItem.localFiles.add(localFile)
|
||||
}
|
||||
}
|
||||
|
||||
if (audioTracks.isEmpty() && !foundEBookFile) {
|
||||
Log.d(
|
||||
tag,
|
||||
"scanDownloadItem did not find any audio tracks or ebook file in folder for ${downloadItem.itemFolderPath}"
|
||||
)
|
||||
return cb(null)
|
||||
}
|
||||
|
||||
// For books sort audio tracks then set
|
||||
if (downloadItem.mediaType == "book") {
|
||||
audioTracks.sortBy { it.index }
|
||||
|
||||
var indexCheck = 1
|
||||
var startOffset = 0.0
|
||||
audioTracks.forEach { audioTrack ->
|
||||
if (audioTrack.index != indexCheck || audioTrack.startOffset != startOffset) {
|
||||
audioTrack.index = indexCheck
|
||||
audioTrack.startOffset = startOffset
|
||||
}
|
||||
indexCheck++
|
||||
startOffset += audioTrack.duration
|
||||
}
|
||||
|
||||
localLibraryItem.media.setAudioTracks(audioTracks)
|
||||
}
|
||||
|
||||
val downloadItemScanResult = DownloadItemScanResult(localLibraryItem, null)
|
||||
|
||||
// If library item had media progress then make local media progress and save
|
||||
downloadItem.userMediaProgress?.let { mediaProgress ->
|
||||
val localMediaProgressId =
|
||||
if (downloadItem.episodeId.isNullOrEmpty()) localLibraryItemId
|
||||
else "$localLibraryItemId-$localEpisodeId"
|
||||
val newLocalMediaProgress =
|
||||
LocalMediaProgress(
|
||||
id = localMediaProgressId,
|
||||
localLibraryItemId = localLibraryItemId,
|
||||
localEpisodeId = localEpisodeId,
|
||||
duration = mediaProgress.duration,
|
||||
progress = mediaProgress.progress,
|
||||
currentTime = mediaProgress.currentTime,
|
||||
isFinished = mediaProgress.isFinished,
|
||||
ebookLocation = mediaProgress.ebookLocation,
|
||||
ebookProgress = mediaProgress.ebookProgress,
|
||||
lastUpdate = mediaProgress.lastUpdate,
|
||||
startedAt = mediaProgress.startedAt,
|
||||
finishedAt = mediaProgress.finishedAt,
|
||||
serverConnectionConfigId = downloadItem.serverConnectionConfigId,
|
||||
serverAddress = downloadItem.serverAddress,
|
||||
serverUserId = downloadItem.serverUserId,
|
||||
libraryItemId = downloadItem.libraryItemId,
|
||||
episodeId = downloadItem.episodeId
|
||||
)
|
||||
Log.d(
|
||||
tag,
|
||||
"scanLibraryItemFolder: Saving local media progress ${newLocalMediaProgress.id} at progress ${newLocalMediaProgress.progress}"
|
||||
)
|
||||
|
||||
DeviceManager.dbManager.saveLocalMediaProgress(newLocalMediaProgress)
|
||||
|
||||
downloadItemScanResult.localMediaProgress = newLocalMediaProgress
|
||||
}
|
||||
|
||||
DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem)
|
||||
|
||||
cb(downloadItemScanResult)
|
||||
val id = localLibraryItemId(itemFolder.id)
|
||||
val localItem =
|
||||
DeviceManager.dbManager.getLocalLibraryItem(id)
|
||||
?: newLocalLibraryItem(
|
||||
id,
|
||||
item,
|
||||
itemFolder.getBasePath(ctx),
|
||||
itemFolder.getAbsolutePath(ctx),
|
||||
itemFolder.uri.toString()
|
||||
)
|
||||
scanParts(item, localItem, itemFolder, callback)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import com.audiobookshelf.app.models.DownloadItem
|
||||
import com.audiobookshelf.app.plugins.AbsLog
|
||||
import com.audiobookshelf.app.plugins.AbsLogger
|
||||
import io.paperdb.Paper
|
||||
import java.io.File
|
||||
|
||||
class DbManager {
|
||||
val tag = "DbManager"
|
||||
@@ -148,7 +147,7 @@ class DbManager {
|
||||
}
|
||||
|
||||
// Make sure all local file ids still exist
|
||||
fun cleanLocalLibraryItems() {
|
||||
fun cleanLocalLibraryItems(context: Context) {
|
||||
val localLibraryItems = getLocalLibraryItems()
|
||||
|
||||
localLibraryItems.forEach { lli ->
|
||||
@@ -157,15 +156,15 @@ class DbManager {
|
||||
// Check local files
|
||||
lli.localFiles =
|
||||
lli.localFiles.filter { localFile ->
|
||||
val file = File(localFile.absolutePath)
|
||||
if (!file.exists()) {
|
||||
val exists = localFile.exists(context)
|
||||
if (!exists) {
|
||||
Log.d(
|
||||
tag,
|
||||
"cleanLocalLibraryItems: Local file ${localFile.absolutePath} was removed from library item ${lli.media.metadata.title}"
|
||||
)
|
||||
hasUpdates = true
|
||||
}
|
||||
file.exists()
|
||||
exists
|
||||
} as
|
||||
MutableList<LocalFile>
|
||||
|
||||
@@ -203,9 +202,11 @@ class DbManager {
|
||||
|
||||
// Check cover still there
|
||||
lli.coverAbsolutePath?.let {
|
||||
val coverFile = File(it)
|
||||
|
||||
if (!coverFile.exists()) {
|
||||
val coverExists =
|
||||
lli.localFiles.any { localFile ->
|
||||
localFile.absolutePath == it && localFile.exists(context)
|
||||
}
|
||||
if (!coverExists) {
|
||||
Log.d(
|
||||
tag,
|
||||
"cleanLocalLibraryItems: Cover $it was removed from library item ${lli.media.metadata.title}"
|
||||
@@ -290,15 +291,13 @@ class DbManager {
|
||||
return sessions
|
||||
}
|
||||
|
||||
fun saveLog(log:AbsLog) {
|
||||
fun saveLog(log: AbsLog) {
|
||||
Paper.book("log").write(log.id, log)
|
||||
}
|
||||
fun getAllLogs() : List<AbsLog> {
|
||||
val logs:MutableList<AbsLog> = mutableListOf()
|
||||
fun getAllLogs(): List<AbsLog> {
|
||||
val logs: MutableList<AbsLog> = mutableListOf()
|
||||
Paper.book("log").allKeys.forEach { logId ->
|
||||
Paper.book("log").read<AbsLog>(logId)?.let {
|
||||
logs.add(it)
|
||||
}
|
||||
Paper.book("log").read<AbsLog>(logId)?.let { logs.add(it) }
|
||||
}
|
||||
return logs.sortedBy { it.timestamp }
|
||||
}
|
||||
@@ -317,7 +316,10 @@ class DbManager {
|
||||
}
|
||||
}
|
||||
if (logsRemoved > 0) {
|
||||
AbsLogger.info("DbManager", "cleanLogs: Removed $logsRemoved logs older than $numberOfHoursToKeep hours")
|
||||
AbsLogger.info(
|
||||
"DbManager",
|
||||
"cleanLogs: Removed $logsRemoved logs older than $numberOfHoursToKeep hours"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+437
-308
@@ -1,16 +1,10 @@
|
||||
package com.audiobookshelf.app.managers
|
||||
|
||||
import android.app.DownloadManager
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.StatFs
|
||||
import android.util.Log
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import com.anggrayudi.storage.callback.FileCallback
|
||||
import com.anggrayudi.storage.file.DocumentFileCompat
|
||||
import com.anggrayudi.storage.file.MimeType
|
||||
import com.anggrayudi.storage.file.getAbsolutePath
|
||||
import com.anggrayudi.storage.file.moveFileTo
|
||||
import com.anggrayudi.storage.media.FileDescription
|
||||
import com.audiobookshelf.app.MainActivity
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.audiobookshelf.app.device.FolderScanner
|
||||
import com.audiobookshelf.app.models.DownloadItem
|
||||
@@ -19,41 +13,44 @@ import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.getcapacitor.JSObject
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.util.*
|
||||
import java.io.FileInputStream
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.math.max
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.Call
|
||||
|
||||
/** Manages download items and their parts. */
|
||||
/** Manages the process-owned queue for app-managed downloads. */
|
||||
class DownloadItemManager(
|
||||
var downloadManager: DownloadManager,
|
||||
private var folderScanner: FolderScanner,
|
||||
var mainActivity: MainActivity,
|
||||
private val folderScanner: FolderScanner,
|
||||
private val context: Context,
|
||||
private var clientEventEmitter: DownloadEventEmitter
|
||||
) {
|
||||
val tag = "DownloadItemManager"
|
||||
private val maxSimultaneousDownloads = 3
|
||||
private var jacksonMapper =
|
||||
private val tag = "DownloadItemManager"
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val activeCalls = ConcurrentHashMap<String, Call>()
|
||||
private val safFolderLocks = ConcurrentHashMap<String, Any>()
|
||||
private val reservations = mutableMapOf<String, Long>()
|
||||
private val lastPersistTime = mutableMapOf<String, Long>()
|
||||
private var watcherRunning = false
|
||||
private val jacksonMapper =
|
||||
jacksonObjectMapper()
|
||||
.enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||
|
||||
enum class DownloadCheckStatus {
|
||||
InProgress,
|
||||
Successful,
|
||||
Failed
|
||||
}
|
||||
|
||||
var downloadItemQueue: MutableList<DownloadItem> =
|
||||
mutableListOf() // All pending and downloading items
|
||||
var currentDownloadItemParts: MutableList<DownloadItemPart> =
|
||||
mutableListOf() // Item parts currently being downloaded
|
||||
var downloadItemQueue: MutableList<DownloadItem> = mutableListOf()
|
||||
private set
|
||||
var currentDownloadItemParts: MutableList<DownloadItemPart> = mutableListOf()
|
||||
private set
|
||||
|
||||
interface DownloadEventEmitter {
|
||||
fun onDownloadItem(downloadItem: DownloadItem)
|
||||
fun onDownloadItemPartUpdate(downloadItemPart: DownloadItemPart)
|
||||
fun onDownloadItemComplete(jsobj: JSObject)
|
||||
fun onQueueChanged(hasWork: Boolean)
|
||||
}
|
||||
|
||||
interface InternalProgressCallback {
|
||||
@@ -61,323 +58,455 @@ class DownloadItemManager(
|
||||
fun onComplete(failed: Boolean)
|
||||
}
|
||||
|
||||
companion object {
|
||||
var isDownloading: Boolean = false
|
||||
init {
|
||||
IncompleteDownloadCleanup.cleanupExpired(context)
|
||||
}
|
||||
|
||||
/** Adds a download item to the queue and starts processing the queue. */
|
||||
fun addDownloadItem(downloadItem: DownloadItem) {
|
||||
DeviceManager.dbManager.saveDownloadItem(downloadItem)
|
||||
Log.i(tag, "Add download item ${downloadItem.media.metadata.title}")
|
||||
@Synchronized
|
||||
fun setEventEmitter(eventEmitter: DownloadEventEmitter) {
|
||||
clientEventEmitter = eventEmitter
|
||||
downloadItemQueue.forEach(clientEventEmitter::onDownloadItem)
|
||||
notifyQueueChanged()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun restoreQueue() {
|
||||
if (downloadItemQueue.isNotEmpty()) return
|
||||
DeviceManager.dbManager.getDownloadItems().forEach { item ->
|
||||
if (item.isDownloadFinished) {
|
||||
downloadItemQueue.add(item)
|
||||
checkDownloadItemFinished(item)
|
||||
return@forEach
|
||||
}
|
||||
item.downloadItemParts.forEach { part ->
|
||||
if (part.moved) return@forEach
|
||||
if (item.terminalFailureAt != null && part.failed) return@forEach
|
||||
part.downloadId = null
|
||||
part.isMoving = false
|
||||
part.failed = false
|
||||
part.completed = false
|
||||
part.waitingForSpace = false
|
||||
part.bytesDownloaded = File(part.destinationPath).takeIf(File::exists)?.length() ?: 0L
|
||||
}
|
||||
downloadItemQueue.add(item)
|
||||
if (item.terminalFailureAt != null) IncompleteDownloadCleanup.schedule(context, item)
|
||||
clientEventEmitter.onDownloadItem(item)
|
||||
}
|
||||
checkUpdateDownloadQueue()
|
||||
notifyQueueChanged()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun addDownloadItem(downloadItem: DownloadItem) {
|
||||
val existingItem = downloadItemQueue.find { it.id == downloadItem.id }
|
||||
if (existingItem != null) {
|
||||
if (existingItem.terminalFailureAt != null) {
|
||||
retryDownloadItem(existingItem)
|
||||
checkUpdateDownloadQueue()
|
||||
notifyQueueChanged()
|
||||
}
|
||||
return
|
||||
}
|
||||
persist(downloadItem, force = true)
|
||||
downloadItemQueue.add(downloadItem)
|
||||
clientEventEmitter.onDownloadItem(downloadItem)
|
||||
checkUpdateDownloadQueue()
|
||||
notifyQueueChanged()
|
||||
}
|
||||
|
||||
/** Checks and updates the download queue. */
|
||||
private fun retryDownloadItem(item: DownloadItem) {
|
||||
item.terminalFailureAt = null
|
||||
IncompleteDownloadCleanup.cancel(context, item.id)
|
||||
item.downloadItemParts.filter { it.failed }.forEach { part ->
|
||||
part.failed = false
|
||||
part.completed = false
|
||||
part.isMoving = false
|
||||
part.downloadId = null
|
||||
part.retryCount = 0
|
||||
}
|
||||
persist(item, force = true)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun cancelAll() {
|
||||
activeCalls.values.forEach(Call::cancel)
|
||||
activeCalls.clear()
|
||||
downloadItemQueue.forEach { item ->
|
||||
item.downloadItemParts.forEach { part -> File(part.destinationPath).delete() }
|
||||
DeviceManager.dbManager.removeDownloadItem(item.id)
|
||||
}
|
||||
currentDownloadItemParts.clear()
|
||||
reservations.clear()
|
||||
downloadItemQueue.clear()
|
||||
notifyQueueChanged()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun hasWork(): Boolean =
|
||||
downloadItemQueue.any { item ->
|
||||
item.downloadItemParts.any { part ->
|
||||
(!part.completed && !part.failed) || part.isMoving
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun checkUpdateDownloadQueue() {
|
||||
for (downloadItem in downloadItemQueue) {
|
||||
val numPartsToGet = maxSimultaneousDownloads - currentDownloadItemParts.size
|
||||
val nextDownloadItemParts = downloadItem.getNextDownloadItemParts(numPartsToGet)
|
||||
Log.d(
|
||||
tag,
|
||||
"checkUpdateDownloadQueue: numPartsToGet=$numPartsToGet, nextDownloadItemParts=${nextDownloadItemParts.size}"
|
||||
)
|
||||
|
||||
if (nextDownloadItemParts.isNotEmpty()) {
|
||||
processDownloadItemParts(nextDownloadItemParts)
|
||||
}
|
||||
|
||||
if (currentDownloadItemParts.size >= maxSimultaneousDownloads) {
|
||||
break
|
||||
downloadItemQueue.toList().forEach { item ->
|
||||
val slots = MAX_SIMULTANEOUS_DOWNLOADS - currentDownloadItemParts.size
|
||||
if (slots <= 0) return@forEach
|
||||
item.getNextDownloadItemParts(slots).forEach { part ->
|
||||
val existingFile = findSharedStorageFile(part)
|
||||
if (existingFile != null) {
|
||||
part.bytesDownloaded = existingFile.length()
|
||||
part.progress = 100L
|
||||
part.completedDestinationUri = existingFile.uri.toString()
|
||||
File(part.destinationPath).delete()
|
||||
completePart(item, part)
|
||||
clientEventEmitter.onDownloadItemPartUpdate(part)
|
||||
} else if (tryReserve(part)) startDownload(item, part)
|
||||
else {
|
||||
part.waitingForSpace = true
|
||||
part.lastUpdateTime = System.currentTimeMillis()
|
||||
persist(item)
|
||||
clientEventEmitter.onDownloadItemPartUpdate(part)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentDownloadItemParts.isNotEmpty()) startWatchingDownloads()
|
||||
if (hasWork()) startWatchingDownloads() else notifyQueueChanged()
|
||||
}
|
||||
|
||||
/** Processes the download item parts. */
|
||||
private fun processDownloadItemParts(nextDownloadItemParts: List<DownloadItemPart>) {
|
||||
nextDownloadItemParts.forEach {
|
||||
if (it.isInternalStorage) {
|
||||
startInternalDownload(it)
|
||||
} else {
|
||||
startExternalDownload(it)
|
||||
}
|
||||
}
|
||||
private fun startDownload(item: DownloadItem, part: DownloadItemPart) {
|
||||
val stagingFile = File(part.destinationPath)
|
||||
stagingFile.parentFile?.mkdirs()
|
||||
part.downloadId = APP_MANAGED_DOWNLOAD_ID
|
||||
part.waitingForSpace = false
|
||||
part.lastUpdateTime = System.currentTimeMillis()
|
||||
currentDownloadItemParts.add(part)
|
||||
persist(item, force = true)
|
||||
val activeConfig = DeviceManager.serverConnectionConfig
|
||||
val token =
|
||||
if (activeConfig?.id == item.serverConnectionConfigId) activeConfig.token
|
||||
else
|
||||
DeviceManager.getServerConnectionConfig(item.serverConnectionConfigId)?.token
|
||||
?: DeviceManager.token
|
||||
activeCalls[part.id] =
|
||||
InternalDownloadManager(
|
||||
stagingFile,
|
||||
part.fileSize,
|
||||
object : InternalProgressCallback {
|
||||
override fun onProgress(totalBytesWritten: Long, progress: Long) {
|
||||
synchronized(this@DownloadItemManager) {
|
||||
if (part !in currentDownloadItemParts) return
|
||||
part.bytesDownloaded = totalBytesWritten
|
||||
part.progress = progress
|
||||
part.lastUpdateTime = System.currentTimeMillis()
|
||||
persist(item)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onComplete(failed: Boolean) {
|
||||
synchronized(this@DownloadItemManager) {
|
||||
if (part !in currentDownloadItemParts) return
|
||||
part.failed = failed
|
||||
part.completed = !failed
|
||||
part.lastUpdateTime = System.currentTimeMillis()
|
||||
activeCalls.remove(part.id)
|
||||
persist(item, force = true)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ hasAvailableSpace(part) }
|
||||
)
|
||||
.download(serverUrl(item, part), token)
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
override fun onProgress(totalBytesWritten: Long, progress: Long) {
|
||||
downloadItemPart.bytesDownloaded = totalBytesWritten
|
||||
downloadItemPart.progress = progress
|
||||
}
|
||||
|
||||
override fun onComplete(failed: Boolean) {
|
||||
downloadItemPart.failed = failed
|
||||
downloadItemPart.completed = true
|
||||
}
|
||||
}
|
||||
|
||||
Log.d(
|
||||
tag,
|
||||
"Start internal download to destination path ${downloadItemPart.finalDestinationPath} from ${downloadItemPart.serverUrl}"
|
||||
)
|
||||
InternalDownloadManager(fileOutputStream, internalProgressCallback)
|
||||
.download(downloadItemPart.serverUrl)
|
||||
downloadItemPart.downloadId = 1
|
||||
currentDownloadItemParts.add(downloadItemPart)
|
||||
}
|
||||
|
||||
/** Starts an external download. */
|
||||
private fun startExternalDownload(downloadItemPart: DownloadItemPart) {
|
||||
val dlRequest = downloadItemPart.getDownloadRequest()
|
||||
val downloadId = downloadManager.enqueue(dlRequest)
|
||||
downloadItemPart.downloadId = downloadId
|
||||
Log.d(tag, "checkUpdateDownloadQueue: Starting download item part, downloadId=$downloadId")
|
||||
currentDownloadItemParts.add(downloadItemPart)
|
||||
}
|
||||
|
||||
/** Starts watching the downloads. */
|
||||
@Synchronized
|
||||
private fun startWatchingDownloads() {
|
||||
if (isDownloading) return // Already watching
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
Log.d(tag, "Starting watching downloads")
|
||||
isDownloading = true
|
||||
|
||||
while (currentDownloadItemParts.isNotEmpty()) {
|
||||
val itemParts = currentDownloadItemParts.filter { !it.isMoving }
|
||||
for (downloadItemPart in itemParts) {
|
||||
if (downloadItemPart.isInternalStorage) {
|
||||
handleInternalDownloadPart(downloadItemPart)
|
||||
} else {
|
||||
handleExternalDownloadPart(downloadItemPart)
|
||||
}
|
||||
}
|
||||
|
||||
delay(500)
|
||||
|
||||
if (currentDownloadItemParts.size < maxSimultaneousDownloads) {
|
||||
if (watcherRunning) return
|
||||
watcherRunning = true
|
||||
scope.launch {
|
||||
while (true) {
|
||||
val activeParts =
|
||||
synchronized(this@DownloadItemManager) { currentDownloadItemParts.toList() }
|
||||
activeParts.forEach(::handlePartUpdate)
|
||||
synchronized(this@DownloadItemManager) {
|
||||
checkUpdateDownloadQueue()
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
if (!hasWork()) {
|
||||
watcherRunning = false
|
||||
notifyQueueChanged()
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log.d(tag, "Download ${downloadItemPart.filename} not found in dlmanager")
|
||||
downloadItemPart.completed = true
|
||||
downloadItemPart.failed = true
|
||||
return DownloadCheckStatus.Failed
|
||||
delay(WATCH_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Handles the result of a download item part check. */
|
||||
private fun handleDownloadItemPartCheck(
|
||||
downloadCheckStatus: DownloadCheckStatus,
|
||||
downloadItemPart: DownloadItemPart
|
||||
) {
|
||||
val downloadItem = downloadItemQueue.find { it.id == downloadItemPart.downloadItemId }
|
||||
if (downloadItem == null) {
|
||||
Log.e(
|
||||
tag,
|
||||
"Download item part finished but download item not found ${downloadItemPart.filename}"
|
||||
)
|
||||
currentDownloadItemParts.remove(downloadItemPart)
|
||||
} else if (downloadCheckStatus == DownloadCheckStatus.Successful) {
|
||||
moveDownloadedFile(downloadItem, downloadItemPart)
|
||||
} else if (downloadCheckStatus != DownloadCheckStatus.InProgress) {
|
||||
checkDownloadItemFinished(downloadItem)
|
||||
currentDownloadItemParts.remove(downloadItemPart)
|
||||
private fun handlePartUpdate(part: DownloadItemPart) {
|
||||
clientEventEmitter.onDownloadItemPartUpdate(part)
|
||||
val item =
|
||||
synchronized(this) { downloadItemQueue.find { it.id == part.downloadItemId } }
|
||||
?: run {
|
||||
removeActivePart(part)
|
||||
return
|
||||
}
|
||||
if (!part.completed && !part.failed) {
|
||||
val lastUpdate = part.lastUpdateTime ?: return
|
||||
if (System.currentTimeMillis() - lastUpdate > STALL_TIMEOUT_MS) {
|
||||
Log.w(tag, "Download stalled: ${part.filename}")
|
||||
activeCalls.remove(part.id)?.cancel()
|
||||
failOrRetry(item, part, "Download stalled")
|
||||
}
|
||||
return
|
||||
}
|
||||
if (part.failed) {
|
||||
failOrRetry(item, part, "Transfer failed")
|
||||
return
|
||||
}
|
||||
if (part.isInternalStorage) finalizeInternalFile(item, part) else moveDownloadedFile(item, part)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun failOrRetry(item: DownloadItem, part: DownloadItemPart, reason: String) {
|
||||
removeActivePart(part)
|
||||
part.retryCount += 1
|
||||
reservations.remove(part.destinationPath)
|
||||
if (part.retryCount > MAX_RETRIES) {
|
||||
Log.e(tag, "$reason after $MAX_RETRIES retries: ${part.filename}")
|
||||
part.failed = true
|
||||
part.completed = false
|
||||
part.downloadId = null
|
||||
item.terminalFailureAt = item.terminalFailureAt ?: System.currentTimeMillis()
|
||||
persist(item, force = true)
|
||||
IncompleteDownloadCleanup.schedule(context, item)
|
||||
notifyQueueChanged()
|
||||
return
|
||||
}
|
||||
part.failed = false
|
||||
part.completed = false
|
||||
part.downloadId = null
|
||||
part.isMoving = false
|
||||
persist(item, force = true)
|
||||
}
|
||||
|
||||
private fun finalizeInternalFile(item: DownloadItem, part: DownloadItemPart) {
|
||||
if (part.moved || part.isMoving) return
|
||||
part.isMoving = true
|
||||
val stagingFile = File(part.destinationPath)
|
||||
val finalFile = File(part.finalDestinationPath)
|
||||
finalFile.parentFile?.mkdirs()
|
||||
val backup = File(finalFile.parentFile, ".${finalFile.name}.abs-backup")
|
||||
try {
|
||||
if (backup.exists() && !backup.delete()) throw IllegalStateException("Could not clear backup")
|
||||
if (finalFile.exists() && !finalFile.renameTo(backup))
|
||||
throw IllegalStateException("Could not protect existing file")
|
||||
if (!stagingFile.renameTo(finalFile)) {
|
||||
if (backup.exists()) backup.renameTo(finalFile)
|
||||
throw IllegalStateException("Could not finalize internal staging file")
|
||||
}
|
||||
backup.delete()
|
||||
completePart(item, part)
|
||||
} catch (e: Exception) {
|
||||
part.isMoving = false
|
||||
part.failed = true
|
||||
failOrRetry(item, part, e.message ?: "Internal finalization failed")
|
||||
}
|
||||
}
|
||||
|
||||
/** Moves the downloaded file to its final destination. */
|
||||
private fun moveDownloadedFile(downloadItem: DownloadItem, downloadItemPart: DownloadItemPart) {
|
||||
val file = DocumentFileCompat.fromUri(mainActivity, downloadItemPart.destinationUri)
|
||||
Log.d(tag, "DOWNLOAD: DESTINATION URI ${downloadItemPart.destinationUri}")
|
||||
private fun moveDownloadedFile(item: DownloadItem, part: DownloadItemPart) {
|
||||
if (part.moved || part.isMoving) return
|
||||
val root =
|
||||
DocumentFile.fromTreeUri(context, Uri.parse(part.localFolderUrl))
|
||||
?: return failFinalization(item, part, "Could not resolve SAF destination")
|
||||
part.isMoving = true
|
||||
persist(item, force = true)
|
||||
scope.launch {
|
||||
try {
|
||||
if (!hasAvailableSpace(part))
|
||||
throw IllegalStateException("Insufficient storage for SAF copy")
|
||||
val folderKey = "${root.uri}/${part.finalDestinationSubfolder}"
|
||||
val folderLock = safFolderLocks.computeIfAbsent(folderKey) { Any() }
|
||||
val folder =
|
||||
synchronized(folderLock) { getOrCreateFolder(root, part.finalDestinationSubfolder) }
|
||||
?: throw IllegalStateException("Could not create SAF destination folder")
|
||||
val temporaryName = ".${part.filename}.${part.id.hashCode()}.part"
|
||||
folder.findFile(temporaryName)?.delete()
|
||||
val temporary =
|
||||
folder.createFile(mimeTypeFor(part), temporaryName)
|
||||
?: throw IllegalStateException("Could not create SAF temporary file")
|
||||
val staging = File(part.destinationPath)
|
||||
FileInputStream(staging).use { input ->
|
||||
context.contentResolver.openOutputStream(temporary.uri, "w")?.use { input.copyTo(it) }
|
||||
?: throw IllegalStateException("Could not open SAF output stream")
|
||||
}
|
||||
if (temporary.length() != staging.length())
|
||||
throw IllegalStateException("SAF copy size mismatch")
|
||||
val existing = folder.findFile(part.filename)
|
||||
if (existing != null && !existing.delete())
|
||||
throw IllegalStateException("Could not replace existing file")
|
||||
if (!temporary.renameTo(part.filename))
|
||||
throw IllegalStateException("Could not finalize SAF temporary file")
|
||||
val destination =
|
||||
folder.findFile(part.filename)
|
||||
?: throw IllegalStateException("Could not reopen finalized SAF file")
|
||||
if (destination.length() != staging.length())
|
||||
throw IllegalStateException("SAF final size mismatch")
|
||||
if (!staging.delete()) Log.w(tag, "Could not remove staging file ${staging.name}")
|
||||
part.completedDestinationUri = destination.uri.toString()
|
||||
completePart(item, part)
|
||||
} catch (e: Exception) {
|
||||
failFinalization(item, part, "SAF copy failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val fcb =
|
||||
object : FileCallback() {
|
||||
override fun onPrepare() {
|
||||
Log.d(tag, "DOWNLOAD: PREPARING MOVE FILE")
|
||||
}
|
||||
@Synchronized
|
||||
private fun failFinalization(item: DownloadItem, part: DownloadItemPart, message: String) {
|
||||
Log.e(tag, message)
|
||||
part.isMoving = false
|
||||
part.failed = true
|
||||
failOrRetry(item, part, message)
|
||||
}
|
||||
|
||||
override fun onFailed(errorCode: ErrorCode) {
|
||||
Log.e(tag, "DOWNLOAD: FAILED TO MOVE FILE $errorCode")
|
||||
downloadItemPart.failed = true
|
||||
downloadItemPart.isMoving = false
|
||||
file?.delete()
|
||||
checkDownloadItemFinished(downloadItem)
|
||||
currentDownloadItemParts.remove(downloadItemPart)
|
||||
}
|
||||
@Synchronized
|
||||
private fun completePart(item: DownloadItem, part: DownloadItemPart) {
|
||||
part.moved = true
|
||||
part.completed = true
|
||||
part.failed = false
|
||||
part.isMoving = false
|
||||
reservations.remove(part.destinationPath)
|
||||
removeActivePart(part)
|
||||
persist(item, force = true)
|
||||
checkDownloadItemFinished(item)
|
||||
}
|
||||
|
||||
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)
|
||||
private fun checkDownloadItemFinished(item: DownloadItem) {
|
||||
if (!item.isDownloadFinished) return
|
||||
scope.launch {
|
||||
folderScanner.scanDownloadItem(item) { scanResult ->
|
||||
val event =
|
||||
JSObject().apply {
|
||||
put("libraryItemId", item.id)
|
||||
put("localFolderId", item.localFolder.id)
|
||||
scanResult?.localLibraryItem?.let {
|
||||
put("localLibraryItem", JSObject(jacksonMapper.writeValueAsString(it)))
|
||||
}
|
||||
scanResult?.localMediaProgress?.let {
|
||||
put("localMediaProgress", JSObject(jacksonMapper.writeValueAsString(it)))
|
||||
}
|
||||
}
|
||||
|
||||
downloadItemPart.moved = true
|
||||
downloadItemPart.isMoving = false
|
||||
checkDownloadItemFinished(downloadItem)
|
||||
currentDownloadItemParts.remove(downloadItemPart)
|
||||
}
|
||||
}
|
||||
|
||||
val localFolderFile =
|
||||
DocumentFileCompat.fromUri(mainActivity, Uri.parse(downloadItemPart.localFolderUrl))
|
||||
if (localFolderFile == null) {
|
||||
// Failed
|
||||
downloadItemPart.failed = true
|
||||
Log.e(tag, "Local Folder File from uri is null")
|
||||
checkDownloadItemFinished(downloadItem)
|
||||
currentDownloadItemParts.remove(downloadItemPart)
|
||||
} else {
|
||||
downloadItemPart.isMoving = true
|
||||
val mimetype = if (downloadItemPart.audioTrack != null) MimeType.AUDIO else MimeType.IMAGE
|
||||
val fileDescription =
|
||||
FileDescription(
|
||||
downloadItemPart.filename,
|
||||
downloadItemPart.finalDestinationSubfolder,
|
||||
mimetype
|
||||
)
|
||||
file?.moveFileTo(mainActivity, localFolderFile, fileDescription, fcb)
|
||||
clientEventEmitter.onDownloadItemComplete(event)
|
||||
synchronized(this@DownloadItemManager) {
|
||||
downloadItemQueue.remove(item)
|
||||
DeviceManager.dbManager.removeDownloadItem(item.id)
|
||||
notifyQueueChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Checks if a download item is finished and processes it. */
|
||||
private fun checkDownloadItemFinished(downloadItem: DownloadItem) {
|
||||
if (downloadItem.isDownloadFinished) {
|
||||
Log.i(tag, "Download Item finished ${downloadItem.media.metadata.title}")
|
||||
private fun tryReserve(part: DownloadItemPart): Boolean {
|
||||
if (part.fileSize <= 0L && currentDownloadItemParts.any { it.fileSize <= 0L }) return false
|
||||
val staging = File(part.destinationPath)
|
||||
staging.parentFile?.mkdirs()
|
||||
val expectedSize = if (part.fileSize > 0L) part.fileSize else UNKNOWN_PART_RESERVATION_BYTES
|
||||
val remaining =
|
||||
(expectedSize - (staging.takeIf(File::exists)?.length() ?: 0L)).coerceAtLeast(0L)
|
||||
val required = if (part.isInternalStorage) remaining else remaining + expectedSize
|
||||
val key = storageKey(staging)
|
||||
val fs = statFsFor(staging)
|
||||
val headroom = max(MIN_FREE_SPACE_BYTES, fs.totalBytes / 20L)
|
||||
val alreadyReserved = reservations.filterKeys { storageKey(File(it)) == key }.values.sum()
|
||||
if (fs.availableBytes - alreadyReserved < required + headroom) return false
|
||||
reservations[part.destinationPath] = required
|
||||
return true
|
||||
}
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
folderScanner.scanDownloadItem(downloadItem) { downloadItemScanResult ->
|
||||
Log.d(
|
||||
tag,
|
||||
"Item download complete ${downloadItem.itemTitle} | local library item id: ${downloadItemScanResult?.localLibraryItem?.id}"
|
||||
)
|
||||
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)
|
||||
}
|
||||
|
||||
val jsobj =
|
||||
JSObject().apply {
|
||||
put("libraryItemId", downloadItem.id)
|
||||
put("localFolderId", downloadItem.localFolder.id)
|
||||
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)
|
||||
}
|
||||
|
||||
downloadItemScanResult?.localLibraryItem?.let { localLibraryItem ->
|
||||
put(
|
||||
"localLibraryItem",
|
||||
JSObject(jacksonMapper.writeValueAsString(localLibraryItem))
|
||||
)
|
||||
}
|
||||
downloadItemScanResult?.localMediaProgress?.let { localMediaProgress ->
|
||||
put(
|
||||
"localMediaProgress",
|
||||
JSObject(jacksonMapper.writeValueAsString(localMediaProgress))
|
||||
)
|
||||
}
|
||||
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"
|
||||
}
|
||||
|
||||
launch(Dispatchers.Main) {
|
||||
clientEventEmitter.onDownloadItemComplete(jsobj)
|
||||
downloadItemQueue.remove(downloadItem)
|
||||
DeviceManager.dbManager.removeDownloadItem(downloadItem.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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()
|
||||
}
|
||||
}
|
||||
+117
-96
@@ -1,114 +1,135 @@
|
||||
package com.audiobookshelf.app.managers
|
||||
|
||||
import android.util.Log
|
||||
import java.io.*
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.TimeUnit
|
||||
import okhttp3.*
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
|
||||
/**
|
||||
* Manages the internal download process.
|
||||
*
|
||||
* @property outputStream The output stream to write the downloaded data.
|
||||
* @property progressCallback The callback to report download progress.
|
||||
*/
|
||||
/** Streams a download into an app-owned staging file. */
|
||||
class InternalDownloadManager(
|
||||
private val outputStream: FileOutputStream,
|
||||
private val progressCallback: DownloadItemManager.InternalProgressCallback
|
||||
) : AutoCloseable {
|
||||
|
||||
private val destinationFile: File,
|
||||
private val expectedSize: Long,
|
||||
private val progressCallback: DownloadItemManager.InternalProgressCallback,
|
||||
private val hasAvailableSpace: () -> Boolean
|
||||
) {
|
||||
private val tag = "InternalDownloadManager"
|
||||
private val client: OkHttpClient =
|
||||
OkHttpClient.Builder().connectTimeout(30, TimeUnit.SECONDS).build()
|
||||
private val writer = BinaryFileWriter(outputStream, progressCallback)
|
||||
|
||||
/**
|
||||
* Downloads a file from the given URL.
|
||||
* Starts or resumes a download.
|
||||
*
|
||||
* @param url The URL to download the file from.
|
||||
* @throws IOException If an I/O error occurs.
|
||||
* @param url download URL
|
||||
* @param token access token sent in the Authorization header
|
||||
* @return active call, used to cancel a stalled transfer
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
fun download(url: String) {
|
||||
val request: Request = Request.Builder().url(url).addHeader("Accept-Encoding", "identity").build()
|
||||
client.newCall(request)
|
||||
.enqueue(
|
||||
object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
Log.e(tag, "Download URL $url FAILED", e)
|
||||
progressCallback.onComplete(true)
|
||||
}
|
||||
fun download(url: String, token: String): Call {
|
||||
destinationFile.parentFile?.mkdirs()
|
||||
val existingBytes = destinationFile.takeIf { it.exists() }?.length() ?: 0L
|
||||
val request =
|
||||
Request.Builder()
|
||||
.url(url)
|
||||
.addHeader("Accept-Encoding", "identity")
|
||||
.addHeader("Authorization", "Bearer $token")
|
||||
.apply { if (existingBytes > 0L) header("Range", "bytes=$existingBytes-") }
|
||||
.build()
|
||||
val call = client.newCall(request)
|
||||
call.enqueue(
|
||||
object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
Log.e(tag, "Download URL failed", e)
|
||||
progressCallback.onComplete(true)
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.body?.let { responseBody ->
|
||||
val length: Long = response.header("Content-Length")?.toLongOrNull() ?: 0L
|
||||
writer.write(responseBody.byteStream(), length)
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use {
|
||||
try {
|
||||
if (response.code == 416 && expectedSize > 0L && existingBytes == expectedSize
|
||||
) {
|
||||
progressCallback.onProgress(existingBytes, 100L)
|
||||
progressCallback.onComplete(false)
|
||||
return
|
||||
}
|
||||
val append =
|
||||
existingBytes > 0L &&
|
||||
response.code == 206 &&
|
||||
hasExpectedRange(response, existingBytes)
|
||||
if (existingBytes > 0L && !append && response.code != 200) {
|
||||
Log.e(
|
||||
tag,
|
||||
"Invalid resume response ${response.code} for offset $existingBytes"
|
||||
)
|
||||
progressCallback.onComplete(true)
|
||||
return
|
||||
}
|
||||
if (!response.isSuccessful || response.body == null) {
|
||||
Log.e(tag, "Download HTTP failure ${response.code}")
|
||||
progressCallback.onComplete(true)
|
||||
return
|
||||
}
|
||||
|
||||
val startingBytes = if (append) existingBytes else 0L
|
||||
val responseLength = response.body!!.contentLength()
|
||||
val totalLength =
|
||||
if (expectedSize > 0L) expectedSize
|
||||
else if (responseLength >= 0L) startingBytes + responseLength else 0L
|
||||
|
||||
FileOutputStream(destinationFile, append).use { output ->
|
||||
response.body!!.byteStream().use { input ->
|
||||
val buffer = ByteArray(CHUNK_SIZE)
|
||||
var totalBytes = startingBytes
|
||||
while (true) {
|
||||
val read = input.read(buffer)
|
||||
if (read < 0) break
|
||||
if (!hasAvailableSpace())
|
||||
throw IOException("Download paused to preserve free storage")
|
||||
output.write(buffer, 0, read)
|
||||
totalBytes += read
|
||||
val progress =
|
||||
if (totalLength > 0L) (totalBytes * 100L) / totalLength else 0L
|
||||
progressCallback.onProgress(totalBytes, progress.coerceAtMost(100L))
|
||||
}
|
||||
?: run {
|
||||
Log.e(tag, "Response doesn't contain a file")
|
||||
progressCallback.onComplete(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (expectedSize > 0L && destinationFile.length() != expectedSize) {
|
||||
Log.e(
|
||||
tag,
|
||||
"Downloaded size ${destinationFile.length()} did not match $expectedSize"
|
||||
)
|
||||
progressCallback.onComplete(true)
|
||||
} else {
|
||||
progressCallback.onComplete(false)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.e(tag, "Could not write staging file", e)
|
||||
progressCallback.onComplete(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
return call
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the download manager and releases resources.
|
||||
*
|
||||
* @throws Exception If an error occurs during closing.
|
||||
*/
|
||||
@Throws(Exception::class)
|
||||
override fun close() {
|
||||
writer.close()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes binary data to an output stream.
|
||||
*
|
||||
* @property outputStream The output stream to write the data to.
|
||||
* @property progressCallback The callback to report write progress.
|
||||
*/
|
||||
class BinaryFileWriter(
|
||||
private val outputStream: OutputStream,
|
||||
private val progressCallback: DownloadItemManager.InternalProgressCallback
|
||||
) : AutoCloseable {
|
||||
|
||||
/**
|
||||
* Writes data from the input stream to the output stream.
|
||||
*
|
||||
* @param inputStream The input stream to read the data from.
|
||||
* @param length The total length of the data to be written.
|
||||
* @return The total number of bytes written.
|
||||
* @throws IOException If an I/O error occurs.
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
fun write(inputStream: InputStream, length: Long): Long {
|
||||
BufferedInputStream(inputStream).use { input ->
|
||||
val dataBuffer = ByteArray(CHUNK_SIZE)
|
||||
var totalBytes: Long = 0
|
||||
var readBytes: Int
|
||||
while (input.read(dataBuffer).also { readBytes = it } != -1) {
|
||||
totalBytes += readBytes
|
||||
outputStream.write(dataBuffer, 0, readBytes)
|
||||
progressCallback.onProgress(totalBytes, (totalBytes * 100L) / length)
|
||||
}
|
||||
progressCallback.onComplete(false)
|
||||
return totalBytes
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the writer and releases resources.
|
||||
*
|
||||
* @throws IOException If an error occurs during closing.
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
override fun close() {
|
||||
outputStream.close()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CHUNK_SIZE = 8192 // Increased chunk size for better performance
|
||||
private fun hasExpectedRange(response: Response, offset: Long): Boolean {
|
||||
val range = response.header("Content-Range") ?: return false
|
||||
val match = CONTENT_RANGE.matchEntire(range) ?: return false
|
||||
return match.groupValues[1].toLongOrNull() == offset &&
|
||||
match.groupValues[2].toLongOrNull()?.let { it >= offset } == true
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val CHUNK_SIZE = 512 * 1024 // 512 KB
|
||||
val CONTENT_RANGE = Regex("bytes (\\d+)-(\\d+)/(?:\\d+|\\*)")
|
||||
val client =
|
||||
OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(60, TimeUnit.SECONDS)
|
||||
.writeTimeout(60, TimeUnit.SECONDS)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,34 +6,37 @@ import com.audiobookshelf.app.data.MediaType
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||
|
||||
data class DownloadItem(
|
||||
val id: String,
|
||||
val libraryItemId:String,
|
||||
val episodeId:String?,
|
||||
val userMediaProgress: MediaProgress?,
|
||||
val serverConnectionConfigId:String,
|
||||
val serverAddress:String,
|
||||
val serverUserId:String,
|
||||
val mediaType: String,
|
||||
val itemFolderPath:String,
|
||||
val localFolder: LocalFolder,
|
||||
val itemTitle: String,
|
||||
val itemSubfolder: String,
|
||||
val media: MediaType,
|
||||
val downloadItemParts: MutableList<DownloadItemPart>
|
||||
val id: String,
|
||||
val libraryItemId: String,
|
||||
val episodeId: String?,
|
||||
val userMediaProgress: MediaProgress?,
|
||||
val serverConnectionConfigId: String,
|
||||
val serverAddress: String,
|
||||
val serverUserId: String,
|
||||
val mediaType: String,
|
||||
val itemFolderPath: String,
|
||||
val localFolder: LocalFolder,
|
||||
val itemTitle: String,
|
||||
val itemSubfolder: String,
|
||||
val media: MediaType,
|
||||
val downloadItemParts: MutableList<DownloadItemPart>,
|
||||
@JsonIgnore var terminalFailureAt: Long? = null
|
||||
) {
|
||||
@get:JsonIgnore
|
||||
val isInternalStorage get() = localFolder.id.startsWith("internal-")
|
||||
val isInternalStorage
|
||||
get() = localFolder.id.startsWith("internal-")
|
||||
|
||||
@get:JsonIgnore
|
||||
val isDownloadFinished get() = !downloadItemParts.any { !it.completed || it.isMoving }
|
||||
val isDownloadFinished
|
||||
get() = !downloadItemParts.any { !it.completed || it.isMoving || it.failed }
|
||||
|
||||
@JsonIgnore
|
||||
fun getNextDownloadItemParts(limit:Int): MutableList<DownloadItemPart> {
|
||||
fun getNextDownloadItemParts(limit: Int): MutableList<DownloadItemPart> {
|
||||
val itemParts = mutableListOf<DownloadItemPart>()
|
||||
if (limit == 0) return itemParts
|
||||
|
||||
for (it in downloadItemParts) {
|
||||
if (!it.completed && it.downloadId == null) {
|
||||
if (!it.completed && !it.failed && it.downloadId == null) {
|
||||
itemParts.add(it)
|
||||
if (itemParts.size >= limit) break
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.audiobookshelf.app.models
|
||||
|
||||
import android.app.DownloadManager
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.data.AudioTrack
|
||||
@@ -16,6 +15,7 @@ data class DownloadItemPart(
|
||||
val downloadItemId: String,
|
||||
val filename: String,
|
||||
val fileSize: Long,
|
||||
@JsonIgnore val destinationPath: String,
|
||||
val finalDestinationPath:String,
|
||||
val serverPath: String,
|
||||
val localFolderName: String,
|
||||
@@ -31,28 +31,29 @@ data class DownloadItemPart(
|
||||
@JsonIgnore val uri: Uri,
|
||||
@JsonIgnore val destinationUri: Uri,
|
||||
@JsonIgnore val finalDestinationUri: Uri,
|
||||
@JsonIgnore var completedDestinationUri: String?,
|
||||
val finalDestinationSubfolder: String,
|
||||
var downloadId: Long?,
|
||||
@JsonIgnore var lastUpdateTime: Long?,
|
||||
var progress: Long,
|
||||
var bytesDownloaded: Long
|
||||
var bytesDownloaded: Long,
|
||||
@JsonIgnore var retryCount: Int = 0,
|
||||
@JsonIgnore var waitingForSpace: Boolean = false
|
||||
) {
|
||||
companion object {
|
||||
fun make(downloadItemId:String, filename:String, fileSize: Long, destinationFile: File, finalDestinationFile: File, subfolder:String, serverPath:String, localFolder: LocalFolder, ebookFile: EBookFile?, audioTrack: AudioTrack?, episode: PodcastEpisode?) :DownloadItemPart {
|
||||
val destinationUri = Uri.fromFile(destinationFile)
|
||||
val finalDestinationUri = Uri.fromFile(finalDestinationFile)
|
||||
val rawCover = if (serverPath.endsWith("/cover")) "?raw=1" else ""
|
||||
val downloadUri = Uri.parse("${DeviceManager.serverAddress}${serverPath}$rawCover")
|
||||
|
||||
var downloadUrl = "${DeviceManager.serverAddress}${serverPath}?token=${DeviceManager.token}"
|
||||
if (serverPath.endsWith("/cover")) {
|
||||
downloadUrl += "&raw=1" // Download raw cover image
|
||||
}
|
||||
|
||||
val downloadUri = Uri.parse(downloadUrl)
|
||||
Log.d("DownloadItemPart", "Audio File Destination Uri: $destinationUri | Final Destination Uri: $finalDestinationUri | Download URI $downloadUri")
|
||||
Log.d("DownloadItemPart", "Audio File Destination Uri: $destinationUri | Final Destination Uri: $finalDestinationUri | Server Path $serverPath")
|
||||
return DownloadItemPart(
|
||||
id = DeviceManager.getBase64Id(finalDestinationFile.absolutePath),
|
||||
downloadItemId,
|
||||
filename = filename,
|
||||
fileSize = fileSize,
|
||||
destinationPath = destinationFile.absolutePath,
|
||||
finalDestinationPath = finalDestinationFile.absolutePath,
|
||||
serverPath = serverPath,
|
||||
localFolderName = localFolder.name,
|
||||
@@ -68,8 +69,10 @@ data class DownloadItemPart(
|
||||
uri = downloadUri,
|
||||
destinationUri = destinationUri,
|
||||
finalDestinationUri = finalDestinationUri,
|
||||
completedDestinationUri = null,
|
||||
finalDestinationSubfolder = subfolder,
|
||||
downloadId = null,
|
||||
lastUpdateTime = null,
|
||||
progress = 0,
|
||||
bytesDownloaded = 0
|
||||
)
|
||||
@@ -79,16 +82,4 @@ data class DownloadItemPart(
|
||||
@get:JsonIgnore
|
||||
val isInternalStorage get() = localFolderId.startsWith("internal-")
|
||||
|
||||
@get:JsonIgnore
|
||||
val serverUrl get() = uri.toString()
|
||||
|
||||
@JsonIgnore
|
||||
fun getDownloadRequest(): DownloadManager.Request {
|
||||
val dlRequest = DownloadManager.Request(uri)
|
||||
dlRequest.setTitle(filename)
|
||||
dlRequest.setDescription("Downloading to $localFolderName with filename $filename")
|
||||
dlRequest.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE)
|
||||
dlRequest.setDestinationUri(destinationUri)
|
||||
return dlRequest
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,7 +229,7 @@ class AbsAudioPlayer : Plugin() {
|
||||
return call.resolve(JSObject("{\"error\":\"Podcast episode not found\"}"))
|
||||
}
|
||||
}
|
||||
if (!it.hasTracks(episode)) {
|
||||
if (!it.hasTracks(mainActivity, episode)) {
|
||||
return call.resolve(JSObject("{\"error\":\"No audio files found on device. Download book again to fix.\"}"))
|
||||
}
|
||||
|
||||
@@ -325,10 +325,10 @@ class AbsAudioPlayer : Plugin() {
|
||||
|
||||
@PluginMethod
|
||||
fun seek(call: PluginCall) {
|
||||
val time:Int = call.getInt("value", 0) ?: 0 // Value in seconds
|
||||
val time: Double = call.getDouble("value", 0.0) ?: 0.0 // Value in seconds, fractional
|
||||
Log.d(tag, "seek action to $time")
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
playerNotificationService.seekPlayer(time * 1000L) // convert to ms
|
||||
playerNotificationService.seekPlayer((time * 1000L).toLong())
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ class AbsDatabase : Plugin() {
|
||||
secureStorage = SecureStorage(mainActivity)
|
||||
|
||||
DeviceManager.dbManager.cleanLocalMediaProgress()
|
||||
DeviceManager.dbManager.cleanLocalLibraryItems()
|
||||
DeviceManager.dbManager.cleanLocalLibraryItems(mainActivity)
|
||||
DeviceManager.dbManager.cleanLogs()
|
||||
}
|
||||
|
||||
@@ -119,7 +119,7 @@ class AbsDatabase : Plugin() {
|
||||
|
||||
@PluginMethod
|
||||
fun setCurrentServerConnectionConfig(call:PluginCall) {
|
||||
Log.d(tag, "setCurrentServerConnectionConfig ${call.data}")
|
||||
Log.d(tag, "setCurrentServerConnectionConfig called")
|
||||
val serverConfigPayload = jacksonMapper.readValue<ServerConnConfigPayload>(call.data.toString())
|
||||
var serverConnectionConfig = DeviceManager.deviceData.serverConnectionConfigs.find { it.id == serverConfigPayload.id }
|
||||
|
||||
@@ -558,7 +558,7 @@ class AbsDatabase : Plugin() {
|
||||
|
||||
@PluginMethod
|
||||
fun updateDeviceSettings(call:PluginCall) { // Returns device data
|
||||
Log.d(tag, "updateDeviceSettings ${call.data}")
|
||||
Log.d(tag, "updateDeviceSettings called")
|
||||
val newDeviceSettings = jacksonMapper.readValue<DeviceSettings>(call.data.toString())
|
||||
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
@@ -576,7 +576,7 @@ class AbsDatabase : Plugin() {
|
||||
|
||||
@PluginMethod
|
||||
fun getMediaItemHistory(call:PluginCall) { // Returns device data
|
||||
Log.d(tag, "getMediaItemHistory ${call.data}")
|
||||
Log.d(tag, "getMediaItemHistory called")
|
||||
val mediaId = call.getString("mediaId") ?: ""
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
package com.audiobookshelf.app.plugins
|
||||
|
||||
import android.app.DownloadManager
|
||||
import android.content.Context
|
||||
import android.os.Environment
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.MainActivity
|
||||
import com.audiobookshelf.app.data.*
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.audiobookshelf.app.device.FolderScanner
|
||||
import com.audiobookshelf.app.models.DownloadItem
|
||||
import com.audiobookshelf.app.models.DownloadItemPart
|
||||
import com.audiobookshelf.app.server.ApiHandler
|
||||
import com.audiobookshelf.app.managers.DownloadItemManager
|
||||
import com.audiobookshelf.app.services.DownloadServiceHost
|
||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.getcapacitor.JSObject
|
||||
@@ -27,9 +25,7 @@ class AbsDownloader : Plugin() {
|
||||
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||
|
||||
lateinit var mainActivity: MainActivity
|
||||
lateinit var downloadManager: DownloadManager
|
||||
lateinit var apiHandler: ApiHandler
|
||||
lateinit var folderScanner: FolderScanner
|
||||
lateinit var downloadItemManager: DownloadItemManager
|
||||
|
||||
private val clientEventEmitter = (object : DownloadItemManager.DownloadEventEmitter {
|
||||
@@ -42,14 +38,44 @@ class AbsDownloader : Plugin() {
|
||||
override fun onDownloadItemComplete(jsobj:JSObject) {
|
||||
notifyListeners("onItemDownloadComplete", jsobj)
|
||||
}
|
||||
override fun onQueueChanged(hasWork: Boolean) {
|
||||
notifyListeners("onQueueChanged", JSObject().put("hasWork", hasWork))
|
||||
}
|
||||
})
|
||||
|
||||
override fun load() {
|
||||
mainActivity = (activity as MainActivity)
|
||||
downloadManager = activity.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
|
||||
folderScanner = FolderScanner(mainActivity)
|
||||
apiHandler = ApiHandler(mainActivity)
|
||||
downloadItemManager = DownloadItemManager(downloadManager, folderScanner, mainActivity, clientEventEmitter)
|
||||
downloadItemManager = DownloadServiceHost.ensure(mainActivity)
|
||||
DownloadServiceHost.attachBridge(mainActivity, clientEventEmitter)
|
||||
}
|
||||
|
||||
override fun handleOnDestroy() {
|
||||
DownloadServiceHost.detachBridge()
|
||||
super.handleOnDestroy()
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun setDownloadNotificationStrings(call: PluginCall) {
|
||||
DownloadServiceHost.setNotificationStrings(
|
||||
mainActivity,
|
||||
call.getString("preparing") ?: "Preparing downloads",
|
||||
call.getString("downloadingFile") ?: "Downloading {0}",
|
||||
call.getString("waitingForStorage") ?: "Waiting for available storage",
|
||||
call.getString("downloads") ?: "Downloads",
|
||||
call.getString("cancel") ?: "Cancel")
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
/** Replays restored queue items when the frontend subscribes to download events. */
|
||||
@PluginMethod(returnType = PluginMethod.RETURN_NONE)
|
||||
override fun addListener(call: PluginCall) {
|
||||
super.addListener(call)
|
||||
if (call.getString("eventName") == "onDownloadItem" && ::downloadItemManager.isInitialized) {
|
||||
downloadItemManager.downloadItemQueue.forEach { item ->
|
||||
notifyListeners("onDownloadItem", JSObject(jacksonMapper.writeValueAsString(item)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
@@ -79,7 +105,7 @@ class AbsDownloader : Plugin() {
|
||||
|
||||
if (localFolder == null && localFolderId.startsWith("internal-")) {
|
||||
Log.d(tag, "Creating new App Storage internal LocalFolder $localFolderId")
|
||||
localFolder = LocalFolder(localFolderId, "Internal App Storage", "", "", "", "", "internal", libraryItem.mediaType)
|
||||
localFolder = LocalFolder(localFolderId, "Internal App Storage", "", "", "", "internal", libraryItem.mediaType)
|
||||
DeviceManager.dbManager.saveLocalFolder(localFolder)
|
||||
}
|
||||
|
||||
@@ -132,7 +158,13 @@ class AbsDownloader : Plugin() {
|
||||
private fun startLibraryItemDownload(libraryItem: LibraryItem, localFolder: LocalFolder, episode:PodcastEpisode?) {
|
||||
val isInternal = localFolder.id.startsWith("internal-")
|
||||
|
||||
val tempFolderPath = if (isInternal) "${mainActivity.filesDir}/downloads/${libraryItem.id}" else mainActivity.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
|
||||
val finalInternalFolderPath = "${mainActivity.filesDir}/downloads/${libraryItem.id}"
|
||||
val tempFolderPath =
|
||||
if (isInternal) {
|
||||
"${mainActivity.filesDir}/download-staging/${libraryItem.id}"
|
||||
} else {
|
||||
"${mainActivity.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) ?: mainActivity.filesDir}/download-staging/${libraryItem.id}"
|
||||
}
|
||||
|
||||
Log.d(tag, "downloadCacheDirectory=$tempFolderPath")
|
||||
|
||||
@@ -143,7 +175,7 @@ class AbsDownloader : Plugin() {
|
||||
val tracks = libraryItem.media.getAudioTracks()
|
||||
Log.d(tag, "Starting library item download with ${tracks.size} tracks")
|
||||
val itemSubfolder = "$bookAuthor/$bookTitle"
|
||||
val itemFolderPath = if (isInternal) "$tempFolderPath" else "${localFolder.absolutePath}/$itemSubfolder"
|
||||
val itemFolderPath = if (isInternal) finalInternalFolderPath else "${localFolder.absolutePath}/$itemSubfolder"
|
||||
val downloadItem = DownloadItem(libraryItem.id, libraryItem.id, null, libraryItem.userMediaProgress,DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, bookTitle, itemSubfolder, libraryItem.media, mutableListOf())
|
||||
|
||||
val book = libraryItem.media as Book
|
||||
@@ -152,17 +184,7 @@ class AbsDownloader : Plugin() {
|
||||
val serverPath = "/api/items/${libraryItem.id}/file/${ebookFile.ino}/download"
|
||||
val destinationFilename = getFilenameFromRelPath(ebookFile.metadata?.relPath ?: "")
|
||||
val finalDestinationFile = File("$itemFolderPath/$destinationFilename")
|
||||
val destinationFile = File("$tempFolderPath/$destinationFilename")
|
||||
|
||||
if (destinationFile.exists()) {
|
||||
Log.d(tag, "TEMP ebook file already exists, removing it from ${destinationFile.absolutePath}")
|
||||
destinationFile.delete()
|
||||
}
|
||||
|
||||
if (finalDestinationFile.exists()) {
|
||||
Log.d(tag, "ebook file already exists, removing it from ${finalDestinationFile.absolutePath}")
|
||||
finalDestinationFile.delete()
|
||||
}
|
||||
val destinationFile = File("$tempFolderPath/$destinationFilename.part")
|
||||
|
||||
val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, fileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,ebookFile,null,null)
|
||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||
@@ -181,17 +203,7 @@ class AbsDownloader : Plugin() {
|
||||
Log.d(tag, "Audio File Server Path $serverPath | AF RelPath ${audioTrack.relPath} | LocalFolder Path ${localFolder.absolutePath} | DestName $destinationFilename")
|
||||
|
||||
val finalDestinationFile = File("$itemFolderPath/$destinationFilename")
|
||||
val destinationFile = File("$tempFolderPath/$destinationFilename")
|
||||
|
||||
if (destinationFile.exists()) {
|
||||
Log.d(tag, "TEMP Audio file already exists, removing it from ${destinationFile.absolutePath}")
|
||||
destinationFile.delete()
|
||||
}
|
||||
|
||||
if (finalDestinationFile.exists()) {
|
||||
Log.d(tag, "Audio file already exists, removing it from ${finalDestinationFile.absolutePath}")
|
||||
finalDestinationFile.delete()
|
||||
}
|
||||
val destinationFile = File("$tempFolderPath/$destinationFilename.part")
|
||||
|
||||
val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, fileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,null,audioTrack,null)
|
||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||
@@ -205,24 +217,14 @@ class AbsDownloader : Plugin() {
|
||||
|
||||
val serverPath = "/api/items/${libraryItem.id}/cover"
|
||||
val destinationFilename = "cover-${libraryItem.id}.jpg"
|
||||
val destinationFile = File("$tempFolderPath/$destinationFilename")
|
||||
val destinationFile = File("$tempFolderPath/$destinationFilename.part")
|
||||
val finalDestinationFile = File("$itemFolderPath/$destinationFilename")
|
||||
|
||||
if (destinationFile.exists()) {
|
||||
Log.d(tag, "TEMP Audio file already exists, removing it from ${destinationFile.absolutePath}")
|
||||
destinationFile.delete()
|
||||
}
|
||||
|
||||
if (finalDestinationFile.exists()) {
|
||||
Log.d(tag, "Cover already exists, removing it from ${finalDestinationFile.absolutePath}")
|
||||
finalDestinationFile.delete()
|
||||
}
|
||||
|
||||
val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, coverFileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,null,null,null)
|
||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||
}
|
||||
|
||||
downloadItemManager.addDownloadItem(downloadItem)
|
||||
DownloadServiceHost.enqueue(mainActivity, downloadItem)
|
||||
}
|
||||
} else {
|
||||
// Podcast episode download
|
||||
@@ -233,7 +235,7 @@ class AbsDownloader : Plugin() {
|
||||
val fileSize = audioTrack?.metadata?.size ?: 0
|
||||
|
||||
Log.d(tag, "Starting podcast episode download")
|
||||
val itemFolderPath = if (isInternal) "$tempFolderPath" else "${localFolder.absolutePath}/$podcastTitle"
|
||||
val itemFolderPath = if (isInternal) finalInternalFolderPath else "${localFolder.absolutePath}/$podcastTitle"
|
||||
val downloadItemId = "${libraryItem.id}-${episode?.id}"
|
||||
val downloadItem = DownloadItem(downloadItemId, libraryItem.id, episode?.id, libraryItem.userMediaProgress, DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, podcastTitle, podcastTitle, libraryItem.media, mutableListOf())
|
||||
|
||||
@@ -241,13 +243,8 @@ class AbsDownloader : Plugin() {
|
||||
var destinationFilename = getFilenameFromRelPath(audioTrack?.relPath ?: "")
|
||||
Log.d(tag, "Audio File Server Path $serverPath | AF RelPath ${audioTrack?.relPath} | LocalFolder Path ${localFolder.absolutePath} | DestName $destinationFilename")
|
||||
|
||||
var destinationFile = File("$tempFolderPath/$destinationFilename")
|
||||
var destinationFile = File("$tempFolderPath/$destinationFilename.part")
|
||||
var finalDestinationFile = File("$itemFolderPath/$destinationFilename")
|
||||
if (finalDestinationFile.exists()) {
|
||||
Log.d(tag, "Audio file already exists, removing it from ${finalDestinationFile.absolutePath}")
|
||||
finalDestinationFile.delete()
|
||||
}
|
||||
|
||||
var downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,fileSize, destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,null,audioTrack,episode)
|
||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||
|
||||
@@ -258,18 +255,14 @@ class AbsDownloader : Plugin() {
|
||||
serverPath = "/api/items/${libraryItem.id}/cover"
|
||||
destinationFilename = "cover.jpg"
|
||||
|
||||
destinationFile = File("$tempFolderPath/$destinationFilename")
|
||||
destinationFile = File("$tempFolderPath/$destinationFilename.part")
|
||||
finalDestinationFile = File("$itemFolderPath/$destinationFilename")
|
||||
|
||||
if (finalDestinationFile.exists()) {
|
||||
Log.d(tag, "Podcast cover already exists - not downloading cover again")
|
||||
} else {
|
||||
downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,coverFileSize,destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,null,null,null)
|
||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||
}
|
||||
downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,coverFileSize,destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,null,null,null)
|
||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||
}
|
||||
|
||||
downloadItemManager.addDownloadItem(downloadItem)
|
||||
DownloadServiceHost.enqueue(mainActivity, downloadItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.audiobookshelf.app.plugins
|
||||
|
||||
import android.app.AlertDialog
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
@@ -23,40 +24,62 @@ import java.io.File
|
||||
class AbsFileSystem : Plugin() {
|
||||
private val TAG = "AbsFileSystem"
|
||||
private val tag = "AbsFileSystem"
|
||||
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||
private var jacksonMapper =
|
||||
jacksonObjectMapper()
|
||||
.enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||
|
||||
lateinit var mainActivity: MainActivity
|
||||
|
||||
override fun load() {
|
||||
mainActivity = (activity as MainActivity)
|
||||
|
||||
mainActivity.storage.storageAccessCallback = object : StorageAccessCallback {
|
||||
override fun onRootPathNotSelected(
|
||||
requestCode: Int,
|
||||
rootPath: String,
|
||||
uri: Uri,
|
||||
selectedStorageType: StorageType,
|
||||
expectedStorageType: StorageType
|
||||
) {
|
||||
Log.d(TAG, "STORAGE ACCESS CALLBACK")
|
||||
}
|
||||
mainActivity.storage.storageAccessCallback =
|
||||
object : StorageAccessCallback {
|
||||
override fun onRootPathNotSelected(
|
||||
requestCode: Int,
|
||||
rootPath: String,
|
||||
uri: Uri,
|
||||
selectedStorageType: StorageType,
|
||||
expectedStorageType: StorageType
|
||||
) {
|
||||
Log.d(TAG, "STORAGE ACCESS CALLBACK")
|
||||
}
|
||||
|
||||
override fun onCanceledByUser(requestCode: Int) {
|
||||
Log.d(TAG, "STORAGE ACCESS CALLBACK")
|
||||
}
|
||||
override fun onCanceledByUser(requestCode: Int) {
|
||||
Log.d(TAG, "STORAGE ACCESS CALLBACK")
|
||||
}
|
||||
|
||||
override fun onExpectedStorageNotSelected(requestCode: Int, selectedFolder: DocumentFile, selectedStorageType: StorageType, expectedBasePath: String, expectedStorageType: StorageType) {
|
||||
Log.d(TAG, "STORAGE ACCESS CALLBACK")
|
||||
}
|
||||
override fun onExpectedStorageNotSelected(
|
||||
requestCode: Int,
|
||||
selectedFolder: DocumentFile,
|
||||
selectedStorageType: StorageType,
|
||||
expectedBasePath: String,
|
||||
expectedStorageType: StorageType
|
||||
) {
|
||||
Log.d(TAG, "STORAGE ACCESS CALLBACK")
|
||||
}
|
||||
|
||||
override fun onStoragePermissionDenied(requestCode: Int) {
|
||||
Log.d(TAG, "STORAGE ACCESS CALLBACK")
|
||||
}
|
||||
override fun onStoragePermissionDenied(requestCode: Int) {
|
||||
Log.d(TAG, "STORAGE ACCESS CALLBACK")
|
||||
}
|
||||
|
||||
override fun onRootPathPermissionGranted(requestCode: Int, root: DocumentFile) {
|
||||
Log.d(TAG, "STORAGE ACCESS CALLBACK")
|
||||
}
|
||||
}
|
||||
override fun onRootPathPermissionGranted(requestCode: Int, root: DocumentFile) {
|
||||
Log.d(TAG, "STORAGE ACCESS CALLBACK")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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
|
||||
@@ -65,60 +88,74 @@ class AbsFileSystem : Plugin() {
|
||||
val REQUEST_CODE_SELECT_FOLDER = 6
|
||||
val REQUEST_CODE_SDCARD_ACCESS = 7
|
||||
|
||||
mainActivity.storage.folderPickerCallback = object : FolderPickerCallback {
|
||||
override fun onFolderSelected(requestCode: Int, folder: DocumentFile) {
|
||||
Log.d(TAG, "ON FOLDER SELECTED ${folder.uri} ${folder.name}")
|
||||
val absolutePath = folder.getAbsolutePath(activity)
|
||||
val storageType = folder.getStorageType(activity)
|
||||
val simplePath = folder.getSimplePath(activity)
|
||||
val basePath = folder.getBasePath(activity)
|
||||
val folderId = android.util.Base64.encodeToString(folder.id.toByteArray(), android.util.Base64.DEFAULT)
|
||||
mainActivity.storage.folderPickerCallback =
|
||||
object : FolderPickerCallback {
|
||||
override fun onFolderSelected(requestCode: Int, folder: DocumentFile) {
|
||||
Log.d(TAG, "ON FOLDER SELECTED ${folder.uri} ${folder.name}")
|
||||
val absolutePath = folder.getAbsolutePath(activity)
|
||||
val storageType = folder.getStorageType(activity)
|
||||
val basePath = folder.getBasePath(activity)
|
||||
val folderId =
|
||||
android.util.Base64.encodeToString(
|
||||
folder.id.toByteArray(),
|
||||
android.util.Base64.DEFAULT
|
||||
)
|
||||
|
||||
val localFolder = LocalFolder(folderId, folder.name ?: "", folder.uri.toString(),basePath,absolutePath, simplePath, storageType.toString(), mediaType)
|
||||
val localFolder =
|
||||
LocalFolder(
|
||||
folderId,
|
||||
folder.name ?: "",
|
||||
folder.uri.toString(),
|
||||
basePath,
|
||||
absolutePath,
|
||||
storageType.toString(),
|
||||
mediaType
|
||||
)
|
||||
|
||||
DeviceManager.dbManager.saveLocalFolder(localFolder)
|
||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(localFolder)))
|
||||
}
|
||||
DeviceManager.dbManager.saveLocalFolder(localFolder)
|
||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(localFolder)))
|
||||
}
|
||||
|
||||
override fun onStorageAccessDenied(
|
||||
requestCode: Int,
|
||||
folder: DocumentFile?,
|
||||
storageType: StorageType,
|
||||
storageId: String
|
||||
) {
|
||||
Log.e(tag, "Storage Access Denied ${folder?.getAbsolutePath(mainActivity)}")
|
||||
override fun onStorageAccessDenied(
|
||||
requestCode: Int,
|
||||
folder: DocumentFile?,
|
||||
storageType: StorageType,
|
||||
storageId: String
|
||||
) {
|
||||
Log.e(tag, "Storage Access Denied ${folder?.getAbsolutePath(mainActivity)}")
|
||||
|
||||
val jsobj = JSObject()
|
||||
if (requestCode == REQUEST_CODE_SELECT_FOLDER) {
|
||||
val jsobj = JSObject()
|
||||
if (requestCode == REQUEST_CODE_SELECT_FOLDER) {
|
||||
|
||||
val builder: AlertDialog.Builder = AlertDialog.Builder(mainActivity)
|
||||
builder.setMessage(
|
||||
"You have no write access to this storage, thus selecting this folder is useless." +
|
||||
"\nWould you like to grant access to this folder?")
|
||||
builder.setNegativeButton("Dont Allow") { _, _ ->
|
||||
run {
|
||||
jsobj.put("error", "User Canceled, Access Denied")
|
||||
call.resolve(jsobj)
|
||||
val builder: AlertDialog.Builder = AlertDialog.Builder(mainActivity)
|
||||
builder.setMessage(folderPickerString(KEY_WRITE_ACCESS_REQUIRED, DEFAULT_WRITE_ACCESS_REQUIRED))
|
||||
builder.setNegativeButton(folderPickerString(KEY_CANCEL, DEFAULT_CANCEL)) { _, _ ->
|
||||
run {
|
||||
jsobj.put("error", folderPickerString(KEY_ACCESS_DENIED, DEFAULT_ACCESS_DENIED))
|
||||
call.resolve(jsobj)
|
||||
}
|
||||
}
|
||||
builder.setPositiveButton(folderPickerString(KEY_ALLOW, DEFAULT_ALLOW)) { _, _ ->
|
||||
mainActivity.storageHelper.requestStorageAccess(
|
||||
REQUEST_CODE_SDCARD_ACCESS,
|
||||
initialPath = FileFullPath(mainActivity, storageId, "")
|
||||
)
|
||||
}
|
||||
builder.show()
|
||||
} else {
|
||||
Log.d(TAG, "STORAGE ACCESS DENIED $requestCode")
|
||||
jsobj.put("error", folderPickerString(KEY_ACCESS_DENIED, DEFAULT_ACCESS_DENIED))
|
||||
call.resolve(jsobj)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStoragePermissionDenied(requestCode: Int) {
|
||||
Log.d(TAG, "STORAGE PERMISSION DENIED $requestCode")
|
||||
val jsobj = JSObject()
|
||||
jsobj.put("error", folderPickerString(KEY_PERMISSION_DENIED, DEFAULT_PERMISSION_DENIED))
|
||||
call.resolve(jsobj)
|
||||
}
|
||||
}
|
||||
}
|
||||
builder.setPositiveButton("Allow.") { _, _ -> mainActivity.storageHelper.requestStorageAccess(REQUEST_CODE_SDCARD_ACCESS, initialPath = FileFullPath(mainActivity, storageId, "")) }
|
||||
builder.show()
|
||||
} else {
|
||||
Log.d(TAG, "STORAGE ACCESS DENIED $requestCode")
|
||||
jsobj.put("error", "Access Denied")
|
||||
call.resolve(jsobj)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun onStoragePermissionDenied(requestCode: Int) {
|
||||
Log.d(TAG, "STORAGE PERMISSION DENIED $requestCode")
|
||||
val jsobj = JSObject()
|
||||
jsobj.put("error", "Permission Denied")
|
||||
call.resolve(jsobj)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
mainActivity.storage.openFolderPicker(REQUEST_CODE_SELECT_FOLDER)
|
||||
}
|
||||
@@ -152,7 +189,7 @@ class AbsFileSystem : Plugin() {
|
||||
val folderUrl = call.data.getString("folderUrl", "").toString()
|
||||
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()
|
||||
jsobj.put("value", hasAccess)
|
||||
@@ -196,26 +233,29 @@ class AbsFileSystem : Plugin() {
|
||||
if (localLibraryItem?.folderId?.startsWith("internal-") == true) {
|
||||
Log.d(tag, "Deleting internal library item at absolutePath $absolutePath")
|
||||
val file = File(absolutePath)
|
||||
success = if (file.exists()) {
|
||||
file.deleteRecursively()
|
||||
} else {
|
||||
true
|
||||
}
|
||||
success =
|
||||
if (file.exists()) {
|
||||
file.deleteRecursively()
|
||||
} else {
|
||||
true
|
||||
}
|
||||
} else {
|
||||
var subfolderPathToDelete = ""
|
||||
localLibraryItem?.folderId?.let { folderId ->
|
||||
val folder = DeviceManager.dbManager.getLocalFolder(folderId)
|
||||
folder?.absolutePath?.let { folderPath ->
|
||||
val splitAbsolutePath = absolutePath.split("/")
|
||||
val fullSubDir = splitAbsolutePath.subList(0, splitAbsolutePath.size - 1).joinToString("/")
|
||||
val fullSubDir =
|
||||
splitAbsolutePath.subList(0, splitAbsolutePath.size - 1).joinToString("/")
|
||||
if (fullSubDir != folderPath) {
|
||||
val subdirHasAnItem = DeviceManager.dbManager.getLocalLibraryItems().any { _localLibraryItem ->
|
||||
if (_localLibraryItem.id == localLibraryItemId) {
|
||||
false
|
||||
} else {
|
||||
_localLibraryItem.absolutePath.startsWith(fullSubDir)
|
||||
}
|
||||
}
|
||||
val subdirHasAnItem =
|
||||
DeviceManager.dbManager.getLocalLibraryItems().any { _localLibraryItem ->
|
||||
if (_localLibraryItem.id == localLibraryItemId) {
|
||||
false
|
||||
} else {
|
||||
_localLibraryItem.absolutePath.startsWith(fullSubDir)
|
||||
}
|
||||
}
|
||||
subfolderPathToDelete = if (subdirHasAnItem) "" else fullSubDir
|
||||
}
|
||||
}
|
||||
@@ -266,4 +306,23 @@ class AbsFileSystem : Plugin() {
|
||||
call.resolve(JSObject("{\"success\":false}"))
|
||||
}
|
||||
}
|
||||
|
||||
private fun folderPickerString(key: String, defaultValue: String): String =
|
||||
mainActivity.getSharedPreferences(FOLDER_PICKER_PREFERENCES, Context.MODE_PRIVATE)
|
||||
.getString(key, defaultValue) ?: defaultValue
|
||||
|
||||
private companion object {
|
||||
const val FOLDER_PICKER_PREFERENCES = "folder_picker"
|
||||
const val KEY_WRITE_ACCESS_REQUIRED = "write_access_required"
|
||||
const val KEY_ALLOW = "allow"
|
||||
const val KEY_CANCEL = "cancel"
|
||||
const val KEY_ACCESS_DENIED = "access_denied"
|
||||
const val KEY_PERMISSION_DENIED = "permission_denied"
|
||||
const val DEFAULT_WRITE_ACCESS_REQUIRED =
|
||||
"You do not have write access to this folder. Would you like to grant access?"
|
||||
const val DEFAULT_ALLOW = "Allow"
|
||||
const val DEFAULT_CANCEL = "Cancel"
|
||||
const val DEFAULT_ACCESS_DENIED = "Access denied"
|
||||
const val DEFAULT_PERMISSION_DENIED = "Permission denied"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -85,22 +85,22 @@
|
||||
<!-- Playback controls - jump buttons, play/pause, chapter navigation -->
|
||||
<div id="playerControls" class="absolute right-0 bottom-0 mx-auto" style="max-width: 414px">
|
||||
<div class="flex items-center max-w-full" :class="playerSettings.lockUi ? 'justify-center' : 'justify-between'">
|
||||
<span v-show="showFullscreen && !playerSettings.lockUi" class="material-symbols next-icon text-fg cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpChapterStart">first_page</span>
|
||||
<div v-show="!playerSettings.lockUi" class="jump-icon text-fg cursor-pointer flex flex-col items-center" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpBackwards">
|
||||
<span v-show="showFullscreen && !playerSettings.lockUi" class="material-symbols next-icon text-fg cursor-pointer" :class="showLoadingState ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpChapterStart">first_page</span>
|
||||
<div v-show="!playerSettings.lockUi" class="jump-icon text-fg cursor-pointer flex flex-col items-center" :class="showLoadingState ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpBackwards">
|
||||
<span class="material-symbols text-3xl leading-none">replay</span>
|
||||
<span v-if="showFullscreen" class="jump-label text-[10px] font-semibold leading-tight">{{ jumpBackwardsLabel }}</span>
|
||||
</div>
|
||||
<div class="play-btn cursor-pointer shadow-sm flex items-center justify-center rounded-full text-primary mx-4 relative overflow-hidden" :style="{ backgroundColor: coverRgb }" :class="{ 'animate-spin': seekLoading }" @mousedown.prevent @mouseup.prevent @click.stop="playPauseClick">
|
||||
<div v-if="!coverBgIsLight" class="absolute top-0 left-0 w-full h-full bg-white bg-opacity-20 pointer-events-none" />
|
||||
|
||||
<span v-if="!isLoading" class="material-symbols fill" :class="{ 'text-white': coverRgb && !coverBgIsLight }">{{ seekLoading ? 'autorenew' : !isPlaying ? 'play_arrow' : 'pause' }}</span>
|
||||
<span v-if="!showLoadingState" class="material-symbols fill" :class="{ 'text-white': coverRgb && !coverBgIsLight }">{{ seekLoading ? 'autorenew' : !isPlaying ? 'play_arrow' : 'pause' }}</span>
|
||||
<widgets-spinner-icon v-else class="h-8 w-8" />
|
||||
</div>
|
||||
<div v-show="!playerSettings.lockUi" class="jump-icon text-fg cursor-pointer flex flex-col items-center" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpForward">
|
||||
<div v-show="!playerSettings.lockUi" class="jump-icon text-fg cursor-pointer flex flex-col items-center" :class="showLoadingState ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpForward">
|
||||
<span class="material-symbols text-3xl leading-none">forward_media</span>
|
||||
<span v-if="showFullscreen" class="jump-label text-[10px] font-semibold leading-tight">{{ jumpForwardLabel }}</span>
|
||||
</div>
|
||||
<span v-show="showFullscreen && !playerSettings.lockUi" class="material-symbols next-icon text-fg cursor-pointer" :class="nextChapter && !isLoading ? 'text-opacity-75' : 'text-opacity-10'" @click.stop="jumpNextChapter">last_page</span>
|
||||
<span v-show="showFullscreen && !playerSettings.lockUi" class="material-symbols next-icon text-fg cursor-pointer" :class="nextChapter && !showLoadingState ? 'text-opacity-75' : 'text-opacity-10'" @click.stop="jumpNextChapter">last_page</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -111,7 +111,7 @@
|
||||
<div class="flex-grow" />
|
||||
<p class="font-mono text-fg" style="font-size: 0.8rem">{{ timeRemainingPretty }}</p>
|
||||
</div>
|
||||
<div ref="track" class="h-1.5 w-full bg-track/50 relative rounded-full" :class="{ 'animate-pulse': isLoading }" @click.stop>
|
||||
<div ref="track" class="h-1.5 w-full bg-track/50 relative rounded-full" :class="{ 'animate-pulse': showLoadingState }" @click.stop>
|
||||
<div ref="readyTrack" class="h-full bg-track-buffered absolute top-0 left-0 rounded-full pointer-events-none" />
|
||||
<div ref="bufferedTrack" class="h-full bg-track absolute top-0 left-0 rounded-full pointer-events-none" />
|
||||
<div ref="playedTrack" class="h-full bg-track-cursor absolute top-0 left-0 rounded-full pointer-events-none" />
|
||||
@@ -133,7 +133,7 @@
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { AbsAudioPlayer } from '@/plugins/capacitor'
|
||||
import { Dialog } from '@capacitor/dialog'
|
||||
import { FastAverageColor } from 'fast-average-color'
|
||||
import { getAverageColorFromCoverUrl } from '@/utils/coverAverageColor'
|
||||
import WrappingMarquee from '@/assets/WrappingMarquee.js'
|
||||
import jumpLabelMixin from '@/mixins/jumpLabel'
|
||||
|
||||
@@ -176,6 +176,7 @@ export default {
|
||||
lockUi: false
|
||||
},
|
||||
isLoading: false,
|
||||
isCheckingServerProgress: false,
|
||||
isDraggingCursor: false,
|
||||
draggingTouchStartX: 0,
|
||||
draggingTouchStartTime: 0,
|
||||
@@ -289,6 +290,9 @@ export default {
|
||||
return 190 * heightScale
|
||||
}
|
||||
},
|
||||
showLoadingState() {
|
||||
return this.isLoading || this.isCheckingServerProgress
|
||||
},
|
||||
showCastBtn() {
|
||||
return this.$store.state.isCastAvailable
|
||||
},
|
||||
@@ -427,17 +431,14 @@ export default {
|
||||
},
|
||||
async coverImageLoaded(fullCoverUrl) {
|
||||
if (!fullCoverUrl) return
|
||||
|
||||
const fac = new FastAverageColor()
|
||||
fac
|
||||
.getColorAsync(fullCoverUrl)
|
||||
.then((color) => {
|
||||
this.coverRgb = color.rgba
|
||||
this.coverBgIsLight = color.isLight
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log(e)
|
||||
})
|
||||
const avg = await getAverageColorFromCoverUrl(this, fullCoverUrl)
|
||||
if (!avg) {
|
||||
this.coverRgb = 'rgb(55, 56, 56)'
|
||||
this.coverBgIsLight = false
|
||||
} else {
|
||||
this.coverRgb = avg.rgba
|
||||
this.coverBgIsLight = avg.isLight
|
||||
}
|
||||
},
|
||||
clickTitleAndAuthor() {
|
||||
if (!this.showFullscreen) return
|
||||
@@ -480,13 +481,13 @@ export default {
|
||||
},
|
||||
async jumpNextChapter() {
|
||||
await this.$hapticsImpact()
|
||||
if (this.isLoading) return
|
||||
if (this.showLoadingState) return
|
||||
if (!this.nextChapter) return
|
||||
this.seek(this.nextChapter.start)
|
||||
},
|
||||
async jumpChapterStart() {
|
||||
await this.$hapticsImpact()
|
||||
if (this.isLoading) return
|
||||
if (this.showLoadingState) return
|
||||
if (!this.currentChapter) {
|
||||
return this.restart()
|
||||
}
|
||||
@@ -516,12 +517,12 @@ export default {
|
||||
},
|
||||
async jumpBackwards() {
|
||||
await this.$hapticsImpact()
|
||||
if (this.isLoading) return
|
||||
if (this.showLoadingState) return
|
||||
AbsAudioPlayer.seekBackward({ value: this.jumpBackwardsTime })
|
||||
},
|
||||
async jumpForward() {
|
||||
await this.$hapticsImpact()
|
||||
if (this.isLoading) return
|
||||
if (this.showLoadingState) return
|
||||
AbsAudioPlayer.seekForward({ value: this.jumpForwardTime })
|
||||
},
|
||||
setStreamReady() {
|
||||
@@ -625,7 +626,7 @@ export default {
|
||||
}
|
||||
},
|
||||
seek(time) {
|
||||
if (this.isLoading) return
|
||||
if (this.showLoadingState) return
|
||||
if (this.seekLoading) {
|
||||
console.error('Already seek loading', this.seekedTime)
|
||||
return
|
||||
@@ -634,7 +635,8 @@ export default {
|
||||
this.seekedTime = time
|
||||
this.seekLoading = true
|
||||
|
||||
AbsAudioPlayer.seek({ value: Math.floor(time) })
|
||||
// Pass fractional seconds so seeks to non-integer chapter starts don't truncate
|
||||
AbsAudioPlayer.seek({ value: time })
|
||||
|
||||
if (this.$refs.playedTrack) {
|
||||
const perc = time / this.totalDuration
|
||||
@@ -657,11 +659,14 @@ export default {
|
||||
},
|
||||
async playPauseClick() {
|
||||
await this.$hapticsImpact()
|
||||
if (this.isLoading) return
|
||||
if (this.showLoadingState) return
|
||||
|
||||
this.isPlaying = !!((await AbsAudioPlayer.playPause()) || {}).playing
|
||||
this.isEnded = false
|
||||
},
|
||||
setIsCheckingServerProgress(value) {
|
||||
this.isCheckingServerProgress = !!value
|
||||
},
|
||||
play() {
|
||||
AbsAudioPlayer.playPlayer()
|
||||
this.startPlayInterval()
|
||||
|
||||
@@ -48,6 +48,9 @@ export default {
|
||||
},
|
||||
isIos() {
|
||||
return this.$platform === 'ios'
|
||||
},
|
||||
currentPlaybackSession() {
|
||||
return this.$store.state.currentPlaybackSession
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -226,7 +229,7 @@ export default {
|
||||
console.log('Already streaming item', startTime)
|
||||
if (startTime !== undefined && startTime !== null) {
|
||||
// seek to start time
|
||||
AbsAudioPlayer.seek({ value: Math.floor(startTime) })
|
||||
AbsAudioPlayer.seek({ value: startTime })
|
||||
} else if (this.$refs.audioPlayer) {
|
||||
this.$refs.audioPlayer.play()
|
||||
}
|
||||
@@ -304,48 +307,129 @@ export default {
|
||||
this.$refs.audioPlayer?.seek(currentTime)
|
||||
},
|
||||
/**
|
||||
* When device gains focus then refresh the timestamps in the audio player
|
||||
* Fetch the current user's media progress from the server for a given library item / episode.
|
||||
* Returns the server media progress object, or null if the request fails, times out, or the
|
||||
* response doesn't match the requested library item.
|
||||
*
|
||||
* The audio player's loading state is shown while the request is in flight so the user
|
||||
* doesn't tap play before we have a chance to update the timestamps. The request timeout
|
||||
* is 7 seconds so a slow/unresponsive server doesn't block the user for long.
|
||||
*/
|
||||
deviceFocused(hasFocus) {
|
||||
if (!this.$store.state.currentPlaybackSession) return
|
||||
async getServerMediaProgressForCurrentSession() {
|
||||
if (!this.$store.state.user.user || !this.$store.state.networkConnected) return null
|
||||
const libraryItemId = this.currentPlaybackSession?.libraryItemId
|
||||
const episodeId = this.currentPlaybackSession?.episodeId
|
||||
if (!libraryItemId) return null
|
||||
|
||||
if (hasFocus) {
|
||||
if (this.$refs.audioPlayer?.isCheckingServerProgress) {
|
||||
console.log('[AudioPlayerContainer] getServerMediaProgressForCurrentSession: already checking server progress')
|
||||
return null
|
||||
}
|
||||
|
||||
const url = episodeId ? `/api/me/progress/${libraryItemId}/${episodeId}` : `/api/me/progress/${libraryItemId}`
|
||||
|
||||
this.$refs.audioPlayer?.setIsCheckingServerProgress(true)
|
||||
try {
|
||||
const data = await this.$nativeHttp.get(url, { connectTimeout: 7000, readTimeout: 7000 })
|
||||
if (!data || data.libraryItemId !== libraryItemId) return null
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error('[AudioPlayerContainer] Failed to get server media progress', error)
|
||||
return null
|
||||
} finally {
|
||||
this.$refs.audioPlayer?.setIsCheckingServerProgress(false)
|
||||
}
|
||||
},
|
||||
getLocalMediaProgressForCurrentSession() {
|
||||
if (!this.currentPlaybackSession) return null
|
||||
return this.$store.getters['globals/getLocalMediaProgressById'](this.currentPlaybackSession.localLibraryItem?.id, this.currentPlaybackSession.localEpisodeId)
|
||||
},
|
||||
/**
|
||||
* Sync the server media progress with the local media progress
|
||||
*/
|
||||
async syncServerMediaProgressWithLocalMediaProgress(localMediaProgressId, serverMediaProgress) {
|
||||
try {
|
||||
const newLocalMediaProgress = await this.$db.syncServerMediaProgressWithLocalMediaProgress({
|
||||
localMediaProgressId,
|
||||
mediaProgress: serverMediaProgress
|
||||
})
|
||||
if (newLocalMediaProgress?.id) {
|
||||
this.$store.commit('globals/updateLocalMediaProgress', newLocalMediaProgress)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[AudioPlayerContainer] Failed to sync server progress with local media progress', error)
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Check if the server media progress is more recent than the local media progress and sync if so
|
||||
*/
|
||||
async checkSyncServerProgressWithLocalProgress(localMediaProgress) {
|
||||
if (!localMediaProgress) return
|
||||
console.log('[AudioPlayerContainer] checkSyncServerProgressWithLocalProgress: checking server media progress for local media item open in player')
|
||||
const serverMediaProgress = await this.getServerMediaProgressForCurrentSession()
|
||||
if (!serverMediaProgress?.lastUpdate || serverMediaProgress.lastUpdate <= localMediaProgress.lastUpdate) return
|
||||
|
||||
console.log('[AudioPlayerContainer] checkSyncServerProgressWithLocalProgress: server progress is more recent than local progress. Server current time:', serverMediaProgress.currentTime, 'vs local', localMediaProgress.currentTime, `(server lastUpdate=${serverMediaProgress.lastUpdate} > local lastUpdate=${localMediaProgress.lastUpdate})`)
|
||||
if (!this.$refs.audioPlayer?.isPlaying && serverMediaProgress.currentTime !== localMediaProgress.currentTime) {
|
||||
// Use seek() so the native audio player's current session is updated
|
||||
this.$refs.audioPlayer.seek(serverMediaProgress.currentTime)
|
||||
}
|
||||
|
||||
await this.syncServerMediaProgressWithLocalMediaProgress(localMediaProgress.id, serverMediaProgress)
|
||||
},
|
||||
/**
|
||||
* When socket is reconnected after a delay, if a local media item is open in the player (paused)
|
||||
* we fetch the server media progress and sync it if it is more recent than the local progress
|
||||
*
|
||||
* If there is no socket connection we may have missed external progress updates
|
||||
*/
|
||||
async socketReconnected() {
|
||||
if (!this.currentPlaybackSession) return
|
||||
// dont update timestamps if player is playing
|
||||
if (this.$refs.audioPlayer?.isPlaying) return
|
||||
|
||||
if (this.$refs.audioPlayer.isLocalPlayMethod) {
|
||||
const localMediaProgress = this.getLocalMediaProgressForCurrentSession()
|
||||
if (!localMediaProgress) {
|
||||
console.error('[AudioPlayerContainer] socket reconnected: Local media progress not found')
|
||||
return
|
||||
}
|
||||
|
||||
await this.checkSyncServerProgressWithLocalProgress(localMediaProgress)
|
||||
}
|
||||
},
|
||||
/**
|
||||
* When device re-gains focus then refresh the timestamps in the audio player
|
||||
* if local item is open then fetch the server media progress and update if more recent
|
||||
*/
|
||||
async deviceFocused(hasFocus) {
|
||||
if (!this.currentPlaybackSession || !hasFocus) return
|
||||
// dont update timestamps if player is playing
|
||||
if (this.$refs.audioPlayer?.isPlaying) return
|
||||
|
||||
if (this.$refs.audioPlayer.isLocalPlayMethod) {
|
||||
const localMediaProgress = this.getLocalMediaProgressForCurrentSession()
|
||||
if (!localMediaProgress) {
|
||||
console.error('[AudioPlayerContainer] device visibility: Local media progress not found')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[AudioPlayerContainer] device visibility: found local media progress', localMediaProgress.currentTime, 'last time in player is', this.currentTime)
|
||||
this.$refs.audioPlayer.currentTime = localMediaProgress.currentTime
|
||||
this.$refs.audioPlayer.timeupdate()
|
||||
|
||||
await this.checkSyncServerProgressWithLocalProgress(localMediaProgress)
|
||||
} else {
|
||||
// server item so fetch server media progress and update player time
|
||||
console.log('[AudioPlayerContainer] device visibility: checking server media progress for server media item open in player')
|
||||
const data = await this.getServerMediaProgressForCurrentSession()
|
||||
if (!data) return
|
||||
if (!this.$refs.audioPlayer?.isPlaying) {
|
||||
const playbackSession = this.$store.state.currentPlaybackSession
|
||||
if (this.$refs.audioPlayer.isLocalPlayMethod) {
|
||||
const localLibraryItemId = playbackSession.localLibraryItem?.id
|
||||
const localEpisodeId = playbackSession.localEpisodeId
|
||||
if (!localLibraryItemId) {
|
||||
console.error('[AudioPlayerContainer] device visibility: no local library item for session', JSON.stringify(playbackSession))
|
||||
return
|
||||
}
|
||||
const localMediaProgress = this.$store.state.globals.localMediaProgress.find((mp) => {
|
||||
if (localEpisodeId) return mp.localEpisodeId === localEpisodeId
|
||||
return mp.localLibraryItemId === localLibraryItemId
|
||||
})
|
||||
if (localMediaProgress) {
|
||||
console.log('[AudioPlayerContainer] device visibility: found local media progress', localMediaProgress.currentTime, 'last time in player is', this.currentTime)
|
||||
this.$refs.audioPlayer.currentTime = localMediaProgress.currentTime
|
||||
this.$refs.audioPlayer.timeupdate()
|
||||
} else {
|
||||
console.error('[AudioPlayerContainer] device visibility: Local media progress not found')
|
||||
}
|
||||
} else {
|
||||
const libraryItemId = playbackSession.libraryItemId
|
||||
const episodeId = playbackSession.episodeId
|
||||
const url = episodeId ? `/api/me/progress/${libraryItemId}/${episodeId}` : `/api/me/progress/${libraryItemId}`
|
||||
this.$nativeHttp
|
||||
.get(url)
|
||||
.then((data) => {
|
||||
if (!this.$refs.audioPlayer?.isPlaying && data.libraryItemId === libraryItemId) {
|
||||
console.log('[AudioPlayerContainer] device visibility: got server media progress', data.currentTime, 'last time in player is', this.currentTime)
|
||||
this.$refs.audioPlayer.currentTime = data.currentTime
|
||||
this.$refs.audioPlayer.timeupdate()
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('[AudioPlayerContainer] device visibility: Failed to get progress', error)
|
||||
})
|
||||
console.log('[AudioPlayerContainer] device visibility: got server media progress', data.currentTime, 'last time in player is', this.currentTime)
|
||||
// Only seek if the difference is greater than 1 second
|
||||
if (Math.abs(data.currentTime - this.currentTime) > 1) {
|
||||
// Use seek() so the native audio player's current session is updated
|
||||
this.$refs.audioPlayer.seek(data.currentTime)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -368,6 +452,7 @@ export default {
|
||||
this.$eventBus.$on('user-settings', this.settingsUpdated)
|
||||
this.$eventBus.$on('playback-time-update', this.playbackTimeUpdate)
|
||||
this.$eventBus.$on('device-focus-update', this.deviceFocused)
|
||||
this.$eventBus.$on('socket-reconnected', this.socketReconnected)
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.onLocalMediaProgressUpdateListener?.remove()
|
||||
@@ -383,6 +468,7 @@ export default {
|
||||
this.$eventBus.$off('user-settings', this.settingsUpdated)
|
||||
this.$eventBus.$off('playback-time-update', this.playbackTimeUpdate)
|
||||
this.$eventBus.$off('device-focus-update', this.deviceFocused)
|
||||
this.$eventBus.$off('socket-reconnected', this.socketReconnected)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -484,7 +484,7 @@ export default {
|
||||
|
||||
const { value } = await Dialog.confirm({
|
||||
title: this.$strings.HeaderConfirm,
|
||||
message: this.$strings.MessageConfirmDeleteServerConfig,
|
||||
message: this.$strings.MessageConfirmDeleteServerConfig
|
||||
})
|
||||
if (value) {
|
||||
this.processing = true
|
||||
@@ -843,7 +843,7 @@ export default {
|
||||
async setUserAndConnection({ user, userDefaultLibraryId, serverSettings, ereaderDevices }) {
|
||||
if (!user) return
|
||||
|
||||
console.log('Successfully logged in', JSON.stringify(user))
|
||||
console.log('Successfully logged in: ' + user.username)
|
||||
|
||||
this.$store.commit('setServerSettings', serverSettings)
|
||||
this.$store.commit('libraries/setEReaderDevices', ereaderDevices)
|
||||
|
||||
@@ -12,7 +12,8 @@ export default {
|
||||
return {
|
||||
downloadItemListener: null,
|
||||
completeListener: null,
|
||||
itemPartUpdateListener: null
|
||||
itemPartUpdateListener: null,
|
||||
queueChangedListener: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -76,17 +77,22 @@ export default {
|
||||
},
|
||||
onDownloadItemPartUpdate(itemPart) {
|
||||
this.$store.commit('globals/updateDownloadItemPart', itemPart)
|
||||
},
|
||||
onQueueChanged(data) {
|
||||
if (!data.hasWork) this.$store.commit('globals/clearItemDownloads')
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
this.downloadItemListener = await AbsDownloader.addListener('onDownloadItem', (data) => this.onDownloadItem(data))
|
||||
this.itemPartUpdateListener = await AbsDownloader.addListener('onDownloadItemPartUpdate', (data) => this.onDownloadItemPartUpdate(data))
|
||||
this.queueChangedListener = await AbsDownloader.addListener('onQueueChanged', (data) => this.onQueueChanged(data))
|
||||
this.completeListener = await AbsDownloader.addListener('onItemDownloadComplete', (data) => this.onItemDownloadComplete(data))
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.downloadItemListener?.remove()
|
||||
this.completeListener?.remove()
|
||||
this.itemPartUpdateListener?.remove()
|
||||
this.queueChangedListener?.remove()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -740,12 +740,12 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 41;
|
||||
CURRENT_PROJECT_VERSION = 43;
|
||||
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 0.11.0;
|
||||
MARKETING_VERSION = 0.13.0;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app.dev;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -764,12 +764,12 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 41;
|
||||
CURRENT_PROJECT_VERSION = 43;
|
||||
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 0.11.0;
|
||||
MARKETING_VERSION = 0.13.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
|
||||
+20
-20
@@ -25,11 +25,11 @@ PODS:
|
||||
- Capacitor
|
||||
- CordovaPlugins (6.2.1):
|
||||
- CapacitorCordova
|
||||
- Realm (10.54.4):
|
||||
- Realm/Headers (= 10.54.4)
|
||||
- Realm/Headers (10.54.4)
|
||||
- RealmSwift (10.54.4):
|
||||
- Realm (= 10.54.4)
|
||||
- Realm (10.54.6):
|
||||
- Realm/Headers (= 10.54.6)
|
||||
- Realm/Headers (10.54.6)
|
||||
- RealmSwift (10.54.6):
|
||||
- Realm (= 10.54.6)
|
||||
- WebnativellcCapacitorFilesharer (7.0.4):
|
||||
- Capacitor
|
||||
|
||||
@@ -89,22 +89,22 @@ EXTERNAL SOURCES:
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
Alamofire: 7193b3b92c74a07f85569e1a6c4f4237291e7496
|
||||
Capacitor: 106e7a4205f4618d582b886a975657c61179138d
|
||||
CapacitorApp: d63334c052278caf5d81585d80b21905c6f93f39
|
||||
CapacitorBrowser: 081852cf532acf77b9d2953f3a88fe5b9711fb06
|
||||
CapacitorClipboard: b98aead5dc7ec595547fc2c5d75bacd2ae3338bc
|
||||
CapacitorCommunityKeepAwake: 00dfd8fa3cca0df003c9a3e2cd7bee678aeec68b
|
||||
CapacitorCommunityVolumeButtons: 8a0443a202ed659688d85f4d44d66f42f62f2b56
|
||||
Capacitor: 03bc7cbdde6a629a8b910a9d7d78c3cc7ed09ea7
|
||||
CapacitorApp: febecbb9582cb353aed037e18ec765141f880fe9
|
||||
CapacitorBrowser: 6299776d496e968505464884d565992faa20444a
|
||||
CapacitorClipboard: 70bfdb42b877b320a6e511ab94fa7a6a55d57ecb
|
||||
CapacitorCommunityKeepAwake: ae762ce29b53147d28cfcaae5273cd1db0c38fc4
|
||||
CapacitorCommunityVolumeButtons: 1b84f7abf29cd9476cef9e8979b2854a64d2eed5
|
||||
CapacitorCordova: 5967b9ba03915ef1d585469d6e31f31dc49be96f
|
||||
CapacitorDialog: 9b934329026b2b0ffa56939bb06df3c67541a2ab
|
||||
CapacitorHaptics: 70e47470fa1a6bd6338cd102552e3846b7f9a1b3
|
||||
CapacitorNetwork: 07ec4c69c1bb696f41c23e00d31bda1bbb221bba
|
||||
CapacitorPreferences: cbf154e5e5519b7f5ab33817a334dda1e98387f9
|
||||
CapacitorStatusBar: 275cbf2f4dfc00388f519ef80c7ec22edda342c9
|
||||
CordovaPlugins: 5a72a85b45469e68556bb172409f1b6d57b27236
|
||||
Realm: 8b5cda39a41f17a1734da2f39c6004eb8745587a
|
||||
RealmSwift: 0b4f808fed6898f1f6c26f501f740efd80dff0b4
|
||||
WebnativellcCapacitorFilesharer: 10b111373d4dc49608935600dcbcc14605258c73
|
||||
CapacitorDialog: 0e09f242f6c3f5e82e4dc76b20f2a056be57a579
|
||||
CapacitorHaptics: 1f1e17041f435d8ead9ff2a34edd592c6aa6a8d6
|
||||
CapacitorNetwork: 15cb4385f0913a8ceb5e9a4d7af1ec554bdb8de8
|
||||
CapacitorPreferences: 6c98117d4d7508034a4af9db64d6b26fc75d7b94
|
||||
CapacitorStatusBar: 6e7af040d8fc4dd655999819625cae9c2d74c36f
|
||||
CordovaPlugins: 2ecbba09775516c41764dbf78ade612427311b7e
|
||||
Realm: b1b3bc68162fa242132eb7eefbf91d7c40f36a85
|
||||
RealmSwift: 456cfd82a4f23dff8e3456980999331ab69bbf3e
|
||||
WebnativellcCapacitorFilesharer: e3a5930240633db3335040251d66aac6762ff111
|
||||
|
||||
PODFILE CHECKSUM: 498821c0cfa2508609567fa95d7244c01cbef538
|
||||
|
||||
|
||||
+41
-13
@@ -24,6 +24,7 @@ export default {
|
||||
inittingLibraries: false,
|
||||
hasMounted: false,
|
||||
disconnectTime: 0,
|
||||
socketDisconnectedTime: 0,
|
||||
timeLostFocus: 0,
|
||||
currentLang: null
|
||||
}
|
||||
@@ -44,7 +45,7 @@ export default {
|
||||
} else {
|
||||
var timeSinceDisconnect = Date.now() - this.disconnectTime
|
||||
if (timeSinceDisconnect > 5000) {
|
||||
console.log('Time since disconnect was', timeSinceDisconnect, 'sync with server')
|
||||
console.log('[default] Time since disconnect was', timeSinceDisconnect, 'sync with server')
|
||||
setTimeout(() => {
|
||||
this.syncLocalSessions(false)
|
||||
}, 4000)
|
||||
@@ -55,6 +56,28 @@ export default {
|
||||
this.disconnectTime = Date.now()
|
||||
}
|
||||
}
|
||||
},
|
||||
socketConnected: {
|
||||
handler(newVal, oldVal) {
|
||||
if (!this.hasMounted) {
|
||||
// watcher runs before mount, handling libraries/connection should be handled in mount
|
||||
return
|
||||
}
|
||||
if (newVal) {
|
||||
// if we havent been receiving socket events then external progress updates may have been missed
|
||||
const timeSinceDisconnect = Date.now() - this.socketDisconnectedTime
|
||||
if (timeSinceDisconnect > 30000 && this.isPlayerOpen) {
|
||||
console.log('[default] socket reconnected after ' + timeSinceDisconnect + 'ms and player is open, triggering server media progress sync')
|
||||
// used for triggering a server media progress sync if local media item is open in player
|
||||
this.$eventBus.$emit('socket-reconnected')
|
||||
} else {
|
||||
console.log('[default] socket reconnected after ' + timeSinceDisconnect + 'ms')
|
||||
}
|
||||
} else {
|
||||
console.log('[default] socket disconnected')
|
||||
this.socketDisconnectedTime = Date.now()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -67,6 +90,9 @@ export default {
|
||||
networkConnected() {
|
||||
return this.$store.state.networkConnected
|
||||
},
|
||||
socketConnected() {
|
||||
return this.$store.state.socketConnected
|
||||
},
|
||||
user() {
|
||||
return this.$store.state.user.user
|
||||
},
|
||||
@@ -218,9 +244,9 @@ export default {
|
||||
AbsLogger.info({ tag: 'default', message: 'Calling syncLocalSessions' })
|
||||
const response = await this.$db.syncLocalSessionsWithServer(isFirstSync)
|
||||
if (response?.error) {
|
||||
console.error('[default] Failed to sync local sessions', response.error)
|
||||
await AbsLogger.error({ tag: 'default', message: `syncLocalSessions: Failed to sync local sessions: ${response.error}` })
|
||||
} else {
|
||||
console.log('[default] Successfully synced local sessions')
|
||||
await AbsLogger.info({ tag: 'default', message: 'syncLocalSessions: Successfully synced local sessions' })
|
||||
// Reload local media progresses
|
||||
await this.$store.dispatch('globals/loadLocalMediaProgress')
|
||||
}
|
||||
@@ -233,11 +259,13 @@ export default {
|
||||
async userMediaProgressUpdated(payload) {
|
||||
const prog = payload.data // MediaProgress
|
||||
await AbsLogger.info({ tag: 'default', message: `userMediaProgressUpdate: Received updated media progress for current user from socket event. Media item id ${payload.id}` })
|
||||
const mediaProgressId = payload.id
|
||||
const itemLabel = `${prog.libraryItemId}${prog.episodeId ? ` episodeId: ${prog.episodeId}` : ''}`
|
||||
|
||||
// Check if this media item is currently open in the player, paused, and this progress update is coming from a different session
|
||||
const isMediaOpenInPlayer = this.$store.getters['getIsMediaStreaming'](prog.libraryItemId, prog.episodeId)
|
||||
if (isMediaOpenInPlayer && this.$store.getters['getCurrentPlaybackSessionId'] !== payload.sessionId && !this.$store.state.playerIsPlaying) {
|
||||
await AbsLogger.info({ tag: 'default', message: `userMediaProgressUpdate: Item is currently open in player, paused and this progress update is coming from a different session. Updating playback time to ${payload.data.currentTime}` })
|
||||
await AbsLogger.info({ tag: 'default', message: `userMediaProgressUpdate: Updating paused player playback time to ${payload.data.currentTime} (${itemLabel}, mediaProgressId: ${mediaProgressId})` })
|
||||
this.$eventBus.$emit('playback-time-update', payload.data.currentTime)
|
||||
}
|
||||
|
||||
@@ -248,17 +276,17 @@ export default {
|
||||
// Progress update is more recent then local progress
|
||||
if (localProg && localProg.lastUpdate < prog.lastUpdate) {
|
||||
if (localProg.currentTime == prog.currentTime && localProg.isFinished == prog.isFinished) {
|
||||
await AbsLogger.info({ tag: 'default', message: `userMediaProgressUpdate: server lastUpdate is more recent but progress is up-to-date (libraryItemId: ${prog.libraryItemId}${prog.episodeId ? ` episodeId: ${prog.episodeId}` : ''})` })
|
||||
await AbsLogger.info({ tag: 'default', message: `userMediaProgressUpdate: server lastUpdate is more recent but progress is up-to-date (${itemLabel}, mediaProgressId: ${mediaProgressId}, server lastUpdate=${prog.lastUpdate} > local lastUpdate=${localProg.lastUpdate})` })
|
||||
return
|
||||
}
|
||||
|
||||
// Server progress is more up-to-date
|
||||
await AbsLogger.info({ tag: 'default', message: `userMediaProgressUpdate: syncing progress from server with local item for "${prog.libraryItemId}" ${prog.episodeId ? `episode ${prog.episodeId}` : ''} | server lastUpdate=${prog.lastUpdate} > local lastUpdate=${localProg.lastUpdate}` })
|
||||
const payload = {
|
||||
await AbsLogger.info({ tag: 'default', message: `userMediaProgressUpdate: Syncing server progress to local (${itemLabel}, mediaProgressId: ${mediaProgressId}, server lastUpdate=${prog.lastUpdate} > local lastUpdate=${localProg.lastUpdate})` })
|
||||
const syncPayload = {
|
||||
localMediaProgressId: localProg.id,
|
||||
mediaProgress: prog
|
||||
}
|
||||
newLocalMediaProgress = await this.$db.syncServerMediaProgressWithLocalMediaProgress(payload)
|
||||
newLocalMediaProgress = await this.$db.syncServerMediaProgressWithLocalMediaProgress(syncPayload)
|
||||
} else if (!localProg) {
|
||||
// Check if local library item exists
|
||||
// local media progress may not exist yet if it hasn't been played
|
||||
@@ -270,20 +298,20 @@ export default {
|
||||
const localEpisode = lliEpisodes.find((ep) => ep.serverEpisodeId === prog.episodeId)
|
||||
if (localEpisode) {
|
||||
// Add new local media progress
|
||||
const payload = {
|
||||
const syncPayload = {
|
||||
localLibraryItemId: localLibraryItem.id,
|
||||
localEpisodeId: localEpisode.id,
|
||||
mediaProgress: prog
|
||||
}
|
||||
newLocalMediaProgress = await this.$db.syncServerMediaProgressWithLocalMediaProgress(payload)
|
||||
newLocalMediaProgress = await this.$db.syncServerMediaProgressWithLocalMediaProgress(syncPayload)
|
||||
}
|
||||
} else {
|
||||
// Add new local media progress
|
||||
const payload = {
|
||||
const syncPayload = {
|
||||
localLibraryItemId: localLibraryItem.id,
|
||||
mediaProgress: prog
|
||||
}
|
||||
newLocalMediaProgress = await this.$db.syncServerMediaProgressWithLocalMediaProgress(payload)
|
||||
newLocalMediaProgress = await this.$db.syncServerMediaProgressWithLocalMediaProgress(syncPayload)
|
||||
}
|
||||
} else {
|
||||
console.log(`[default] userMediaProgressUpdate no local media progress or lli found for this server item ${prog.id}`)
|
||||
@@ -291,7 +319,7 @@ export default {
|
||||
}
|
||||
|
||||
if (newLocalMediaProgress?.id) {
|
||||
await AbsLogger.info({ tag: 'default', message: `userMediaProgressUpdate: local media progress updated for ${newLocalMediaProgress.id}` })
|
||||
await AbsLogger.info({ tag: 'default', message: `userMediaProgressUpdate: Local media progress updated (${itemLabel}, localId: ${newLocalMediaProgress.id})` })
|
||||
this.$store.commit('globals/updateLocalMediaProgress', newLocalMediaProgress)
|
||||
}
|
||||
},
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "audiobookshelf-app",
|
||||
"version": "0.12.0-beta",
|
||||
"version": "0.13.0-beta",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "audiobookshelf-app",
|
||||
"version": "0.12.0-beta",
|
||||
"version": "0.13.0-beta",
|
||||
"dependencies": {
|
||||
"@capacitor-community/keep-awake": "^7.0.0",
|
||||
"@capacitor-community/volume-buttons": "^7.0.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf-app",
|
||||
"version": "0.12.0-beta",
|
||||
"version": "0.13.0-beta",
|
||||
"author": "advplyr",
|
||||
"scripts": {
|
||||
"dev": "nuxt --hostname 0.0.0.0 --port 1337",
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative" @click="showFullscreenCover = true">
|
||||
<covers-book-cover :library-item="libraryItem" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" no-bg raw @imageLoaded="coverImageLoaded" />
|
||||
<covers-book-cover :library-item="libraryItem" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" no-bg raw />
|
||||
<div v-if="!isPodcast" class="absolute bottom-0 left-0 h-1 z-10 box-shadow-progressbar" :class="userIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: coverWidth * progressPercent + 'px' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -172,7 +172,7 @@
|
||||
<script>
|
||||
import { Dialog } from '@capacitor/dialog'
|
||||
import { AbsFileSystem, AbsDownloader } from '@/plugins/capacitor'
|
||||
import { FastAverageColor } from 'fast-average-color'
|
||||
import { getAverageColorFromCoverUrl } from '@/utils/coverAverageColor'
|
||||
import cellularPermissionHelpers from '@/mixins/cellularPermissionHelpers'
|
||||
|
||||
export default {
|
||||
@@ -496,17 +496,10 @@ export default {
|
||||
},
|
||||
async coverImageLoaded(fullCoverUrl) {
|
||||
if (!fullCoverUrl) return
|
||||
|
||||
const fac = new FastAverageColor()
|
||||
fac
|
||||
.getColorAsync(fullCoverUrl)
|
||||
.then((color) => {
|
||||
this.coverRgb = color.rgba
|
||||
this.coverBgIsLight = color.isLight
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log(e)
|
||||
})
|
||||
const avg = await getAverageColorFromCoverUrl(this, fullCoverUrl)
|
||||
if (!avg) return
|
||||
this.coverRgb = avg.rgba
|
||||
this.coverBgIsLight = avg.isLight
|
||||
},
|
||||
moreButtonPress() {
|
||||
this.showMoreMenu = true
|
||||
|
||||
@@ -51,7 +51,7 @@ class AbsDatabaseWeb extends WebPlugin {
|
||||
ssc.customHeaders = serverConnectionConfig.customHeaders || {}
|
||||
|
||||
if (serverConnectionConfig.refreshToken) {
|
||||
console.log('[AbsDatabase] Updating refresh token...', serverConnectionConfig.refreshToken)
|
||||
console.log('[AbsDatabase] Updating refresh token...')
|
||||
// Only using local storage for web version that is only used for testing
|
||||
localStorage.setItem(`refresh_token_${ssc.id}`, serverConnectionConfig.refreshToken)
|
||||
}
|
||||
@@ -71,7 +71,7 @@ class AbsDatabaseWeb extends WebPlugin {
|
||||
}
|
||||
|
||||
if (serverConnectionConfig.refreshToken) {
|
||||
console.log('[AbsDatabase] Setting refresh token...', serverConnectionConfig.refreshToken)
|
||||
console.log('[AbsDatabase] Setting refresh token...')
|
||||
// Only using local storage for web version that is only used for testing
|
||||
localStorage.setItem(`refresh_token_${ssc.id}`, serverConnectionConfig.refreshToken)
|
||||
}
|
||||
@@ -119,7 +119,6 @@ class AbsDatabaseWeb extends WebPlugin {
|
||||
name: 'Audiobooks',
|
||||
contentUrl: 'test',
|
||||
absolutePath: '/audiobooks',
|
||||
simplePath: 'audiobooks',
|
||||
storageType: 'primary',
|
||||
mediaType: 'book'
|
||||
}
|
||||
@@ -196,7 +195,6 @@ class AbsDatabaseWeb extends WebPlugin {
|
||||
filename: 'lf1.mp3',
|
||||
contentUrl: 'test',
|
||||
absolutePath: 'test',
|
||||
simplePath: 'test',
|
||||
mimeType: 'audio/mpeg',
|
||||
size: 39048290
|
||||
}
|
||||
|
||||
+1
-2
@@ -128,13 +128,12 @@ export default ({ app, store }, inject) => {
|
||||
|
||||
// Listen for token refresh events from native app
|
||||
AbsDatabase.addListener('onTokenRefresh', (data) => {
|
||||
console.log('[db] onTokenRefresh', data)
|
||||
store.commit('user/setAccessToken', data.accessToken)
|
||||
})
|
||||
|
||||
// Listen for token refresh failure events from native app
|
||||
AbsDatabase.addListener('onTokenRefreshFailure', async (data) => {
|
||||
console.log('[db] onTokenRefreshFailure', data)
|
||||
console.log('[db] onTokenRefreshFailure')
|
||||
// Clear store and redirect to login page
|
||||
await store.dispatch('user/logout')
|
||||
if (window.location.pathname !== '/connect') {
|
||||
|
||||
+23
-1
@@ -1,10 +1,13 @@
|
||||
import Vue from 'vue'
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { AbsDownloader, AbsFileSystem } from '@/plugins/capacitor'
|
||||
import enUsStrings from '../strings/en-us.json'
|
||||
|
||||
const defaultCode = 'en-us'
|
||||
let $localStore = null
|
||||
|
||||
const languageCodeMap = {
|
||||
be: { label: 'Беларуская', dateFnsLocale: 'be' },
|
||||
bn: { label: 'বাংলা', dateFnsLocale: 'bn' },
|
||||
bg: { label: 'Български', dateFnsLocale: 'bg' },
|
||||
ca: { label: 'Català', dateFnsLocale: 'ca' },
|
||||
@@ -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) => {
|
||||
return {
|
||||
text: languageCodeMap[code].label,
|
||||
@@ -107,6 +128,7 @@ async function loadi18n(code) {
|
||||
}
|
||||
|
||||
Vue.prototype.$setDateFnsLocale(languageCodeMap[code].dateFnsLocale)
|
||||
syncDownloadNotificationStrings()
|
||||
|
||||
this.$eventBus.$emit('change-lang', code)
|
||||
return true
|
||||
@@ -144,5 +166,5 @@ async function initialize() {
|
||||
|
||||
export default ({ app, store }, inject) => {
|
||||
$localStore = app.$localStore
|
||||
initialize()
|
||||
initialize().finally(syncDownloadNotificationStrings)
|
||||
}
|
||||
|
||||
@@ -43,7 +43,8 @@ export default function ({ store, $db, $socket }, inject) {
|
||||
}
|
||||
if (res.status >= 400) {
|
||||
console.error(`[nativeHttp] ${res.status} status for url "${url}"`)
|
||||
throw new Error(res.data)
|
||||
const message = typeof res.data === 'string' ? res.data : `HTTP ${res.status}`
|
||||
throw new Error(message)
|
||||
}
|
||||
return res.data
|
||||
})
|
||||
@@ -100,7 +101,8 @@ export default function ({ store, $db, $socket }, inject) {
|
||||
|
||||
if (retryResponse.status >= 400) {
|
||||
console.error(`[nativeHttp] Retry request failed with status ${retryResponse.status}`)
|
||||
throw new Error(retryResponse.data)
|
||||
const message = typeof retryResponse.data === 'string' ? retryResponse.data : `HTTP ${retryResponse.status}`
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
return retryResponse.data
|
||||
|
||||
+1
-1
@@ -110,7 +110,7 @@ class ServerSocket extends EventEmitter {
|
||||
}
|
||||
|
||||
onAuthFailed(data) {
|
||||
console.log('[SOCKET] Auth failed', data)
|
||||
console.log('[SOCKET] Auth failed: ' + (data?.message || 'Unknown reason'))
|
||||
this.isAuthenticated = false
|
||||
}
|
||||
|
||||
|
||||
@@ -135,6 +135,9 @@ export const mutations = {
|
||||
removeItemDownload(state, id) {
|
||||
state.itemDownloads = state.itemDownloads.filter((i) => i.id != id)
|
||||
},
|
||||
clearItemDownloads(state) {
|
||||
state.itemDownloads = []
|
||||
},
|
||||
setBookshelfListView(state, val) {
|
||||
state.bookshelfListView = val
|
||||
},
|
||||
|
||||
@@ -231,7 +231,6 @@ export const mutations = {
|
||||
state.user = user
|
||||
},
|
||||
setAccessToken(state, accessToken) {
|
||||
console.log('[user] setAccessToken', accessToken)
|
||||
state.accessToken = accessToken
|
||||
},
|
||||
removeMediaProgress(state, id) {
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
"HeaderChapters": "الفصول",
|
||||
"HeaderCollection": "مجموعة",
|
||||
"HeaderCollectionItems": "عناصر المجموعة",
|
||||
"HeaderConfirm": "تأكيد",
|
||||
"HeaderConnectionStatus": "حالة الاتصال",
|
||||
"HeaderDataSettings": "إعدادات البيانات",
|
||||
"HeaderDetails": "التفاصيل",
|
||||
@@ -91,6 +92,7 @@
|
||||
"HeaderStatsRecentSessions": "الجلسات الأخيرة",
|
||||
"HeaderTableOfContents": "جدول المحتويات",
|
||||
"HeaderUserInterfaceSettings": "إعدادات واجهة المستخدم",
|
||||
"HeaderWelcome": "مرحبا, <strong>{0}</strong>",
|
||||
"HeaderYourStats": "إحصائياتك",
|
||||
"LabelAddToPlaylist": "أضف إلى قائمة التشغيل",
|
||||
"LabelAddedAt": "أضيفت على",
|
||||
|
||||
+8
-8
@@ -105,8 +105,8 @@
|
||||
"LabelAndroidAutoBrowseSeriesSequenceOrder": "Парадак кніг у серыі",
|
||||
"LabelAskConfirmation": "Запытваць пацверджанне",
|
||||
"LabelAuthor": "Аўтар",
|
||||
"LabelAuthorFirstLast": "Аўтар (Імя Прозвішча)",
|
||||
"LabelAuthorLastFirst": "Аўтар (Прозвішча, Імя)",
|
||||
"LabelAuthorFirstLast": "Аўтар (імя, прозвішча)",
|
||||
"LabelAuthorLastFirst": "Аўтар (прозвішча, імя)",
|
||||
"LabelAuthors": "Аўтары",
|
||||
"LabelAutoDownloadEpisodes": "Аўтаматычна спампоўваць выпускі",
|
||||
"LabelAutoRewindTime": "Час аўтаматычнай перамоткі назад",
|
||||
@@ -123,7 +123,7 @@
|
||||
"LabelComplete": "Завяршыць",
|
||||
"LabelContinueBooks": "Працягнуць кнігі",
|
||||
"LabelContinueEpisodes": "Працягнуць выпускі",
|
||||
"LabelContinueListening": "Працягваць слухаць",
|
||||
"LabelContinueListening": "Працяг праслухоўвання",
|
||||
"LabelContinueReading": "Працягнуць чытанне",
|
||||
"LabelContinueSeries": "Працягнуць серыі",
|
||||
"LabelCustomTime": "Свой час",
|
||||
@@ -135,7 +135,7 @@
|
||||
"LabelDisableShakeToResetHelp": "Калі патрасці прыладу падчас працы таймера АБО на працягу 2 хвілін пасля заканчэння таймера, таймер сну скідваецца. Уключыце гэтую наладу, каб адключыць скід страсеннем.",
|
||||
"LabelDisableVibrateOnReset": "Адключыць вібрацыю пры скідзе",
|
||||
"LabelDisableVibrateOnResetHelp": "Калі таймер сну скідваецца, ваша прылада будзе вібраваць. Уключыце гэтую наладу, каб адключыць вібрацыю пры скідзе таймера сну.",
|
||||
"LabelDiscover": "Знайсці",
|
||||
"LabelDiscover": "Знаходкі",
|
||||
"LabelDownload": "Спампаваць",
|
||||
"LabelDownloadUsingCellular": "Спампоўваць праз мабільны інтэрнэт",
|
||||
"LabelDownloaded": "Спампавана",
|
||||
@@ -255,9 +255,9 @@
|
||||
"LabelSleepTimer": "Таймер сну",
|
||||
"LabelSleepTimerAlmostDoneChime": "Гукавы сігнал, калі амаль звершаны",
|
||||
"LabelSleepTimerAlmostDoneChimeHelp": "Памякчыць звонак, калі на таймеры сну застанецца 30 секунд",
|
||||
"LabelStart": "Пачаць",
|
||||
"LabelStart": "Пачатак",
|
||||
"LabelStartTime": "Час пачатку",
|
||||
"LabelStatsBestDay": "Лепшы дзень",
|
||||
"LabelStatsBestDay": "Найлепшы дзень",
|
||||
"LabelStatsDailyAverage": "У сярэднім за дзень",
|
||||
"LabelStatsDays": "Дзён",
|
||||
"LabelStatsDaysListened": "Дзён праслухана",
|
||||
@@ -281,7 +281,7 @@
|
||||
"LabelType": "Тып",
|
||||
"LabelUnknown": "Невядома",
|
||||
"LabelUnlockPlayer": "Разблакаваць прайгравальнік",
|
||||
"LabelUseBookshelfView": "Выкарыстоўваць выгляд кніжнай паліцы",
|
||||
"LabelUseBookshelfView": "Паказваць кніжныя паліцы",
|
||||
"LabelUser": "Карыстальнік",
|
||||
"LabelUsername": "Імя карыстальніка",
|
||||
"LabelVeryHigh": "Вельмі высокі",
|
||||
@@ -348,7 +348,7 @@
|
||||
"MessageOldServerConnectionWarningHelp": "Вы першапачаткова наладзілі падключэнне да гэтага сервера да міграцыі базы даных у версіі 2.3.0, якая выйшла ў чэрвені 2023 года. У будучым абнаўленні сервера магчымасць уваходу праз гэтае старое падключэнне будзе выдалена. Калі ласка, выдаліце існуючае падключэнне да сервера і падключыцеся зноў (выкарыстоўваючы той жа адрас сервера і ўліковыя даныя). Калі на гэтай прыладзе ёсць спампаваныя медыяфайлы, іх трэба будзе спампаваць зноў для сінхранізацыі з серверам.",
|
||||
"MessagePodcastSearchField": "Увядзіце пошукавы запыт або URL RSS-стужкі",
|
||||
"MessageProgressSyncFailed": "Апошняя спроба адправіць ваш прагрэс праслухоўвання на сервер не ўдалася. Спробы сінхранізаваць прагрэс будуць працягвацца кожныя 15 секунд на працягу 1 хвіліны падчас прайгравання медыяфайла.",
|
||||
"MessageReportBugsAndContribute": "Паведамляйце пра памылкі, прапануйце новыя функцыі і ўдзельнічайце на",
|
||||
"MessageReportBugsAndContribute": "Паведамляйце пра памылкі, прапануйце функцыі і ўносьце свой уклад на",
|
||||
"MessageSeriesAlreadyDownloaded": "Вы ўжо спампавалі ўсе кнігі з гэтай серыі.",
|
||||
"MessageSeriesDownloadConfirm": "Спампаваць адсутныя {0} кніг(і) з {1} файлам(-і), агульным памерам {2}, у папку {3}?",
|
||||
"MessageSeriesDownloadConfirmIos": "Спампаваць адсутныя {0} кніг(і) з {1} файл(амі), агульным памерам {2}?",
|
||||
|
||||
+18
-1
@@ -63,6 +63,7 @@
|
||||
"HeaderChapters": "Глави",
|
||||
"HeaderCollection": "Колекция",
|
||||
"HeaderCollectionItems": "Елемент в колекция",
|
||||
"HeaderConfirm": "Потвърди",
|
||||
"HeaderConnectionStatus": "Състояние на връзката",
|
||||
"HeaderDataSettings": "Настройки на данните",
|
||||
"HeaderDetails": "Детайли",
|
||||
@@ -91,6 +92,7 @@
|
||||
"HeaderStatsRecentSessions": "Последни сесии",
|
||||
"HeaderTableOfContents": "Съдържание",
|
||||
"HeaderUserInterfaceSettings": "Настройки на потребителския интерфейс",
|
||||
"HeaderWelcome": "Добре дошли, <strong>{0}</strong>",
|
||||
"HeaderYourStats": "Вашата статистика",
|
||||
"LabelAddToPlaylist": "Добави в плейлист",
|
||||
"LabelAddedAt": "Добавено в",
|
||||
@@ -113,6 +115,7 @@
|
||||
"LabelAutoSleepTimerAutoRewindHelp": "Когато автоматичният таймер на изключване изтече, повторното пускане на елемента автоматично ще върне позицията ви назад.",
|
||||
"LabelAutoSleepTimerHelp": "Когато се възпроизвеждате медия между посочените начален и краен час, автоматично ще стартира таймер за изключване.",
|
||||
"LabelBooks": "Книги",
|
||||
"LabelByAuthor": "от {0}",
|
||||
"LabelChapterTrack": "Трак на глава",
|
||||
"LabelChapters": "Глави",
|
||||
"LabelClosePlayer": "Затвори плейъра",
|
||||
@@ -155,6 +158,9 @@
|
||||
"LabelFinished": "Дата на приключване",
|
||||
"LabelFolder": "Папка",
|
||||
"LabelFontBoldness": "Дебелина на шрифта",
|
||||
"LabelFontFamily": "Шрифт",
|
||||
"LabelFontFamilySans": "Sans",
|
||||
"LabelFontFamilySerif": "Serif",
|
||||
"LabelFontScale": "Мащаб на шрифта",
|
||||
"LabelGenre": "Жанр",
|
||||
"LabelGenres": "Жанрове",
|
||||
@@ -174,6 +180,9 @@
|
||||
"LabelLayout": "Оформление",
|
||||
"LabelLayoutAuto": "Авто",
|
||||
"LabelLayoutSinglePage": "Единична страница",
|
||||
"LabelLibrarySortByProgress": "Прогрес: Последно обновление",
|
||||
"LabelLibrarySortByProgressFinished": "Прогрес: Приключено",
|
||||
"LabelLibrarySortByProgressStarted": "Прогрес: Започнато",
|
||||
"LabelLight": "Светло",
|
||||
"LabelLineSpacing": "Междуредие",
|
||||
"LabelListenAgain": "Слушай отново",
|
||||
@@ -233,6 +242,7 @@
|
||||
"LabelScaleElapsedTimeBySpeed": "Скалируй изминалото време спрямо скоростта",
|
||||
"LabelSeason": "Сезон",
|
||||
"LabelSelectADevice": "Избери устройство",
|
||||
"LabelSelectMediaType": "Изберете тип на медията",
|
||||
"LabelSequenceAscending": "Възходяща последователност",
|
||||
"LabelSequenceDescending": "Низходяща последователност",
|
||||
"LabelSeries": "От сериите",
|
||||
@@ -285,12 +295,17 @@
|
||||
"MessageAudiobookshelfServerNotConnected": "Audiobookshelf сървър не е включен",
|
||||
"MessageAudiobookshelfServerRequired": "<strong>ВАЖНО!</strong> Това приложение е създадено да работи с Audiobookshelf сървър, който вие или някой, когото познавате, хоства. Това приложение не предоставя никакво съдържание.",
|
||||
"MessageBookshelfEmpty": "Библиотеката е празна",
|
||||
"MessageConfirmAppExit": "Искате ли да излезете от апликацията?",
|
||||
"MessageConfirmDeleteEpisodeDownloadQueue": "Сигурни ли сте, че искате да изчистите опашката за сваляне на епизоди?",
|
||||
"MessageConfirmDeleteLocalEpisode": "Премахни \"{0}\" епизода от устройството? Файловете на сървъра няма да бъдат премахнати.",
|
||||
"MessageConfirmDeleteLocalFiles": "Да премахна ли локалните файлове на този елемент от вашето устройство? Файловете на сървъра и вашият напредък няма да бъдат засегнати.",
|
||||
"MessageConfirmDeleteServerConfig": "Премахнете тази сървърна настройка?",
|
||||
"MessageConfirmDeleteServerEpisode": "Сигурни ли сте че искате да изтрийете епизод \"{0}\" от сървъра?\nПредупреждение: Това ще изтрийе и аудио файла.",
|
||||
"MessageConfirmDisableAutoTimer": "Сигурни ли сте, че искате да деактивирате автоматичния таймер за остатъка от днешния ден? Таймерът ще бъде активиран отново в края на този период на автоматичния таймер за изключване или ако рестартирате приложението.",
|
||||
"MessageConfirmDiscardProgress": "Сигурни ли сте, че искате да нулирате напредъка си?",
|
||||
"MessageConfirmDownloadUsingCellular": "Ще изтегляте, използвайки мобилни данни. Това може да включва такси за данни от оператора. Искате ли да продължите?",
|
||||
"MessageConfirmMarkAsFinished": "Сигурни ли сте, че искате да маркирате този елемент като завършен?",
|
||||
"MessageConfirmPlaybackTime": "Започни възпроизвеждане на \"{0}\" в {1}?",
|
||||
"MessageConfirmRemoveBookmark": "Сигурни ли сте, че искате да премахнете отметката?",
|
||||
"MessageConfirmStreamingUsingCellular": "Ще стриймвате, използвайки мобилни данни. Това може да включва такси за данни от оператора. Искате ли да продължите?",
|
||||
"MessageDiscardProgress": "Нулирай прогреса",
|
||||
@@ -355,5 +370,7 @@
|
||||
"ToastPodcastCreateSuccess": "Подкаст успешно създаден",
|
||||
"ToastRSSFeedCloseFailed": "Неуспешно затваряне на RSS емисията",
|
||||
"ToastRSSFeedCloseSuccess": "RSS емисията е затворена",
|
||||
"ToastStreamingNotAllowedOnCellular": "Стриймването не е разрешено чрез мобилни данни"
|
||||
"ToastStreamingNotAllowedOnCellular": "Стриймването не е разрешено чрез мобилни данни",
|
||||
"UnitMinutesShort": "{0}мин",
|
||||
"UnitSecondsShort": "{0}сек"
|
||||
}
|
||||
|
||||
+12
-1
@@ -92,6 +92,7 @@
|
||||
"HeaderStatsRecentSessions": "Poslední sezení",
|
||||
"HeaderTableOfContents": "Obsah",
|
||||
"HeaderUserInterfaceSettings": "Nastavení uživatelského rozhraní",
|
||||
"HeaderWelcome": "Vítejte, <strong>{0}</strong>",
|
||||
"HeaderYourStats": "Vaše statistiky",
|
||||
"LabelAddToPlaylist": "Přidat do seznamu skladeb",
|
||||
"LabelAddedAt": "Přidáno v",
|
||||
@@ -114,6 +115,7 @@
|
||||
"LabelAutoSleepTimerAutoRewindHelp": "Když dojde k uspání automatickým časovačem spánku, pozice přehrávání je posunuta zpět o vybraný čas.",
|
||||
"LabelAutoSleepTimerHelp": "Během přehrávání média v časovém rozmezí \"Od\" a \"Do\" se automaticky spustí časovač spánku.",
|
||||
"LabelBooks": "Knihy",
|
||||
"LabelByAuthor": "od {0}",
|
||||
"LabelChapterTrack": "Stopa kapitoly",
|
||||
"LabelChapters": "Kapitoly",
|
||||
"LabelClosePlayer": "Zavřít přehrávač",
|
||||
@@ -179,6 +181,7 @@
|
||||
"LabelLayoutAuto": "Automatické",
|
||||
"LabelLayoutSinglePage": "Jedna stránka",
|
||||
"LabelLibrarySortByProgress": "Pokrok: naposledy aktualizováno",
|
||||
"LabelLibrarySortByProgressFinished": "Pokrok: dokončeno",
|
||||
"LabelLibrarySortByProgressStarted": "Pokrok: začato",
|
||||
"LabelLight": "Slabá",
|
||||
"LabelLineSpacing": "Řádkování",
|
||||
@@ -239,6 +242,7 @@
|
||||
"LabelScaleElapsedTimeBySpeed": "Škálovat uplynulý čas podle rychlosti",
|
||||
"LabelSeason": "Sezóna",
|
||||
"LabelSelectADevice": "Vyberte zařízení",
|
||||
"LabelSelectMediaType": "Vyberte typ média",
|
||||
"LabelSequenceAscending": "Řadit vzestupně",
|
||||
"LabelSequenceDescending": "Řadit sestupně",
|
||||
"LabelSeries": "Série",
|
||||
@@ -291,12 +295,17 @@
|
||||
"MessageAudiobookshelfServerNotConnected": "Server Audiobookshelf není připojen",
|
||||
"MessageAudiobookshelfServerRequired": "<strong>Důležité!</strong> Tato aplikace je navržena, aby pracovala se serverem Audiobookshelf, který hostuje buď vy, nebo někdo, koho znáte. Tato aplikace neposkytuje žádný obsah.",
|
||||
"MessageBookshelfEmpty": "Knihovna je prázdná",
|
||||
"MessageConfirmAppExit": "Opravdu chcete opustit aplikaci?",
|
||||
"MessageConfirmDeleteEpisodeDownloadQueue": "Přejete si skutečně vymazat frontu stahování epizod?",
|
||||
"MessageConfirmDeleteLocalEpisode": "Odebrat místní epizodu „{0}“ ze zařízení? Soubor na serveru zůstane nezměněný.",
|
||||
"MessageConfirmDeleteLocalFiles": "Odebrat místní soubory této položky ze zařízení? Soubory na serveru a váš pokrok nebudou ovlivněny.",
|
||||
"MessageConfirmDeleteServerConfig": "Odebrat tuto konfiguraci serveru?",
|
||||
"MessageConfirmDeleteServerEpisode": "Přejete si skutečně odstranit epizodu \"{0}\" z serveru?\nUpozornění: Toto odstraní zvukový soubor.",
|
||||
"MessageConfirmDisableAutoTimer": "Určitě chcete vypnout automatický časovač pro zbytek dnešního dne? Časovač bude opět aktivován po uběhnutí doby automatického spánku nebo po restartování aplikace.",
|
||||
"MessageConfirmDiscardProgress": "Opravdu chcete zahodit svůj pokrok?",
|
||||
"MessageConfirmDownloadUsingCellular": "Chystáte se stahovat přes mobilní data. Toto může zahrnovat poplatky za mobilní data. Chcete pokračovat?",
|
||||
"MessageConfirmMarkAsFinished": "Opravdu chcete tuto položku označit jako dokončenou?",
|
||||
"MessageConfirmPlaybackTime": "Spustit přehrávání pro \"{0}\" v {1}?",
|
||||
"MessageConfirmRemoveBookmark": "Opravdu chcete odebrat záložku?",
|
||||
"MessageConfirmStreamingUsingCellular": "Chystáte se přehrávat přes mobilní data. Toto může zahrnovat poplatky za mobilní data. Chcete pokračovat?",
|
||||
"MessageDiscardProgress": "Zahodit pokrok",
|
||||
@@ -361,5 +370,7 @@
|
||||
"ToastPodcastCreateSuccess": "Podcast byl úspěšně vytvořen",
|
||||
"ToastRSSFeedCloseFailed": "Nepodařilo se zavřít RSS kanál",
|
||||
"ToastRSSFeedCloseSuccess": "RSS kanál uzavřen",
|
||||
"ToastStreamingNotAllowedOnCellular": "Přehrávání přes mobilní data není povoleno"
|
||||
"ToastStreamingNotAllowedOnCellular": "Přehrávání přes mobilní data není povoleno",
|
||||
"UnitMinutesShort": "{0}m",
|
||||
"UnitSecondsShort": "{0}s"
|
||||
}
|
||||
|
||||
+12
-3
@@ -110,7 +110,7 @@
|
||||
"LabelAuthors": "Forfattere",
|
||||
"LabelAutoDownloadEpisodes": "Auto Download Episoder",
|
||||
"LabelAutoRewindTime": "Automatisk tilbagespolingstid",
|
||||
"LabelAutoSleepTimer": "Auto sleep timer",
|
||||
"LabelAutoSleepTimer": "Auto søvn-timer",
|
||||
"LabelAutoSleepTimerAutoRewind": "Automatisk sleep timer automatisk tilbagespoling",
|
||||
"LabelAutoSleepTimerAutoRewindHelp": "Når den automatiske sleep-timer er færdig, vil genafspilning af elementet automatisk spole din position tilbage.",
|
||||
"LabelAutoSleepTimerHelp": "Når der afspilles medie mellem de angivne start- og sluttidspunkter, starter en sleep-timer automatisk.",
|
||||
@@ -180,6 +180,9 @@
|
||||
"LabelLayout": "Layout",
|
||||
"LabelLayoutAuto": "Automatisk",
|
||||
"LabelLayoutSinglePage": "Enkel Side",
|
||||
"LabelLibrarySortByProgress": "Fremgang: Sidst opdateret",
|
||||
"LabelLibrarySortByProgressFinished": "Fremgang: Afsluttet",
|
||||
"LabelLibrarySortByProgressStarted": "Fremgang: Startet",
|
||||
"LabelLight": "Lys",
|
||||
"LabelLineSpacing": "Linjeafstand",
|
||||
"LabelListenAgain": "Lyt Igen",
|
||||
@@ -239,6 +242,7 @@
|
||||
"LabelScaleElapsedTimeBySpeed": "Skalér Forløbet Tid med Hastighed",
|
||||
"LabelSeason": "Sæson",
|
||||
"LabelSelectADevice": "Vælg en Enhed",
|
||||
"LabelSelectMediaType": "Vælg medietype",
|
||||
"LabelSequenceAscending": "Sekvens Stigende",
|
||||
"LabelSequenceDescending": "Sekvens Faldende",
|
||||
"LabelSeries": "Serie",
|
||||
@@ -289,10 +293,13 @@
|
||||
"MessageAndroid10Downloads": "Android 10 og mindre vil bruge intern lagerplads til downloads.",
|
||||
"MessageAttemptingServerConnection": "Prøver at tilgå server...",
|
||||
"MessageAudiobookshelfServerNotConnected": "Audiobookshel server er ikke tilsluttet",
|
||||
"MessageAudiobookshelfServerRequired": "<strong>Vigtig</strong> Denne app er designet til at fungere med en Audiobookshelf server, som du eller en du kender hoster. Denne app levere ikke noget indhold.",
|
||||
"MessageAudiobookshelfServerRequired": "<strong>Vigtigt!</strong> Denne app er designet til at fungere med en Audiobookshelf-server, som du eller en du kender hoster. Denne app leverer ikke noget indhold.",
|
||||
"MessageBookshelfEmpty": "Bogreol Tom",
|
||||
"MessageConfirmAppExit": "Ville du lukke appen?",
|
||||
"MessageConfirmDeleteEpisodeDownloadQueue": "Er du sikker på, at du vil rydde downloadkøen for episoder?",
|
||||
"MessageConfirmDeleteLocalEpisode": "Slet lokal episode \"{0}\" fra din enhed? Filen på serveren vil ikke blive påvirket.",
|
||||
"MessageConfirmDeleteLocalFiles": "Slet lokale filer af denne type fra din enhed? Filerne på serveren og din fremgang vil ikke blive påvirket.",
|
||||
"MessageConfirmDeleteServerConfig": "Fjern denne serverkonfiguration?",
|
||||
"MessageConfirmDisableAutoTimer": "Er du sikker på at du vil slå auto-timer fra resten af dagen? Timeren vil blive slået til ved slutningen af auto-sleep timer perioden eller appen genstartes.",
|
||||
"MessageConfirmDiscardProgress": "Er du sikker på at du vil nulstille din fremgang?",
|
||||
"MessageConfirmDownloadUsingCellular": "Du er ved at downloade via mobildata. Dette kan medføre ekstra omkostninger fra din teleoperatør. Vil du fortsætte?",
|
||||
@@ -361,5 +368,7 @@
|
||||
"ToastPodcastCreateSuccess": "Podcast oprettet med succes",
|
||||
"ToastRSSFeedCloseFailed": "Mislykkedes lukning af RSS-feed",
|
||||
"ToastRSSFeedCloseSuccess": "RSS-feed lukket",
|
||||
"ToastStreamingNotAllowedOnCellular": "Det er ikke tillad at streame over mobildata"
|
||||
"ToastStreamingNotAllowedOnCellular": "Det er ikke tillad at streame over mobildata",
|
||||
"UnitMinutesShort": "{0}m",
|
||||
"UnitSecondsShort": "{0}s"
|
||||
}
|
||||
|
||||
+9
-2
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"ButtonAdd": "Add",
|
||||
"ButtonAddNewServer": "Add New Server",
|
||||
"ButtonAllow": "Allow",
|
||||
"ButtonAuthors": "Authors",
|
||||
"ButtonBack": "Back",
|
||||
"ButtonCancel": "Cancel",
|
||||
@@ -295,7 +296,7 @@
|
||||
"MessageAudiobookshelfServerNotConnected": "Audiobookshelf server not connected",
|
||||
"MessageAudiobookshelfServerRequired": "<strong>Important!</strong> This app is designed to work with an Audiobookshelf server that you or someone you know is hosting. This app does not provide any content.",
|
||||
"MessageBookshelfEmpty": "Bookshelf empty",
|
||||
"MessageConfirmAppExit":"Did you want to exit the app?",
|
||||
"MessageConfirmAppExit": "Did you want to exit the app?",
|
||||
"MessageConfirmDeleteEpisodeDownloadQueue": "Are you sure you want to clear episode download queue?",
|
||||
"MessageConfirmDeleteLocalEpisode": "Remove local episode \"{0}\" from your device? The file on the server will be unaffected.",
|
||||
"MessageConfirmDeleteLocalFiles": "Remove local files of this item from your device? The files on the server and your progress will be unaffected.",
|
||||
@@ -305,13 +306,14 @@
|
||||
"MessageConfirmDiscardProgress": "Are you sure you want to reset your progress?",
|
||||
"MessageConfirmDownloadUsingCellular": "You are about to download using cellular data. This may include carrier data charges. Do you wish to continue?",
|
||||
"MessageConfirmMarkAsFinished": "Are you sure you want to mark this item as finished?",
|
||||
"MessageConfirmPlaybackTime":"Start playback for \"{0}\" at {1}?",
|
||||
"MessageConfirmPlaybackTime": "Start playback for \"{0}\" at {1}?",
|
||||
"MessageConfirmRemoveBookmark": "Are you sure you want to remove bookmark?",
|
||||
"MessageConfirmStreamingUsingCellular": "You are about to stream using cellular data. This may include carrier data charges. Do you wish to continue?",
|
||||
"MessageDiscardProgress": "Discard Progress",
|
||||
"MessageDownloadCompleteProcessing": "Download complete. Processing...",
|
||||
"MessageDownloading": "Downloading...",
|
||||
"MessageDownloadingEpisode": "Downloading episode",
|
||||
"MessageDownloadingFile": "Downloading {0}",
|
||||
"MessageEpisodesQueuedForDownload": "{0} Episode(s) queued for download",
|
||||
"MessageFailedToRefreshToken": "Failed to refresh token, re-login required",
|
||||
"MessageFeedURLWillBe": "Feed URL will be {0}",
|
||||
@@ -347,6 +349,7 @@
|
||||
"MessageOldServerConnectionWarning": "Server connection config is using an old user ID. Please delete and re-add this server connection.",
|
||||
"MessageOldServerConnectionWarningHelp": "You originally set up the connection to this server prior to the database migration in 2.3.0, released June 2023. A future server update will remove the ability to sign in with this old connection. Please delete the existing server connection and connect again (using the same server address and credentials). If you have any downloaded media on this device, the media will need to be downloaded again to sync with the server.",
|
||||
"MessagePodcastSearchField": "Enter search term or RSS feed URL",
|
||||
"MessagePreparingDownloads": "Preparing downloads",
|
||||
"MessageProgressSyncFailed": "The most recent attempt to report your listening progress to the server has failed. Progress sync requests will continue to be attempted every 15 seconds to 1 minute while media is playing.",
|
||||
"MessageReportBugsAndContribute": "Report bugs, request features, and contribute on",
|
||||
"MessageSeriesAlreadyDownloaded": "You have already downloaded all books in this series.",
|
||||
@@ -357,6 +360,10 @@
|
||||
"MessageSocketConnectedOverUnmeteredCellular": "Socket connected over unmetered cellular",
|
||||
"MessageSocketConnectedOverUnmeteredWifi": "Socket connected over unmetered wifi",
|
||||
"MessageSocketNotConnected": "Socket not connected",
|
||||
"MessageStorageAccessDenied": "Access denied",
|
||||
"MessageStoragePermissionDenied": "Permission denied",
|
||||
"MessageStorageWriteAccessRequired": "You do not have write access to this folder. Would you like to grant access?",
|
||||
"MessageWaitingForAvailableStorage": "Waiting for available storage",
|
||||
"NoteRSSFeedPodcastAppsHttps": "Warning: Most podcast apps will require the RSS feed URL is using HTTPS",
|
||||
"NoteRSSFeedPodcastAppsPubDate": "Warning: 1 or more of your episodes do not have a Pub Date. Some podcast apps require this.",
|
||||
"ToastBookmarkCreateFailed": "Failed to create bookmark",
|
||||
|
||||
+78
-64
@@ -5,7 +5,7 @@
|
||||
"ButtonBack": "Atrás",
|
||||
"ButtonCancel": "Cancelar",
|
||||
"ButtonCancelTimer": "Cancelar temporizador",
|
||||
"ButtonClearFilter": "Quitar filtros",
|
||||
"ButtonClearFilter": "Vaciar filtro",
|
||||
"ButtonClearLogs": "Vaciar registros",
|
||||
"ButtonCloseFeed": "Cerrar suministro",
|
||||
"ButtonCollections": "Colecciones",
|
||||
@@ -18,16 +18,16 @@
|
||||
"ButtonDeleteLocalEpisode": "Eliminar episodio local",
|
||||
"ButtonDeleteLocalFile": "Eliminar archivo local",
|
||||
"ButtonDeleteLocalItem": "Eliminar elemento local",
|
||||
"ButtonDisableAutoTimer": "Desactivar temporizador automático",
|
||||
"ButtonDisableAutoTimer": "Inhabilitar temporizador automático",
|
||||
"ButtonDisconnect": "Desconectar",
|
||||
"ButtonGoToWebClient": "Ir al cliente web",
|
||||
"ButtonHistory": "Historial",
|
||||
"ButtonHome": "Inicio",
|
||||
"ButtonIssues": "Problemas",
|
||||
"ButtonIssues": "Incidencias",
|
||||
"ButtonLatest": "Más recientes",
|
||||
"ButtonLibrary": "Biblioteca",
|
||||
"ButtonLocalMedia": "Medios locales",
|
||||
"ButtonLogs": "Registros",
|
||||
"ButtonLogs": "Bitácoras",
|
||||
"ButtonManageLocalFiles": "Gestionar archivos locales",
|
||||
"ButtonMaskServerAddress": "Enmascarar dirección de servidor",
|
||||
"ButtonNewFolder": "Carpeta nueva",
|
||||
@@ -49,103 +49,105 @@
|
||||
"ButtonSearch": "Buscar",
|
||||
"ButtonSendEbookToDevice": "Enviar libro al dispositivo",
|
||||
"ButtonSeries": "Series",
|
||||
"ButtonSetTimer": "Ajustar el temporizador",
|
||||
"ButtonSetTimer": "Ajustar cronómetro",
|
||||
"ButtonStream": "En directo",
|
||||
"ButtonSubmit": "Enviar",
|
||||
"ButtonSubmit": "Entregar",
|
||||
"ButtonSwitchServerUser": "Cambiar servidor/usuario",
|
||||
"ButtonUnmaskServerAddress": "Desenmascarar dirección de servidor",
|
||||
"ButtonUserStats": "Estadísticas de usuario",
|
||||
"ButtonYes": "Sí",
|
||||
"HeaderAccount": "Cuenta",
|
||||
"HeaderAdvanced": "Avanzado",
|
||||
"HeaderAndroidAutoSettings": "Configuración de Android Auto",
|
||||
"HeaderAudioTracks": "Pistas de audio",
|
||||
"HeaderAndroidAutoSettings": "Ajustes automáticos de Android",
|
||||
"HeaderAudioTracks": "Pistas de Audio",
|
||||
"HeaderChapters": "Capítulos",
|
||||
"HeaderCollection": "Colección",
|
||||
"HeaderCollectionItems": "Elementos en la colección",
|
||||
"HeaderConnectionStatus": "Estado de la conexión",
|
||||
"HeaderDataSettings": "Configuración de datos",
|
||||
"HeaderCollectionItems": "Elementos de colección",
|
||||
"HeaderConfirm": "Confirmar",
|
||||
"HeaderConnectionStatus": "Estado de conexión",
|
||||
"HeaderDataSettings": "Ajustes de datos",
|
||||
"HeaderDetails": "Detalles",
|
||||
"HeaderDownloads": "Descargas",
|
||||
"HeaderEbookFiles": "Archivos de libros digitales",
|
||||
"HeaderEpisodes": "Episodios",
|
||||
"HeaderEreaderSettings": "Configuración del lector",
|
||||
"HeaderEreaderSettings": "Ajustes del Lector-e",
|
||||
"HeaderLatestEpisodes": "Episodios más recientes",
|
||||
"HeaderLibraries": "Bibliotecas",
|
||||
"HeaderLocalFolders": "Carpetas locales",
|
||||
"HeaderLocalLibraryItems": "Elementos de la biblioteca local",
|
||||
"HeaderLocalLibraryItems": "Elementos de Biblioteca Local",
|
||||
"HeaderNewPlaylist": "Nueva lista de reproducción",
|
||||
"HeaderOpenRSSFeed": "Abrir suministro RSS",
|
||||
"HeaderPlaybackSettings": "Configuración de reproducción",
|
||||
"HeaderPlaybackSettings": "Ajustes de Reproducción",
|
||||
"HeaderPlaylist": "Lista de reproducción",
|
||||
"HeaderPlaylistItems": "Elementos de lista de reproducción",
|
||||
"HeaderProgressSyncFailed": "Falló la sincronización del progreso",
|
||||
"HeaderProgressSyncFailed": "Progreso de sincronización incorrecto",
|
||||
"HeaderRSSFeed": "Suministro RSS",
|
||||
"HeaderRSSFeedGeneral": "Detalles de RSS",
|
||||
"HeaderRSSFeedIsOpen": "El suministro RSS está abierto",
|
||||
"HeaderSelectDownloadLocation": "Seleccionar ubicación de descarga",
|
||||
"HeaderSettings": "Configuración",
|
||||
"HeaderSleepTimer": "Temporizador de apagado",
|
||||
"HeaderSleepTimerSettings": "Ajustes del temporizador para dormir",
|
||||
"HeaderSelectDownloadLocation": "Seleccionar lugar de descarga",
|
||||
"HeaderSettings": "Ajustes",
|
||||
"HeaderSleepTimer": "Cronómetro de dormida",
|
||||
"HeaderSleepTimerSettings": "Ajustes del temporizador para dormida",
|
||||
"HeaderStatsMinutesListeningChart": "Minutos escuchando (últimos 7 días)",
|
||||
"HeaderStatsRecentSessions": "Sesiones recientes",
|
||||
"HeaderTableOfContents": "Sumario",
|
||||
"HeaderUserInterfaceSettings": "Configuración de interfaz de usuario",
|
||||
"HeaderUserInterfaceSettings": "Ajustes de interfaz de usuario",
|
||||
"HeaderWelcome": "Bienvenido/a, <strong>{0}</strong>",
|
||||
"HeaderYourStats": "Sus estadísticas",
|
||||
"LabelAddToPlaylist": "Añadir a lista de reproducción",
|
||||
"LabelAddedAt": "Añadido",
|
||||
"LabelAddedDate": "{0} Añadido",
|
||||
"LabelAddedAt": "Añadido en",
|
||||
"LabelAddedDate": "Añadido {0}",
|
||||
"LabelAll": "Todos",
|
||||
"LabelAllowSeekingOnMediaControls": "Permitir la búsqueda de posición en los controles de notificación de medios",
|
||||
"LabelAllowSeekingOnMediaControls": "Concede la búsqueda de posición en los controles de notificación de medios",
|
||||
"LabelAlways": "Siempre",
|
||||
"LabelAndroidAutoBrowseLimitForGrouping": "Limite del despliegue alfabético",
|
||||
"LabelAndroidAutoBrowseLimitForGrouping": "Límite del despliegue alfabético",
|
||||
"LabelAndroidAutoBrowseLimitForGroupingHelp": "No utilice el despliegue alfabético cuando haya menos de esta cantidad de elementos para mostrar",
|
||||
"LabelAndroidAutoBrowseSeriesSequenceOrder": "Orden de libros de Series",
|
||||
"LabelAndroidAutoBrowseSeriesSequenceOrder": "Ordenación de libros de Series",
|
||||
"LabelAskConfirmation": "Pedir confirmación",
|
||||
"LabelAuthor": "Autor",
|
||||
"LabelAuthorFirstLast": "Autor (Nombre Apellido)",
|
||||
"LabelAuthorLastFirst": "Autor (Apellido, Nombre)",
|
||||
"LabelAuthors": "Autores",
|
||||
"LabelAutoDownloadEpisodes": "Descargar episodios automáticamente",
|
||||
"LabelAutoRewindTime": "Tiempo de rebobinado automático",
|
||||
"LabelAutoSleepTimer": "Temporizador de apagado automático",
|
||||
"LabelAutoSleepTimerAutoRewind": "Temporizador de apagado automático con rebobinado automático",
|
||||
"LabelAutoSleepTimerAutoRewindHelp": "Cuando el temporizador de auto apagado finaliza, reproducir el elemento nuevamente rebobinará automáticamente tu posición.",
|
||||
"LabelAutoDownloadEpisodes": "Auto‐Descargar episodios",
|
||||
"LabelAutoRewindTime": "Tiempo de auto‐rebobinado",
|
||||
"LabelAutoSleepTimer": "Cronómetro de auto‐apagado",
|
||||
"LabelAutoSleepTimerAutoRewind": "Cronómetro de auto‐apagado con auto‐rebobinado",
|
||||
"LabelAutoSleepTimerAutoRewindHelp": "Cuando el cronómetro de auto‐apagado finaliza, reproducir el elemento nuevamente auto‐rebobinará tu posición.",
|
||||
"LabelAutoSleepTimerHelp": "Cuando se reproduce contenido multimedia entre las horas de inicio y finalización especificadas, se activará automáticamente un temporizador de apagado.",
|
||||
"LabelBooks": "Libros",
|
||||
"LabelByAuthor": "por",
|
||||
"LabelByAuthor": "por {0}",
|
||||
"LabelChapterTrack": "Seguimiento de Capítulo",
|
||||
"LabelChapters": "Capítulos",
|
||||
"LabelClosePlayer": "Cerrar reproductor",
|
||||
"LabelCollapseSeries": "Colapsar serie",
|
||||
"LabelCollapseSeries": "Colapsar Series",
|
||||
"LabelComplete": "Completo",
|
||||
"LabelContinueBooks": "Continuar libros",
|
||||
"LabelContinueEpisodes": "Continuar episodios",
|
||||
"LabelContinueListening": "Seguir escuchando",
|
||||
"LabelContinueEpisodes": "Continuar Episodios",
|
||||
"LabelContinueListening": "Seguir Escuchando",
|
||||
"LabelContinueReading": "Continuar leyendo",
|
||||
"LabelContinueSeries": "Continuar series",
|
||||
"LabelCustomTime": "Tiempo personalizado",
|
||||
"LabelDescription": "Descripción",
|
||||
"LabelDisableAudioFadeOut": "Desactivar el fundido de audio",
|
||||
"LabelDisableAudioFadeOutHelp": "El volumen de audio comenzará a disminuir cuando quede menos de 1 minuto en el temporizador de apagado. Habilite este ajuste para que no se desvanezca.",
|
||||
"LabelDisableAutoRewind": "Desactivar rebobinado automático",
|
||||
"LabelDisableShakeToReset": "Desactivar agitar para reiniciar",
|
||||
"LabelDisableShakeToResetHelp": "Si agitas el dispositivo mientras el temporizador está en marcha o en los 2 minutos siguientes a la expiración del temporizador, éste se reiniciará. Habilita esta configuración para desactivar el restablecimiento al agitar.",
|
||||
"LabelDisableAudioFadeOutHelp": "El volumen de audio comenzará a disminuir cuando quede menos de 1 minuto en el temporizador de dormida. Habilite este ajuste para que no se desvanezca.",
|
||||
"LabelDisableAutoRewind": "Desactivar auto‐rebobinado",
|
||||
"LabelDisableShakeToReset": "Desactivar agitar para restablecer",
|
||||
"LabelDisableShakeToResetHelp": "Si agitas el dispositivo mientras el temporizador está en marcha o en los 2 minutos siguientes a la caducidad del temporizador, éste se reiniciará. Habilita estos ajustes para desactivar el restablecimiento al agitar.",
|
||||
"LabelDisableVibrateOnReset": "Desactivar vibración al reiniciar",
|
||||
"LabelDisableVibrateOnResetHelp": "Cuando el temporizador de apagado se reinicia, el dispositivo vibra. Activa esta opción para que no vibre cuando se reinicie el temporizador.",
|
||||
"LabelDisableVibrateOnResetHelp": "Cuando el temporizador de apagado se reinicia, el dispositivo vibra. Activa esta opción para que no vibre cuando se duerme el temporizador.",
|
||||
"LabelDiscover": "Descubrir",
|
||||
"LabelDownload": "Descargar",
|
||||
"LabelDownloadUsingCellular": "Descargar usando el móvil",
|
||||
"LabelDownloaded": "Descargado",
|
||||
"LabelDuration": "Duración",
|
||||
"LabelEbook": "Libro electrónico",
|
||||
"LabelEbooks": "Libros electrónicos",
|
||||
"LabelEbook": "Libro-e",
|
||||
"LabelEbooks": "Libros-e",
|
||||
"LabelEnable": "Activar",
|
||||
"LabelEnableMp3IndexSeeking": "Activar la búsqueda de índices mp3",
|
||||
"LabelEnableMp3IndexSeekingHelp": "Esta configuración solo debe activarse si tiene archivos MP3 en los que no se puede buscar correctamente. La búsqueda inexacta probablemente se deba a archivos MP3 con tasas de bits variables (VBR). Esta configuración forzará la búsqueda de índice, en la que se construye una asignación de tiempo a bytes mientras se lee el archivo. En algunos casos, con archivos MP3 grandes, puede haber un retraso al buscar hacia el final del archivo.",
|
||||
"LabelEnableMp3IndexSeekingHelp": "Estos ajustes solo estaría activado si tiene archivos mp3 en los que no se puede buscar correctamente. La búsqueda inexacta probablemente se deba a archivos mp3 con tasas de los bit variables (VBR). Este ajuste forzará la búsqueda de índice, en la que se construye una asignación de tiempo a bytes mientras se lee el archivo. En algunos casos, con archivos mp3 grandes, puede haber un retraso al buscar hacia el final del archivo.",
|
||||
"LabelEnd": "Fin",
|
||||
"LabelEndOfChapter": "Fin del capítulo",
|
||||
"LabelEndTime": "Hora de finalización",
|
||||
"LabelEndTime": "Tiempo de finalización",
|
||||
"LabelEpisode": "Episodio",
|
||||
"LabelExplicit": "Explícito",
|
||||
"LabelFeedURL": "URL del suministro",
|
||||
@@ -153,38 +155,41 @@
|
||||
"LabelFileBirthtime": "Archivo creado en",
|
||||
"LabelFileModified": "Archivo modificado",
|
||||
"LabelFilename": "Nombre del archivo",
|
||||
"LabelFinished": "Terminado",
|
||||
"LabelFinished": "Finalizado",
|
||||
"LabelFolder": "Carpeta",
|
||||
"LabelFontBoldness": "Peso tipográfico",
|
||||
"LabelFontBoldness": "Tipográfico sin Negrita",
|
||||
"LabelFontFamily": "Familia tipográfica",
|
||||
"LabelFontFamilySans": "Sans",
|
||||
"LabelFontFamilySerif": "Serif",
|
||||
"LabelFontScale": "Escala de letra",
|
||||
"LabelGenre": "Género",
|
||||
"LabelGenres": "Géneros",
|
||||
"LabelHapticFeedback": "Respuesta háptica",
|
||||
"LabelHasEbook": "Tiene un libro",
|
||||
"LabelHasSupplementaryEbook": "Tiene un libro complementario",
|
||||
"LabelHeavy": "Pesado",
|
||||
"LabelHapticFeedback": "Comentario háptico",
|
||||
"LabelHasEbook": "Tiene libro-e",
|
||||
"LabelHasSupplementaryEbook": "Tiene un libro-e suplementario",
|
||||
"LabelHeavy": "Denso",
|
||||
"LabelHigh": "Alto",
|
||||
"LabelHost": "Anfitrión",
|
||||
"LabelInProgress": "En proceso",
|
||||
"LabelIncomplete": "Incompleto",
|
||||
"LabelInternalAppStorage": "Almacenamiento interno de aplicaciones",
|
||||
"LabelJumpBackwardsTime": "Saltar atrás en el tiempo",
|
||||
"LabelJumpForwardsTime": "Salto adelante en el tiempo",
|
||||
"LabelInternalAppStorage": "Almacenamiento Interno de App",
|
||||
"LabelJumpBackwardsTime": "Volver en el tiempo",
|
||||
"LabelJumpForwardsTime": "Avanzar en el tiempo",
|
||||
"LabelKeepScreenAwake": "Mantener la pantalla encendida",
|
||||
"LabelLanguage": "Idioma",
|
||||
"LabelLayout": "Disposición",
|
||||
"LabelLayoutAuto": "Automático",
|
||||
"LabelLayoutSinglePage": "Página única",
|
||||
"LabelLibrarySortByProgress": "Progreso: Último actualizado",
|
||||
"LabelLibrarySortByProgressFinished": "Progreso: Finalizado",
|
||||
"LabelLibrarySortByProgressStarted": "Progreso: Iniciado",
|
||||
"LabelLight": "Claro",
|
||||
"LabelLineSpacing": "Interlineado",
|
||||
"LabelListenAgain": "Volver a escuchar",
|
||||
"LabelLocalBooks": "Libros Locales",
|
||||
"LabelLocalPodcasts": "Pódcast locales",
|
||||
"LabelLockOrientation": "Bloquear orientación",
|
||||
"LabelLockPlayer": "Bloquear el reproductor",
|
||||
"LabelLockPlayer": "Bloquear reproductor",
|
||||
"LabelLow": "Bajo",
|
||||
"LabelMediaType": "Tipo de multimedia",
|
||||
"LabelMedium": "Medio",
|
||||
@@ -195,7 +200,7 @@
|
||||
"LabelNarrator": "Narrador",
|
||||
"LabelNarrators": "Narradores",
|
||||
"LabelNavigateWithVolume": "Navegar con las teclas de volumen",
|
||||
"LabelNavigateWithVolumeMirrored": "Espejo",
|
||||
"LabelNavigateWithVolumeMirrored": "Espejado",
|
||||
"LabelNavigateWithVolumeWhilePlaying": "También puede utilizar los botones de volumen para desplazarse durante la reproducción",
|
||||
"LabelNavigateWithVolumeWhilePlayingDisabled": "Apagado",
|
||||
"LabelNavigateWithVolumeWhilePlayingEnabled": "Encender",
|
||||
@@ -203,11 +208,11 @@
|
||||
"LabelNewestAuthors": "Autores más nuevos",
|
||||
"LabelNewestEpisodes": "Episodios más nuevos",
|
||||
"LabelNo": "No",
|
||||
"LabelNotFinished": "No terminado",
|
||||
"LabelNotFinished": "No finalizado",
|
||||
"LabelNotStarted": "Sin iniciar",
|
||||
"LabelNumEpisodes": "{0} episodios",
|
||||
"LabelNumEpisodesIncomplete": "{0} episodios, {1} incompletos",
|
||||
"LabelNumberOfEpisodes": "N.º de episodios",
|
||||
"LabelNumberOfEpisodes": "Nº de episodios",
|
||||
"LabelOff": "Apagado",
|
||||
"LabelOn": "Encendido",
|
||||
"LabelPassword": "Contraseña",
|
||||
@@ -218,16 +223,16 @@
|
||||
"LabelPlaybackTranscode": "Transcodificar",
|
||||
"LabelPodcast": "Pódcast",
|
||||
"LabelPodcasts": "Pódcast",
|
||||
"LabelPreventIndexing": "Evite que los directorios de pódcast de iTunes y Google indicen su suministro",
|
||||
"LabelPreventIndexing": "Evite que los directorios de pódcast de iTunes y Google indexen su suministro",
|
||||
"LabelProgress": "Progreso",
|
||||
"LabelPubDate": "Fecha de publicación",
|
||||
"LabelPublishYear": "Año de publicación",
|
||||
"LabelPublishedDate": "Publicado {0}",
|
||||
"LabelRSSFeedCustomOwnerEmail": "Correo electrónico de dueño personalizado",
|
||||
"LabelRSSFeedCustomOwnerName": "Nombre de dueño personalizado",
|
||||
"LabelRSSFeedCustomOwnerEmail": "Correo-e de propietario personalizado",
|
||||
"LabelRSSFeedCustomOwnerName": "Nombre de propietario personalizado",
|
||||
"LabelRSSFeedOpen": "Fuente RSS Abierta",
|
||||
"LabelRSSFeedPreventIndexing": "Evitar indización",
|
||||
"LabelRSSFeedSlug": "«Slug» de suministro RSS",
|
||||
"LabelRSSFeedSlug": "Ficha de suministro RSS",
|
||||
"LabelRandomly": "Aleatorio",
|
||||
"LabelRead": "Leído",
|
||||
"LabelReadAgain": "Volver a leer",
|
||||
@@ -237,16 +242,17 @@
|
||||
"LabelScaleElapsedTimeBySpeed": "Escala el tiempo transcurrido según la velocidad",
|
||||
"LabelSeason": "Temporada",
|
||||
"LabelSelectADevice": "Seleccione un dispositivo",
|
||||
"LabelSelectMediaType": "Seleccionar tipo de medio",
|
||||
"LabelSequenceAscending": "Secuencia ascendente",
|
||||
"LabelSequenceDescending": "Secuencia descendente",
|
||||
"LabelSeries": "Serie",
|
||||
"LabelSeries": "Series",
|
||||
"LabelServerAddress": "Dirección del servidor",
|
||||
"LabelSetEbookAsPrimary": "Establecer como primario",
|
||||
"LabelSetEbookAsSupplementary": "Establecer como suplementario",
|
||||
"LabelShakeSensitivity": "Sensibilidad a la sacudida",
|
||||
"LabelShowAll": "Mostrar todo",
|
||||
"LabelSize": "Tamaño",
|
||||
"LabelSleepTimer": "Temporizador de apagado",
|
||||
"LabelSleepTimer": "Temporizador de dormida",
|
||||
"LabelSleepTimerAlmostDoneChime": "Timbrar casi al terminar",
|
||||
"LabelSleepTimerAlmostDoneChimeHelp": "Reproducir una señal acústica cuando queden 30 segundos en el temporizador",
|
||||
"LabelStart": "Iniciar",
|
||||
@@ -287,14 +293,19 @@
|
||||
"MessageAndroid10Downloads": "Android 10 e inferiores utilizarán el almacenamiento interno de aplicaciones para las descargas.",
|
||||
"MessageAttemptingServerConnection": "Intentando conectar con el servidor...",
|
||||
"MessageAudiobookshelfServerNotConnected": "Servidor de Audiobookshelf no conectado",
|
||||
"MessageAudiobookshelfServerRequired": "<strong>¡Importante!</strong> Esta aplicación está diseñada para trabajar con un servidor Audiobookshelf que usted o alguien que usted conoce es el anfitrión. Esta aplicación no proporciona ningún contenido.",
|
||||
"MessageAudiobookshelfServerRequired": "<strong>¡Importante!</strong> Esta aplicación está diseñada para trabajar con un servidor Audiobookshelf que tú o alguien que tú conozcas es el host. Esta aplicación no proporciona ningún contenido.",
|
||||
"MessageBookshelfEmpty": "Estantería vacía",
|
||||
"MessageConfirmAppExit": "¿Desea salir de la aplicación?",
|
||||
"MessageConfirmDeleteEpisodeDownloadQueue": "¿Seguro que desea vaciar la cola de descarga del episodio?",
|
||||
"MessageConfirmDeleteLocalEpisode": "¿Quiere eliminar el episodio local «{0}» del dispositivo? El archivo en el servidor no se verá afectado.",
|
||||
"MessageConfirmDeleteLocalFiles": "¿Quiere quitar los archivos locales de este elemento del dispositivo? Los archivos del servidor y su progreso no se verán afectados.",
|
||||
"MessageConfirmDeleteServerConfig": "¿Quito esta configuración del servidor?",
|
||||
"MessageConfirmDeleteServerEpisode": "¿Seguro que desea eliminar el episodio «{0}» desde el servidor?\nAdvertencia: esto eliminará el archivo de sonido.",
|
||||
"MessageConfirmDisableAutoTimer": "¿Confirma que quiere desactivar el temporizador automático por el resto del día? El temporizador volverá a activarse al final de este periodo de temporizador de apagado automático, o si reinicia la aplicación.",
|
||||
"MessageConfirmDiscardProgress": "¿Confirma que quiere restablecer su progreso?",
|
||||
"MessageConfirmDownloadUsingCellular": "Está a punto de efectuar una descarga con datos móviles. Esto puede conllevar tarifas de datos de la operadora. ¿Quiere continuar?",
|
||||
"MessageConfirmMarkAsFinished": "¿Confirma que quiere marcar este elemento como terminado?",
|
||||
"MessageConfirmPlaybackTime": "¿Inicio la reproducción para «{0}» en {1}?",
|
||||
"MessageConfirmRemoveBookmark": "¿Confirma que quiere quitar el marcador?",
|
||||
"MessageConfirmStreamingUsingCellular": "Está a punto de hacer una transmisión con datos móviles. Esto puede conllevar tarifas de datos de la operadora. ¿Quiere continuar?",
|
||||
"MessageDiscardProgress": "Descartar progreso",
|
||||
@@ -332,6 +343,7 @@
|
||||
"MessageNoUserPlaylists": "No tiene ninguna lista de reproducción",
|
||||
"MessageOldServerAuthReLoginRequired": "La autenticación se ha mejorado para seguridad en la versión v2.26.0 del servidor. Todos los usuarios deberán reiniciar sesión.",
|
||||
"MessageOldServerAuthWarning": "El servidor está utilizando un método de autenticación anticuado",
|
||||
"MessageOldServerAuthWarningHelp": "Este servidor está ejecutando una versión más antigua que la v2.26.0. Ha sido añadido un sistema de autenticación más seguro en v2.26.0. Se recomienda encarecidamente que actualice el servidor a la última versión. Si ya tiene el servidor actualizado, acceda de nuevo para utilizar la autenticación nueva.",
|
||||
"MessageOldServerConnectionWarning": "La configuración de la conexión al servidor utiliza un identificador de usuario antiguo. Elimine y vuelva a añadir esta conexión al servidor.",
|
||||
"MessageOldServerConnectionWarningHelp": "Usted configuró originalmente la conexión a este servidor antes de la migración de la base de datos en la versión 2.3.0, publicada en junio de 2023. Una futura actualización del servidor eliminará la posibilidad de iniciar sesión con esta conexión antigua. Por favor, elimine la conexión existente al servidor y conéctese de nuevo (utilizando la misma dirección del servidor y las mismas credenciales). Si tiene algún medio descargado en este dispositivo, será necesario descargarlo de nuevo para sincronizarlo con el servidor.",
|
||||
"MessagePodcastSearchField": "Introduzca el término de búsqueda o el URL del suministro RSS",
|
||||
@@ -358,5 +370,7 @@
|
||||
"ToastPodcastCreateSuccess": "Se creó el pódcast correctamente",
|
||||
"ToastRSSFeedCloseFailed": "Error al cerrar el suministro RSS",
|
||||
"ToastRSSFeedCloseSuccess": "Suministro RSS cerrado",
|
||||
"ToastStreamingNotAllowedOnCellular": "El streaming no está permitido con datos móviles"
|
||||
"ToastStreamingNotAllowedOnCellular": "El streaming no está permitido con datos móviles",
|
||||
"UnitMinutesShort": "{0}m",
|
||||
"UnitSecondsShort": "{0}s"
|
||||
}
|
||||
|
||||
+4
-4
@@ -129,7 +129,7 @@
|
||||
"LabelCustomTime": "Egyéni idő",
|
||||
"LabelDescription": "Leírás",
|
||||
"LabelDisableAudioFadeOut": "Hang fokozatos csendesítés letiltása",
|
||||
"LabelDisableAudioFadeOutHelp": "Az audio hangerő csökkenése kezdődik, amikor kevesebb, mint 1 perc marad az alvásidőzítőből. Engedélyezze ezt a beállítást, hogy ne csendesedjen el fokozatosan a hang.",
|
||||
"LabelDisableAudioFadeOutHelp": "A hangerő csökkenni kezd az alvásidőzítő utolsó percében. Kapcsold be ezt a beállítást a halkítás kikapcsolásához.",
|
||||
"LabelDisableAutoRewind": "Automatikus visszatekerés letiltása",
|
||||
"LabelDisableShakeToReset": "Rázás a visszaállításhoz letiltása",
|
||||
"LabelDisableShakeToResetHelp": "Ha az időzítő futása alatt vagy az időzítő lejárta előtti 2 percben megrázza az eszközt, az alvásidőzítő visszaáll. Engedélyezze ezt a beállítást a rázásra történő visszaállítás letiltásához.",
|
||||
@@ -300,7 +300,7 @@
|
||||
"MessageConfirmDeleteLocalEpisode": "\"{0}\" helyi epizód eltávolítása az eszközről? A szerveren lévő fájl nem érintett.",
|
||||
"MessageConfirmDeleteLocalFiles": "Ezen elem helyi fájljainak eltávolítása az eszközről? A szerveren lévő fájlok és a haladás nem érintettek.",
|
||||
"MessageConfirmDeleteServerConfig": "Eltávolítja ezt a szerverkonfigurációt?",
|
||||
"MessageConfirmDeleteServerEpisode": "Biztosan törölni szeretnéd a(z) „{0}” epizódot a szerverről?\nFigyelem: ezzel az audi fájl is törlődik.",
|
||||
"MessageConfirmDeleteServerEpisode": "Biztosan törölni szeretnéd a(z) „{0}” epizódot a szerverről?\nFigyelem: ezzel az audiófájl is törlődik.",
|
||||
"MessageConfirmDisableAutoTimer": "Biztos, hogy a mai nap hátralévő részére ki akarja kapcsolni az automatikus időzítőt? Az időzítő újra aktiválódik az automatikus alvásidőzítő időszak végén, vagy ha újraindítja az alkalmazást.",
|
||||
"MessageConfirmDiscardProgress": "Biztosan alaphelyzetbe akarja állítani a haladást?",
|
||||
"MessageConfirmDownloadUsingCellular": "Mobiladat-hálózaton keresztül készülsz letölteni. Ez adatforgalmi díjakkal járhat. Szeretnéd folytatni?",
|
||||
@@ -323,11 +323,11 @@
|
||||
"MessageLoadingServerData": "Szerveradatok betöltése...",
|
||||
"MessageLocalFolderDescription": "A „Belső alkalmazás-tároló” csak ezzel az alkalmazással érhető el. Ez az alkalmazás csak a közvetlenül az alkalmazáson keresztül letöltött médiát támogatja. A megosztott tároló mappák segítségével más alkalmazások is hozzáférhetnek az alkalmazás által letöltött médiához.",
|
||||
"MessageMarkAsFinished": "Befejezettnek jelölés",
|
||||
"MessageMediaLinkedToADifferentServer": "A média egy másik címen található Audiobookshelf szerverhez kapcsolódik ({0}). A haladás szinkronizálva lesz, amikor csatlakozik ehhez a szervercímhez.",
|
||||
"MessageMediaLinkedToADifferentServer": "A média egy másik címen ({0}) lévő Audiobookshelf szerverhez van kapcsolva. A folyamatjelölés akkor lesz szinkronizálva, amikor ehhez a szervercímhez csatlakozik.",
|
||||
"MessageMediaLinkedToADifferentUser": "A média ehhez a szerverhez kapcsolódik, de egy másik felhasználó töltötte le. A haladás csak a letöltést végrehajtó felhasználóhoz lesz szinkronizálva.",
|
||||
"MessageMediaLinkedToServer": "Szerverhez kapcsolt {0}",
|
||||
"MessageMediaLinkedToThisServer": "A letöltött média ehhez a szerverhez kapcsolódik",
|
||||
"MessageMediaNotLinkedToServer": "A média nem kapcsolódik egy Audiobookshelf szerverhez. Nem lesz szinkronizálva a haladás.",
|
||||
"MessageMediaNotLinkedToServer": "A média nincs Audiobookshelf szerverhez kapcsolva. A folyamatjelölés nem lesz szinkronizálva.",
|
||||
"MessageNoBookmarks": "Nincsenek könyvjelzők",
|
||||
"MessageNoChapters": "Nincsenek fejezetek",
|
||||
"MessageNoCollections": "Nincs gyűjtemény",
|
||||
|
||||
+16
-3
@@ -50,7 +50,7 @@
|
||||
"ButtonSendEbookToDevice": "Invia il libro al dispositivo",
|
||||
"ButtonSeries": "Serie",
|
||||
"ButtonSetTimer": "Imposta Timer",
|
||||
"ButtonStream": "Stream",
|
||||
"ButtonStream": "Flusso",
|
||||
"ButtonSubmit": "Invia",
|
||||
"ButtonSwitchServerUser": "Cambia Server/Utente",
|
||||
"ButtonUnmaskServerAddress": "Mostra l'indirizzo server",
|
||||
@@ -63,6 +63,7 @@
|
||||
"HeaderChapters": "Capitoli",
|
||||
"HeaderCollection": "Raccolta",
|
||||
"HeaderCollectionItems": "Elementi della raccolta",
|
||||
"HeaderConfirm": "Conferma",
|
||||
"HeaderConnectionStatus": "Stato connessione",
|
||||
"HeaderDataSettings": "Impostazioni dei dati",
|
||||
"HeaderDetails": "Dettagli",
|
||||
@@ -91,6 +92,7 @@
|
||||
"HeaderStatsRecentSessions": "Sessioni Recenti",
|
||||
"HeaderTableOfContents": "Indice",
|
||||
"HeaderUserInterfaceSettings": "Impostazioni interfaccia utente",
|
||||
"HeaderWelcome": "Benvenuto, <strong>{0}</strong>",
|
||||
"HeaderYourStats": "Statistiche personali",
|
||||
"LabelAddToPlaylist": "Aggiungi alla playlist",
|
||||
"LabelAddedAt": "Aggiunto il",
|
||||
@@ -178,6 +180,9 @@
|
||||
"LabelLayout": "Disposizione",
|
||||
"LabelLayoutAuto": "Automatico",
|
||||
"LabelLayoutSinglePage": "Pagina singola",
|
||||
"LabelLibrarySortByProgress": "Progresso: ultimo aggiornamento",
|
||||
"LabelLibrarySortByProgressFinished": "Progresso: finito",
|
||||
"LabelLibrarySortByProgressStarted": "Progresso: iniziato",
|
||||
"LabelLight": "Leggera",
|
||||
"LabelLineSpacing": "Interlinea",
|
||||
"LabelListenAgain": "Ascolta ancora",
|
||||
@@ -237,6 +242,7 @@
|
||||
"LabelScaleElapsedTimeBySpeed": "Scala il tempo trascorso in base alla velocità",
|
||||
"LabelSeason": "Stagione",
|
||||
"LabelSelectADevice": "Seletiona dispositivo",
|
||||
"LabelSelectMediaType": "Seleziona il tipo di supporto",
|
||||
"LabelSequenceAscending": "Sequenza ascendente",
|
||||
"LabelSequenceDescending": "Sequenza decrescente",
|
||||
"LabelSeries": "Serie",
|
||||
@@ -289,12 +295,17 @@
|
||||
"MessageAudiobookshelfServerNotConnected": "Audiobookshelf server non connesso",
|
||||
"MessageAudiobookshelfServerRequired": "<strong>Importante!</strong> Questa app è progettata per funzionare con un server Audiobookshelf ospitato da te o da qualcuno che conosci. Questa app non fornisce alcun contenuto.",
|
||||
"MessageBookshelfEmpty": "Scaffale vuoto",
|
||||
"MessageConfirmAppExit": "Volevi uscire dall'app?",
|
||||
"MessageConfirmDeleteEpisodeDownloadQueue": "Sei sicuro di voler cancellare la coda di download degli episodi?",
|
||||
"MessageConfirmDeleteLocalEpisode": "Rimuovi episodi locali \"{0}\" dal tuo dispositivo? i file sul server non verranno toccati.",
|
||||
"MessageConfirmDeleteLocalFiles": "Rimuovi i file locali dell'oggetto? I file sul server e i progressi non verranno toccati.",
|
||||
"MessageConfirmDeleteServerConfig": "Rimuovere questa configurazione del server?",
|
||||
"MessageConfirmDeleteServerEpisode": "Sei sicuro di voler eliminare l'episodio \"{0}\" dal server?\nAttenzione: questa operazione eliminerà il file audio.",
|
||||
"MessageConfirmDisableAutoTimer": "Vuoi davvero disattivare il timer automatico per il resto della giornata? Il timer verrà riattivato alla fine di questo periodo di spegnimento automatico o se riavvii l'app.",
|
||||
"MessageConfirmDiscardProgress": "Sei sicuro/sicura di voler ripristinare i tuoi progressi?",
|
||||
"MessageConfirmDownloadUsingCellular": "Stai per eseguire il download utilizzando la rete dati cellulare. Ciò potrebbe includere addebiti per i dati dell'operatore. Vuoi continuare?",
|
||||
"MessageConfirmMarkAsFinished": "Sei sicuro/sicura di voler contrassegnare questo elemento come finito?",
|
||||
"MessageConfirmPlaybackTime": "Avviare la riproduzione per \"{0}\" alle {1}?",
|
||||
"MessageConfirmRemoveBookmark": "Sei sicuro/sicura di voler rimuovere il segnalibro?",
|
||||
"MessageConfirmStreamingUsingCellular": "Stai per eseguire lo streaming utilizzando la rete dati. Ciò potrebbe includere addebiti per i dati dell'operatore. Vuoi continuare?",
|
||||
"MessageDiscardProgress": "Elimina i progressi",
|
||||
@@ -323,7 +334,7 @@
|
||||
"MessageNoItems": "Nessun oggetto",
|
||||
"MessageNoItemsFound": "Nessun oggetto trovato",
|
||||
"MessageNoListeningSessions": "Nessuna sessione di ascolto",
|
||||
"MessageNoLogs": "Nessun registro",
|
||||
"MessageNoLogs": "Nessun rapporto",
|
||||
"MessageNoMediaFolders": "Nessuna cartella media",
|
||||
"MessageNoNetworkConnection": "Nessuna connessione di rete",
|
||||
"MessageNoPodcastsFound": "Nessun podcast trovato",
|
||||
@@ -359,5 +370,7 @@
|
||||
"ToastPodcastCreateSuccess": "Podcast creato correttamente",
|
||||
"ToastRSSFeedCloseFailed": "Errore chiusura flusso RSS",
|
||||
"ToastRSSFeedCloseSuccess": "Flusso RSS chiuso",
|
||||
"ToastStreamingNotAllowedOnCellular": "Lo streaming non è consentito sui dati mobili"
|
||||
"ToastStreamingNotAllowedOnCellular": "Lo streaming non è consentito sui dati mobili",
|
||||
"UnitMinutesShort": "{0}m",
|
||||
"UnitSecondsShort": "{0}s"
|
||||
}
|
||||
|
||||
+103
-22
@@ -40,7 +40,7 @@
|
||||
"ButtonPlayEpisode": "エピソードを再生",
|
||||
"ButtonPlaylists": "プレイリスト",
|
||||
"ButtonRead": "読む",
|
||||
"ButtonReadLess": "閉じる",
|
||||
"ButtonReadLess": "少なく表示",
|
||||
"ButtonReadMore": "もっと見る",
|
||||
"ButtonRemove": "削除",
|
||||
"ButtonRemoveFromServer": "サーバーから削除",
|
||||
@@ -88,7 +88,7 @@
|
||||
"HeaderSettings": "設定",
|
||||
"HeaderSleepTimer": "スリープタイマー",
|
||||
"HeaderSleepTimerSettings": "スリープタイマー設定",
|
||||
"HeaderStatsMinutesListeningChart": "過去7日間の視聴時間(分)",
|
||||
"HeaderStatsMinutesListeningChart": "過去7日間のリスニング時間(分)",
|
||||
"HeaderStatsRecentSessions": "最近の再生履歴",
|
||||
"HeaderTableOfContents": "目次",
|
||||
"HeaderUserInterfaceSettings": "画面設定",
|
||||
@@ -96,7 +96,7 @@
|
||||
"HeaderYourStats": "再生統計",
|
||||
"LabelAddToPlaylist": "プレイリストの追加",
|
||||
"LabelAddedAt": "追加日時",
|
||||
"LabelAddedDate": "追加日時 {0}",
|
||||
"LabelAddedDate": "追加日時 {0}",
|
||||
"LabelAll": "すべて",
|
||||
"LabelAllowSeekingOnMediaControls": "メディア通知コントロールでの位置情報の取得を許可",
|
||||
"LabelAlways": "常に",
|
||||
@@ -104,28 +104,28 @@
|
||||
"LabelAndroidAutoBrowseLimitForGroupingHelp": "表示項目数がこの数未満の場合、アルファベット順ドローダウンを使用しません",
|
||||
"LabelAndroidAutoBrowseSeriesSequenceOrder": "シリーズ内の順序",
|
||||
"LabelAskConfirmation": "確認を求める",
|
||||
"LabelAuthor": "著者",
|
||||
"LabelAuthorFirstLast": "著者(名 氏)",
|
||||
"LabelAuthorLastFirst": "著者(氏 名)",
|
||||
"LabelAuthors": "著者",
|
||||
"LabelAuthor": "作者",
|
||||
"LabelAuthorFirstLast": "作者(名 氏)",
|
||||
"LabelAuthorLastFirst": "作者(氏 名)",
|
||||
"LabelAuthors": "作者",
|
||||
"LabelAutoDownloadEpisodes": "エピソードの自動ダウンロード",
|
||||
"LabelAutoRewindTime": "自動巻き戻し時間",
|
||||
"LabelAutoSleepTimer": "自動スリープタイマー",
|
||||
"LabelAutoSleepTimerAutoRewind": "スリープタイマー時の自動巻き戻し",
|
||||
"LabelAutoSleepTimerAutoRewindHelp": "自動スリープタイマーが終了した後、再生を再開すると再生位置が自動的に巻き戻されます。",
|
||||
"LabelAutoSleepTimerHelp": "指定した開始時刻から終了時刻の間にメディアを再生すると、スリープタイマーが自動的に開始されます。",
|
||||
"LabelBooks": "ほん",
|
||||
"LabelByAuthor": "著 {0}",
|
||||
"LabelBooks": "本",
|
||||
"LabelByAuthor": "by {0}",
|
||||
"LabelChapterTrack": "チャプタートラック",
|
||||
"LabelChapters": "チャプター",
|
||||
"LabelClosePlayer": "プレイヤーを閉じる",
|
||||
"LabelClosePlayer": "プレーヤーを閉じる",
|
||||
"LabelCollapseSeries": "シリーズを折りたたむ",
|
||||
"LabelComplete": "完了",
|
||||
"LabelContinueBooks": "続きを読む本",
|
||||
"LabelContinueEpisodes": "続きのエピソード",
|
||||
"LabelContinueListening": "続きから聞く",
|
||||
"LabelContinueListening": "続きから聴く",
|
||||
"LabelContinueReading": "続きを読む",
|
||||
"LabelContinueSeries": "シリーズを続く",
|
||||
"LabelContinueSeries": "シリーズを続ける",
|
||||
"LabelCustomTime": "指定時間",
|
||||
"LabelDescription": "説明",
|
||||
"LabelDisableAudioFadeOut": "オーディオフェードアウトを無効",
|
||||
@@ -140,8 +140,8 @@
|
||||
"LabelDownloadUsingCellular": "モバイル通信を使用してダウンロード",
|
||||
"LabelDownloaded": "ダウンロード完了",
|
||||
"LabelDuration": "長さ",
|
||||
"LabelEbook": "Eブック",
|
||||
"LabelEbooks": "Eブック",
|
||||
"LabelEbook": "電子書籍",
|
||||
"LabelEbooks": "電子書籍",
|
||||
"LabelEnable": "有効",
|
||||
"LabelEnableMp3IndexSeeking": "mp3インデックス検索を有効化",
|
||||
"LabelEnableMp3IndexSeekingHelp": "この設定は、正しくシークできないMP3ファイルがある場合にのみ有効にしてください。シークの不正確さは、可変ビットレート(VBR)のMP3ファイルが原因である可能性が高いです。この設定はインデックスシークを強制し、ファイルの読み取り時に時間-バイトマッピングを構築します。大きなMP3ファイルの場合、ファイルの終わり付近をシークする際に遅延が生じる場合があります。",
|
||||
@@ -150,13 +150,13 @@
|
||||
"LabelEndTime": "終了時間",
|
||||
"LabelEpisode": "エピソード",
|
||||
"LabelExplicit": "露骨な表現",
|
||||
"LabelFeedURL": "Feed URL",
|
||||
"LabelFeedURL": "フィードURL",
|
||||
"LabelFile": "ファイル",
|
||||
"LabelFileBirthtime": "ファイル作成日時",
|
||||
"LabelFileModified": "ファイル更新日時",
|
||||
"LabelFilename": "ファイル名",
|
||||
"LabelFinished": "完了",
|
||||
"LabelFolder": "フォルダ",
|
||||
"LabelFolder": "フォルダー",
|
||||
"LabelFontBoldness": "フォントの太さ",
|
||||
"LabelFontFamily": "フォントファミリー",
|
||||
"LabelFontFamilySans": "サン",
|
||||
@@ -165,8 +165,8 @@
|
||||
"LabelGenre": "ジャンル",
|
||||
"LabelGenres": "ジャンル",
|
||||
"LabelHapticFeedback": "ハプティックフィードバック",
|
||||
"LabelHasEbook": "eBookあり",
|
||||
"LabelHasSupplementaryEbook": "付属eBookあり",
|
||||
"LabelHasEbook": "電子書籍あり",
|
||||
"LabelHasSupplementaryEbook": "付属電子書籍あり",
|
||||
"LabelHeavy": "思い",
|
||||
"LabelHigh": "高い",
|
||||
"LabelHost": "ホスト",
|
||||
@@ -178,30 +178,111 @@
|
||||
"LabelLayout": "レイアウト",
|
||||
"LabelLayoutAuto": "自動",
|
||||
"LabelLayoutSinglePage": "単ページ",
|
||||
"LabelLibrarySortByProgress": "進捗: 最終更新",
|
||||
"LabelLibrarySortByProgressFinished": "進捗: 完了",
|
||||
"LabelLibrarySortByProgressStarted": "進捗: 開始済み",
|
||||
"LabelLight": "ライト",
|
||||
"LabelLineSpacing": "行間",
|
||||
"LabelListenAgain": "再度視聴",
|
||||
"LabelListenAgain": "もう一度聴く",
|
||||
"LabelLocalBooks": "ローカルブック",
|
||||
"LabelLocalPodcasts": "ローカルポッドキャスト",
|
||||
"LabelLockPlayer": "プレイヤーのロック",
|
||||
"LabelMediaType": "メディアの種類",
|
||||
"LabelMissing": "消失",
|
||||
"LabelMore": "多い",
|
||||
"LabelMoreInfo": "追加情報",
|
||||
"LabelName": "名",
|
||||
"LabelName": "名前",
|
||||
"LabelNarrator": "ナレーター",
|
||||
"LabelNarrators": "ナレーター",
|
||||
"LabelNavigateWithVolume": "音量キーで操作",
|
||||
"LabelNavigateWithVolumeWhilePlaying": "再生中に音量キーで操作を許可",
|
||||
"LabelNavigateWithVolumeWhilePlayingDisabled": "オフ",
|
||||
"LabelNavigateWithVolumeWhilePlayingEnabled": "オン",
|
||||
"LabelNewestAuthors": "最新の著者",
|
||||
"LabelNewestAuthors": "最新の作者",
|
||||
"LabelNewestEpisodes": "最新エピソード",
|
||||
"LabelNotFinished": "未完了",
|
||||
"LabelNotStarted": "未開始",
|
||||
"LabelNumEpisodes": "{0} エピソード",
|
||||
"LabelNumEpisodesIncomplete": "{0} エピソード, {1} 未完了",
|
||||
"LabelNumberOfEpisodes": "エピソード数",
|
||||
"LabelPassword": "パスワード",
|
||||
"LabelPath": "パス",
|
||||
"LabelPlaybackLocal": "ローカル",
|
||||
"LabelPodcast": "ポッドキャスト",
|
||||
"LabelPodcasts": "ポッドキャスト",
|
||||
"LabelPreventIndexing": "フィードがiTunesおよびGoogleのポッドキャストディレクトリにインデックス登録されるのを防ぎます",
|
||||
"LabelPublishYear": "公開年"
|
||||
"LabelProgress": "進捗",
|
||||
"LabelPubDate": "公開日",
|
||||
"LabelPublishYear": "公開年",
|
||||
"LabelPublishedDate": "{0}発行",
|
||||
"LabelRSSFeedCustomOwnerEmail": "カスタムオーナーメール",
|
||||
"LabelRSSFeedCustomOwnerName": "カスタムオーナー名",
|
||||
"LabelRSSFeedOpen": "RSSフィードを公開",
|
||||
"LabelRSSFeedPreventIndexing": "インデックス化を防止",
|
||||
"LabelRSSFeedSlug": "RSSフィードスラグ",
|
||||
"LabelRandomly": "ランダム",
|
||||
"LabelRead": "読む",
|
||||
"LabelReadAgain": "もう一度読む",
|
||||
"LabelRecentSeries": "最近のシリーズ",
|
||||
"LabelRecentlyAdded": "最近追加",
|
||||
"LabelSeason": "シーズン",
|
||||
"LabelSeries": "シリーズ",
|
||||
"LabelSetEbookAsPrimary": "メインとして設定",
|
||||
"LabelSetEbookAsSupplementary": "付属として設定",
|
||||
"LabelShowAll": "すべて表示",
|
||||
"LabelSize": "サイズ",
|
||||
"LabelSleepTimer": "スリープタイマー",
|
||||
"LabelStart": "開始",
|
||||
"LabelStatsBestDay": "日ごとの最長",
|
||||
"LabelStatsDailyAverage": "日ごとの平均",
|
||||
"LabelStatsDays": "日数",
|
||||
"LabelStatsDaysListened": "聴いた日数",
|
||||
"LabelStatsInARow": "連続",
|
||||
"LabelStatsItemsFinished": "完了したアイテム",
|
||||
"LabelStatsMinutes": "分",
|
||||
"LabelStatsMinutesListening": "聴いた時間(分)",
|
||||
"LabelStatsWeekListening": "週間リスニング",
|
||||
"LabelTag": "タグ",
|
||||
"LabelTags": "タグ",
|
||||
"LabelTheme": "テーマ",
|
||||
"LabelThemeDark": "ダーク",
|
||||
"LabelThemeLight": "ライト",
|
||||
"LabelTimeRemaining": "残り{0}",
|
||||
"LabelTitle": "タイトル",
|
||||
"LabelTracks": "トラック",
|
||||
"LabelType": "タイプ",
|
||||
"LabelUnknown": "不明",
|
||||
"LabelUser": "ユーザー",
|
||||
"LabelUsername": "ユーザー名",
|
||||
"LabelYearReviewHide": "年間振り返りを非表示",
|
||||
"LabelYearReviewShow": "年間振り返りを表示",
|
||||
"LabelYourBookmarks": "ブックマーク",
|
||||
"LabelYourProgress": "進捗状況",
|
||||
"MessageDownloadingEpisode": "エピソードをダウンロード中",
|
||||
"MessageEpisodesQueuedForDownload": "{0}件のエピソードがダウンロードキューに追加されました",
|
||||
"MessageFeedURLWillBe": "フィードURLは {0} になります",
|
||||
"MessageFetching": "取得中...",
|
||||
"MessageLoading": "読み込み中...",
|
||||
"MessageMarkAsFinished": "完了済みにする",
|
||||
"MessageNoBookmarks": "ブックマークなし",
|
||||
"MessageNoChapters": "チャプターなし",
|
||||
"MessageNoCollections": "コレクションなし",
|
||||
"MessageNoItems": "アイテムなし",
|
||||
"MessageNoItemsFound": "アイテムが見つかりません",
|
||||
"MessageNoListeningSessions": "リスニングセッションなし",
|
||||
"MessageNoPodcastsFound": "ポッドキャストが見つかりません",
|
||||
"MessageNoUpdatesWereNecessary": "更新は不要でした",
|
||||
"MessageNoUserPlaylists": "プレイリストがありません",
|
||||
"MessagePodcastSearchField": "検索キーワードまたはRSSフィードURLを入力",
|
||||
"MessageReportBugsAndContribute": "バグ報告、機能リクエスト、コントリビューション:",
|
||||
"NoteRSSFeedPodcastAppsHttps": "警告: ほとんどのポッドキャストアプリはHTTPSのRSSフィードURLが必要です",
|
||||
"NoteRSSFeedPodcastAppsPubDate": "警告: 1件以上のエピソードに公開日がありません。一部のポッドキャストアプリではこれが必要です。",
|
||||
"ToastBookmarkCreateFailed": "ブックマークの作成に失敗しました",
|
||||
"ToastItemMarkedAsFinishedFailed": "完了としてマークするのに失敗しました",
|
||||
"ToastItemMarkedAsNotFinishedFailed": "未完了としてマークするのに失敗しました",
|
||||
"ToastPlaylistCreateFailed": "プレイリストの作成に失敗しました",
|
||||
"ToastPodcastCreateFailed": "ポッドキャストの作成に失敗しました",
|
||||
"ToastPodcastCreateSuccess": "ポッドキャストを作成しました",
|
||||
"ToastRSSFeedCloseFailed": "RSSフィードのクローズに失敗しました",
|
||||
"ToastRSSFeedCloseSuccess": "RSSフィードをクローズしました"
|
||||
}
|
||||
|
||||
+24
-7
@@ -41,7 +41,7 @@
|
||||
"ButtonPlaylists": "Afspeellijsten",
|
||||
"ButtonRead": "Lezen",
|
||||
"ButtonReadLess": "Minder lezen",
|
||||
"ButtonReadMore": "Meer lezen",
|
||||
"ButtonReadMore": "Lees meer",
|
||||
"ButtonRemove": "Verwijder",
|
||||
"ButtonRemoveFromServer": "Verwijder van server",
|
||||
"ButtonSave": "Opslaan",
|
||||
@@ -63,11 +63,12 @@
|
||||
"HeaderChapters": "Hoofdstukken",
|
||||
"HeaderCollection": "Collectie",
|
||||
"HeaderCollectionItems": "Collectie-objecten",
|
||||
"HeaderConfirm": "Bevestigen",
|
||||
"HeaderConnectionStatus": "Verbindingsstatus",
|
||||
"HeaderDataSettings": "Data Instellingen",
|
||||
"HeaderDataSettings": "Gegevens Instellingen",
|
||||
"HeaderDetails": "Details",
|
||||
"HeaderDownloads": "Downloads",
|
||||
"HeaderEbookFiles": "Ebook bestanden",
|
||||
"HeaderEbookFiles": "E-book bestanden",
|
||||
"HeaderEpisodes": "Afleveringen",
|
||||
"HeaderEreaderSettings": "Ereader-instellingen",
|
||||
"HeaderLatestEpisodes": "Laatste afleveringen",
|
||||
@@ -91,6 +92,7 @@
|
||||
"HeaderStatsRecentSessions": "Recente sessies",
|
||||
"HeaderTableOfContents": "Inhoudsopgave",
|
||||
"HeaderUserInterfaceSettings": "Gebruiker Interface Instellingen",
|
||||
"HeaderWelcome": "Welkom, <strong>{0}</strong>",
|
||||
"HeaderYourStats": "Je statistieken",
|
||||
"LabelAddToPlaylist": "Toevoegen aan afspeellijst",
|
||||
"LabelAddedAt": "Toegevoegd op",
|
||||
@@ -113,6 +115,7 @@
|
||||
"LabelAutoSleepTimerAutoRewindHelp": "Wanneer de automatische slaap timer afloop zal het item terugspoelen naar de vorige positie.",
|
||||
"LabelAutoSleepTimerHelp": "Wanneer er tussen de opgegeven begin- en eindtijd media wordt afgespeeld, start er automatisch een slaaptimer.",
|
||||
"LabelBooks": "Boeken",
|
||||
"LabelByAuthor": "door {0}",
|
||||
"LabelChapterTrack": "Hoofdstuk Track",
|
||||
"LabelChapters": "Hoofdstukken",
|
||||
"LabelClosePlayer": "Sluit speler",
|
||||
@@ -155,6 +158,9 @@
|
||||
"LabelFinished": "Voltooid",
|
||||
"LabelFolder": "Map",
|
||||
"LabelFontBoldness": "Lettertype Dikte",
|
||||
"LabelFontFamily": "Letterfamilie",
|
||||
"LabelFontFamilySans": "Sans",
|
||||
"LabelFontFamilySerif": "Serif",
|
||||
"LabelFontScale": "Lettertype schaal",
|
||||
"LabelGenre": "Genre",
|
||||
"LabelGenres": "Categorieën",
|
||||
@@ -174,6 +180,9 @@
|
||||
"LabelLayout": "Layout",
|
||||
"LabelLayoutAuto": "Automatisch",
|
||||
"LabelLayoutSinglePage": "Enkele pagina",
|
||||
"LabelLibrarySortByProgress": "Voortgang: Laatst geüpdatet",
|
||||
"LabelLibrarySortByProgressFinished": "Voortgang: Voltooid",
|
||||
"LabelLibrarySortByProgressStarted": "Voortgang: Gestart",
|
||||
"LabelLight": "Licht",
|
||||
"LabelLineSpacing": "Regelruimte",
|
||||
"LabelListenAgain": "Opnieuw Beluisteren",
|
||||
@@ -233,6 +242,7 @@
|
||||
"LabelScaleElapsedTimeBySpeed": "Schaal verstreken tijd per snelheid",
|
||||
"LabelSeason": "Seizoen",
|
||||
"LabelSelectADevice": "Apparaat Selecteren",
|
||||
"LabelSelectMediaType": "Selecteer mediatype",
|
||||
"LabelSequenceAscending": "Sequentie Oplopend",
|
||||
"LabelSequenceDescending": "Sequentie Aflopend",
|
||||
"LabelSeries": "Serie",
|
||||
@@ -285,12 +295,17 @@
|
||||
"MessageAudiobookshelfServerNotConnected": "Audiobookshelf server niet geconnecteerd",
|
||||
"MessageAudiobookshelfServerRequired": "<strong>Important!</strong> Deze app is ontworpen om te werken met een Audiobookshelf-server die u of iemand die u kent host. Deze app biedt geen content.",
|
||||
"MessageBookshelfEmpty": "Boekenplank leeg",
|
||||
"MessageConfirmDeleteLocalEpisode": "Lokale aflevering \"{0}\" van uw apparaat verwijderen? Het bestand op de server blijft onaangetast.",
|
||||
"MessageConfirmAppExit": "Wil je de app afsluiten?",
|
||||
"MessageConfirmDeleteEpisodeDownloadQueue": "Weet je zeker dat je de downloadwachtrij voor afleveringen wilt wissen?",
|
||||
"MessageConfirmDeleteLocalEpisode": "Lokale aflevering \"{0}\" van uw apparaat verwijderen? Het bestand op de server blijft bewaard.",
|
||||
"MessageConfirmDeleteLocalFiles": "Lokale bestanden van dit item van uw apparaat verwijderen? De bestanden op de server en uw voortgang worden niet beïnvloed.",
|
||||
"MessageConfirmDeleteServerConfig": "Verwijder deze server configuratie?",
|
||||
"MessageConfirmDeleteServerEpisode": "Ben je zeker dat je aflevering “{0}” van de server wil verwijderen?\nWaarschuwing: Dit zal het audiobestand verwijderden.",
|
||||
"MessageConfirmDisableAutoTimer": "Weet u zeker dat je de automatische timer voor de rest van vandaag wilt uitschakelen? De timer wordt automatisch weer ingeschakeld aan het einde van deze sluimerperiode of als u de app opnieuw start.",
|
||||
"MessageConfirmDiscardProgress": "Weet u zeker dat u uw voortgang wilt resetten?",
|
||||
"MessageConfirmDownloadUsingCellular": "U staat op het punt om te downloaden met behulp van mobiele data. Dit kan kosten voor data van de provider met zich meebrengen. Wilt u doorgaan?",
|
||||
"MessageConfirmMarkAsFinished": "Weet u zeker dat u dit item als voltooid wilt markeren?",
|
||||
"MessageConfirmPlaybackTime": "Start afspelen van “{0}” op {1}?",
|
||||
"MessageConfirmRemoveBookmark": "Weet u zeker dat u de bladwijzer wilt verwijderen?",
|
||||
"MessageConfirmStreamingUsingCellular": "U staat op het punt om te streamen met behulp van mobiele data. Dit kan kosten voor data van de provider met zich meebrengen. Wilt u doorgaan?",
|
||||
"MessageDiscardProgress": "Voortgang negeren",
|
||||
@@ -303,7 +318,7 @@
|
||||
"MessageFetching": "Aan het ophalen...",
|
||||
"MessageFollowTheProjectOnGithub": "Volg dit project op Github",
|
||||
"MessageItemDownloadCompleteFailedToCreate": "Itemdownload voltooid, maar het aanmaken van een bibliotheekitem is mislukt",
|
||||
"MessageItemMissing": "Item ontbreekt en moet opgelost worden op de server. Een item is typisch gemarkeerd als ontbrekend wanneer het file pad niet toegankelijk is.",
|
||||
"MessageItemMissing": "Item is niet beschikbaar en moet opgelost worden op de server. Meestal is een item gemarkeerd als missend in verband met niet bereikbare bestandspaden.",
|
||||
"MessageLoading": "Aan het laden...",
|
||||
"MessageLoadingServerData": "Server data laden...",
|
||||
"MessageLocalFolderDescription": "'Interne app-opslag' is alleen toegankelijk voor deze app. Deze app ondersteunt alleen media die rechtstreeks via de app is gedownload. Gedeelde opslagmappen kunnen worden gebruikt om andere apps toegang te geven tot media die door deze app zijn gedownload.",
|
||||
@@ -331,7 +346,7 @@
|
||||
"MessageOldServerAuthWarningHelp": "Deze server draait een versie ouder dan v2.26.0. Een veiliger authenticatie systeem is geïntroduceerd in v2.26.0. Het wordt met klem aangeraden om de server te updaten naar de laatste versie. Mocht de server al geüpdatet zijn, log dan opnieuw in om het nieuwe authenticatie mechanisme te gebruiken.",
|
||||
"MessageOldServerConnectionWarning": "Server connectie configuratie gebruikt een oud user ID. Gelieve deze server connectie te verwijderen en opnieuw toe te voegen.",
|
||||
"MessageOldServerConnectionWarningHelp": "U hebt de verbinding met deze server oorspronkelijk ingesteld vóór de databasemigratie in versie 2.3.0, uitgebracht in juni 2023. Een toekomstige serverupdate zal de mogelijkheid om in te loggen met deze oude verbinding verwijderen. Verwijder de bestaande serververbinding en maak opnieuw verbinding (met hetzelfde serveradres en dezelfde inloggegevens). Als u gedownloade media op dit apparaat hebt, moet u deze opnieuw downloaden om te synchroniseren met de server.",
|
||||
"MessagePodcastSearchField": "Zoekterm of RSS feed URL invullen",
|
||||
"MessagePodcastSearchField": "Voer zoekterm of RSS-feed-URL in",
|
||||
"MessageProgressSyncFailed": "De meest recente poging om je luistervoortgang aan de server te rapporteren is mislukt. Verzoeken om voortgangssynchronisatie worden nog steeds elke 15 seconden tot 1 minuut uitgevoerd terwijl de media wordt afgespeeld.",
|
||||
"MessageReportBugsAndContribute": "Rapporteer bugs, vraag functionaliteiten aan en draag bij op",
|
||||
"MessageSeriesAlreadyDownloaded": "Je hebt alle boeken in deze serie al gedownload.",
|
||||
@@ -355,5 +370,7 @@
|
||||
"ToastPodcastCreateSuccess": "Podcast aangemaakt",
|
||||
"ToastRSSFeedCloseFailed": "Sluiten RSS-feed mislukt",
|
||||
"ToastRSSFeedCloseSuccess": "RSS-feed gesloten",
|
||||
"ToastStreamingNotAllowedOnCellular": "Streamen is niet toegstaan via mobiele verbinding"
|
||||
"ToastStreamingNotAllowedOnCellular": "Streamen is niet toegstaan via mobiele verbinding",
|
||||
"UnitMinutesShort": "{0}m",
|
||||
"UnitSecondsShort": "{0}s"
|
||||
}
|
||||
|
||||
+15
-2
@@ -63,6 +63,7 @@
|
||||
"HeaderChapters": "Главы",
|
||||
"HeaderCollection": "Коллекция",
|
||||
"HeaderCollectionItems": "Элементы коллекции",
|
||||
"HeaderConfirm": "Подтвердить",
|
||||
"HeaderConnectionStatus": "Состояние подключения",
|
||||
"HeaderDataSettings": "Настройки данных",
|
||||
"HeaderDetails": "Подробности",
|
||||
@@ -91,6 +92,7 @@
|
||||
"HeaderStatsRecentSessions": "Последние сеансы",
|
||||
"HeaderTableOfContents": "Содержание",
|
||||
"HeaderUserInterfaceSettings": "Настройки интерфейса",
|
||||
"HeaderWelcome": "Добро пожаловать,<strong>{0}</strong>",
|
||||
"HeaderYourStats": "Ваша статистика",
|
||||
"LabelAddToPlaylist": "Добавить в плейлист",
|
||||
"LabelAddedAt": "Дата добавления",
|
||||
@@ -163,7 +165,7 @@
|
||||
"LabelGenre": "Жанр",
|
||||
"LabelGenres": "Жанры",
|
||||
"LabelHapticFeedback": "Тактильная отдача",
|
||||
"LabelHasEbook": "Есть e-книга",
|
||||
"LabelHasEbook": "Есть электронная книга",
|
||||
"LabelHasSupplementaryEbook": "Есть дополнительная e-книга",
|
||||
"LabelHeavy": "Тяжелый",
|
||||
"LabelHigh": "Сильно",
|
||||
@@ -178,6 +180,9 @@
|
||||
"LabelLayout": "Макет",
|
||||
"LabelLayoutAuto": "Авто",
|
||||
"LabelLayoutSinglePage": "Одна страница",
|
||||
"LabelLibrarySortByProgress": "Прогресс: Последнее обновление",
|
||||
"LabelLibrarySortByProgressFinished": "Прогресс: Закончена",
|
||||
"LabelLibrarySortByProgressStarted": "Прогресс: Начата",
|
||||
"LabelLight": "Подсветка",
|
||||
"LabelLineSpacing": "Межстрочный интервал",
|
||||
"LabelListenAgain": "Послушать снова",
|
||||
@@ -237,6 +242,7 @@
|
||||
"LabelScaleElapsedTimeBySpeed": "Масштабирование затраченного времени по скорости",
|
||||
"LabelSeason": "Сезон",
|
||||
"LabelSelectADevice": "Выбор девайса",
|
||||
"LabelSelectMediaType": "Выберите тип медиа",
|
||||
"LabelSequenceAscending": "Последовательность по возрастанию",
|
||||
"LabelSequenceDescending": "Последовательность по убыванию",
|
||||
"LabelSeries": "Серия",
|
||||
@@ -289,12 +295,17 @@
|
||||
"MessageAudiobookshelfServerNotConnected": "Сервер Audiobookshelf не подключен",
|
||||
"MessageAudiobookshelfServerRequired": "<strong>Важно!</strong> Это приложение предназначено для работы с сервером Audiobookshelf, который размещаете вы или кто-то из ваших знакомых. Это приложение не предоставляет никакого контента.",
|
||||
"MessageBookshelfEmpty": "Книжная полка пуста",
|
||||
"MessageConfirmAppExit": "Выйти из приложения?",
|
||||
"MessageConfirmDeleteEpisodeDownloadQueue": "Вы точно хотите очистить очередь загрузки выпусков?",
|
||||
"MessageConfirmDeleteLocalEpisode": "Удалить локальный выпуск \"{0}\" с вашего устройства? Файл на сервере не будет затронут.",
|
||||
"MessageConfirmDeleteLocalFiles": "Удалить локальные файлы этого элемента с вашего устройства? Это не повлияет на файлы на сервере и ваш прогресс.",
|
||||
"MessageConfirmDeleteServerConfig": "Удалить эту конфигурацию сервера?",
|
||||
"MessageConfirmDeleteServerEpisode": "Вы точно хотите удалить выпуск \"{0}\" с сервера?\nВнимание! Это физически удалит аудиофайл.",
|
||||
"MessageConfirmDisableAutoTimer": "Вы уверены, что хотите отключить автоматический таймер до конца сегодняшнего дня? Таймер будет снова включен по истечении этого периода автоматического отключения или если вы перезапустите приложение.",
|
||||
"MessageConfirmDiscardProgress": "Вы уверены, что хотите сбросить свой прогресс?",
|
||||
"MessageConfirmDownloadUsingCellular": "Вы собираетесь выполнить загрузку с помощью сотовых данных. Это может включать плату за передачу данных оператором. Хотите продолжить?",
|
||||
"MessageConfirmMarkAsFinished": "Вы уверены, что хотите пометить этот элемент как завершенный?",
|
||||
"MessageConfirmPlaybackTime": "Начать воспроизведение \"{0}\" с {1}?",
|
||||
"MessageConfirmRemoveBookmark": "Вы уверены, что хотите удалить закладку?",
|
||||
"MessageConfirmStreamingUsingCellular": "Вы собираетесь вести потоковую передачу с использованием сотовых данных. Это может включать плату за передачу данных оператором. Хотите продолжить?",
|
||||
"MessageDiscardProgress": "Отбросить прогресс",
|
||||
@@ -359,5 +370,7 @@
|
||||
"ToastPodcastCreateSuccess": "Подкаст успешно создан",
|
||||
"ToastRSSFeedCloseFailed": "Не удалось закрыть RSS-ленту",
|
||||
"ToastRSSFeedCloseSuccess": "RSS-лента закрыта",
|
||||
"ToastStreamingNotAllowedOnCellular": "Потоковая передача данных по сотовой сети запрещена"
|
||||
"ToastStreamingNotAllowedOnCellular": "Потоковая передача данных по сотовой сети запрещена",
|
||||
"UnitMinutesShort": "{0}м",
|
||||
"UnitSecondsShort": "{0}с"
|
||||
}
|
||||
|
||||
+1
-1
@@ -115,7 +115,7 @@
|
||||
"LabelAutoSleepTimerAutoRewindHelp": "Keď Auto časovač spánku skončí, pred nasledujúcim prehratím položky sa prehrávanie automaticky posunie.",
|
||||
"LabelAutoSleepTimerHelp": "keď sa prehrávanie začne vo vymedzenom časovom intervale, časovač spánku sa automaticky spustí tiež.",
|
||||
"LabelBooks": "Knihy",
|
||||
"LabelByAuthor": "od",
|
||||
"LabelByAuthor": "od {0}",
|
||||
"LabelChapterTrack": "Zvuková stopa kapitoly",
|
||||
"LabelChapters": "Kapitoly",
|
||||
"LabelClosePlayer": "Zavrieť prehrávač",
|
||||
|
||||
+14
-1
@@ -63,6 +63,7 @@
|
||||
"HeaderChapters": "Глави",
|
||||
"HeaderCollection": "Добірка",
|
||||
"HeaderCollectionItems": "Елементи добірки",
|
||||
"HeaderConfirm": "Підтвердити",
|
||||
"HeaderConnectionStatus": "Стан з'єднання",
|
||||
"HeaderDataSettings": "Налаштування даних",
|
||||
"HeaderDetails": "Подробиці",
|
||||
@@ -91,6 +92,7 @@
|
||||
"HeaderStatsRecentSessions": "Останні сеанси",
|
||||
"HeaderTableOfContents": "Зміст",
|
||||
"HeaderUserInterfaceSettings": "Налаштування користувацького інтерфейсу",
|
||||
"HeaderWelcome": "Ласкаво просимо, <strong>{0}</strong>",
|
||||
"HeaderYourStats": "Ваша статистика",
|
||||
"LabelAddToPlaylist": "Додати до списку відтворення",
|
||||
"LabelAddedAt": "Дата додавання",
|
||||
@@ -178,6 +180,9 @@
|
||||
"LabelLayout": "Вигляд",
|
||||
"LabelLayoutAuto": "Авто",
|
||||
"LabelLayoutSinglePage": "Одна",
|
||||
"LabelLibrarySortByProgress": "Прогрес: Останнє оновлення",
|
||||
"LabelLibrarySortByProgressFinished": "Прогрес: Завершено",
|
||||
"LabelLibrarySortByProgressStarted": "Прогрес: Розпочато",
|
||||
"LabelLight": "Легко",
|
||||
"LabelLineSpacing": "Інтервал",
|
||||
"LabelListenAgain": "Слухати знову",
|
||||
@@ -237,6 +242,7 @@
|
||||
"LabelScaleElapsedTimeBySpeed": "Час відповідно швидкості",
|
||||
"LabelSeason": "Сезон",
|
||||
"LabelSelectADevice": "Обрати пристрій",
|
||||
"LabelSelectMediaType": "Оберіть тип медіа",
|
||||
"LabelSequenceAscending": "Послідовність за зростанням",
|
||||
"LabelSequenceDescending": "Послідовність за спаданням",
|
||||
"LabelSeries": "Серії",
|
||||
@@ -289,12 +295,17 @@
|
||||
"MessageAudiobookshelfServerNotConnected": "Сервер Audiobookshelf не підключений",
|
||||
"MessageAudiobookshelfServerRequired": "<strong>Важливо!</strong> Цей додаток розроблений для роботи з сервером Audiobookshelf, яким володієте ви або ваші знайомі. Цей додаток не надає жодного контенту.",
|
||||
"MessageBookshelfEmpty": "Полиця порожня",
|
||||
"MessageConfirmAppExit": "Ви хочете вийти з програми?",
|
||||
"MessageConfirmDeleteEpisodeDownloadQueue": "Ви впевнені, що хочете очистити чергу завантаження епізодів?",
|
||||
"MessageConfirmDeleteLocalEpisode": "Видалити локальний епізод \"{0}\" з вашого пристрою? Файл лишиться на сервері.",
|
||||
"MessageConfirmDeleteLocalFiles": "Видалити локальні файли цього елемента з вашого пристрою? Файли лишаться на сервері.",
|
||||
"MessageConfirmDeleteServerConfig": "Видалити цю конфігурацію сервера?",
|
||||
"MessageConfirmDeleteServerEpisode": "Ви впевнені, що хочете видалити епізод \"{0}\" із сервера?\nПопередження: Це призведе до видалення аудіофайлу.",
|
||||
"MessageConfirmDisableAutoTimer": "Ви впевнені, що бажаєте вимкнути автоматичний таймер до кінця сьогоднішнього дня? Таймер буде знову ввімкнено в кінці цього періоду автоматичного переходу в режим сну або якщо ви перезапустите програму.",
|
||||
"MessageConfirmDiscardProgress": "Ви дійсно бажаєте скинути ваш прогрес?",
|
||||
"MessageConfirmDownloadUsingCellular": "Ви збираєтеся завантажувати через мобільний інтернет. Оператор може брати кошти. Бажаєте продовжити?",
|
||||
"MessageConfirmMarkAsFinished": "Ви дійсно бажаєте позначити цей елемент завершеним?",
|
||||
"MessageConfirmPlaybackTime": "Почати відтворення \"{0}\" о {1}?",
|
||||
"MessageConfirmRemoveBookmark": "Ви дійсно бажаєте видалити закладку?",
|
||||
"MessageConfirmStreamingUsingCellular": "Ви збираєтеся транслювати через мобільний інтернет. Оператор може брати кошти. Бажаєте продовжити?",
|
||||
"MessageDiscardProgress": "Скинути прогрес",
|
||||
@@ -359,5 +370,7 @@
|
||||
"ToastPodcastCreateSuccess": "Подкаст успішно створено",
|
||||
"ToastRSSFeedCloseFailed": "Не вдалося закрити RSS-канал",
|
||||
"ToastRSSFeedCloseSuccess": "RSS-канал закрито",
|
||||
"ToastStreamingNotAllowedOnCellular": "Трансляція через мобільний інтернет заборонена"
|
||||
"ToastStreamingNotAllowedOnCellular": "Трансляція через мобільний інтернет заборонена",
|
||||
"UnitMinutesShort": "{0}хв",
|
||||
"UnitSecondsShort": "{0}сек"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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