mirror of
https://github.com/advplyr/audiobookshelf-app.git
synced 2026-07-26 14:38:39 +02:00
Compare commits
177
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6672a21049 | ||
|
|
06a0a68143 | ||
|
|
1a7ae468bd | ||
|
|
e8f74d7358 | ||
|
|
984f01da23 | ||
|
|
d6e75410a5 | ||
|
|
7627e1b58c | ||
|
|
5fb31b18ae | ||
|
|
20903e8217 | ||
|
|
8fe348d4eb | ||
|
|
24cda8a179 | ||
|
|
fc954a968b | ||
|
|
aae53a54cd | ||
|
|
cfedcffc98 | ||
|
|
0e74c74a6c | ||
|
|
fb86c45e78 | ||
|
|
124300b215 | ||
|
|
6746c0627b | ||
|
|
0f2cd3b132 | ||
|
|
e94d6fdc1e | ||
|
|
e301b3071c | ||
|
|
3d3dad5e4a | ||
|
|
f8a9033738 | ||
|
|
edb25f5bcd | ||
|
|
bcb239efd1 | ||
|
|
d208211c6f | ||
|
|
0b20b72241 | ||
|
|
5c29459af2 | ||
|
|
e54fd38784 | ||
|
|
94774f13df | ||
|
|
43ec35fe67 | ||
|
|
b2645dc444 | ||
|
|
c79624a82c | ||
|
|
8201f3c9a6 | ||
|
|
e0ac6a1c38 | ||
|
|
400da5566d | ||
|
|
9b67037774 | ||
|
|
48596542c7 | ||
|
|
9715d65d88 | ||
|
|
2e14b31a69 | ||
|
|
1c8ce6c3db | ||
|
|
dc74a6213d | ||
|
|
e9a66bb7a7 | ||
|
|
3c6d4b5f19 | ||
|
|
f04eebe0d8 | ||
|
|
63b5227ccc | ||
|
|
934d9b1df4 | ||
|
|
8994cb3a6e | ||
|
|
4a32de482b | ||
|
|
95f47793d2 | ||
|
|
f27048618d | ||
|
|
c90ad419e8 | ||
|
|
2cdc3ecb9d | ||
|
|
9f89fcd968 | ||
|
|
7c2d51f811 | ||
|
|
41fe7a29cd | ||
|
|
54f3eec303 | ||
|
|
cd91013f69 | ||
|
|
0af16ef05f | ||
|
|
5ba830d56e | ||
|
|
81919e963a | ||
|
|
8a1d4bb958 | ||
|
|
eabac0e709 | ||
|
|
d90783d7c8 | ||
|
|
faf8211e5c | ||
|
|
65d90947c5 | ||
|
|
752e918d26 | ||
|
|
ad08cc69f4 | ||
|
|
da3882fd5e | ||
|
|
c04c7a55d9 | ||
|
|
3318846b43 | ||
|
|
e0860feeb9 | ||
|
|
c0253a69b9 | ||
|
|
9242c7e6e0 | ||
|
|
0bfc579ae4 | ||
|
|
5fad06756b | ||
|
|
5be6afa6c1 | ||
|
|
5ba3b8be7a | ||
|
|
0eb62f541d | ||
|
|
6baff881c7 | ||
|
|
8c23c1e795 | ||
|
|
9706c66a27 | ||
|
|
f4f7252537 | ||
|
|
430d200151 | ||
|
|
7be82d03f3 | ||
|
|
15aaef742d | ||
|
|
e443ba09da | ||
|
|
8235d1f1f7 | ||
|
|
9a39dcceee | ||
|
|
a707b7fc86 | ||
|
|
b6fea85ef4 | ||
|
|
7e3b3f55ae | ||
|
|
d2d3bbe8a1 | ||
|
|
41a65c0f9d | ||
|
|
165ddd58a6 | ||
|
|
09dc04c0de | ||
|
|
57d5dbc0d0 | ||
|
|
11af75e127 | ||
|
|
f8d82a3020 | ||
|
|
81d7b1cfd7 | ||
|
|
e15c1bcb32 | ||
|
|
826d414d9f | ||
|
|
0da6fca727 | ||
|
|
2327bc1705 | ||
|
|
3bb76cecac | ||
|
|
e3f82bb237 | ||
|
|
fcd6270f2d | ||
|
|
d5411703ca | ||
|
|
8fc2058db8 | ||
|
|
d03949f5d1 | ||
|
|
ff01786063 | ||
|
|
bb117ab4da | ||
|
|
f5ec247b47 | ||
|
|
6e6dd00523 | ||
|
|
c9fb98f033 | ||
|
|
75ecff5e6d | ||
|
|
91a9f918fb | ||
|
|
03305f90ca | ||
|
|
9428bc07c5 | ||
|
|
f38f9c3502 | ||
|
|
64a796aece | ||
|
|
63039fe8c9 | ||
|
|
3f2b723d0f | ||
|
|
ffd7fb1cf9 | ||
|
|
b049b206cf | ||
|
|
35f56dc8a9 | ||
|
|
a71cd326cc | ||
|
|
cc841d1c7c | ||
|
|
16ecdcf96e | ||
|
|
96ec56d1ad | ||
|
|
9b15bafc9f | ||
|
|
aa02f2e465 | ||
|
|
15464617ef | ||
|
|
60ecceb59d | ||
|
|
3fe943e989 | ||
|
|
5e7d039454 | ||
|
|
f928df146a | ||
|
|
6e57cb49c4 | ||
|
|
2f8494b013 | ||
|
|
6f1b93bd9f | ||
|
|
19b928d7f6 | ||
|
|
b849be6f5e | ||
|
|
86096df1a4 | ||
|
|
98a465c7b4 | ||
|
|
3315f61ae4 | ||
|
|
f977445d7a | ||
|
|
095d5e1cec | ||
|
|
69fbfa4b33 | ||
|
|
1c89d33726 | ||
|
|
acee907932 | ||
|
|
08ad216fe2 | ||
|
|
3cc7a0937c | ||
|
|
322cad5548 | ||
|
|
73d70dd480 | ||
|
|
56bc569d4a | ||
|
|
981ae58f6a | ||
|
|
b2bfa4c002 | ||
|
|
8b6003c5e3 | ||
|
|
017b6237ca | ||
|
|
d5496c2e9a | ||
|
|
5ef8961c33 | ||
|
|
e8bea48577 | ||
|
|
6cae78f5c2 | ||
|
|
d9d31a0620 | ||
|
|
3682c5fca2 | ||
|
|
14b04a14b6 | ||
|
|
f218d90bfb | ||
|
|
020f936ad4 | ||
|
|
224e5efe48 | ||
|
|
439ab2c8c0 | ||
|
|
26d0209763 | ||
|
|
29086fc783 | ||
|
|
bb656a265f | ||
|
|
b11ad9dc5b | ||
|
|
2b1667e532 | ||
|
|
228a198598 | ||
|
|
2843308fb8 |
@@ -1,37 +0,0 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
labels: bug
|
||||
|
||||
---
|
||||
|
||||
### Steps to reproduce
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
### Expected behaviour
|
||||
- Tell us what should happen
|
||||
|
||||
### Actual behaviour
|
||||
- Tell us what happens
|
||||
|
||||
### Environment data
|
||||
|
||||
Audiobookshelf Version:
|
||||
|
||||
- [ ] Android App?
|
||||
- [ ] iOS App?
|
||||
|
||||
#### Android Issue
|
||||
Android version:
|
||||
|
||||
Device model:
|
||||
|
||||
Stock or customized system:
|
||||
|
||||
#### iOS Issue
|
||||
iOS Version:
|
||||
|
||||
iPhone model:
|
||||
@@ -0,0 +1,86 @@
|
||||
name: 🐞 ABS App Bug Report
|
||||
description: File a bug/issue and help us improve the Audiobookshelf mobile apps.
|
||||
title: '[Bug]: '
|
||||
labels: ['bug', 'triage']
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: '## App Bug Description'
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: 'Thank you for filing a bug report! 🐛'
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: 'Join the [discord server](https://discord.gg/HQgCbd6E75) for questions or if you are not sure about a bug.'
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
label: What was the Problem?
|
||||
description: Please help us understand the problem.
|
||||
placeholder: Describe the problem you saw or experienced in detail! Include a screenshot or video, if applicable.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: steps-to-reproduce
|
||||
attributes:
|
||||
label: Steps to Reproduce the Issue
|
||||
description: Please help us understand how we can reliably reproduce the issue.
|
||||
placeholder: '1. Go to the library page of a Podcast library and...'
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: what-was-expected
|
||||
attributes:
|
||||
label: What was Expected?
|
||||
description: Please help us understand what should have happened.
|
||||
placeholder: Please help us visualize by including a doodle or screenshot.
|
||||
validations:
|
||||
required: true
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: '## Mobile Environment'
|
||||
- type: input
|
||||
id: phone-model
|
||||
attributes:
|
||||
label: Phone Model
|
||||
description: What kind of phone are you using?
|
||||
placeholder: e.g. Pixel 6, iPhone 14, Samusung Galaxy s23, etc
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: phone-os
|
||||
attributes:
|
||||
label: Phone OS
|
||||
description: What operating system? If Android, is it stock or a custom system?
|
||||
placeholder: e.g. Android 14, iOS 17.4, etc
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: app-version
|
||||
attributes:
|
||||
label: Audiobookshelf App Version
|
||||
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.9.63
|
||||
- iOS App - 0.9.63
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: install-source
|
||||
attributes:
|
||||
label: Installation Source
|
||||
multiple: true
|
||||
options:
|
||||
- Google Play Store
|
||||
- Testflight
|
||||
- SideStore
|
||||
- Other (List in "Additional Notes")
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: additional-notes
|
||||
attributes:
|
||||
label: Additional Notes
|
||||
description: Anything else you want to add?
|
||||
placeholder: 'e.g. I have tried X, Y, and Z.'
|
||||
@@ -1,18 +0,0 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
labels: enhancement
|
||||
|
||||
---
|
||||
|
||||
### Is your feature request related to a problem? Please describe.
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
### Describe the solution you'd like
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
### Describe alternatives you've considered
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
### Additional context
|
||||
Add any other context or screenshots about the feature request here.
|
||||
@@ -0,0 +1,55 @@
|
||||
name: 🚀 App Feature Request
|
||||
description: Request a feature/enhancement
|
||||
title: '[Enhancement]: '
|
||||
labels: ['enhancement']
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: '## App Feature Request Description'
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: 'Please first search in both issues & discussions for your enhancement and make sure your app is up to date.'
|
||||
- type: textarea
|
||||
id: describe
|
||||
attributes:
|
||||
label: Describe the Feature/Enhancement
|
||||
description: Please help us understand what you want.
|
||||
placeholder: What is your vision?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: the-why
|
||||
attributes:
|
||||
label: Why would this be helpful?
|
||||
description: Please help us understand why this would enhance your experience.
|
||||
placeholder: Explain the "why" or "use case".
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: image
|
||||
attributes:
|
||||
label: Future Implementation (Screenshot)
|
||||
description: Please help us visualize by including a doodle or screenshot.
|
||||
placeholder: How could this look or where could it go?
|
||||
validations:
|
||||
required: true
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: '## App Current Implementation'
|
||||
- type: dropdown
|
||||
id: version
|
||||
attributes:
|
||||
label: Audiobookshelf App Version
|
||||
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.9.63
|
||||
- iOS App - 0.9.63
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: current-image
|
||||
attributes:
|
||||
label: Current Implementation (Screenshot)
|
||||
description: What screen were you looking at when you thought of this enhancement?
|
||||
placeholder: If an image is not applicable, please explain why.
|
||||
@@ -16,10 +16,10 @@ jobs:
|
||||
- name: checkout sources
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: use Node.js 16.x
|
||||
- name: use Node.js 20.x
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16
|
||||
node-version: 20
|
||||
|
||||
- name: Set up Java
|
||||
uses: actions/setup-java@v2
|
||||
|
||||
@@ -15,10 +15,10 @@ jobs:
|
||||
- name: checkout sources
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: use Node.js 16.x
|
||||
- name: use Node.js 20.x
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16
|
||||
node-version: 20
|
||||
|
||||
- name: Set up Java
|
||||
uses: actions/setup-java@v2
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
name: Verify all i18n files are alphabetized
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
paths:
|
||||
- strings/** # Should only check if any strings changed
|
||||
|
||||
jobs:
|
||||
update_translations:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# Check out the repository
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Set up node to run the javascript
|
||||
- name: Set up node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
# The only argument is the `directory`, which is where the i18n files are
|
||||
# stored.
|
||||
- name: Run Update JSON Files action
|
||||
uses: audiobookshelf/audiobookshelf-i18n-updater@v1.2.0
|
||||
with:
|
||||
directory: 'strings/' # Adjust the directory path as needed
|
||||
@@ -33,8 +33,8 @@ android {
|
||||
applicationId "com.audiobookshelf.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 101
|
||||
versionName "0.9.70-beta"
|
||||
versionCode 105
|
||||
versionName "0.9.74-beta"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
manifestPlaceholders = [
|
||||
"appAuthRedirectScheme": "com.audiobookshelf.app"
|
||||
@@ -64,6 +64,8 @@ repositories {
|
||||
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
|
||||
}
|
||||
mavenCentral()
|
||||
// TODO: Temporarily using SNAPSHOT version of Simple Storage that resolves https://github.com/anggrayudi/SimpleStorage/issues/133
|
||||
maven { url "https://oss.sonatype.org/content/repositories/snapshots" }
|
||||
}
|
||||
|
||||
configurations.configureEach {
|
||||
@@ -118,7 +120,7 @@ dependencies {
|
||||
implementation 'io.github.pilgr:paperdb:2.7.2'
|
||||
|
||||
// Simple Storage
|
||||
implementation "com.anggrayudi:storage:1.5.4"
|
||||
implementation "com.anggrayudi:storage:1.5.5-SNAPSHOT"
|
||||
|
||||
// OK HTTP
|
||||
implementation 'com.squareup.okhttp3:okhttp:4.9.2'
|
||||
|
||||
@@ -9,6 +9,7 @@ android {
|
||||
|
||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||
dependencies {
|
||||
implementation project(':byteowls-capacitor-filesharer')
|
||||
implementation project(':capacitor-app')
|
||||
implementation project(':capacitor-browser')
|
||||
implementation project(':capacitor-clipboard')
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
[
|
||||
{
|
||||
"pkg": "@byteowls/capacitor-filesharer",
|
||||
"classpath": "com.byteowls.capacitor.filesharer.FileSharerPlugin"
|
||||
},
|
||||
{
|
||||
"pkg": "@capacitor/app",
|
||||
"classpath": "com.capacitorjs.plugins.app.AppPlugin"
|
||||
|
||||
@@ -103,6 +103,9 @@ class MainActivity : BridgeActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
fun isPlayerNotificationServiceInitialized():Boolean {
|
||||
return ::foregroundService.isInitialized
|
||||
}
|
||||
|
||||
fun stopMyService() {
|
||||
if (mBounded) {
|
||||
@@ -116,6 +119,7 @@ class MainActivity : BridgeActivity() {
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
storageHelper.onSaveInstanceState(outState)
|
||||
super.onSaveInstanceState(outState)
|
||||
outState.clear()
|
||||
}
|
||||
|
||||
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
|
||||
|
||||
@@ -107,6 +107,7 @@ data class PlayItemRequestPayload(
|
||||
data class DeviceSettings(
|
||||
var disableAutoRewind:Boolean,
|
||||
var enableAltView:Boolean,
|
||||
var allowSeekingOnMediaControls:Boolean,
|
||||
var jumpBackwardsTime:Int,
|
||||
var jumpForwardTime:Int,
|
||||
var enableMp3IndexSeeking:Boolean,
|
||||
@@ -130,6 +131,7 @@ data class DeviceSettings(
|
||||
return DeviceSettings(
|
||||
disableAutoRewind = false,
|
||||
enableAltView = true,
|
||||
allowSeekingOnMediaControls = false,
|
||||
jumpBackwardsTime = 10,
|
||||
jumpForwardTime = 10,
|
||||
enableMp3IndexSeeking = false,
|
||||
|
||||
@@ -247,14 +247,18 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
|
||||
}
|
||||
|
||||
private fun checkSetValidServerConnectionConfig(cb: (Boolean) -> Unit) = runBlocking {
|
||||
Log.d(tag, "checkSetValidServerConnectionConfig | $serverConfigIdUsed")
|
||||
Log.d(tag, "checkSetValidServerConnectionConfig | serverConfigIdUsed=$serverConfigIdUsed | lastServerConnectionConfigId=${DeviceManager.deviceData.lastServerConnectionConfigId}")
|
||||
|
||||
coroutineScope {
|
||||
if (!DeviceManager.checkConnectivity(ctx)) {
|
||||
serverUserMediaProgress = mutableListOf()
|
||||
Log.d(tag, "checkSetValidServerConnectionConfig: No connectivity")
|
||||
cb(false)
|
||||
} else if (DeviceManager.deviceData.lastServerConnectionConfigId.isNullOrBlank()) { // If in offline mode last server connection config is unset
|
||||
serverUserMediaProgress = mutableListOf()
|
||||
Log.d(tag, "checkSetValidServerConnectionConfig: No last server connection config")
|
||||
cb(false)
|
||||
} else {
|
||||
|
||||
var hasValidConn = false
|
||||
var lookupMediaProgress = true
|
||||
|
||||
@@ -332,6 +336,7 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
|
||||
checkSetValidServerConnectionConfig { isConnected ->
|
||||
if (isConnected) {
|
||||
serverConfigIdUsed = DeviceManager.serverConnectionConfigId
|
||||
Log.d(tag, "loadAndroidAutoItems: Connected to server config id=$serverConfigIdUsed")
|
||||
|
||||
loadLibraries { libraries ->
|
||||
if (libraries.isEmpty()) {
|
||||
@@ -358,6 +363,7 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
|
||||
}
|
||||
}
|
||||
} else { // Not connected to server
|
||||
Log.d(tag, "loadAndroidAutoItems: Not connected to server")
|
||||
cb()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,6 +238,8 @@ class MediaProgressSyncer(val playerNotificationService: PlayerNotificationServi
|
||||
saveLocalProgress(it)
|
||||
lastSyncTime = System.currentTimeMillis()
|
||||
|
||||
Log.d(tag, "Sync local device current serverConnectionConfigId=${DeviceManager.serverConnectionConfig?.id}")
|
||||
|
||||
// Local library item is linked to a server library item
|
||||
// Send sync to server also if connected to this server and local item belongs to this server
|
||||
if (hasNetworkConnection && shouldSyncServer && !it.libraryItemId.isNullOrEmpty() && it.serverConnectionConfigId != null && DeviceManager.serverConnectionConfig?.id == it.serverConnectionConfigId) {
|
||||
|
||||
+2
-4
@@ -15,8 +15,6 @@ import com.google.android.exoplayer2.Player
|
||||
import com.google.android.exoplayer2.ui.PlayerNotificationManager
|
||||
import kotlinx.coroutines.*
|
||||
|
||||
const val NOTIFICATION_LARGE_ICON_SIZE = 144 // px
|
||||
|
||||
class AbMediaDescriptionAdapter constructor(private val controller: MediaControllerCompat, private val playerNotificationService: PlayerNotificationService) : PlayerNotificationManager.MediaDescriptionAdapter {
|
||||
private val tag = "MediaDescriptionAdapter"
|
||||
|
||||
@@ -77,7 +75,7 @@ class AbMediaDescriptionAdapter constructor(private val controller: MediaControl
|
||||
.load(uri)
|
||||
.placeholder(R.drawable.icon)
|
||||
.error(R.drawable.icon)
|
||||
.submit(NOTIFICATION_LARGE_ICON_SIZE, NOTIFICATION_LARGE_ICON_SIZE)
|
||||
.submit()
|
||||
.get()
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
@@ -85,7 +83,7 @@ class AbMediaDescriptionAdapter constructor(private val controller: MediaControl
|
||||
Glide.with(playerNotificationService)
|
||||
.asBitmap()
|
||||
.load(Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon))
|
||||
.submit(NOTIFICATION_LARGE_ICON_SIZE, NOTIFICATION_LARGE_ICON_SIZE)
|
||||
.submit()
|
||||
.get()
|
||||
}
|
||||
}
|
||||
|
||||
+17
-10
@@ -34,6 +34,7 @@ import com.audiobookshelf.app.managers.SleepTimerManager
|
||||
import com.audiobookshelf.app.media.MediaManager
|
||||
import com.audiobookshelf.app.media.MediaProgressSyncer
|
||||
import com.audiobookshelf.app.server.ApiHandler
|
||||
import com.audiobookshelf.app.BuildConfig
|
||||
import com.google.android.exoplayer2.*
|
||||
import com.google.android.exoplayer2.audio.AudioAttributes
|
||||
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
|
||||
@@ -329,15 +330,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
}
|
||||
}
|
||||
|
||||
mediaSessionConnector.setEnabledPlaybackActions(
|
||||
PlaybackStateCompat.ACTION_PLAY_PAUSE
|
||||
or PlaybackStateCompat.ACTION_PLAY
|
||||
or PlaybackStateCompat.ACTION_PAUSE
|
||||
or PlaybackStateCompat.ACTION_SEEK_TO
|
||||
or PlaybackStateCompat.ACTION_FAST_FORWARD
|
||||
or PlaybackStateCompat.ACTION_REWIND
|
||||
or PlaybackStateCompat.ACTION_STOP
|
||||
)
|
||||
setMediaSessionConnectorPlaybackActions()
|
||||
mediaSessionConnector.setQueueNavigator(queueNavigator)
|
||||
mediaSessionConnector.setPlaybackPreparer(MediaSessionPlaybackPreparer(this))
|
||||
|
||||
@@ -512,6 +505,20 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
mediaSessionConnector.setCustomActionProviders(*customActionProviders.toTypedArray())
|
||||
}
|
||||
|
||||
fun setMediaSessionConnectorPlaybackActions() {
|
||||
var playbackActions = PlaybackStateCompat.ACTION_PLAY_PAUSE or
|
||||
PlaybackStateCompat.ACTION_PLAY or
|
||||
PlaybackStateCompat.ACTION_PAUSE or
|
||||
PlaybackStateCompat.ACTION_FAST_FORWARD or
|
||||
PlaybackStateCompat.ACTION_REWIND or
|
||||
PlaybackStateCompat.ACTION_STOP
|
||||
|
||||
if (deviceSettings.allowSeekingOnMediaControls) {
|
||||
playbackActions = playbackActions or PlaybackStateCompat.ACTION_SEEK_TO
|
||||
}
|
||||
mediaSessionConnector.setEnabledPlaybackActions(playbackActions)
|
||||
}
|
||||
|
||||
fun handlePlayerPlaybackError(errorMessage:String) {
|
||||
// On error and was attempting to direct play - fallback to transcode
|
||||
currentPlaybackSession?.let { playbackSession ->
|
||||
@@ -941,7 +948,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
appVersion: 0.9.46-beta
|
||||
*/
|
||||
val deviceId = Settings.Secure.getString(ctx.contentResolver, Settings.Secure.ANDROID_ID)
|
||||
return DeviceInfo(deviceId, Build.MANUFACTURER, Build.MODEL, Build.VERSION.SDK_INT, Build.VERSION.RELEASE)
|
||||
return DeviceInfo(deviceId, Build.MANUFACTURER, Build.MODEL, Build.VERSION.SDK_INT, BuildConfig.VERSION_NAME)
|
||||
}
|
||||
|
||||
private val deviceSettings get() = DeviceManager.deviceData.deviceSettings ?: DeviceSettings.default()
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.audiobookshelf.app.plugins
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.MainActivity
|
||||
import com.audiobookshelf.app.data.*
|
||||
@@ -493,9 +495,16 @@ class AbsDatabase : Plugin() {
|
||||
fun updateDeviceSettings(call:PluginCall) { // Returns device data
|
||||
Log.d(tag, "updateDeviceSettings ${call.data}")
|
||||
val newDeviceSettings = jacksonMapper.readValue<DeviceSettings>(call.data.toString())
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
DeviceManager.deviceData.deviceSettings = newDeviceSettings
|
||||
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
|
||||
|
||||
// Updates playback actions for media notification (handles media control seek locking setting)
|
||||
if (mainActivity.isPlayerNotificationServiceInitialized()) {
|
||||
mainActivity.foregroundService.setMediaSessionConnectorPlaybackActions()
|
||||
}
|
||||
|
||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(DeviceManager.deviceData)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package com.audiobookshelf.app.server
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.data.*
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
@@ -8,6 +11,7 @@ import com.audiobookshelf.app.media.MediaEventManager
|
||||
import com.audiobookshelf.app.media.MediaProgressSyncData
|
||||
import com.audiobookshelf.app.media.SyncResult
|
||||
import com.audiobookshelf.app.models.User
|
||||
import com.audiobookshelf.app.BuildConfig
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
@@ -31,7 +35,7 @@ class ApiHandler(var ctx:Context) {
|
||||
private var pingClient = OkHttpClient.Builder().callTimeout(3, TimeUnit.SECONDS).build()
|
||||
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||
|
||||
data class LocalSessionsSyncRequestPayload(val sessions:List<PlaybackSession>)
|
||||
data class LocalSessionsSyncRequestPayload(val sessions:List<PlaybackSession>, val deviceInfo:DeviceInfo)
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class LocalSessionSyncResult(val id:String, val success:Boolean, val progressSynced:Boolean?, val error:String?)
|
||||
data class LocalSessionsSyncResponsePayload(val results:List<LocalSessionSyncResult>)
|
||||
@@ -322,7 +326,11 @@ class ApiHandler(var ctx:Context) {
|
||||
}
|
||||
|
||||
fun sendSyncLocalSessions(playbackSessions:List<PlaybackSession>, cb: (Boolean, String?) -> Unit) {
|
||||
val payload = JSObject(jacksonMapper.writeValueAsString(LocalSessionsSyncRequestPayload(playbackSessions)))
|
||||
@SuppressLint("HardwareIds")
|
||||
val deviceId = Settings.Secure.getString(ctx.contentResolver, Settings.Secure.ANDROID_ID)
|
||||
val deviceInfo = DeviceInfo(deviceId, Build.MANUFACTURER, Build.MODEL, Build.VERSION.SDK_INT, BuildConfig.VERSION_NAME)
|
||||
|
||||
val payload = JSObject(jacksonMapper.writeValueAsString(LocalSessionsSyncRequestPayload(playbackSessions, deviceInfo)))
|
||||
Log.d(tag, "Sending ${playbackSessions.size} saved local playback sessions to server")
|
||||
postRequest("/api/session/local-all", payload, null) {
|
||||
if (!it.getString("error").isNullOrEmpty()) {
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
include ':capacitor-android'
|
||||
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
|
||||
|
||||
include ':byteowls-capacitor-filesharer'
|
||||
project(':byteowls-capacitor-filesharer').projectDir = new File('../node_modules/@byteowls/capacitor-filesharer/android')
|
||||
|
||||
include ':capacitor-app'
|
||||
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
|
||||
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
class WrappingMarquee {
|
||||
#scrollDelay = 2000
|
||||
#scrollSpeed = 30
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} el
|
||||
*/
|
||||
constructor(el) {
|
||||
this.el = el
|
||||
/** @type {HTMLElement} */
|
||||
this.pEl = el?.firstElementChild
|
||||
|
||||
this.innerText = ''
|
||||
this.isScrolling = false
|
||||
|
||||
/** @type {NodeJS.Timeout} */
|
||||
this.timer = null
|
||||
/** @type {number} */
|
||||
this.animationId = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Transparent gradient mask shown when text is scrolling left and overflowing right
|
||||
*
|
||||
* @param {boolean} showLeft
|
||||
*/
|
||||
setMask(showLeft) {
|
||||
if (!this.el) return
|
||||
this.el.style.maskImage = showLeft ? 'linear-gradient(90deg, transparent 0%, #fff 10%, #000 90%, transparent)' : 'linear-gradient(90deg, #000 90%, transparent)'
|
||||
}
|
||||
|
||||
startScroll() {
|
||||
if (this.isScrolling) {
|
||||
console.warn('Already scrolling')
|
||||
return
|
||||
}
|
||||
|
||||
this.isScrolling = true
|
||||
this.setMask(true)
|
||||
|
||||
let textScrollAmount = this.el.scrollWidth
|
||||
this.pEl.innerHTML = this.innerText + ' '.repeat(15)
|
||||
let totalScrollAmount = this.el.scrollWidth
|
||||
let scrollDuration = totalScrollAmount * this.#scrollSpeed
|
||||
|
||||
this.pEl.innerHTML = this.pEl.innerHTML + this.innerText
|
||||
|
||||
let done = false
|
||||
let start, previousTimeStamp
|
||||
|
||||
const step = (timeStamp) => {
|
||||
if (start === undefined) {
|
||||
start = timeStamp
|
||||
}
|
||||
const elapsed = timeStamp - start
|
||||
|
||||
if (this.isScrolling && previousTimeStamp !== timeStamp) {
|
||||
const amountToMove = Math.min(elapsed / scrollDuration * totalScrollAmount, totalScrollAmount)
|
||||
this.pEl.style.transform = `translateX(-${amountToMove}px)`
|
||||
if (amountToMove === totalScrollAmount) done = true
|
||||
if (amountToMove > textScrollAmount) this.setMask(false)
|
||||
}
|
||||
|
||||
if (!this.isScrolling || done) { // canceled or done
|
||||
this.isScrolling = false
|
||||
this.pEl.style.transform = 'translateX(0px)'
|
||||
this.pEl.innerText = this.innerText
|
||||
this.setMask(false)
|
||||
if (done) {
|
||||
this.startTimer()
|
||||
}
|
||||
} else if (elapsed < scrollDuration) { // step
|
||||
previousTimeStamp = timeStamp
|
||||
this.animationId = window.requestAnimationFrame(step)
|
||||
}
|
||||
}
|
||||
this.animationId = window.requestAnimationFrame(step)
|
||||
}
|
||||
|
||||
startTimer() {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = setTimeout(() => {
|
||||
this.startScroll()
|
||||
}, this.#scrollDelay)
|
||||
}
|
||||
|
||||
reset() {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = null
|
||||
this.isScrolling = false
|
||||
window.cancelAnimationFrame(this.animationId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize and start marquee if text overflows container
|
||||
* resets the marquee if already active
|
||||
*
|
||||
* @param {string} innerText
|
||||
*/
|
||||
init(innerText) {
|
||||
if (!this.el || !this.pEl) return
|
||||
|
||||
this.reset()
|
||||
|
||||
this.innerText = innerText
|
||||
this.pEl.innerText = innerText
|
||||
this.pEl.style.transform = 'translateX(0px)'
|
||||
|
||||
if (this.el.scrollWidth > this.el.clientWidth) {
|
||||
this.setMask(false)
|
||||
this.startTimer()
|
||||
} else {
|
||||
this.el.style.maskImage = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
export default WrappingMarquee
|
||||
+1
-4
@@ -2,6 +2,7 @@
|
||||
@import "./fonts.css";
|
||||
@import './defaultStyles.css';
|
||||
@import './absicons.css';
|
||||
@import './transitions.css';
|
||||
|
||||
* {
|
||||
-webkit-touch-callout: none;
|
||||
@@ -16,10 +17,6 @@ textarea {
|
||||
user-select: auto;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #262626;
|
||||
}
|
||||
|
||||
.layout-wrapper {
|
||||
height: calc(100vh - env(safe-area-inset-top));
|
||||
min-height: calc(100vh - env(safe-area-inset-top));
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
(function (global, factory) {
|
||||
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
|
||||
typeof define === 'function' && define.amd ? define(['exports'], factory) :
|
||||
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global['fast-sort'] = {}));
|
||||
}(this, (function (exports) {
|
||||
'use strict';
|
||||
|
||||
// >>> INTERFACES <<<
|
||||
// >>> HELPERS <<<
|
||||
var castComparer = function (comparer) { return function (a, b, order) { return comparer(a, b, order) * order; }; };
|
||||
var throwInvalidConfigErrorIfTrue = function (condition, context) {
|
||||
if (condition)
|
||||
throw Error("Invalid sort config: " + context);
|
||||
};
|
||||
var unpackObjectSorter = function (sortByObj) {
|
||||
var _a = sortByObj || {}, asc = _a.asc, desc = _a.desc;
|
||||
var order = asc ? 1 : -1;
|
||||
var sortBy = (asc || desc);
|
||||
// Validate object config
|
||||
throwInvalidConfigErrorIfTrue(!sortBy, 'Expected `asc` or `desc` property');
|
||||
throwInvalidConfigErrorIfTrue(asc && desc, 'Ambiguous object with `asc` and `desc` config properties');
|
||||
var comparer = sortByObj.comparer && castComparer(sortByObj.comparer);
|
||||
return { order: order, sortBy: sortBy, comparer: comparer };
|
||||
};
|
||||
// >>> SORTERS <<<
|
||||
var multiPropertySorterProvider = function (defaultComparer) {
|
||||
return function multiPropertySorter(sortBy, sortByArr, depth, order, comparer, a, b) {
|
||||
var valA;
|
||||
var valB;
|
||||
if (typeof sortBy === 'string') {
|
||||
valA = a[sortBy];
|
||||
valB = b[sortBy];
|
||||
}
|
||||
else if (typeof sortBy === 'function') {
|
||||
valA = sortBy(a);
|
||||
valB = sortBy(b);
|
||||
}
|
||||
else {
|
||||
var objectSorterConfig = unpackObjectSorter(sortBy);
|
||||
return multiPropertySorter(objectSorterConfig.sortBy, sortByArr, depth, objectSorterConfig.order, objectSorterConfig.comparer || defaultComparer, a, b);
|
||||
}
|
||||
var equality = comparer(valA, valB, order);
|
||||
if ((equality === 0 || (valA == null && valB == null)) &&
|
||||
sortByArr.length > depth) {
|
||||
return multiPropertySorter(sortByArr[depth], sortByArr, depth + 1, order, comparer, a, b);
|
||||
}
|
||||
return equality;
|
||||
};
|
||||
};
|
||||
function getSortStrategy(sortBy, comparer, order) {
|
||||
// Flat array sorter
|
||||
if (sortBy === undefined || sortBy === true) {
|
||||
return function (a, b) { return comparer(a, b, order); };
|
||||
}
|
||||
// Sort list of objects by single object key
|
||||
if (typeof sortBy === 'string') {
|
||||
throwInvalidConfigErrorIfTrue(sortBy.includes('.'), 'String syntax not allowed for nested properties.');
|
||||
return function (a, b) { return comparer(a[sortBy], b[sortBy], order); };
|
||||
}
|
||||
// Sort list of objects by single function sorter
|
||||
if (typeof sortBy === 'function') {
|
||||
return function (a, b) { return comparer(sortBy(a), sortBy(b), order); };
|
||||
}
|
||||
// Sort by multiple properties
|
||||
if (Array.isArray(sortBy)) {
|
||||
var multiPropSorter_1 = multiPropertySorterProvider(comparer);
|
||||
return function (a, b) { return multiPropSorter_1(sortBy[0], sortBy, 1, order, comparer, a, b); };
|
||||
}
|
||||
// Unpack object config to get actual sorter strategy
|
||||
var objectSorterConfig = unpackObjectSorter(sortBy);
|
||||
return getSortStrategy(objectSorterConfig.sortBy, objectSorterConfig.comparer || comparer, objectSorterConfig.order);
|
||||
}
|
||||
var sortArray = function (order, ctx, sortBy, comparer) {
|
||||
var _a;
|
||||
if (!Array.isArray(ctx)) {
|
||||
return ctx;
|
||||
}
|
||||
// Unwrap sortBy if array with only 1 value to get faster sort strategy
|
||||
if (Array.isArray(sortBy) && sortBy.length < 2) {
|
||||
_a = sortBy, sortBy = _a[0];
|
||||
}
|
||||
return ctx.sort(getSortStrategy(sortBy, comparer, order));
|
||||
};
|
||||
// >>> Public <<<
|
||||
var createNewSortInstance = function (opts) {
|
||||
var comparer = castComparer(opts.comparer);
|
||||
return function (_ctx) {
|
||||
var ctx = Array.isArray(_ctx) && !opts.inPlaceSorting
|
||||
? _ctx.slice()
|
||||
: _ctx;
|
||||
return {
|
||||
/**
|
||||
* Sort array in ascending order.
|
||||
* @example
|
||||
* sort([3, 1, 4]).asc();
|
||||
* sort(users).asc(u => u.firstName);
|
||||
* sort(users).asc([
|
||||
* U => u.firstName
|
||||
* u => u.lastName,
|
||||
* ]);
|
||||
*/
|
||||
asc: function (sortBy) {
|
||||
return sortArray(1, ctx, sortBy, comparer);
|
||||
},
|
||||
/**
|
||||
* Sort array in descending order.
|
||||
* @example
|
||||
* sort([3, 1, 4]).desc();
|
||||
* sort(users).desc(u => u.firstName);
|
||||
* sort(users).desc([
|
||||
* U => u.firstName
|
||||
* u => u.lastName,
|
||||
* ]);
|
||||
*/
|
||||
desc: function (sortBy) {
|
||||
return sortArray(-1, ctx, sortBy, comparer);
|
||||
},
|
||||
/**
|
||||
* Sort array in ascending or descending order. It allows sorting on multiple props
|
||||
* in different order for each of them.
|
||||
* @example
|
||||
* sort(users).by([
|
||||
* { asc: u => u.score }
|
||||
* { desc: u => u.age }
|
||||
* ]);
|
||||
*/
|
||||
by: function (sortBy) {
|
||||
return sortArray(1, ctx, sortBy, comparer);
|
||||
},
|
||||
};
|
||||
};
|
||||
};
|
||||
var defaultComparer = function (a, b, order) {
|
||||
if (a == null)
|
||||
return order;
|
||||
if (b == null)
|
||||
return -order;
|
||||
if (a < b)
|
||||
return -1;
|
||||
if (a === b)
|
||||
return 0;
|
||||
return 1;
|
||||
};
|
||||
var sort = createNewSortInstance({
|
||||
comparer: defaultComparer,
|
||||
});
|
||||
var inPlaceSort = createNewSortInstance({
|
||||
comparer: defaultComparer,
|
||||
inPlaceSorting: true,
|
||||
});
|
||||
|
||||
exports.createNewSortInstance = createNewSortInstance;
|
||||
exports.inPlaceSort = inPlaceSort;
|
||||
exports.sort = sort;
|
||||
|
||||
Object.defineProperty(exports, '__esModule', { value: true });
|
||||
|
||||
})));
|
||||
+6
-2
@@ -18,7 +18,7 @@
|
||||
--color-track: 107 114 128;
|
||||
--color-track-buffered: 75 85 99;
|
||||
--gradient-item-page: linear-gradient(169deg, rgba(0, 0, 0, 0.4) 0%, rgba(55, 56, 56, 1) 80%);
|
||||
--gradient-audio-player: linear-gradient(169deg, rgba(0, 0, 0, 0) 0%, rgba(38, 38, 38, 1) 80%);
|
||||
--gradient-audio-player: linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, rgba(38, 38, 38, 1) 80%);
|
||||
--gradient-minimized-audio-player: linear-gradient(145deg, rgba(38, 38, 38, 0.5) 0%, rgba(38, 38, 38, 0.9) 20%, rgb(38, 38, 38) 60%);
|
||||
}
|
||||
|
||||
@@ -37,7 +37,11 @@
|
||||
--color-track: 189 191 191;
|
||||
--color-track-buffered: 129 131 131;
|
||||
--gradient-item-page: linear-gradient(169deg, rgba(255, 255, 255, 0.4) 0%, rgba(255, 255, 255, 1) 80%);
|
||||
--gradient-audio-player: linear-gradient(169deg, rgba(255, 255, 255, 0.4) 0%, rgba(255, 255, 255, 1) 80%);
|
||||
--gradient-audio-player: linear-gradient(180deg, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 1) 80%);
|
||||
--gradient-minimized-audio-player: linear-gradient(145deg, rgba(255, 255, 255, 0.5) 0%, rgba(255, 255, 255, 0.9) 20%, rgb(255, 255, 255) 60%);
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-primary
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
.slide-enter-active {
|
||||
-moz-transition-duration: 0.1s;
|
||||
-webkit-transition-duration: 0.1s;
|
||||
-o-transition-duration: 0.1s;
|
||||
transition-duration: 0.1s;
|
||||
-moz-transition-timing-function: ease-in;
|
||||
-webkit-transition-timing-function: ease-in;
|
||||
-o-transition-timing-function: ease-in;
|
||||
transition-timing-function: ease-in;
|
||||
}
|
||||
|
||||
.slide-leave-active {
|
||||
-moz-transition-duration: 0.2s;
|
||||
-webkit-transition-duration: 0.2s;
|
||||
-o-transition-duration: 0.2s;
|
||||
transition-duration: 0.2s;
|
||||
-moz-transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
|
||||
-webkit-transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
|
||||
-o-transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
|
||||
transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
|
||||
}
|
||||
|
||||
.slide-enter-to,
|
||||
.slide-leave {
|
||||
max-height: 600px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.slide-enter,
|
||||
.slide-leave-to {
|
||||
overflow: hidden;
|
||||
max-height: 0;
|
||||
}
|
||||
|
||||
.menu-enter,
|
||||
.menu-leave-active {
|
||||
transform: translateY(-15px);
|
||||
}
|
||||
|
||||
.menu-enter-active {
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.menu-leave-active {
|
||||
transition: all 0.1s;
|
||||
}
|
||||
|
||||
.menu-enter,
|
||||
.menu-leave-active {
|
||||
opacity: 0;
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
<a v-if="showBack" @click="back" class="rounded-full h-10 w-10 flex items-center justify-center mr-2 cursor-pointer">
|
||||
<span class="material-icons text-3xl text-fg">arrow_back</span>
|
||||
</a>
|
||||
<div v-if="user && currentLibrary">
|
||||
<div v-if="user && currentLibrary && networkConnected">
|
||||
<div class="pl-1.5 pr-2.5 py-2 bg-bg bg-opacity-30 rounded-md flex items-center" @click="clickShowLibraryModal">
|
||||
<ui-library-icon :icon="currentLibraryIcon" :size="4" font-size="base" />
|
||||
<p class="text-sm leading-4 ml-2 mt-0.5 max-w-24 truncate">{{ currentLibraryName }}</p>
|
||||
@@ -54,14 +54,17 @@ export default {
|
||||
this.$store.commit('setCastAvailable', val)
|
||||
}
|
||||
},
|
||||
networkConnected() {
|
||||
return this.$store.state.networkConnected
|
||||
},
|
||||
currentLibrary() {
|
||||
return this.$store.getters['libraries/getCurrentLibrary']
|
||||
},
|
||||
currentLibraryName() {
|
||||
return this.currentLibrary ? this.currentLibrary.name : ''
|
||||
return this.currentLibrary?.name || ''
|
||||
},
|
||||
currentLibraryIcon() {
|
||||
return this.currentLibrary ? this.currentLibrary.icon : 'database'
|
||||
return this.currentLibrary?.icon || 'database'
|
||||
},
|
||||
showBack() {
|
||||
if (!this.$route.name) return true
|
||||
@@ -71,7 +74,7 @@ export default {
|
||||
return this.$store.state.user.user
|
||||
},
|
||||
username() {
|
||||
return this.user ? this.user.username : 'err'
|
||||
return this.user?.username || 'err'
|
||||
},
|
||||
isCasting() {
|
||||
return this.$store.state.isCasting
|
||||
|
||||
+182
-81
@@ -15,7 +15,7 @@
|
||||
<p class="top-4 absolute left-0 right-0 mx-auto text-center uppercase tracking-widest text-opacity-75" :class="{ 'text-black text-opacity-75': coverBgIsLight }" style="font-size: 10px">{{ isDirectPlayMethod ? $strings.LabelPlaybackDirect : isLocalPlayMethod ? $strings.LabelPlaybackLocal : $strings.LabelPlaybackTranscode }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="useChapterTrack && useTotalTrack && showFullscreen" class="absolute total-track w-full z-30 px-6">
|
||||
<div v-if="playerSettings.useChapterTrack && playerSettings.useTotalTrack && showFullscreen" class="absolute total-track w-full z-30 px-6">
|
||||
<div class="flex">
|
||||
<p class="font-mono text-fg" style="font-size: 0.8rem">{{ currentTimePretty }}</p>
|
||||
<div class="flex-grow" />
|
||||
@@ -41,7 +41,9 @@
|
||||
</div>
|
||||
|
||||
<div class="title-author-texts absolute z-30 left-0 right-0 overflow-hidden" @click="clickTitleAndAuthor">
|
||||
<p class="title-text truncate">{{ title }}</p>
|
||||
<div ref="titlewrapper" class="overflow-hidden relative">
|
||||
<p class="title-text whitespace-nowrap"></p>
|
||||
</div>
|
||||
<p class="author-text text-fg text-opacity-75 truncate">{{ authorName }}</p>
|
||||
</div>
|
||||
|
||||
@@ -66,17 +68,17 @@
|
||||
<div v-else class="w-full h-full absolute top-0 left-0 pointer-events-none" style="background: var(--gradient-minimized-audio-player)" />
|
||||
|
||||
<div id="playerControls" class="absolute right-0 bottom-0 mx-auto" style="max-width: 414px">
|
||||
<div class="flex items-center max-w-full" :class="lockUi ? 'justify-center' : 'justify-between'">
|
||||
<span v-show="showFullscreen && !lockUi" class="material-icons next-icon text-fg cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpChapterStart">first_page</span>
|
||||
<span v-show="!lockUi" class="material-icons jump-icon text-fg cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpBackwards">{{ jumpBackwardsIcon }}</span>
|
||||
<div class="flex items-center max-w-full" :class="playerSettings.lockUi ? 'justify-center' : 'justify-between'">
|
||||
<span v-show="showFullscreen && !playerSettings.lockUi" class="material-icons next-icon text-fg cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpChapterStart">first_page</span>
|
||||
<span v-show="!playerSettings.lockUi" class="material-icons jump-icon text-fg cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpBackwards">{{ jumpBackwardsIcon }}</span>
|
||||
<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-icons" :class="{ 'text-white': coverRgb && !coverBgIsLight }">{{ seekLoading ? 'autorenew' : !isPlaying ? 'play_arrow' : 'pause' }}</span>
|
||||
<widgets-spinner-icon v-else class="h-8 w-8" />
|
||||
</div>
|
||||
<span v-show="!lockUi" class="material-icons jump-icon text-fg cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpForward">{{ jumpForwardIcon }}</span>
|
||||
<span v-show="showFullscreen && !lockUi" class="material-icons next-icon text-fg cursor-pointer" :class="nextChapter && !isLoading ? 'text-opacity-75' : 'text-opacity-10'" @click.stop="jumpNextChapter">last_page</span>
|
||||
<span v-show="!playerSettings.lockUi" class="material-icons jump-icon text-fg cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpForward">{{ jumpForwardIcon }}</span>
|
||||
<span v-show="showFullscreen && !playerSettings.lockUi" class="material-icons next-icon text-fg cursor-pointer" :class="nextChapter && !isLoading ? 'text-opacity-75' : 'text-opacity-10'" @click.stop="jumpNextChapter">last_page</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -90,7 +92,7 @@
|
||||
<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" />
|
||||
<div ref="trackCursor" class="h-7 w-7 rounded-full absolute pointer-events-auto flex items-center justify-center" :style="{ top: '-11px' }" :class="{ 'opacity-0': lockUi || !showFullscreen }" @touchstart="touchstartCursor">
|
||||
<div ref="trackCursor" class="h-7 w-7 rounded-full absolute pointer-events-auto flex items-center justify-center" :style="{ top: '-11px' }" :class="{ 'opacity-0': playerSettings.lockUi || !showFullscreen }" @touchstart="touchstartCursor">
|
||||
<div class="bg-track-cursor rounded-full w-3.5 h-3.5 pointer-events-none" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -98,7 +100,7 @@
|
||||
</div>
|
||||
|
||||
<modals-chapters-modal v-model="showChapterModal" :current-chapter="currentChapter" :chapters="chapters" :playback-rate="currentPlaybackRate" @select="selectChapter" />
|
||||
<modals-dialog v-model="showMoreMenuDialog" :items="menuItems" @action="clickMenuAction" />
|
||||
<modals-dialog v-model="showMoreMenuDialog" :items="menuItems" width="80vw" @action="clickMenuAction" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -106,6 +108,7 @@
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { AbsAudioPlayer } from '@/plugins/capacitor'
|
||||
import { FastAverageColor } from 'fast-average-color'
|
||||
import WrappingMarquee from '@/assets/WrappingMarquee.js'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
@@ -145,10 +148,12 @@ export default {
|
||||
onPlaybackSpeedChangedListener: null,
|
||||
touchStartY: 0,
|
||||
touchStartTime: 0,
|
||||
touchEndY: 0,
|
||||
useChapterTrack: false,
|
||||
useTotalTrack: true,
|
||||
lockUi: false,
|
||||
playerSettings: {
|
||||
useChapterTrack: false,
|
||||
useTotalTrack: true,
|
||||
scaleElapsedTimeBySpeed: true,
|
||||
lockUi: false
|
||||
},
|
||||
isLoading: false,
|
||||
isDraggingCursor: false,
|
||||
draggingTouchStartX: 0,
|
||||
@@ -157,16 +162,22 @@ export default {
|
||||
syncStatus: 0,
|
||||
showMoreMenuDialog: false,
|
||||
coverRgb: 'rgb(55, 56, 56)',
|
||||
coverBgIsLight: false
|
||||
coverBgIsLight: false,
|
||||
titleMarquee: null,
|
||||
isRefreshingUI: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
showFullscreen(val) {
|
||||
this.updateScreenSize()
|
||||
this.$store.commit('setPlayerFullscreen', !!val)
|
||||
document.querySelector('body').style.backgroundColor = this.showFullscreen ? this.coverRgb : ''
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
this.updateScreenSize()
|
||||
},
|
||||
title(val) {
|
||||
if (this.titleMarquee) this.titleMarquee.init(val)
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -186,17 +197,22 @@ export default {
|
||||
{
|
||||
text: this.$strings.LabelTotalTrack,
|
||||
value: 'total_track',
|
||||
icon: this.useTotalTrack ? 'check_box' : 'check_box_outline_blank'
|
||||
icon: this.playerSettings.useTotalTrack ? 'check_box' : 'check_box_outline_blank'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelChapterTrack,
|
||||
value: 'chapter_track',
|
||||
icon: this.useChapterTrack ? 'check_box' : 'check_box_outline_blank'
|
||||
icon: this.playerSettings.useChapterTrack ? 'check_box' : 'check_box_outline_blank'
|
||||
},
|
||||
{
|
||||
text: this.lockUi ? this.$strings.LabelUnlockPlayer : this.$strings.LabelLockPlayer,
|
||||
text: this.$strings.LabelScaleElapsedTimeBySpeed,
|
||||
value: 'scale_elapsed_time',
|
||||
icon: this.playerSettings.scaleElapsedTimeBySpeed ? 'check_box' : 'check_box_outline_blank'
|
||||
},
|
||||
{
|
||||
text: this.playerSettings.lockUi ? this.$strings.LabelUnlockPlayer : this.$strings.LabelLockPlayer,
|
||||
value: 'lock',
|
||||
icon: this.lockUi ? 'lock' : 'lock_open'
|
||||
icon: this.playerSettings.lockUi ? 'lock' : 'lock_open'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelClosePlayer,
|
||||
@@ -256,22 +272,22 @@ export default {
|
||||
return this.mediaPlayer === 'cast-player'
|
||||
},
|
||||
mediaPlayer() {
|
||||
return this.playbackSession ? this.playbackSession.mediaPlayer : null
|
||||
return this.playbackSession?.mediaPlayer || null
|
||||
},
|
||||
mediaType() {
|
||||
return this.playbackSession ? this.playbackSession.mediaType : null
|
||||
return this.playbackSession?.mediaType || null
|
||||
},
|
||||
isPodcast() {
|
||||
return this.mediaType === 'podcast'
|
||||
},
|
||||
mediaMetadata() {
|
||||
return this.playbackSession ? this.playbackSession.mediaMetadata : null
|
||||
return this.playbackSession?.mediaMetadata || null
|
||||
},
|
||||
libraryItem() {
|
||||
return this.playbackSession ? this.playbackSession.libraryItem || null : null
|
||||
return this.playbackSession?.libraryItem || null
|
||||
},
|
||||
localLibraryItem() {
|
||||
return this.playbackSession ? this.playbackSession.localLibraryItem || null : null
|
||||
return this.playbackSession?.localLibraryItem || null
|
||||
},
|
||||
localLibraryItemCoverSrc() {
|
||||
var localItemCover = this.localLibraryItem?.coverContentUrl || null
|
||||
@@ -279,7 +295,7 @@ export default {
|
||||
return null
|
||||
},
|
||||
playMethod() {
|
||||
return this.playbackSession ? this.playbackSession.playMethod : null
|
||||
return this.playbackSession?.playMethod || 0
|
||||
},
|
||||
isLocalPlayMethod() {
|
||||
return this.playMethod == this.$constants.PlayMethod.LOCAL
|
||||
@@ -288,19 +304,19 @@ export default {
|
||||
return this.playMethod == this.$constants.PlayMethod.DIRECTPLAY
|
||||
},
|
||||
title() {
|
||||
if (this.currentChapterTitle && this.showFullscreen) return this.currentChapterTitle
|
||||
if (this.playbackSession) return this.playbackSession.displayTitle
|
||||
return this.mediaMetadata ? this.mediaMetadata.title : 'Title'
|
||||
const mediaItemTitle = this.playbackSession?.displayTitle || this.mediaMetadata?.title || 'Title'
|
||||
if (this.currentChapterTitle) {
|
||||
if (this.showFullscreen) return this.currentChapterTitle
|
||||
return `${mediaItemTitle} | ${this.currentChapterTitle}`
|
||||
}
|
||||
return mediaItemTitle
|
||||
},
|
||||
authorName() {
|
||||
if (this.playbackSession) return this.playbackSession.displayAuthor
|
||||
return this.mediaMetadata ? this.mediaMetadata.authorName : 'Author'
|
||||
return this.mediaMetadata?.authorName || 'Author'
|
||||
},
|
||||
chapters() {
|
||||
if (this.playbackSession && this.playbackSession.chapters) {
|
||||
return this.playbackSession.chapters
|
||||
}
|
||||
return []
|
||||
return this.playbackSession?.chapters || []
|
||||
},
|
||||
currentChapter() {
|
||||
if (!this.chapters.length) return null
|
||||
@@ -311,7 +327,7 @@ export default {
|
||||
return this.chapters.find((c) => Number(Number(c.start).toFixed(2)) > this.currentTime)
|
||||
},
|
||||
currentChapterTitle() {
|
||||
return this.currentChapter ? this.currentChapter.title : ''
|
||||
return this.currentChapter?.title || ''
|
||||
},
|
||||
currentChapterDuration() {
|
||||
return this.currentChapter ? this.currentChapter.end - this.currentChapter.start : this.totalDuration
|
||||
@@ -321,11 +337,14 @@ export default {
|
||||
},
|
||||
currentTimePretty() {
|
||||
let currentTimeToUse = this.isDraggingCursor ? this.draggingCurrentTime : this.currentTime
|
||||
return this.$secondsToTimestamp(currentTimeToUse / this.currentPlaybackRate)
|
||||
if (this.playerSettings.scaleElapsedTimeBySpeed) {
|
||||
currentTimeToUse = currentTimeToUse / this.currentPlaybackRate
|
||||
}
|
||||
return this.$secondsToTimestamp(currentTimeToUse)
|
||||
},
|
||||
timeRemaining() {
|
||||
let currentTimeToUse = this.isDraggingCursor ? this.draggingCurrentTime : this.currentTime
|
||||
if (this.useChapterTrack && this.currentChapter) {
|
||||
if (this.playerSettings.useChapterTrack && this.currentChapter) {
|
||||
var currChapTime = currentTimeToUse - this.currentChapter.start
|
||||
return (this.currentChapterDuration - currChapTime) / this.currentPlaybackRate
|
||||
}
|
||||
@@ -411,7 +430,11 @@ export default {
|
||||
AbsAudioPlayer.requestSession()
|
||||
},
|
||||
clickContainer() {
|
||||
this.expandToFullscreen()
|
||||
},
|
||||
expandToFullscreen() {
|
||||
this.showFullscreen = true
|
||||
if (this.titleMarquee) this.titleMarquee.reset()
|
||||
|
||||
// Update track for total time bar if useChapterTrack is set
|
||||
this.$nextTick(() => {
|
||||
@@ -420,6 +443,8 @@ export default {
|
||||
},
|
||||
collapseFullscreen() {
|
||||
this.showFullscreen = false
|
||||
if (this.titleMarquee) this.titleMarquee.reset()
|
||||
|
||||
this.forceCloseDropdownMenu()
|
||||
},
|
||||
async jumpNextChapter() {
|
||||
@@ -493,7 +518,7 @@ export default {
|
||||
this.updateReadyTrack()
|
||||
},
|
||||
updateReadyTrack() {
|
||||
if (this.useChapterTrack) {
|
||||
if (this.playerSettings.useChapterTrack) {
|
||||
if (this.$refs.totalReadyTrack) {
|
||||
this.$refs.totalReadyTrack.style.width = this.readyTrackWidth + 'px'
|
||||
}
|
||||
@@ -510,11 +535,14 @@ export default {
|
||||
}
|
||||
|
||||
let currentTime = this.isDraggingCursor ? this.draggingCurrentTime : this.currentTime
|
||||
if (this.useChapterTrack && this.currentChapter) {
|
||||
if (this.playerSettings.useChapterTrack && this.currentChapter) {
|
||||
currentTime = Math.max(0, currentTime - this.currentChapter.start)
|
||||
}
|
||||
if (this.playerSettings.scaleElapsedTimeBySpeed) {
|
||||
currentTime = currentTime / this.currentPlaybackRate
|
||||
}
|
||||
|
||||
ts.innerText = this.$secondsToTimestamp(currentTime / this.currentPlaybackRate)
|
||||
ts.innerText = this.$secondsToTimestamp(currentTime)
|
||||
},
|
||||
timeupdate() {
|
||||
if (!this.$refs.playedTrack) {
|
||||
@@ -542,7 +570,7 @@ export default {
|
||||
let bufferedPercent = this.bufferedTime / this.totalDuration
|
||||
const totalBufferedPercent = bufferedPercent
|
||||
|
||||
if (this.useChapterTrack && this.currentChapter) {
|
||||
if (this.playerSettings.useChapterTrack && this.currentChapter) {
|
||||
const currChapTime = currentTimeToUse - this.currentChapter.start
|
||||
percentDone = currChapTime / this.currentChapterDuration
|
||||
bufferedPercent = Math.max(0, Math.min(1, (this.bufferedTime - this.currentChapter.start) / this.currentChapterDuration))
|
||||
@@ -556,7 +584,7 @@ export default {
|
||||
this.$refs.trackCursor.style.left = ptWidth - 14 + 'px'
|
||||
}
|
||||
|
||||
if (this.useChapterTrack) {
|
||||
if (this.playerSettings.useChapterTrack) {
|
||||
if (this.$refs.totalPlayedTrack) this.$refs.totalPlayedTrack.style.width = Math.round(totalPercentDone * this.trackWidth) + 'px'
|
||||
if (this.$refs.totalBufferedTrack) this.$refs.totalBufferedTrack.style.width = Math.round(totalBufferedPercent * this.trackWidth) + 'px'
|
||||
}
|
||||
@@ -583,7 +611,7 @@ export default {
|
||||
}
|
||||
},
|
||||
async touchstartCursor(e) {
|
||||
if (!e || !e.touches || !this.$refs.track || !this.showFullscreen || this.lockUi) return
|
||||
if (!e || !e.touches || !this.$refs.track || !this.showFullscreen || this.playerSettings.lockUi) return
|
||||
|
||||
await this.$hapticsImpact()
|
||||
this.isDraggingCursor = true
|
||||
@@ -624,38 +652,49 @@ export default {
|
||||
resetStream(startTime) {
|
||||
this.closePlayback()
|
||||
},
|
||||
handleGesture() {
|
||||
var touchDistance = this.touchEndY - this.touchStartY
|
||||
if (touchDistance > 100) {
|
||||
this.collapseFullscreen()
|
||||
}
|
||||
},
|
||||
touchstart(e) {
|
||||
if (!this.showFullscreen || !e.changedTouches) return
|
||||
if (!e.changedTouches || this.$store.state.globals.isModalOpen) return
|
||||
const touchPosY = e.changedTouches[0].pageY
|
||||
// when minimized only listen to touchstart on the player
|
||||
if (!this.showFullscreen && touchPosY < window.innerHeight - 120) return
|
||||
|
||||
this.touchStartY = e.changedTouches[0].screenY
|
||||
if (this.touchStartY > window.innerHeight / 3) {
|
||||
// console.log('touch too low')
|
||||
return
|
||||
// for ios
|
||||
if (!this.showFullscreen && e.pageX < 20) {
|
||||
e.preventDefault()
|
||||
e.stopImmediatePropagation()
|
||||
}
|
||||
|
||||
this.touchStartY = touchPosY
|
||||
this.touchStartTime = Date.now()
|
||||
},
|
||||
touchend(e) {
|
||||
if (!e.changedTouches) return
|
||||
const touchDuration = Date.now() - this.touchStartTime
|
||||
const touchEndY = e.changedTouches[0].pageY
|
||||
const touchDistanceY = touchEndY - this.touchStartY
|
||||
|
||||
// reset touch start data
|
||||
this.touchStartTime = 0
|
||||
this.touchStartY = 0
|
||||
|
||||
if (this.isDraggingCursor) {
|
||||
if (this.draggingCurrentTime !== this.currentTime) {
|
||||
this.seek(this.draggingCurrentTime)
|
||||
}
|
||||
this.isDraggingCursor = false
|
||||
} else if (this.showFullscreen) {
|
||||
this.touchEndY = e.changedTouches[0].screenY
|
||||
var touchDuration = Date.now() - this.touchStartTime
|
||||
} else {
|
||||
if (touchDuration > 1200) {
|
||||
// console.log('touch too long', touchDuration)
|
||||
return
|
||||
}
|
||||
this.handleGesture()
|
||||
if (this.showFullscreen) {
|
||||
// Touch start higher than touchend
|
||||
if (touchDistanceY > 100) {
|
||||
this.collapseFullscreen()
|
||||
}
|
||||
} else if (touchDistanceY < -100) {
|
||||
this.expandToFullscreen()
|
||||
}
|
||||
}
|
||||
},
|
||||
touchmove(e) {
|
||||
@@ -665,7 +704,7 @@ export default {
|
||||
let duration = this.totalDuration
|
||||
let minTime = 0
|
||||
let maxTime = duration
|
||||
if (this.useChapterTrack && this.currentChapter) {
|
||||
if (this.playerSettings.useChapterTrack && this.currentChapter) {
|
||||
duration = this.currentChapterDuration
|
||||
minTime = this.currentChapter.start
|
||||
maxTime = minTime + duration
|
||||
@@ -685,32 +724,42 @@ export default {
|
||||
if (action === 'history') {
|
||||
this.$router.push(`/media/${this.mediaId}/history?title=${this.title}`)
|
||||
this.showFullscreen = false
|
||||
} else if (action === 'scale_elapsed_time') {
|
||||
this.playerSettings.scaleElapsedTimeBySpeed = !this.playerSettings.scaleElapsedTimeBySpeed
|
||||
this.updateTimestamp()
|
||||
this.savePlayerSettings()
|
||||
} else if (action === 'lock') {
|
||||
this.lockUi = !this.lockUi
|
||||
this.$localStore.setPlayerLock(this.lockUi)
|
||||
this.playerSettings.lockUi = !this.playerSettings.lockUi
|
||||
this.savePlayerSettings()
|
||||
} else if (action === 'chapter_track') {
|
||||
this.useChapterTrack = !this.useChapterTrack
|
||||
this.useTotalTrack = !this.useChapterTrack || this.useTotalTrack
|
||||
this.playerSettings.useChapterTrack = !this.playerSettings.useChapterTrack
|
||||
this.playerSettings.useTotalTrack = !this.playerSettings.useChapterTrack || this.playerSettings.useTotalTrack
|
||||
|
||||
this.updateTimestamp()
|
||||
this.updateTrack()
|
||||
this.updateReadyTrack()
|
||||
this.$localStore.setUseTotalTrack(this.useTotalTrack)
|
||||
this.$localStore.setUseChapterTrack(this.useChapterTrack)
|
||||
this.updateUseChapterTrack()
|
||||
this.savePlayerSettings()
|
||||
} else if (action === 'total_track') {
|
||||
this.useTotalTrack = !this.useTotalTrack
|
||||
this.useChapterTrack = !this.useTotalTrack || this.useChapterTrack
|
||||
this.playerSettings.useTotalTrack = !this.playerSettings.useTotalTrack
|
||||
this.playerSettings.useChapterTrack = !this.playerSettings.useTotalTrack || this.playerSettings.useChapterTrack
|
||||
|
||||
this.updateTimestamp()
|
||||
this.updateTrack()
|
||||
this.updateReadyTrack()
|
||||
this.$localStore.setUseTotalTrack(this.useTotalTrack)
|
||||
this.$localStore.setUseChapterTrack(this.useChapterTrack)
|
||||
this.updateUseChapterTrack()
|
||||
this.savePlayerSettings()
|
||||
} else if (action === 'close') {
|
||||
this.closePlayback()
|
||||
}
|
||||
})
|
||||
},
|
||||
updateUseChapterTrack() {
|
||||
// Chapter track in NowPlaying only supported on iOS for now
|
||||
if (this.$platform === 'ios') {
|
||||
AbsAudioPlayer.setChapterTrack({ enabled: this.playerSettings.useChapterTrack })
|
||||
}
|
||||
},
|
||||
forceCloseDropdownMenu() {
|
||||
if (this.$refs.dropdownMenu && this.$refs.dropdownMenu.closeMenu) {
|
||||
this.$refs.dropdownMenu.closeMenu()
|
||||
@@ -727,6 +776,30 @@ export default {
|
||||
this.isLoading = false
|
||||
this.playbackSession = null
|
||||
},
|
||||
async loadPlayerSettings() {
|
||||
const savedPlayerSettings = await this.$localStore.getPlayerSettings()
|
||||
if (!savedPlayerSettings) {
|
||||
// In 0.9.72-beta 'useChapterTrack', 'useTotalTrack' and 'playerLock' was replaced with 'playerSettings' JSON object
|
||||
// Check if this old key was set and if so migrate them over to 'playerSettings'
|
||||
const chapterTrackPref = await this.$localStore.getPreferenceByKey('useChapterTrack')
|
||||
if (chapterTrackPref) {
|
||||
this.playerSettings.useChapterTrack = chapterTrackPref === '1'
|
||||
const totalTrackPref = await this.$localStore.getPreferenceByKey('useTotalTrack')
|
||||
this.playerSettings.useTotalTrack = totalTrackPref === '1'
|
||||
const playerLockPref = await this.$localStore.getPreferenceByKey('playerLock')
|
||||
this.playerSettings.lockUi = playerLockPref === '1'
|
||||
}
|
||||
this.savePlayerSettings()
|
||||
} else {
|
||||
this.playerSettings.useChapterTrack = !!savedPlayerSettings.useChapterTrack
|
||||
this.playerSettings.useTotalTrack = !!savedPlayerSettings.useTotalTrack
|
||||
this.playerSettings.lockUi = !!savedPlayerSettings.lockUi
|
||||
this.playerSettings.scaleElapsedTimeBySpeed = !!savedPlayerSettings.scaleElapsedTimeBySpeed
|
||||
}
|
||||
},
|
||||
savePlayerSettings() {
|
||||
return this.$localStore.setPlayerSettings({ ...this.playerSettings })
|
||||
},
|
||||
//
|
||||
// Listeners from audio AbsAudioPlayer
|
||||
//
|
||||
@@ -771,6 +844,10 @@ export default {
|
||||
|
||||
// Set track width
|
||||
this.$nextTick(() => {
|
||||
if (this.titleMarquee) this.titleMarquee.reset()
|
||||
this.titleMarquee = new WrappingMarquee(this.$refs.titlewrapper)
|
||||
this.titleMarquee.init(this.title)
|
||||
|
||||
if (this.$refs.track) {
|
||||
this.trackWidth = this.$refs.track.clientWidth
|
||||
} else {
|
||||
@@ -793,9 +870,7 @@ export default {
|
||||
this.updateTimestamp()
|
||||
},
|
||||
async init() {
|
||||
this.useChapterTrack = await this.$localStore.getUseChapterTrack()
|
||||
this.useTotalTrack = await this.$localStore.getUseTotalTrack()
|
||||
this.lockUi = await this.$localStore.getPlayerLock()
|
||||
await this.loadPlayerSettings()
|
||||
|
||||
this.onPlaybackSessionListener = AbsAudioPlayer.addListener('onPlaybackSession', this.onPlaybackSession)
|
||||
this.onPlaybackClosedListener = AbsAudioPlayer.addListener('onPlaybackClosed', this.onPlaybackClosed)
|
||||
@@ -806,26 +881,51 @@ export default {
|
||||
this.onProgressSyncSuccess = AbsAudioPlayer.addListener('onProgressSyncSuccess', this.showProgressSyncSuccess)
|
||||
this.onPlaybackSpeedChangedListener = AbsAudioPlayer.addListener('onPlaybackSpeedChanged', this.onPlaybackSpeedChanged)
|
||||
},
|
||||
screenOrientationChange() {
|
||||
setTimeout(() => {
|
||||
this.updateScreenSize()
|
||||
if (this.$refs.track) {
|
||||
this.trackWidth = this.$refs.track.clientWidth
|
||||
this.updateTrack()
|
||||
this.updateReadyTrack()
|
||||
async screenOrientationChange() {
|
||||
if (this.isRefreshingUI) return
|
||||
this.isRefreshingUI = true
|
||||
const windowWidth = window.innerWidth
|
||||
this.refreshUI()
|
||||
|
||||
// Window width does not always change right away. Wait up to 250ms for a change.
|
||||
// iPhone 10 on iOS 16 took between 100 - 200ms to update when going from portrait to landscape
|
||||
// but landscape to portrait was immediate
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
if (window.innerWidth !== windowWidth) {
|
||||
this.refreshUI()
|
||||
break
|
||||
}
|
||||
}, 50)
|
||||
}
|
||||
|
||||
this.isRefreshingUI = false
|
||||
},
|
||||
refreshUI() {
|
||||
this.updateScreenSize()
|
||||
if (this.$refs.track) {
|
||||
this.trackWidth = this.$refs.track.clientWidth
|
||||
this.updateTrack()
|
||||
this.updateReadyTrack()
|
||||
}
|
||||
},
|
||||
updateScreenSize() {
|
||||
setTimeout(() => {
|
||||
if (this.titleMarquee) this.titleMarquee.init(this.title)
|
||||
}, 500)
|
||||
|
||||
this.windowHeight = window.innerHeight
|
||||
this.windowWidth = window.innerWidth
|
||||
const coverHeight = this.fullscreenBookCoverWidth * this.bookCoverAspectRatio
|
||||
const coverImageWidthCollapsed = 46 / this.bookCoverAspectRatio
|
||||
const titleAuthorLeftOffsetCollapsed = 30 + coverImageWidthCollapsed
|
||||
const titleAuthorWidthCollapsed = this.windowWidth - 128 - titleAuthorLeftOffsetCollapsed - 10
|
||||
|
||||
document.documentElement.style.setProperty('--cover-image-width', this.fullscreenBookCoverWidth + 'px')
|
||||
document.documentElement.style.setProperty('--cover-image-height', coverHeight + 'px')
|
||||
document.documentElement.style.setProperty('--cover-image-width-collapsed', coverImageWidthCollapsed + 'px')
|
||||
document.documentElement.style.setProperty('--cover-image-height-collapsed', 46 + 'px')
|
||||
document.documentElement.style.setProperty('--title-author-left-offset-collapsed', 30 + coverImageWidthCollapsed + 'px')
|
||||
document.documentElement.style.setProperty('--title-author-left-offset-collapsed', titleAuthorLeftOffsetCollapsed + 'px')
|
||||
document.documentElement.style.setProperty('--title-author-width-collapsed', titleAuthorWidthCollapsed + 'px')
|
||||
},
|
||||
minimizePlayerEvt() {
|
||||
this.collapseFullscreen()
|
||||
@@ -848,7 +948,7 @@ export default {
|
||||
window.addEventListener('resize', this.screenOrientationChange)
|
||||
|
||||
this.$eventBus.$on('minimize-player', this.minimizePlayerEvt)
|
||||
document.body.addEventListener('touchstart', this.touchstart)
|
||||
document.body.addEventListener('touchstart', this.touchstart, { passive: false })
|
||||
document.body.addEventListener('touchend', this.touchend)
|
||||
document.body.addEventListener('touchmove', this.touchmove)
|
||||
this.$nextTick(this.init)
|
||||
@@ -893,6 +993,7 @@ export default {
|
||||
--cover-image-width-collapsed: 46px;
|
||||
--cover-image-height-collapsed: 46px;
|
||||
--title-author-left-offset-collapsed: 80px;
|
||||
--title-author-width-collapsed: 40%;
|
||||
}
|
||||
|
||||
.playerContainer {
|
||||
@@ -940,7 +1041,7 @@ export default {
|
||||
transition-property: left, bottom, width, height;
|
||||
transform-origin: left bottom;
|
||||
|
||||
width: 40%;
|
||||
width: var(--title-author-width-collapsed);
|
||||
bottom: 76px;
|
||||
left: var(--title-author-left-offset-collapsed);
|
||||
text-align: left;
|
||||
|
||||
@@ -79,7 +79,10 @@ export default {
|
||||
return this.$store.state.serverSettings || {}
|
||||
},
|
||||
username() {
|
||||
return this.user ? this.user.username : ''
|
||||
return this.user?.username || ''
|
||||
},
|
||||
userIsAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
navItems() {
|
||||
var items = [
|
||||
@@ -132,6 +135,12 @@ export default {
|
||||
})
|
||||
|
||||
if (this.serverConnectionConfig) {
|
||||
items.push({
|
||||
icon: 'language',
|
||||
text: this.$strings.ButtonGoToWebClient,
|
||||
action: 'openWebClient'
|
||||
})
|
||||
|
||||
items.push({
|
||||
icon: 'login',
|
||||
text: this.$strings.ButtonSwitchServerUser,
|
||||
@@ -151,6 +160,10 @@ export default {
|
||||
if (action === 'logout') {
|
||||
await this.logout()
|
||||
this.$router.push('/connect')
|
||||
} else if (action === 'openWebClient') {
|
||||
this.show = false
|
||||
let path = `/library/${this.$store.state.libraries.currentLibraryId}`
|
||||
await this.$store.dispatch('user/openWebClient', path)
|
||||
}
|
||||
},
|
||||
clickBackground() {
|
||||
|
||||
@@ -79,7 +79,7 @@ export default {
|
||||
return this.page
|
||||
},
|
||||
hasFilter() {
|
||||
if (this.page === 'series' || this.page === 'collections') return false
|
||||
if (this.page === 'series' || this.page === 'collections' || this.page === 'playlists') return false
|
||||
return this.filterBy !== 'all'
|
||||
},
|
||||
orderBy() {
|
||||
|
||||
@@ -7,9 +7,12 @@
|
||||
|
||||
<!-- Alternative bookshelf title/author/sort -->
|
||||
<div v-if="isAltViewEnabled" class="absolute left-0 z-50 w-full" :style="{ bottom: `-${titleDisplayBottomOffset}rem` }">
|
||||
<p class="truncate" :style="{ fontSize: 0.9 * sizeMultiplier + 'rem' }">
|
||||
{{ displayTitle }}
|
||||
</p>
|
||||
<div :style="{ fontSize: 0.9 * sizeMultiplier + 'rem' }" class="flex items-center">
|
||||
<p class="truncate" :style="{ fontSize: 0.9 * sizeMultiplier + 'rem' }">
|
||||
{{ displayTitle }}
|
||||
</p>
|
||||
<widgets-explicit-indicator v-if="isExplicit" />
|
||||
</div>
|
||||
<p class="truncate text-fg-muted" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displayLineTwo || ' ' }}</p>
|
||||
<p v-if="displaySortLine" class="truncate text-fg-muted" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
|
||||
</div>
|
||||
@@ -59,7 +62,7 @@
|
||||
<div v-if="!collapsedSeries && (!isPodcast || recentEpisode)" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div>
|
||||
|
||||
<!-- Downloaded icon -->
|
||||
<div v-if="showHasLocalDownload" class="absolute right-0 top-0 z-20" :style="{ top: (isPodcast ? 1.75 : 0.375) * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
||||
<div v-if="showHasLocalDownload" class="absolute right-0 top-0 z-20" :style="{ top: (isPodcast || (seriesSequence && showSequence) ? 1.75 : 0.375) * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
||||
<span class="material-icons text-2xl text-success">{{ isLocalOnly ? 'task' : 'download_done' }}</span>
|
||||
</div>
|
||||
|
||||
@@ -310,7 +313,7 @@ export default {
|
||||
return !!this.userProgress?.isFinished
|
||||
},
|
||||
showError() {
|
||||
return this.numMissingParts || this.isMissing || this.isInvalid
|
||||
return this.isMissing || this.isInvalid
|
||||
},
|
||||
localLibraryItemId() {
|
||||
if (this.isLocal) return this.libraryItemId
|
||||
@@ -341,9 +344,8 @@ export default {
|
||||
isInvalid() {
|
||||
return this._libraryItem.isInvalid
|
||||
},
|
||||
numMissingParts() {
|
||||
if (this.isPodcast) return 0
|
||||
return this.media.numMissingParts
|
||||
isExplicit() {
|
||||
return !!this.mediaMetadata.explicit
|
||||
},
|
||||
overlayWrapperClasslist() {
|
||||
var classes = []
|
||||
@@ -366,9 +368,6 @@ export default {
|
||||
userCanDownload() {
|
||||
return this.store.getters['user/getUserCanDownload']
|
||||
},
|
||||
userIsRoot() {
|
||||
return this.store.getters['user/getIsRoot']
|
||||
},
|
||||
titleFontSize() {
|
||||
return 0.75 * this.sizeMultiplier
|
||||
},
|
||||
@@ -493,7 +492,12 @@ export default {
|
||||
if (router) {
|
||||
if (this.recentEpisode) router.push(`/item/${this.libraryItemId}/${this.recentEpisode.id}`)
|
||||
else if (this.collapsedSeries) router.push(`/bookshelf/series/${this.collapsedSeries.id}`)
|
||||
else router.push(`/item/${this.libraryItemId}`)
|
||||
else if (this.localLibraryItem) {
|
||||
// Pass local library item id to server page to allow falling back to offline page
|
||||
router.push(`/item/${this.libraryItemId}?localLibraryItemId=${this.localLibraryItemId}`)
|
||||
} else {
|
||||
router.push(`/item/${this.libraryItemId}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<div v-if="!isPodcast && !collapsedSeries" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: coverWidth * userProgressPercent + 'px' }"></div>
|
||||
</div>
|
||||
<div class="flex-grow px-2">
|
||||
<p class="whitespace-normal" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">
|
||||
<p class="whitespace-normal line-clamp-2" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">
|
||||
<span v-if="seriesSequence">#{{ seriesSequence }} </span>{{ displayTitle }}
|
||||
</p>
|
||||
<p class="truncate text-fg-muted" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">{{ displayAuthor }}</p>
|
||||
@@ -215,7 +215,7 @@ export default {
|
||||
return !!this.userProgress?.isFinished
|
||||
},
|
||||
showError() {
|
||||
return this.numMissingParts || this.isMissing || this.isInvalid
|
||||
return this.isMissing || this.isInvalid
|
||||
},
|
||||
isStreaming() {
|
||||
return this.store.getters['getlibraryItemIdStreaming'] === this.libraryItemId
|
||||
@@ -235,10 +235,6 @@ export default {
|
||||
isInvalid() {
|
||||
return this._libraryItem.isInvalid
|
||||
},
|
||||
numMissingParts() {
|
||||
if (this.isPodcast) return 0
|
||||
return this.media.numMissingParts
|
||||
},
|
||||
store() {
|
||||
return this.$store || this.$nuxt.$store
|
||||
},
|
||||
|
||||
@@ -64,14 +64,14 @@ export default {
|
||||
return this.$route.query || {}
|
||||
},
|
||||
entityTitle() {
|
||||
if (this.isPodcast) return this.$strings.LabelPodcasts
|
||||
if (this.page === 'library') return this.$strings.LabelBooks
|
||||
else if (this.page === 'series') {
|
||||
if (this.page === 'library') {
|
||||
return this.isPodcast ? this.$strings.LabelPodcasts : this.$strings.LabelBooks
|
||||
} else if (this.page === 'playlists') {
|
||||
return this.$strings.ButtonPlaylists
|
||||
} else if (this.page === 'series') {
|
||||
return this.$strings.LabelSeries
|
||||
} else if (this.page === 'collections') {
|
||||
return this.$strings.ButtonCollections
|
||||
} else if (this.page === 'playlists') {
|
||||
return this.$strings.ButtonPlaylists
|
||||
} else if (this.page === 'authors') {
|
||||
return this.$strings.LabelAuthors
|
||||
}
|
||||
|
||||
@@ -1,27 +1,28 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" :width="200" height="100%">
|
||||
<template #outer>
|
||||
<div class="absolute top-8 left-4 z-40">
|
||||
<div class="absolute top-11 left-4 z-40">
|
||||
<p class="text-white text-2xl truncate capitalize">{{ $strings.LabelAutoRewindTime }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div
|
||||
class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center"
|
||||
@click="
|
||||
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="
|
||||
show = false
|
||||
manualTimerModal = false
|
||||
"
|
||||
>
|
||||
">
|
||||
<div class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
|
||||
<div v-if="manualTimerModal" class="p-4">
|
||||
<div class="flex mb-4" @click="manualTimerModal = false">
|
||||
<span class="material-icons text-3xl">arrow_back</span>
|
||||
</div>
|
||||
<div class="flex my-2 justify-between">
|
||||
<ui-btn @click="decreaseManualTimeout" class="w-9 h-9" :padding-x="0" small style="max-width: 36px"><span class="material-icons">remove</span></ui-btn>
|
||||
<ui-btn @click="decreaseManualTimeout" class="w-9 h-9" :padding-x="0" small style="max-width: 36px">
|
||||
<span class="material-icons">remove</span>
|
||||
</ui-btn>
|
||||
<p class="text-2xl font-mono text-center">{{ manualTimeoutMin }} min</p>
|
||||
<ui-btn @click="increaseManualTimeout" class="w-9 h-9" :padding-x="0" small style="max-width: 36px"><span class="material-icons">add</span></ui-btn>
|
||||
<ui-btn @click="increaseManualTimeout" class="w-9 h-9" :padding-x="0" small style="max-width: 36px">
|
||||
<span class="material-icons">add</span>
|
||||
</ui-btn>
|
||||
</div>
|
||||
<ui-btn @click="clickedOption(manualTimeoutMin)" class="w-full">{{ $strings.ButtonSetTimer }}</ui-btn>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" :width="400" height="100%">
|
||||
<template #outer>
|
||||
<div class="absolute top-8 left-4 z-40">
|
||||
<div class="absolute top-11 left-4 z-40">
|
||||
<p class="text-white text-2xl truncate">{{ $strings.LabelYourBookmarks }}</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -14,9 +14,7 @@
|
||||
</div>
|
||||
<p class="text-xl pl-2">{{ selectedBookmark ? 'Edit Bookmark' : 'New Bookmark' }}</p>
|
||||
<div class="flex-grow" />
|
||||
<p class="text-xl font-mono">
|
||||
{{ this.$secondsToTimestamp(currentTime) }}
|
||||
</p>
|
||||
<p class="text-xl font-mono">{{ this.$secondsToTimestamp(currentTime) }}</p>
|
||||
</div>
|
||||
|
||||
<ui-text-input-with-label v-model="newBookmarkTitle" label="Note" />
|
||||
@@ -34,9 +32,7 @@
|
||||
<div v-show="canCreateBookmark" class="flex px-4 py-2 items-center text-center justify-between border-b border-fg/10 bg-success cursor-pointer text-white text-opacity-80" @click.stop="createBookmark">
|
||||
<span class="material-icons">add</span>
|
||||
<p class="text-base pl-2">{{ $strings.ButtonCreateBookmark }}</p>
|
||||
<p class="text-sm font-mono">
|
||||
{{ this.$secondsToTimestamp(currentTime) }}
|
||||
</p>
|
||||
<p class="text-sm font-mono">{{ this.$secondsToTimestamp(currentTime) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -40,7 +40,7 @@ export default {
|
||||
default: '16px'
|
||||
},
|
||||
width: {
|
||||
type: Number,
|
||||
type: [String, Number],
|
||||
default: 300
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" width="90%" height="100%">
|
||||
<template #outer>
|
||||
<div v-show="selected !== 'all'" class="absolute top-10 left-4 z-40">
|
||||
<ui-btn class="text-lg border-yellow-400 border-opacity-40" @click="clearSelected">{{ $strings.ButtonClearFilter }}</ui-btn>
|
||||
<div v-show="selected !== 'all'" class="absolute top-12 left-4 z-40">
|
||||
<ui-btn class="text-lg border-yellow-400 border-opacity-40 h-10" :padding-y="0" @click="clearSelected">{{ $strings.ButtonClearFilter }}</ui-btn>
|
||||
</div>
|
||||
</template>
|
||||
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
|
||||
<div class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-fg/20" style="max-height: 75%" @click.stop>
|
||||
<div class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-fg/20 mt-8" style="max-height: 75%" @click.stop>
|
||||
<ul v-show="!sublist" class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||
<template v-for="item in items">
|
||||
<li :key="item.value" class="text-fg select-none relative py-4 pr-9 cursor-pointer" :class="item.value === selected ? 'bg-bg bg-opacity-50' : ''" role="option" @click="clickedOption(item)">
|
||||
@@ -210,7 +210,7 @@ export default {
|
||||
]
|
||||
},
|
||||
sublistItems() {
|
||||
return (this[this.sublist] || []).map((item) => {
|
||||
const sublistItems = (this[this.sublist] || []).map((item) => {
|
||||
if (typeof item === 'string') {
|
||||
return {
|
||||
text: item,
|
||||
@@ -223,6 +223,13 @@ export default {
|
||||
}
|
||||
}
|
||||
})
|
||||
if (this.sublist === 'series') {
|
||||
sublistItems.unshift({
|
||||
text: this.$strings.MessageNoSeries,
|
||||
value: this.$encode('no-series')
|
||||
})
|
||||
}
|
||||
return sublistItems
|
||||
},
|
||||
filterData() {
|
||||
return this.$store.state.libraries.filterData || {}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div ref="wrapper" class="modal modal-bg w-screen fixed bottom-0 left-0 flex items-center justify-center z-50" :class="halfScreen ? 'h-[50vh]' : 'h-screen'" @click.stop @touchstart.stop @touchend.stop>
|
||||
<div ref="wrapper" class="modal modal-bg w-screen fixed bottom-0 left-0 flex items-center justify-center z-50" :class="halfScreen ? 'h-[50vh] min-h-[400px]' : 'h-screen'" @click.stop @touchstart.stop @touchend.stop>
|
||||
<div ref="content" class="relative text-fg h-full w-full bg-bg">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" :width="400" height="100%">
|
||||
<template #outer>
|
||||
<div class="absolute top-8 left-4 z-40">
|
||||
<div class="absolute top-11 left-4 z-40">
|
||||
<p class="text-white text-2xl truncate">{{ $strings.HeaderDetails }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -26,6 +26,10 @@ export default {
|
||||
type: Object,
|
||||
default: () => null
|
||||
},
|
||||
playlist: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
},
|
||||
hideRssFeedOption: Boolean
|
||||
},
|
||||
data() {
|
||||
@@ -92,6 +96,15 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
// If on playlist page show remove from playlist option
|
||||
if (this.playlist) {
|
||||
items.push({
|
||||
text: this.$strings.LabelRemoveFromPlaylist,
|
||||
value: 'removeFromPlaylist',
|
||||
icon: 'playlist_remove'
|
||||
})
|
||||
}
|
||||
|
||||
if (this.showRSSFeedOption) {
|
||||
items.push({
|
||||
text: this.rssFeed ? this.$strings.HeaderRSSFeed : this.$strings.HeaderOpenRSSFeed,
|
||||
@@ -122,6 +135,14 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
if (this.isConnectedToServer) {
|
||||
items.push({
|
||||
text: this.$strings.ButtonGoToWebClient,
|
||||
value: 'openWebClient',
|
||||
icon: 'language'
|
||||
})
|
||||
}
|
||||
|
||||
if (!this.episode) {
|
||||
items.push({
|
||||
text: this.$strings.LabelMoreInfo,
|
||||
@@ -255,6 +276,8 @@ export default {
|
||||
} else if (action === 'playlist') {
|
||||
this.$store.commit('globals/setSelectedPlaylistItems', [{ libraryItem: this.libraryItem, episode: this.episode }])
|
||||
this.$store.commit('globals/setShowPlaylistsAddCreateModal', true)
|
||||
} else if (action === 'removeFromPlaylist') {
|
||||
this.removeFromPlaylistClick()
|
||||
} else if (action === 'markFinished') {
|
||||
if (this.episode) this.toggleEpisodeFinished()
|
||||
else this.toggleFinished()
|
||||
@@ -270,6 +293,8 @@ export default {
|
||||
this.clickRSSFeed()
|
||||
} else if (action === 'sendEbook') {
|
||||
this.showSendEbookDevicesModal = true
|
||||
} else if (action === 'openWebClient') {
|
||||
this.$store.dispatch('user/openWebClient', `/item/${this.serverLibraryItemId}`)
|
||||
}
|
||||
},
|
||||
async toggleFinished() {
|
||||
@@ -456,6 +481,29 @@ export default {
|
||||
.finally(() => {
|
||||
this.$emit('update:processing', false)
|
||||
})
|
||||
},
|
||||
removeFromPlaylistClick() {
|
||||
if (!this.playlist) {
|
||||
this.$toast.error('Invalid: No Playlist')
|
||||
return
|
||||
}
|
||||
|
||||
this.$emit('update:processing', true)
|
||||
let url = `/api/playlists/${this.playlist.id}/item/${this.serverLibraryItemId}`
|
||||
if (this.serverEpisodeId) url += `/${this.serverEpisodeId}`
|
||||
this.$nativeHttp
|
||||
.delete(url)
|
||||
.then(() => {
|
||||
this.$toast.success('Item removed from playlist')
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorMsg = error.response?.data || 'Unknown error'
|
||||
console.error('Failed to remove item from playlist', error)
|
||||
this.$toast.error('Failed to remove from playlist: ' + errorMsg)
|
||||
})
|
||||
.finally(() => {
|
||||
this.$emit('update:processing', false)
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" :width="300" :processing="processing" height="100%">
|
||||
<template #outer>
|
||||
<div class="absolute top-8 left-4 z-40" style="max-width: 80%">
|
||||
<div class="absolute top-11 left-4 z-40" style="max-width: 80%">
|
||||
<p class="text-white text-2xl truncate">{{ $strings.HeaderLibraries }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div ref="wrapper" class="modal modal-bg w-full h-full max-h-screen fixed top-0 left-0 bg-primary bg-opacity-75 flex items-center justify-center z-50 opacity-0">
|
||||
<div class="absolute top-0 left-0 w-full h-40 bg-gradient-to-b from-black to-transparent opacity-90 pointer-events-none" />
|
||||
|
||||
<div class="absolute z-40 top-10 right-4 h-10 w-10 flex items-center justify-center cursor-pointer text-white hover:text-gray-300" @click="show = false">
|
||||
<div class="absolute z-40 top-11 right-4 h-10 w-10 flex items-center justify-center cursor-pointer text-white hover:text-gray-300" @click="show = false">
|
||||
<span class="material-icons text-4xl">close</span>
|
||||
</div>
|
||||
<slot name="outer" />
|
||||
|
||||
@@ -71,7 +71,7 @@ export default {
|
||||
}
|
||||
},
|
||||
rates() {
|
||||
return [0.5, 1, 1.2, 1.5, 2, 3]
|
||||
return [0.5, 1, 1.2, 1.5, 1.7, 2, 3]
|
||||
},
|
||||
canIncrement() {
|
||||
return this.playbackRate + 0.1 <= this.MAX_SPEED
|
||||
|
||||
@@ -6,13 +6,7 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div
|
||||
class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center"
|
||||
@click="
|
||||
show = false
|
||||
manualTimerModal = false
|
||||
"
|
||||
>
|
||||
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
|
||||
<div class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-border" style="max-height: 75%" @click.stop>
|
||||
<div v-if="manualTimerModal" class="p-4">
|
||||
<div class="flex mb-4" @click="manualTimerModal = false">
|
||||
@@ -71,7 +65,7 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
manualTimerModal: null,
|
||||
manualTimerModal: true,
|
||||
manualTimeoutMin: 1
|
||||
}
|
||||
},
|
||||
@@ -81,6 +75,9 @@ export default {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
if (!val) {
|
||||
this.manualTimerModal = false
|
||||
}
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -349,7 +349,7 @@ export default {
|
||||
}
|
||||
},
|
||||
parseFilenames(filenames) {
|
||||
const acceptableImages = ['.jpeg', '.jpg', '.png']
|
||||
const acceptableImages = ['.jpeg', '.jpg', '.png', '.webp']
|
||||
var imageFiles = filenames.filter((f) => {
|
||||
return acceptableImages.includes((Path.extname(f) || '').toLowerCase())
|
||||
})
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
<!-- ereader settings modal -->
|
||||
<modals-fullscreen-modal v-model="showSettingsModal" :theme="ereaderTheme" half-screen>
|
||||
<div style="box-shadow: 0px -8px 8px #11111155">
|
||||
<div class="flex items-end justify-between h-20 px-4 pb-2 mb-8">
|
||||
<div class="flex items-end justify-between h-20 px-4 pb-2 mb-6">
|
||||
<h1 class="text-lg">{{ $strings.HeaderEreaderSettings }}</h1>
|
||||
<button class="flex" @click="showSettingsModal = false">
|
||||
<span class="material-icons">close</span>
|
||||
@@ -64,24 +64,30 @@
|
||||
</div>
|
||||
<div class="w-full overflow-y-auto overflow-x-hidden h-full max-h-[calc(100vh-85px)]">
|
||||
<div class="w-full h-full px-4">
|
||||
<div class="flex items-center mb-8">
|
||||
<div class="flex items-center mb-6">
|
||||
<div class="w-32">
|
||||
<p class="text-base">{{ $strings.LabelTheme }}:</p>
|
||||
</div>
|
||||
<ui-toggle-btns v-model="ereaderSettings.theme" :items="themeItems" @input="settingsUpdated" />
|
||||
</div>
|
||||
<div class="flex items-center mb-8">
|
||||
<div class="flex items-center mb-6">
|
||||
<div class="w-32">
|
||||
<p class="text-base">{{ $strings.LabelFontScale }}:</p>
|
||||
</div>
|
||||
<ui-range-input v-model="ereaderSettings.fontScale" :min="5" :max="300" :step="5" input-width="180px" @input="settingsUpdated" />
|
||||
</div>
|
||||
<div class="flex items-center mb-8">
|
||||
<div class="flex items-center mb-6">
|
||||
<div class="w-32">
|
||||
<p class="text-base">{{ $strings.LabelLineSpacing }}:</p>
|
||||
</div>
|
||||
<ui-range-input v-model="ereaderSettings.lineSpacing" :min="100" :max="300" :step="5" input-width="180px" @input="settingsUpdated" />
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="w-32">
|
||||
<p class="text-base">{{ $strings.LabelLayout }}:</p>
|
||||
</div>
|
||||
<ui-toggle-btns v-model="ereaderSettings.spread" :items="spreadItems" @input="settingsUpdated" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -109,7 +115,8 @@ export default {
|
||||
ereaderSettings: {
|
||||
theme: 'dark',
|
||||
fontScale: 100,
|
||||
lineSpacing: 115
|
||||
lineSpacing: 115,
|
||||
spread: 'auto'
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -154,6 +161,18 @@ export default {
|
||||
if (this.isEpub) return this.ereaderSettings.theme
|
||||
return document.documentElement.dataset.theme || 'dark'
|
||||
},
|
||||
spreadItems() {
|
||||
return [
|
||||
{
|
||||
text: this.$strings.LabelLayoutSinglePage,
|
||||
value: 'none'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelLayoutAuto,
|
||||
value: 'auto'
|
||||
}
|
||||
]
|
||||
},
|
||||
themeItems() {
|
||||
return [
|
||||
{
|
||||
@@ -350,7 +369,12 @@ export default {
|
||||
try {
|
||||
const settings = localStorage.getItem('ereaderSettings')
|
||||
if (settings) {
|
||||
this.ereaderSettings = JSON.parse(settings)
|
||||
const ereaderSettings = JSON.parse(settings)
|
||||
if (!ereaderSettings.spread) {
|
||||
// Added in 0.9.71
|
||||
ereaderSettings.spread = 'auto'
|
||||
}
|
||||
this.ereaderSettings = ereaderSettings
|
||||
this.settingsUpdated()
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="processing" class="max-w-[800px] h-80 md:h-[800px] mx-auto flex items-center justify-center">
|
||||
<widgets-loading-spinner />
|
||||
</div>
|
||||
<img v-else-if="dataUrl" :src="dataUrl" class="mx-auto" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { FileSharer } from '@byteowls/capacitor-filesharer'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
variant: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
year: Number,
|
||||
processing: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
canvas: null,
|
||||
dataUrl: null,
|
||||
yearStats: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
variant() {
|
||||
this.init()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async initCanvas() {
|
||||
if (!this.yearStats) return
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = 800
|
||||
canvas.height = 800
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
const createRoundedRect = (x, y, w, h) => {
|
||||
const grd1 = ctx.createLinearGradient(x, y, x + w, y + h)
|
||||
grd1.addColorStop(0, '#44444455')
|
||||
grd1.addColorStop(1, '#ffffff11')
|
||||
ctx.fillStyle = grd1
|
||||
ctx.strokeStyle = '#C0C0C088'
|
||||
ctx.beginPath()
|
||||
ctx.roundRect(x, y, w, h, [20])
|
||||
ctx.fill()
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
const addText = (text, fontSize, fontWeight, color, letterSpacing, x, y, maxWidth = 0) => {
|
||||
ctx.fillStyle = color
|
||||
ctx.font = `${fontWeight} ${fontSize} Source Sans Pro`
|
||||
ctx.letterSpacing = letterSpacing
|
||||
|
||||
// If maxWidth is specified then continue to remove chars until under maxWidth and add ellipsis
|
||||
if (maxWidth) {
|
||||
let txtWidth = ctx.measureText(text).width
|
||||
while (txtWidth > maxWidth) {
|
||||
console.warn(`Text "${text}" is greater than max width ${maxWidth} (width:${txtWidth})`)
|
||||
if (text.endsWith('...')) text = text.slice(0, -4) // Repeated checks remove 1 char at a time
|
||||
else text = text.slice(0, -3) // First check remove last 3 chars
|
||||
text += '...'
|
||||
txtWidth = ctx.measureText(text).width
|
||||
console.log(`Checking text "${text}" (width:${txtWidth})`)
|
||||
}
|
||||
}
|
||||
|
||||
ctx.fillText(text, x, y)
|
||||
}
|
||||
|
||||
const addIcon = (icon, color, fontSize, x, y) => {
|
||||
ctx.fillStyle = color
|
||||
ctx.font = `${fontSize} Material Icons Outlined`
|
||||
ctx.fillText(icon, x, y)
|
||||
}
|
||||
|
||||
// Bg color
|
||||
ctx.fillStyle = '#232323'
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
// Cover image tiles
|
||||
const bookCovers = this.yearStats.finishedBooksWithCovers
|
||||
bookCovers.push(...this.yearStats.booksWithCovers)
|
||||
|
||||
let finishedBookCoverImgs = {}
|
||||
|
||||
if (bookCovers.length) {
|
||||
let index = 0
|
||||
ctx.globalAlpha = 0.25
|
||||
ctx.save()
|
||||
ctx.translate(canvas.width / 2, canvas.height / 2)
|
||||
ctx.rotate((-Math.PI / 180) * 25)
|
||||
ctx.translate(-canvas.width / 2, -canvas.height / 2)
|
||||
ctx.translate(-130, -120)
|
||||
for (let x = 0; x < 5; x++) {
|
||||
for (let y = 0; y < 5; y++) {
|
||||
const coverIndex = index % bookCovers.length
|
||||
let libraryItemId = bookCovers[coverIndex]
|
||||
index++
|
||||
|
||||
await new Promise((resolve) => {
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
img.addEventListener('load', () => {
|
||||
let sw = img.width
|
||||
if (img.width > img.height) {
|
||||
sw = img.height
|
||||
}
|
||||
let sx = -(sw - img.width) / 2
|
||||
let sy = -(sw - img.height) / 2
|
||||
ctx.drawImage(img, sx, sy, sw, sw, 215 * x, 215 * y, 215, 215)
|
||||
resolve()
|
||||
if (this.yearStats.finishedBooksWithCovers.includes(libraryItemId) && !finishedBookCoverImgs[libraryItemId]) {
|
||||
finishedBookCoverImgs[libraryItemId] = {
|
||||
img,
|
||||
sx,
|
||||
sy,
|
||||
sw
|
||||
}
|
||||
}
|
||||
})
|
||||
img.addEventListener('error', () => {
|
||||
resolve()
|
||||
})
|
||||
img.src = this.$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId)
|
||||
})
|
||||
}
|
||||
}
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1
|
||||
ctx.textBaseline = 'middle'
|
||||
|
||||
// Create gradient
|
||||
const grd1 = ctx.createLinearGradient(0, 0, canvas.width, canvas.height)
|
||||
grd1.addColorStop(0, '#000000aa')
|
||||
grd1.addColorStop(1, '#cd9d49aa')
|
||||
ctx.fillStyle = grd1
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
// Top Abs icon
|
||||
let tanColor = '#ffdb70'
|
||||
ctx.fillStyle = tanColor
|
||||
ctx.font = '42px absicons'
|
||||
ctx.fillText('\ue900', 15, 36)
|
||||
|
||||
// Top text
|
||||
addText('audiobookshelf', '28px', 'normal', tanColor, '0px', 65, 28)
|
||||
addText(`${this.year} YEAR IN REVIEW`, '18px', 'bold', 'white', '1px', 65, 51)
|
||||
|
||||
// Top left box
|
||||
createRoundedRect(50, 100, 340, 160)
|
||||
addText(this.yearStats.numBooksFinished, '64px', 'bold', 'white', '0px', 160, 165)
|
||||
addText('books finished', '28px', 'normal', tanColor, '0px', 160, 210)
|
||||
const readIconPath = new Path2D()
|
||||
readIconPath.addPath(new Path2D('M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-9 15l-5-5 1.41-1.41L10 13.17l7.59-7.59L19 7l-9 9z'), { a: 2, d: 2, e: 100, f: 160 })
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.fill(readIconPath)
|
||||
|
||||
// Box top right
|
||||
createRoundedRect(410, 100, 340, 160)
|
||||
addText(this.$elapsedPrettyExtended(this.yearStats.totalListeningTime, true, false), '40px', 'bold', 'white', '0px', 500, 165)
|
||||
addText('spent listening', '28px', 'normal', tanColor, '0px', 500, 205)
|
||||
addIcon('watch_later', 'white', '52px', 440, 180)
|
||||
|
||||
// Box bottom left
|
||||
createRoundedRect(50, 280, 340, 160)
|
||||
addText(this.yearStats.totalListeningSessions, '64px', 'bold', 'white', '0px', 160, 345)
|
||||
addText('sessions', '28px', 'normal', tanColor, '1px', 160, 390)
|
||||
addIcon('headphones', 'white', '52px', 95, 360)
|
||||
|
||||
// Box bottom right
|
||||
createRoundedRect(410, 280, 340, 160)
|
||||
addText(this.yearStats.numBooksListened, '64px', 'bold', 'white', '0px', 500, 345)
|
||||
addText('books listened to', '28px', 'normal', tanColor, '0px', 500, 390)
|
||||
addIcon('local_library', 'white', '52px', 440, 360)
|
||||
|
||||
if (!this.variant) {
|
||||
// Text stats
|
||||
const topNarrator = this.yearStats.mostListenedNarrator
|
||||
if (topNarrator) {
|
||||
addText('TOP NARRATOR', '24px', 'normal', tanColor, '1px', 70, 520)
|
||||
addText(topNarrator.name, '36px', 'bolder', 'white', '0px', 70, 564, 330)
|
||||
addText(this.$elapsedPrettyExtended(topNarrator.time, true, false), '24px', 'lighter', 'white', '1px', 70, 599)
|
||||
}
|
||||
|
||||
const topGenre = this.yearStats.topGenres[0]
|
||||
if (topGenre) {
|
||||
addText('TOP GENRE', '24px', 'normal', tanColor, '1px', 430, 520)
|
||||
addText(topGenre.genre, '36px', 'bolder', 'white', '0px', 430, 564, 330)
|
||||
addText(this.$elapsedPrettyExtended(topGenre.time, true, false), '24px', 'lighter', 'white', '1px', 430, 599)
|
||||
}
|
||||
|
||||
const topAuthor = this.yearStats.topAuthors[0]
|
||||
if (topAuthor) {
|
||||
addText('TOP AUTHOR', '24px', 'normal', tanColor, '1px', 70, 670)
|
||||
addText(topAuthor.name, '36px', 'bolder', 'white', '0px', 70, 714, 330)
|
||||
addText(this.$elapsedPrettyExtended(topAuthor.time, true, false), '24px', 'lighter', 'white', '1px', 70, 749)
|
||||
}
|
||||
|
||||
if (this.yearStats.mostListenedMonth?.time) {
|
||||
const jsdate = new Date(this.year, this.yearStats.mostListenedMonth.month, 1)
|
||||
const monthName = this.$formatJsDate(jsdate, 'LLLL')
|
||||
addText('TOP MONTH', '24px', 'normal', tanColor, '1px', 430, 670)
|
||||
addText(monthName, '36px', 'bolder', 'white', '0px', 430, 714, 330)
|
||||
addText(this.$elapsedPrettyExtended(this.yearStats.mostListenedMonth.time, true, false), '24px', 'lighter', 'white', '1px', 430, 749)
|
||||
}
|
||||
} else if (this.variant === 1) {
|
||||
// Bottom images
|
||||
finishedBookCoverImgs = Object.values(finishedBookCoverImgs)
|
||||
if (finishedBookCoverImgs.length > 0) {
|
||||
ctx.textAlign = 'center'
|
||||
addText('Some books finished this year...', '28px', 'normal', tanColor, '0px', canvas.width / 2, 530)
|
||||
|
||||
for (let i = 0; i < Math.min(5, finishedBookCoverImgs.length); i++) {
|
||||
let imgToAdd = finishedBookCoverImgs[i]
|
||||
ctx.drawImage(imgToAdd.img, imgToAdd.sx, imgToAdd.sy, imgToAdd.sw, imgToAdd.sw, 40 + 145 * i, 570, 140, 140)
|
||||
}
|
||||
}
|
||||
} else if (this.variant === 2) {
|
||||
// Text stats
|
||||
if (this.yearStats.topAuthors.length) {
|
||||
addText('TOP AUTHORS', '24px', 'normal', tanColor, '1px', 70, 524)
|
||||
for (let i = 0; i < this.yearStats.topAuthors.length; i++) {
|
||||
addText(this.yearStats.topAuthors[i].name, '36px', 'bolder', 'white', '0px', 70, 584 + i * 60, 330)
|
||||
}
|
||||
}
|
||||
|
||||
if (this.yearStats.topGenres.length) {
|
||||
addText('TOP GENRES', '24px', 'normal', tanColor, '1px', 430, 524)
|
||||
for (let i = 0; i < this.yearStats.topGenres.length; i++) {
|
||||
addText(this.yearStats.topGenres[i].genre, '36px', 'bolder', 'white', '0px', 430, 584 + i * 60, 330)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.canvas = canvas
|
||||
this.dataUrl = canvas.toDataURL('png')
|
||||
},
|
||||
refresh() {
|
||||
this.init()
|
||||
},
|
||||
share() {
|
||||
const base64Data = this.dataUrl.split(';base64,').pop()
|
||||
FileSharer.share({
|
||||
filename: `audiobookshelf_my_${this.year}.png`,
|
||||
contentType: 'image/png',
|
||||
base64Data
|
||||
}).catch((error) => {
|
||||
if (error.message !== 'USER_CANCELLED') {
|
||||
console.error('Failed to share', error.message)
|
||||
this.$toast.error('Failed to share: ' + error.message)
|
||||
}
|
||||
})
|
||||
},
|
||||
init() {
|
||||
this.$emit('update:processing', true)
|
||||
this.$nativeHttp
|
||||
.get(`/api/me/stats/year/${this.year}`)
|
||||
.then((data) => {
|
||||
this.yearStats = data || []
|
||||
return this.initCanvas()
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
this.$toast.error('Failed to load year stats')
|
||||
})
|
||||
.finally(() => {
|
||||
this.$emit('update:processing', false)
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-2 sm:p-4 mb-4">
|
||||
<!-- hack to get icon fonts loaded on init -->
|
||||
<div class="h-0 w-0 overflow-hidden opacity-0">
|
||||
<span class="material-icons-outlined">close</span>
|
||||
<span class="abs-icons icon-audiobookshelf" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<p class="hidden md:block text-xl font-semibold">{{ yearInReviewYear }} Year in Review</p>
|
||||
<div class="hidden md:block flex-grow" />
|
||||
<ui-btn class="w-full md:w-auto" @click.stop="clickShowYearInReview">{{ showYearInReview ? 'Hide Year in Review' : `See ${yearInReviewYear} Year in Review` }}</ui-btn>
|
||||
</div>
|
||||
|
||||
<!-- your year in review -->
|
||||
<div v-if="showYearInReview">
|
||||
<div class="w-full h-px bg-slate-200/10 my-4" />
|
||||
|
||||
<div class="flex items-center justify-center mb-2 max-w-[800px] mx-auto">
|
||||
<!-- previous button -->
|
||||
<ui-btn small :disabled="!yearInReviewVariant || processingYearInReview" class="inline-flex items-center font-semibold" @click="yearInReviewVariant--">
|
||||
<span class="material-icons text-lg sm:pr-1 py-px sm:py-0">chevron_left</span>
|
||||
<span class="hidden sm:inline-block pr-2">Previous</span>
|
||||
</ui-btn>
|
||||
<!-- share button -->
|
||||
<ui-btn small :disabled="processingYearInReview" class="inline-flex sm:hidden items-center font-semibold ml-1 sm:ml-2" @click="shareYearInReview"> Share </ui-btn>
|
||||
|
||||
<div class="flex-grow" />
|
||||
<p class="hidden sm:block text-lg font-semibold">Your Year in Review ({{ yearInReviewVariant + 1 }})</p>
|
||||
<p class="block sm:hidden text-lg font-semibold">{{ yearInReviewVariant + 1 }}</p>
|
||||
<div class="flex-grow" />
|
||||
|
||||
<!-- refresh button -->
|
||||
<ui-btn small :disabled="processingYearInReview" class="inline-flex items-center font-semibold mr-1 sm:mr-2" @click="refreshYearInReview">
|
||||
<span class="hidden sm:inline-block">Refresh</span>
|
||||
<span class="material-icons sm:!hidden text-lg py-px">refresh</span>
|
||||
</ui-btn>
|
||||
<!-- next button -->
|
||||
<ui-btn small :disabled="yearInReviewVariant >= 2 || processingYearInReview" class="inline-flex items-center font-semibold" @click="yearInReviewVariant++">
|
||||
<span class="hidden sm:inline-block pl-2">Next</span>
|
||||
<span class="material-icons-outlined text-lg sm:pl-1 py-px sm:py-0">chevron_right</span>
|
||||
</ui-btn>
|
||||
</div>
|
||||
<stats-year-in-review ref="yearInReview" :variant="yearInReviewVariant" :year="yearInReviewYear" :processing.sync="processingYearInReview" />
|
||||
|
||||
<!-- your year in review short -->
|
||||
<div class="w-full max-w-[800px] mx-auto my-4">
|
||||
<!-- share button -->
|
||||
<ui-btn small :disabled="processingYearInReviewShort" class="inline-flex sm:hidden items-center font-semibold mb-1" @click="shareYearInReviewShort"> Share </ui-btn>
|
||||
<stats-year-in-review-short ref="yearInReviewShort" :year="yearInReviewYear" :processing.sync="processingYearInReviewShort" />
|
||||
</div>
|
||||
|
||||
<!-- your server in review -->
|
||||
<div v-if="isAdminOrUp" class="w-full max-w-[800px] mx-auto mb-2 mt-4 border-t pt-4 border-white/10">
|
||||
<div class="flex items-center justify-center mb-2">
|
||||
<!-- previous button -->
|
||||
<ui-btn small :disabled="!yearInReviewServerVariant || processingYearInReviewServer" class="inline-flex items-center font-semibold" @click="yearInReviewServerVariant--">
|
||||
<span class="material-icons text-lg sm:pr-1 py-px sm:py-0">chevron_left</span>
|
||||
<span class="hidden sm:inline-block pr-2">Previous</span>
|
||||
</ui-btn>
|
||||
<!-- share button -->
|
||||
<ui-btn small :disabled="processingYearInReviewServer" class="inline-flex sm:hidden items-center font-semibold ml-1 sm:ml-2" @click="shareYearInReviewServer"> Share </ui-btn>
|
||||
|
||||
<div class="flex-grow" />
|
||||
<p class="hidden sm:block text-lg font-semibold">Server Year in Review ({{ yearInReviewServerVariant + 1 }})</p>
|
||||
<p class="block sm:hidden text-lg font-semibold">{{ yearInReviewServerVariant + 1 }}</p>
|
||||
<div class="flex-grow" />
|
||||
|
||||
<!-- refresh button -->
|
||||
<ui-btn small :disabled="processingYearInReviewServer" class="inline-flex items-center font-semibold mr-1 sm:mr-2" @click="refreshYearInReviewServer">
|
||||
<span class="hidden sm:inline-block">Refresh</span>
|
||||
<span class="material-icons sm:!hidden text-lg py-px">refresh</span>
|
||||
</ui-btn>
|
||||
<!-- next button -->
|
||||
<ui-btn small :disabled="yearInReviewServerVariant >= 2 || processingYearInReviewServer" class="inline-flex items-center font-semibold" @click="yearInReviewServerVariant++">
|
||||
<span class="hidden sm:inline-block pl-2">Next</span>
|
||||
<span class="material-icons-outlined text-lg sm:pl-1 py-px sm:py-0">chevron_right</span>
|
||||
</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
<stats-year-in-review-server v-if="isAdminOrUp" ref="yearInReviewServer" :year="yearInReviewYear" :variant="yearInReviewServerVariant" :processing.sync="processingYearInReviewServer" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
showYearInReview: false,
|
||||
yearInReviewYear: 0,
|
||||
yearInReviewVariant: 0,
|
||||
yearInReviewServerVariant: 0,
|
||||
processingYearInReview: false,
|
||||
processingYearInReviewShort: false,
|
||||
processingYearInReviewServer: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
shareYearInReviewServer() {
|
||||
this.$refs.yearInReviewServer.share()
|
||||
},
|
||||
shareYearInReview() {
|
||||
this.$refs.yearInReview.share()
|
||||
},
|
||||
shareYearInReviewShort() {
|
||||
this.$refs.yearInReviewShort.share()
|
||||
},
|
||||
refreshYearInReviewServer() {
|
||||
this.$refs.yearInReviewServer.refresh()
|
||||
},
|
||||
refreshYearInReview() {
|
||||
this.$refs.yearInReview.refresh()
|
||||
this.$refs.yearInReviewShort.refresh()
|
||||
},
|
||||
clickShowYearInReview() {
|
||||
this.showYearInReview = !this.showYearInReview
|
||||
}
|
||||
},
|
||||
beforeMount() {
|
||||
this.yearInReviewYear = new Date().getFullYear()
|
||||
// When not December show previous year
|
||||
if (new Date().getMonth() < 11) {
|
||||
this.yearInReviewYear--
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,262 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="processing" class="max-w-[800px] h-80 md:h-[800px] mx-auto flex items-center justify-center">
|
||||
<widgets-loading-spinner />
|
||||
</div>
|
||||
<img v-else-if="dataUrl" :src="dataUrl" class="mx-auto" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { FileSharer } from '@byteowls/capacitor-filesharer'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
variant: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
processing: Boolean,
|
||||
year: Number
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
canvas: null,
|
||||
dataUrl: null,
|
||||
yearStats: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
variant() {
|
||||
this.init()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async initCanvas() {
|
||||
if (!this.yearStats) return
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = 800
|
||||
canvas.height = 800
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
const createRoundedRect = (x, y, w, h) => {
|
||||
const grd1 = ctx.createLinearGradient(x, y, x + w, y + h)
|
||||
grd1.addColorStop(0, '#44444455')
|
||||
grd1.addColorStop(1, '#ffffff11')
|
||||
ctx.fillStyle = grd1
|
||||
ctx.strokeStyle = '#C0C0C088'
|
||||
ctx.beginPath()
|
||||
ctx.roundRect(x, y, w, h, [20])
|
||||
ctx.fill()
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
const addText = (text, fontSize, fontWeight, color, letterSpacing, x, y, maxWidth = 0) => {
|
||||
ctx.fillStyle = color
|
||||
ctx.font = `${fontWeight} ${fontSize} Source Sans Pro`
|
||||
ctx.letterSpacing = letterSpacing
|
||||
|
||||
// If maxWidth is specified then continue to remove chars until under maxWidth and add ellipsis
|
||||
if (maxWidth) {
|
||||
let txtWidth = ctx.measureText(text).width
|
||||
while (txtWidth > maxWidth) {
|
||||
console.warn(`Text "${text}" is greater than max width ${maxWidth} (width:${txtWidth})`)
|
||||
if (text.endsWith('...')) text = text.slice(0, -4) // Repeated checks remove 1 char at a time
|
||||
else text = text.slice(0, -3) // First check remove last 3 chars
|
||||
text += '...'
|
||||
txtWidth = ctx.measureText(text).width
|
||||
console.log(`Checking text "${text}" (width:${txtWidth})`)
|
||||
}
|
||||
}
|
||||
|
||||
ctx.fillText(text, x, y)
|
||||
}
|
||||
|
||||
// Bg color
|
||||
ctx.fillStyle = '#232323'
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
// Cover image tiles
|
||||
let imgsToAdd = {}
|
||||
|
||||
if (this.yearStats.booksAddedWithCovers.length) {
|
||||
let index = 0
|
||||
ctx.globalAlpha = 0.25
|
||||
ctx.save()
|
||||
ctx.translate(canvas.width / 2, canvas.height / 2)
|
||||
ctx.rotate((-Math.PI / 180) * 25)
|
||||
ctx.translate(-canvas.width / 2, -canvas.height / 2)
|
||||
ctx.translate(-130, -120)
|
||||
for (let x = 0; x < 5; x++) {
|
||||
for (let y = 0; y < 5; y++) {
|
||||
const coverIndex = index % this.yearStats.booksAddedWithCovers.length
|
||||
let libraryItemId = this.yearStats.booksAddedWithCovers[coverIndex]
|
||||
index++
|
||||
|
||||
await new Promise((resolve) => {
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
img.addEventListener('load', () => {
|
||||
let sw = img.width
|
||||
if (img.width > img.height) {
|
||||
sw = img.height
|
||||
}
|
||||
let sx = -(sw - img.width) / 2
|
||||
let sy = -(sw - img.height) / 2
|
||||
ctx.drawImage(img, sx, sy, sw, sw, 215 * x, 215 * y, 215, 215)
|
||||
if (!imgsToAdd[libraryItemId]) {
|
||||
imgsToAdd[libraryItemId] = {
|
||||
img,
|
||||
sx,
|
||||
sy,
|
||||
sw
|
||||
}
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
img.addEventListener('error', () => {
|
||||
resolve()
|
||||
})
|
||||
img.src = this.$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId)
|
||||
})
|
||||
}
|
||||
}
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1
|
||||
ctx.textBaseline = 'middle'
|
||||
|
||||
// Create gradient
|
||||
const grd1 = ctx.createLinearGradient(0, 0, canvas.width, canvas.height)
|
||||
grd1.addColorStop(0, '#000000aa')
|
||||
grd1.addColorStop(1, '#cd9d49aa')
|
||||
ctx.fillStyle = grd1
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
// Top Abs icon
|
||||
let tanColor = '#ffdb70'
|
||||
ctx.fillStyle = tanColor
|
||||
ctx.font = '42px absicons'
|
||||
ctx.fillText('\ue900', 15, 36)
|
||||
|
||||
// Top text
|
||||
addText('audiobookshelf', '28px', 'normal', tanColor, '0px', 65, 28)
|
||||
addText(`${this.year} YEAR IN REVIEW`, '18px', 'bold', 'white', '1px', 65, 51)
|
||||
|
||||
// Top left box
|
||||
createRoundedRect(40, 100, 230, 100)
|
||||
ctx.textAlign = 'center'
|
||||
addText(this.yearStats.numBooksAdded, '48px', 'bold', 'white', '0px', 155, 140)
|
||||
addText('books added', '18px', 'normal', tanColor, '0px', 155, 170)
|
||||
|
||||
// Box top right
|
||||
createRoundedRect(285, 100, 230, 100)
|
||||
addText(this.yearStats.numAuthorsAdded, '48px', 'bold', 'white', '0px', 400, 140)
|
||||
addText('authors added', '18px', 'normal', tanColor, '0px', 400, 170)
|
||||
|
||||
// Box bottom left
|
||||
createRoundedRect(530, 100, 230, 100)
|
||||
addText(this.yearStats.numListeningSessions, '48px', 'bold', 'white', '0px', 645, 140)
|
||||
addText('sessions', '18px', 'normal', tanColor, '1px', 645, 170)
|
||||
|
||||
// Text stats
|
||||
if (this.yearStats.totalBooksAddedSize) {
|
||||
addText('Your book collection grew to...', '24px', 'normal', tanColor, '0px', canvas.width / 2, 260)
|
||||
addText(this.$bytesPretty(this.yearStats.totalBooksSize), '36px', 'bolder', 'white', '0px', canvas.width / 2, 300)
|
||||
addText('+' + this.$bytesPretty(this.yearStats.totalBooksAddedSize), '20px', 'lighter', 'white', '0px', canvas.width / 2, 330)
|
||||
}
|
||||
|
||||
if (this.yearStats.totalBooksAddedDuration) {
|
||||
addText('With a total duration of...', '24px', 'normal', tanColor, '0px', canvas.width / 2, 400)
|
||||
addText(this.$elapsedPrettyExtended(this.yearStats.totalBooksDuration, true, false), '36px', 'bolder', 'white', '0px', canvas.width / 2, 440)
|
||||
addText('+' + this.$elapsedPrettyExtended(this.yearStats.totalBooksAddedDuration, true, false), '20px', 'lighter', 'white', '0px', canvas.width / 2, 470)
|
||||
}
|
||||
|
||||
if (!this.variant) {
|
||||
// Bottom images
|
||||
imgsToAdd = Object.values(imgsToAdd)
|
||||
if (imgsToAdd.length > 0) {
|
||||
addText('Some additions include...', '24px', 'normal', tanColor, '0px', canvas.width / 2, 540)
|
||||
|
||||
for (let i = 0; i < Math.min(5, imgsToAdd.length); i++) {
|
||||
let imgToAdd = imgsToAdd[i]
|
||||
ctx.drawImage(imgToAdd.img, imgToAdd.sx, imgToAdd.sy, imgToAdd.sw, imgToAdd.sw, 40 + 145 * i, 580, 140, 140)
|
||||
}
|
||||
}
|
||||
} else if (this.variant === 1) {
|
||||
// Text stats
|
||||
ctx.textAlign = 'left'
|
||||
if (this.yearStats.topAuthors.length) {
|
||||
addText('TOP AUTHORS', '24px', 'normal', tanColor, '1px', 70, 549)
|
||||
for (let i = 0; i < this.yearStats.topAuthors.length; i++) {
|
||||
addText(this.yearStats.topAuthors[i].name, '36px', 'bolder', 'white', '0px', 70, 609 + i * 60, 330)
|
||||
}
|
||||
}
|
||||
|
||||
if (this.yearStats.topNarrators.length) {
|
||||
addText('TOP NARRATORS', '24px', 'normal', tanColor, '1px', 430, 549)
|
||||
for (let i = 0; i < this.yearStats.topNarrators.length; i++) {
|
||||
addText(this.yearStats.topNarrators[i].name, '36px', 'bolder', 'white', '0px', 430, 609 + i * 60, 330)
|
||||
}
|
||||
}
|
||||
} else if (this.variant === 2) {
|
||||
// Text stats
|
||||
ctx.textAlign = 'left'
|
||||
if (this.yearStats.topAuthors.length) {
|
||||
addText('TOP AUTHORS', '24px', 'normal', tanColor, '1px', 70, 549)
|
||||
for (let i = 0; i < this.yearStats.topAuthors.length; i++) {
|
||||
addText(this.yearStats.topAuthors[i].name, '36px', 'bolder', 'white', '0px', 70, 609 + i * 60, 330)
|
||||
}
|
||||
}
|
||||
|
||||
if (this.yearStats.topGenres.length) {
|
||||
addText('TOP GENRES', '24px', 'normal', tanColor, '1px', 430, 549)
|
||||
for (let i = 0; i < this.yearStats.topGenres.length; i++) {
|
||||
addText(this.yearStats.topGenres[i].genre, '36px', 'bolder', 'white', '0px', 430, 609 + i * 60, 330)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.canvas = canvas
|
||||
this.dataUrl = canvas.toDataURL('png')
|
||||
},
|
||||
share() {
|
||||
const base64Data = this.dataUrl.split(';base64,').pop()
|
||||
FileSharer.share({
|
||||
filename: `audiobookshelf_server_${this.year}.png`,
|
||||
contentType: 'image/png',
|
||||
base64Data
|
||||
}).catch((error) => {
|
||||
if (error.message !== 'USER_CANCELLED') {
|
||||
console.error('Failed to share', error.message)
|
||||
this.$toast.error('Failed to share: ' + error.message)
|
||||
}
|
||||
})
|
||||
},
|
||||
refresh() {
|
||||
this.init()
|
||||
},
|
||||
init() {
|
||||
this.$emit('update:processing', true)
|
||||
this.$nativeHttp
|
||||
.get(`/api/stats/year/${this.year}`)
|
||||
.then((data) => {
|
||||
this.yearStats = data || []
|
||||
return this.initCanvas()
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
this.$toast.error('Failed to load year stats')
|
||||
})
|
||||
.finally(() => {
|
||||
this.$emit('update:processing', false)
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,192 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="processing" class="max-w-[600px] h-32 sm:h-[200px] flex items-center justify-center">
|
||||
<widgets-loading-spinner />
|
||||
</div>
|
||||
<img v-else-if="dataUrl" :src="dataUrl" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { FileSharer } from '@byteowls/capacitor-filesharer'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
processing: Boolean,
|
||||
year: Number
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
canvas: null,
|
||||
dataUrl: null,
|
||||
yearStats: null
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async initCanvas() {
|
||||
if (!this.yearStats) return
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = 600
|
||||
canvas.height = 200
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
const createRoundedRect = (x, y, w, h) => {
|
||||
const grd1 = ctx.createLinearGradient(x, y, x + w, y + h)
|
||||
grd1.addColorStop(0, '#44444455')
|
||||
grd1.addColorStop(1, '#ffffff11')
|
||||
ctx.fillStyle = grd1
|
||||
ctx.strokeStyle = '#C0C0C088'
|
||||
ctx.beginPath()
|
||||
ctx.roundRect(x, y, w, h, [20])
|
||||
ctx.fill()
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
const addText = (text, fontSize, fontWeight, color, letterSpacing, x, y, maxWidth = 0) => {
|
||||
ctx.fillStyle = color
|
||||
ctx.font = `${fontWeight} ${fontSize} Source Sans Pro`
|
||||
ctx.letterSpacing = letterSpacing
|
||||
|
||||
// If maxWidth is specified then continue to remove chars until under maxWidth and add ellipsis
|
||||
if (maxWidth) {
|
||||
let txtWidth = ctx.measureText(text).width
|
||||
while (txtWidth > maxWidth) {
|
||||
console.warn(`Text "${text}" is greater than max width ${maxWidth} (width:${txtWidth})`)
|
||||
if (text.endsWith('...')) text = text.slice(0, -4) // Repeated checks remove 1 char at a time
|
||||
else text = text.slice(0, -3) // First check remove last 3 chars
|
||||
text += '...'
|
||||
txtWidth = ctx.measureText(text).width
|
||||
console.log(`Checking text "${text}" (width:${txtWidth})`)
|
||||
}
|
||||
}
|
||||
|
||||
ctx.fillText(text, x, y)
|
||||
}
|
||||
|
||||
const addIcon = (icon, color, fontSize, x, y) => {
|
||||
ctx.fillStyle = color
|
||||
ctx.font = `${fontSize} Material Icons Outlined`
|
||||
ctx.fillText(icon, x, y)
|
||||
}
|
||||
|
||||
// Bg color
|
||||
ctx.fillStyle = '#232323'
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
// Cover image tiles
|
||||
const bookCovers = this.yearStats.finishedBooksWithCovers
|
||||
bookCovers.push(...this.yearStats.booksWithCovers)
|
||||
|
||||
if (bookCovers.length) {
|
||||
let index = 0
|
||||
ctx.globalAlpha = 0.25
|
||||
ctx.save()
|
||||
ctx.translate(canvas.width / 2, canvas.height / 2)
|
||||
ctx.rotate((-Math.PI / 180) * 25)
|
||||
ctx.translate(-canvas.width / 2, -canvas.height / 2)
|
||||
ctx.translate(-10, -90)
|
||||
for (let x = 0; x < 4; x++) {
|
||||
for (let y = 0; y < 3; y++) {
|
||||
const coverIndex = index % bookCovers.length
|
||||
let libraryItemId = bookCovers[coverIndex]
|
||||
index++
|
||||
|
||||
await new Promise((resolve) => {
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
img.addEventListener('load', () => {
|
||||
let sw = img.width
|
||||
if (img.width > img.height) {
|
||||
sw = img.height
|
||||
}
|
||||
let sx = -(sw - img.width) / 2
|
||||
let sy = -(sw - img.height) / 2
|
||||
ctx.drawImage(img, sx, sy, sw, sw, 155 * x, 155 * y, 155, 155)
|
||||
resolve()
|
||||
})
|
||||
img.addEventListener('error', () => {
|
||||
resolve()
|
||||
})
|
||||
img.src = this.$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId)
|
||||
})
|
||||
}
|
||||
}
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1
|
||||
ctx.textBaseline = 'middle'
|
||||
|
||||
// Create gradient
|
||||
const grd1 = ctx.createLinearGradient(0, 0, canvas.width, canvas.height)
|
||||
grd1.addColorStop(0, '#000000aa')
|
||||
grd1.addColorStop(1, '#cd9d49aa')
|
||||
ctx.fillStyle = grd1
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
// Top Abs icon
|
||||
let tanColor = '#ffdb70'
|
||||
ctx.fillStyle = tanColor
|
||||
ctx.font = '42px absicons'
|
||||
ctx.fillText('\ue900', 15, 36)
|
||||
|
||||
// Top text
|
||||
addText('audiobookshelf', '28px', 'normal', tanColor, '0px', 65, 28)
|
||||
addText(`${this.year} YEAR IN REVIEW`, '18px', 'bold', 'white', '1px', 65, 51)
|
||||
|
||||
// Top left box
|
||||
createRoundedRect(15, 75, 280, 110)
|
||||
addText(this.yearStats.numBooksFinished, '48px', 'bold', 'white', '0px', 105, 120)
|
||||
addText('books finished', '20px', 'normal', tanColor, '0px', 105, 155)
|
||||
const readIconPath = new Path2D()
|
||||
readIconPath.addPath(new Path2D('M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-9 15l-5-5 1.41-1.41L10 13.17l7.59-7.59L19 7l-9 9z'), { a: 1.5, d: 1.5, e: 55, f: 115 })
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.fill(readIconPath)
|
||||
|
||||
createRoundedRect(305, 75, 280, 110)
|
||||
addText(this.yearStats.numBooksListened, '48px', 'bold', 'white', '0px', 400, 120)
|
||||
addText('books listened to', '20px', 'normal', tanColor, '0px', 400, 155)
|
||||
addIcon('local_library', 'white', '42px', 345, 130)
|
||||
|
||||
this.canvas = canvas
|
||||
this.dataUrl = canvas.toDataURL('png')
|
||||
},
|
||||
share() {
|
||||
const base64Data = this.dataUrl.split(';base64,').pop()
|
||||
FileSharer.share({
|
||||
filename: `audiobookshelf_my_${this.year}_short.png`,
|
||||
contentType: 'image/png',
|
||||
base64Data
|
||||
}).catch((error) => {
|
||||
if (error.message !== 'USER_CANCELLED') {
|
||||
console.error('Failed to share', error.message)
|
||||
this.$toast.error('Failed to share: ' + error.message)
|
||||
}
|
||||
})
|
||||
},
|
||||
refresh() {
|
||||
this.init()
|
||||
},
|
||||
init() {
|
||||
this.$emit('update:processing', true)
|
||||
this.$nativeHttp
|
||||
.get(`/api/me/stats/year/${this.year}`)
|
||||
.then((data) => {
|
||||
this.yearStats = data || []
|
||||
return this.initCanvas()
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
this.$toast.error('Failed to load year stats')
|
||||
})
|
||||
.finally(() => {
|
||||
this.$emit('update:processing', false)
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -6,8 +6,11 @@
|
||||
<covers-preview-cover :src="$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId)" :width="40" :book-cover-aspect-ratio="bookCoverAspectRatio" :show-resolution="false" class="md:hidden" />
|
||||
</div>
|
||||
<div class="flex-grow px-2">
|
||||
<div class="-mt-0.5 mb-0.5" @click.stop>
|
||||
<nuxt-link :to="`/item/${libraryItemId}`" class="text-sm text-fg underline">{{ podcast.metadata.title }}</nuxt-link>
|
||||
<div class="flex items-center">
|
||||
<div class="-mt-0.5 mb-0.5" @click.stop>
|
||||
<nuxt-link :to="`/item/${libraryItemId}`" class="text-sm text-fg underline">{{ podcast.metadata.title }}</nuxt-link>
|
||||
</div>
|
||||
<widgets-explicit-indicator v-if="podcast.metadata.explicit" />
|
||||
</div>
|
||||
<p v-if="publishedAt" class="text-xs text-fg-muted">{{ $dateDistanceFromNow(publishedAt) }}</p>
|
||||
</div>
|
||||
|
||||
@@ -30,6 +30,7 @@ export default {
|
||||
default: ''
|
||||
},
|
||||
paddingX: Number,
|
||||
paddingY: Number,
|
||||
small: Boolean,
|
||||
loading: Boolean,
|
||||
disabled: Boolean
|
||||
@@ -48,14 +49,17 @@ export default {
|
||||
if (this.small) {
|
||||
list.push('text-sm')
|
||||
if (this.paddingX === undefined) list.push('px-4')
|
||||
list.push('py-1')
|
||||
if (this.paddingY === undefined) list.push('py-1')
|
||||
} else {
|
||||
if (this.paddingX === undefined) list.push('px-8')
|
||||
list.push('py-2')
|
||||
if (this.paddingY === undefined) list.push('py-2')
|
||||
}
|
||||
if (this.paddingX !== undefined) {
|
||||
list.push(`px-${this.paddingX}`)
|
||||
}
|
||||
if (this.paddingY !== undefined) {
|
||||
list.push(`py-${this.paddingY}`)
|
||||
}
|
||||
if (this.disabled) {
|
||||
list.push('cursor-not-allowed')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<div>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12px" height="12px" viewBox="0 0 512 512" class="ml-1">
|
||||
<path
|
||||
fill="white"
|
||||
d="M 89.00,40.12
|
||||
C 89.00,40.12 127.00,40.12 127.00,40.12
|
||||
127.00,40.12 198.00,40.12 198.00,40.12
|
||||
198.00,40.12 416.00,40.12 416.00,40.12
|
||||
446.58,40.05 472.95,66.42 473.00,97.00
|
||||
473.00,97.00 473.00,303.00 473.00,303.00
|
||||
473.00,303.00 473.00,418.00 473.00,418.00
|
||||
472.65,447.55 445.06,472.95 416.00,473.00
|
||||
416.00,473.00 210.00,473.00 210.00,473.00
|
||||
210.00,473.00 95.00,473.00 95.00,473.00
|
||||
65.45,472.65 40.05,445.06 40.00,416.00
|
||||
40.00,416.00 40.00,136.00 40.00,136.00
|
||||
40.00,136.00 40.00,109.00 40.00,109.00
|
||||
40.00,109.00 40.00,96.00 40.00,96.00
|
||||
40.07,81.58 46.89,67.14 57.01,57.01
|
||||
61.17,52.86 64.86,50.13 70.00,47.31
|
||||
77.25,43.33 81.02,42.18 89.00,40.12 Z
|
||||
M 337.00,121.00
|
||||
C 337.00,121.00 175.00,121.00 175.00,121.00
|
||||
175.00,121.00 175.00,392.00 175.00,392.00
|
||||
175.00,392.00 337.00,392.00 337.00,392.00
|
||||
337.00,392.00 337.00,349.00 337.00,349.00
|
||||
337.00,349.00 226.00,349.00 226.00,349.00
|
||||
226.00,349.00 226.00,274.00 226.00,274.00
|
||||
226.00,274.00 332.00,274.00 332.00,274.00
|
||||
332.00,274.00 332.00,232.00 332.00,232.00
|
||||
332.00,232.00 226.00,232.00 226.00,232.00
|
||||
226.00,232.00 226.00,164.00 226.00,164.00
|
||||
226.00,164.00 337.00,164.00 337.00,164.00
|
||||
337.00,164.00 337.00,121.00 337.00,121.00 Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -65,6 +65,9 @@
|
||||
E9E8814D28DA6B9000D750C1 /* PlayerTimeUtilsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9E8814C28DA6B9000D750C1 /* PlayerTimeUtilsTests.swift */; };
|
||||
E9E985F828B02D9400957F23 /* PlayerProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9E985F728B02D9400957F23 /* PlayerProgress.swift */; };
|
||||
E9FA07E328C82848005520B0 /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9FA07E228C82848005520B0 /* Logger.swift */; };
|
||||
EACB38122BCCA1330060DA4A /* AudioPlayerRateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = EACB38112BCCA1330060DA4A /* AudioPlayerRateManager.swift */; };
|
||||
EACB38142BCCA1410060DA4A /* LegacyAudioPlayerRateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = EACB38132BCCA1410060DA4A /* LegacyAudioPlayerRateManager.swift */; };
|
||||
EACB38162BCCA1500060DA4A /* DefaultedAudioPlayerRateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = EACB38152BCCA1500060DA4A /* DefaultedAudioPlayerRateManager.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -142,6 +145,9 @@
|
||||
E9E8814C28DA6B9000D750C1 /* PlayerTimeUtilsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerTimeUtilsTests.swift; sourceTree = "<group>"; };
|
||||
E9E985F728B02D9400957F23 /* PlayerProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerProgress.swift; sourceTree = "<group>"; };
|
||||
E9FA07E228C82848005520B0 /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = "<group>"; };
|
||||
EACB38112BCCA1330060DA4A /* AudioPlayerRateManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AudioPlayerRateManager.swift; sourceTree = "<group>"; };
|
||||
EACB38132BCCA1410060DA4A /* LegacyAudioPlayerRateManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LegacyAudioPlayerRateManager.swift; sourceTree = "<group>"; };
|
||||
EACB38152BCCA1500060DA4A /* DefaultedAudioPlayerRateManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DefaultedAudioPlayerRateManager.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -183,6 +189,9 @@
|
||||
3ABF6190280432610070250E /* player */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
EACB38152BCCA1500060DA4A /* DefaultedAudioPlayerRateManager.swift */,
|
||||
EACB38132BCCA1410060DA4A /* LegacyAudioPlayerRateManager.swift */,
|
||||
EACB38112BCCA1330060DA4A /* AudioPlayerRateManager.swift */,
|
||||
E9E8814828DA641B00D750C1 /* util */,
|
||||
3A200C1427D64D7E00CBF02E /* AudioPlayer.swift */,
|
||||
E9DFCBFA28C28F4A00B36356 /* AudioPlayerSleepTimer.swift */,
|
||||
@@ -521,6 +530,7 @@
|
||||
E9D5506228AC1CC900C746DD /* PlayerState.swift in Sources */,
|
||||
3AD4FCE728043E72006DB301 /* AbsDatabase.m in Sources */,
|
||||
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
|
||||
EACB38122BCCA1330060DA4A /* AudioPlayerRateManager.swift in Sources */,
|
||||
E9FA07E328C82848005520B0 /* Logger.swift in Sources */,
|
||||
3A90295F280968E700E1D427 /* PlaybackReport.swift in Sources */,
|
||||
E9D5505A28AC1C4500C746DD /* Folder.swift in Sources */,
|
||||
@@ -540,6 +550,7 @@
|
||||
3A200C1527D64D7E00CBF02E /* AudioPlayer.swift in Sources */,
|
||||
E9D5507128AC1EC700C746DD /* DownloadItemPart.swift in Sources */,
|
||||
4D66B956282EE951008272D4 /* AbsFileSystem.m in Sources */,
|
||||
EACB38142BCCA1410060DA4A /* LegacyAudioPlayerRateManager.swift in Sources */,
|
||||
3AFCB5E827EA240D00ECCC05 /* NowPlayingInfo.swift in Sources */,
|
||||
3AB34053280829BF0039308B /* Extensions.swift in Sources */,
|
||||
E9D5505828AC1C1A00C746DD /* Library.swift in Sources */,
|
||||
@@ -548,6 +559,7 @@
|
||||
4DABC04F2B0139CA000F6264 /* User.swift in Sources */,
|
||||
4D66B952282EE822008272D4 /* AbsDownloader.m in Sources */,
|
||||
E9D5506828AC1DC300C746DD /* LocalPodcastEpisode.swift in Sources */,
|
||||
EACB38162BCCA1500060DA4A /* DefaultedAudioPlayerRateManager.swift in Sources */,
|
||||
E9D5505228AC1B5D00C746DD /* Chapter.swift in Sources */,
|
||||
E9D5506028AC1CA900C746DD /* PlaybackMetadata.swift in Sources */,
|
||||
E9D5504828AC1A7A00C746DD /* MediaType.swift in Sources */,
|
||||
@@ -728,12 +740,12 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
CURRENT_PROJECT_VERSION = 30;
|
||||
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 0.9.70;
|
||||
MARKETING_VERSION = 0.9.73;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app.dev;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -752,12 +764,12 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
CURRENT_PROJECT_VERSION = 30;
|
||||
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 0.9.70;
|
||||
MARKETING_VERSION = 0.9.73;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
|
||||
@@ -4,17 +4,17 @@ import RealmSwift
|
||||
|
||||
@UIApplicationMain
|
||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
|
||||
|
||||
private let logger = AppLogger(category: "AppDelegate")
|
||||
|
||||
lazy var window: UIWindow? = UIWindow(frame: UIScreen.main.bounds)
|
||||
var backgroundCompletionHandler: (() -> Void)?
|
||||
|
||||
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||
// Override point for customization after application launch.
|
||||
|
||||
|
||||
let configuration = Realm.Configuration(
|
||||
schemaVersion: 15,
|
||||
schemaVersion: 17,
|
||||
migrationBlock: { [weak self] migration, oldSchemaVersion in
|
||||
if (oldSchemaVersion < 1) {
|
||||
self?.logger.log("Realm schema version was \(oldSchemaVersion)")
|
||||
@@ -48,10 +48,17 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
newObject?["languageCode"] = "en-us"
|
||||
}
|
||||
}
|
||||
if (oldSchemaVersion < 16) {
|
||||
self?.logger.log("Realm schema version was \(oldSchemaVersion)... Adding chapterTrack setting")
|
||||
migration.enumerateObjects(ofType: PlayerSettings.className()) { oldObject, newObject in
|
||||
newObject?["chapterTrack"] = false
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
)
|
||||
Realm.Configuration.defaultConfiguration = configuration
|
||||
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -93,7 +100,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
// tracking app url opens, make sure to keep this call
|
||||
return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler)
|
||||
}
|
||||
|
||||
|
||||
func application(_ application: UIApplication, handleEventsForBackgroundURLSession identifier: String, completionHandler: @escaping () -> Void) {
|
||||
// Stores the completion handler for background downloads
|
||||
// The identifier of this method can be ignored at this time as we only have one background url session
|
||||
|
||||
@@ -15,6 +15,7 @@ CAP_PLUGIN(AbsAudioPlayer, "AbsAudioPlayer",
|
||||
CAP_PLUGIN_METHOD(closePlayback, CAPPluginReturnPromise);
|
||||
|
||||
CAP_PLUGIN_METHOD(setPlaybackSpeed, CAPPluginReturnPromise);
|
||||
CAP_PLUGIN_METHOD(setChapterTrack, CAPPluginReturnPromise);
|
||||
|
||||
CAP_PLUGIN_METHOD(playPlayer, CAPPluginReturnPromise);
|
||||
CAP_PLUGIN_METHOD(pausePlayer, CAPPluginReturnPromise);
|
||||
|
||||
@@ -119,6 +119,17 @@ public class AbsAudioPlayer: CAPPlugin {
|
||||
PlayerHandler.setPlaybackSpeed(speed: settings.playbackRate)
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
@objc func setChapterTrack(_ call: CAPPluginCall) {
|
||||
let chapterTrack = call.getBool("enabled", true)
|
||||
logger.log(String(chapterTrack))
|
||||
let settings = PlayerSettings.main()
|
||||
try? settings.update {
|
||||
settings.chapterTrack = chapterTrack
|
||||
}
|
||||
PlayerHandler.setChapterTrack()
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
@objc func playPlayer(_ call: CAPPluginCall) {
|
||||
PlayerHandler.paused = false
|
||||
|
||||
@@ -147,14 +147,15 @@ public class AbsDatabase: CAPPlugin {
|
||||
}
|
||||
|
||||
@objc func syncLocalSessionsWithServer(_ call: CAPPluginCall) {
|
||||
logger.log("syncLocalSessionsWithServer: Starting")
|
||||
let isFirstSync = call.getBool("isFirstSync", false)
|
||||
logger.log("syncLocalSessionsWithServer: Starting (First sync: \(isFirstSync))")
|
||||
guard Store.serverConfig != nil else {
|
||||
call.reject("syncLocalSessionsWithServer not connected to server")
|
||||
return call.resolve()
|
||||
}
|
||||
|
||||
Task {
|
||||
await ApiClient.syncLocalSessionsWithServer()
|
||||
await ApiClient.syncLocalSessionsWithServer(isFirstSync: isFirstSync)
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
@@ -237,6 +238,7 @@ public class AbsDatabase: CAPPlugin {
|
||||
@objc func updateDeviceSettings(_ call: CAPPluginCall) {
|
||||
let disableAutoRewind = call.getBool("disableAutoRewind") ?? false
|
||||
let enableAltView = call.getBool("enableAltView") ?? false
|
||||
let allowSeekingOnMediaControls = call.getBool("allowSeekingOnMediaControls") ?? false
|
||||
let jumpBackwardsTime = call.getInt("jumpBackwardsTime") ?? 10
|
||||
let jumpForwardTime = call.getInt("jumpForwardTime") ?? 10
|
||||
let lockOrientation = call.getString("lockOrientation") ?? "NONE"
|
||||
@@ -245,6 +247,7 @@ public class AbsDatabase: CAPPlugin {
|
||||
let settings = DeviceSettings()
|
||||
settings.disableAutoRewind = disableAutoRewind
|
||||
settings.enableAltView = enableAltView
|
||||
settings.allowSeekingOnMediaControls = allowSeekingOnMediaControls
|
||||
settings.jumpBackwardsTime = jumpBackwardsTime
|
||||
settings.jumpForwardTime = jumpForwardTime
|
||||
settings.lockOrientation = lockOrientation
|
||||
@@ -253,7 +256,9 @@ public class AbsDatabase: CAPPlugin {
|
||||
|
||||
Database.shared.setDeviceSettings(deviceSettings: settings)
|
||||
|
||||
// call.resolve([ "value": [] ])
|
||||
// Updates the media notification controls (for allowSeekingOnMediaControls setting)
|
||||
PlayerHandler.updateRemoteTransportControls()
|
||||
|
||||
getDeviceData(call)
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ install! 'cocoapods', :disable_input_output_paths => true
|
||||
def capacitor_pods
|
||||
pod 'Capacitor', :path => '../../node_modules/@capacitor/ios'
|
||||
pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios'
|
||||
pod 'ByteowlsCapacitorFilesharer', :path => '../../node_modules/@byteowls/capacitor-filesharer'
|
||||
pod 'CapacitorApp', :path => '../../node_modules/@capacitor/app'
|
||||
pod 'CapacitorBrowser', :path => '../../node_modules/@capacitor/browser'
|
||||
pod 'CapacitorClipboard', :path => '../../node_modules/@capacitor/clipboard'
|
||||
|
||||
+16
-10
@@ -1,5 +1,7 @@
|
||||
PODS:
|
||||
- Alamofire (5.6.4)
|
||||
- Alamofire (5.8.1)
|
||||
- ByteowlsCapacitorFilesharer (5.0.0):
|
||||
- Capacitor
|
||||
- Capacitor (5.4.0):
|
||||
- CapacitorCordova
|
||||
- CapacitorApp (5.0.6):
|
||||
@@ -21,14 +23,15 @@ PODS:
|
||||
- Capacitor
|
||||
- CordovaPlugins (5.4.0):
|
||||
- CapacitorCordova
|
||||
- Realm (10.36.0):
|
||||
- Realm/Headers (= 10.36.0)
|
||||
- Realm/Headers (10.36.0)
|
||||
- RealmSwift (10.36.0):
|
||||
- Realm (= 10.36.0)
|
||||
- Realm (10.47.0):
|
||||
- Realm/Headers (= 10.47.0)
|
||||
- Realm/Headers (10.47.0)
|
||||
- RealmSwift (10.47.0):
|
||||
- Realm (= 10.47.0)
|
||||
|
||||
DEPENDENCIES:
|
||||
- Alamofire (~> 5.5)
|
||||
- "ByteowlsCapacitorFilesharer (from `../../node_modules/@byteowls/capacitor-filesharer`)"
|
||||
- "Capacitor (from `../../node_modules/@capacitor/ios`)"
|
||||
- "CapacitorApp (from `../../node_modules/@capacitor/app`)"
|
||||
- "CapacitorBrowser (from `../../node_modules/@capacitor/browser`)"
|
||||
@@ -49,6 +52,8 @@ SPEC REPOS:
|
||||
- RealmSwift
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
ByteowlsCapacitorFilesharer:
|
||||
:path: "../../node_modules/@byteowls/capacitor-filesharer"
|
||||
Capacitor:
|
||||
:path: "../../node_modules/@capacitor/ios"
|
||||
CapacitorApp:
|
||||
@@ -73,7 +78,8 @@ EXTERNAL SOURCES:
|
||||
:path: "../capacitor-cordova-ios-plugins"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
Alamofire: 4e95d97098eacb88856099c4fc79b526a299e48c
|
||||
Alamofire: 3ca42e259043ee0dc5c0cdd76c4bc568b8e42af7
|
||||
ByteowlsCapacitorFilesharer: f6a773825632d65d5404a34764c4a3fd857bb176
|
||||
Capacitor: a5cd803e02b471591c81165f400ace01f40b11d3
|
||||
CapacitorApp: 024e1b1bea5f883d79f6330d309bc441c88ad04a
|
||||
CapacitorBrowser: 7a0fb6a1011abfaaf2dfedfd8248f942a8eda3d6
|
||||
@@ -85,9 +91,9 @@ SPEC CHECKSUMS:
|
||||
CapacitorPreferences: f03954bcb0ff09c792909e46bff88e3183c16b10
|
||||
CapacitorStatusBar: 565c0a1ebd79bb40d797606a8992b4a105885309
|
||||
CordovaPlugins: a5db67e5ac1061b9869a0efd754f2c2f776aeccc
|
||||
Realm: 3fd136cb4c83a927482a7f1612496d37beed3cf5
|
||||
RealmSwift: 513d4dcbf5bfc4d573454088b592685fc48dd716
|
||||
Realm: e43fb540ae947497e3ea8a662443256920602060
|
||||
RealmSwift: 8b06ed06b5d16749ae0c4d91c0cba414a9e28189
|
||||
|
||||
PODFILE CHECKSUM: 7a8fc177ef0646dd60a1ee8aa387964975fcc1e3
|
||||
PODFILE CHECKSUM: 02e6ffe2f51a453ce222ee9af0e55e9448d8514c
|
||||
|
||||
COCOAPODS: 1.12.1
|
||||
|
||||
@@ -11,6 +11,7 @@ import RealmSwift
|
||||
class DeviceSettings: Object {
|
||||
@Persisted var disableAutoRewind: Bool = false
|
||||
@Persisted var enableAltView: Bool = true
|
||||
@Persisted var allowSeekingOnMediaControls: Bool = false
|
||||
@Persisted var jumpBackwardsTime: Int = 10
|
||||
@Persisted var jumpForwardTime: Int = 10
|
||||
@Persisted var lockOrientation: String = "NONE"
|
||||
@@ -26,6 +27,7 @@ func deviceSettingsToJSON(settings: DeviceSettings) -> Dictionary<String, Any> {
|
||||
return [
|
||||
"disableAutoRewind": settings.disableAutoRewind,
|
||||
"enableAltView": settings.enableAltView,
|
||||
"allowSeekingOnMediaControls": settings.allowSeekingOnMediaControls,
|
||||
"jumpBackwardsTime": settings.jumpBackwardsTime,
|
||||
"jumpForwardTime": settings.jumpForwardTime,
|
||||
"lockOrientation": settings.lockOrientation,
|
||||
|
||||
@@ -160,3 +160,11 @@ class PlaybackSession: Object, Codable, Deletable {
|
||||
try container.encode(localMediaProgressId, forKey: .localMediaProgressId)
|
||||
}
|
||||
}
|
||||
|
||||
extension PlaybackSession {
|
||||
func getCurrentChapter() -> Chapter? {
|
||||
return chapters.first { chapter in
|
||||
chapter.start <= self.currentTime && chapter.end > self.currentTime
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ class PlayerSettings: Object {
|
||||
// The webapp has a persisted setting for playback speed, but it's not always available to the native code
|
||||
// Lets track it natively as well, so we never have a situation where the UI and native player are out of sync
|
||||
@Persisted var playbackRate: Float = 1.0
|
||||
@Persisted var chapterTrack: Bool = true
|
||||
|
||||
|
||||
// Singleton pattern for Realm objects
|
||||
static func main() -> PlayerSettings {
|
||||
|
||||
@@ -39,3 +39,13 @@ class Chapter: EmbeddedObject, Codable {
|
||||
try container.encode(title, forKey: .title)
|
||||
}
|
||||
}
|
||||
|
||||
extension Chapter {
|
||||
func getRelativeChapterCurrentTime(sessionCurrentTime: Double) -> Double {
|
||||
return sessionCurrentTime - self.start
|
||||
}
|
||||
|
||||
func getRelativeChapterEndTime() -> Double {
|
||||
return self.end - self.start
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,9 +29,7 @@ class AudioPlayer: NSObject {
|
||||
internal let logger = AppLogger(category: "AudioPlayer")
|
||||
|
||||
private var status: PlayerStatus
|
||||
internal var rate: Float
|
||||
|
||||
private var tmpRate: Float = 1.0
|
||||
internal var rateManager: AudioPlayerRateManager
|
||||
|
||||
private var playerContext = 0
|
||||
private var playerItemContext = 0
|
||||
@@ -64,11 +62,18 @@ class AudioPlayer: NSObject {
|
||||
self.audioPlayer.automaticallyWaitsToMinimizeStalling = true
|
||||
self.sessionId = sessionId
|
||||
self.status = .uninitialized
|
||||
self.rate = 0.0
|
||||
self.tmpRate = playbackRate
|
||||
|
||||
if #available(iOS 16.0, *) {
|
||||
self.rateManager = DefaultedAudioPlayerRateManager(audioPlayer: self.audioPlayer, defaultRate: playbackRate)
|
||||
} else {
|
||||
self.rateManager = LegacyAudioPlayerRateManager(audioPlayer: self.audioPlayer, defaultRate: playbackRate)
|
||||
}
|
||||
|
||||
super.init()
|
||||
|
||||
self.rateManager.rateChangedCompletion = self.updateNowPlaying
|
||||
self.rateManager.defaultRateChangedCompletion = self.setupTimeObservers
|
||||
|
||||
initAudioSession()
|
||||
setupRemoteTransportControls()
|
||||
|
||||
@@ -81,7 +86,6 @@ class AudioPlayer: NSObject {
|
||||
|
||||
// Listen to player events
|
||||
self.setupAudioSessionNotifications()
|
||||
self.audioPlayer.addObserver(self, forKeyPath: #keyPath(AVPlayer.rate), options: .new, context: &playerContext)
|
||||
self.audioPlayer.addObserver(self, forKeyPath: #keyPath(AVPlayer.currentItem), options: .new, context: &playerContext)
|
||||
|
||||
for track in playbackSession.audioTracks {
|
||||
@@ -150,7 +154,6 @@ class AudioPlayer: NSObject {
|
||||
}
|
||||
|
||||
// Remove observers
|
||||
self.audioPlayer.removeObserver(self, forKeyPath: #keyPath(AVPlayer.rate), context: &playerContext)
|
||||
self.audioPlayer.removeObserver(self, forKeyPath: #keyPath(AVPlayer.currentItem), context: &playerContext)
|
||||
self.removeTimeObservers()
|
||||
|
||||
@@ -224,6 +227,8 @@ class AudioPlayer: NSObject {
|
||||
// Update the UI
|
||||
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.sleepSet.rawValue), object: nil)
|
||||
}
|
||||
// Update the now playing and chapter info
|
||||
self.updateNowPlaying()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -330,7 +335,7 @@ class AudioPlayer: NSObject {
|
||||
self.markAudioSessionAs(active: true)
|
||||
DispatchQueue.runOnMainQueue {
|
||||
self.audioPlayer.play()
|
||||
self.audioPlayer.rate = self.tmpRate
|
||||
self.rateManager.handlePlayEvent()
|
||||
}
|
||||
self.status = .playing
|
||||
|
||||
@@ -422,27 +427,14 @@ class AudioPlayer: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
public func setPlaybackRate(_ rate: Float, observed: Bool = false) {
|
||||
let playbackSpeedChanged = rate > 0.0 && rate != self.tmpRate && !(observed && rate == 1)
|
||||
|
||||
if self.audioPlayer.rate != rate {
|
||||
logger.log("setPlaybakRate rate changed from \(self.audioPlayer.rate) to \(rate)")
|
||||
DispatchQueue.runOnMainQueue {
|
||||
self.audioPlayer.rate = rate
|
||||
}
|
||||
}
|
||||
|
||||
self.rate = rate
|
||||
self.updateNowPlaying()
|
||||
|
||||
if playbackSpeedChanged {
|
||||
self.tmpRate = rate
|
||||
|
||||
// Setup the time observer again at the new rate
|
||||
self.setupTimeObservers()
|
||||
}
|
||||
public func setPlaybackRate(_ rate: Float) {
|
||||
self.rateManager.setPlaybackRate(rate)
|
||||
}
|
||||
|
||||
public func setChapterTrack() {
|
||||
self.updateNowPlaying()
|
||||
}
|
||||
|
||||
public func getCurrentTime() -> Double? {
|
||||
guard let playbackSession = self.getPlaybackSession() else { return nil }
|
||||
let currentTrackTime = self.audioPlayer.currentTime().seconds
|
||||
@@ -528,14 +520,24 @@ class AudioPlayer: NSObject {
|
||||
return
|
||||
}
|
||||
|
||||
switch type {
|
||||
case .ended:
|
||||
guard let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt else { return }
|
||||
let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
|
||||
if options.contains(.shouldResume) {
|
||||
self.play(allowSeekBack: true)
|
||||
// When interruption is from the app suspending then don't resume playback
|
||||
if #available(iOS 14.5, *) {
|
||||
let reasonValue = userInfo[AVAudioSessionInterruptionReasonKey] as? UInt ?? 0
|
||||
let reason = AVAudioSession.InterruptionReason(rawValue: reasonValue)
|
||||
if (reason == .appWasSuspended) {
|
||||
logger.log("AVAudioSession was suspended")
|
||||
return
|
||||
}
|
||||
default: ()
|
||||
}
|
||||
|
||||
switch type {
|
||||
case .ended:
|
||||
guard let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt else { return }
|
||||
let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
|
||||
if options.contains(.shouldResume) {
|
||||
self.play(allowSeekBack: true)
|
||||
}
|
||||
default: ()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -572,7 +574,7 @@ class AudioPlayer: NSObject {
|
||||
}
|
||||
|
||||
// MARK: - Now playing
|
||||
private func setupRemoteTransportControls() {
|
||||
func setupRemoteTransportControls() {
|
||||
DispatchQueue.runOnMainQueue {
|
||||
UIApplication.shared.beginReceivingRemoteControlEvents()
|
||||
}
|
||||
@@ -582,17 +584,43 @@ class AudioPlayer: NSObject {
|
||||
let jumpBackwardsTime = deviceSettings.jumpBackwardsTime
|
||||
|
||||
commandCenter.playCommand.isEnabled = true
|
||||
commandCenter.playCommand.removeTarget(nil)
|
||||
commandCenter.playCommand.addTarget { [weak self] event in
|
||||
self?.play(allowSeekBack: true)
|
||||
guard let strongSelf = self else { return .commandFailed }
|
||||
if strongSelf.isPlaying() {
|
||||
strongSelf.pause()
|
||||
} else {
|
||||
strongSelf.play(allowSeekBack: true)
|
||||
}
|
||||
return .success
|
||||
}
|
||||
|
||||
commandCenter.pauseCommand.isEnabled = true
|
||||
commandCenter.pauseCommand.removeTarget(nil)
|
||||
commandCenter.pauseCommand.addTarget { [weak self] event in
|
||||
self?.pause()
|
||||
guard let strongSelf = self else { return .commandFailed }
|
||||
if strongSelf.isPlaying() {
|
||||
strongSelf.pause()
|
||||
} else {
|
||||
strongSelf.play(allowSeekBack: true)
|
||||
}
|
||||
return .success
|
||||
}
|
||||
|
||||
commandCenter.togglePlayPauseCommand.isEnabled = true
|
||||
commandCenter.togglePlayPauseCommand.removeTarget(nil)
|
||||
commandCenter.togglePlayPauseCommand.addTarget { [weak self] event in
|
||||
guard let strongSelf = self else { return .commandFailed }
|
||||
if strongSelf.isPlaying() {
|
||||
strongSelf.pause()
|
||||
} else {
|
||||
strongSelf.play(allowSeekBack: true)
|
||||
}
|
||||
return .success
|
||||
}
|
||||
|
||||
commandCenter.skipForwardCommand.isEnabled = true
|
||||
commandCenter.skipForwardCommand.removeTarget(nil)
|
||||
commandCenter.skipForwardCommand.preferredIntervals = [NSNumber(value: jumpForwardTime)]
|
||||
commandCenter.skipForwardCommand.addTarget { [weak self] event in
|
||||
guard let command = event.command as? MPSkipIntervalCommand else {
|
||||
@@ -605,6 +633,7 @@ class AudioPlayer: NSObject {
|
||||
return .success
|
||||
}
|
||||
commandCenter.skipBackwardCommand.isEnabled = true
|
||||
commandCenter.skipBackwardCommand.removeTarget(nil)
|
||||
commandCenter.skipBackwardCommand.preferredIntervals = [NSNumber(value: jumpBackwardsTime)]
|
||||
commandCenter.skipBackwardCommand.addTarget { [weak self] event in
|
||||
guard let command = event.command as? MPSkipIntervalCommand else {
|
||||
@@ -618,6 +647,7 @@ class AudioPlayer: NSObject {
|
||||
}
|
||||
|
||||
commandCenter.nextTrackCommand.isEnabled = true
|
||||
commandCenter.nextTrackCommand.removeTarget(nil)
|
||||
commandCenter.nextTrackCommand.addTarget { [weak self] _ in
|
||||
guard let currentTime = self?.getCurrentTime() else {
|
||||
return .commandFailed
|
||||
@@ -626,6 +656,7 @@ class AudioPlayer: NSObject {
|
||||
return .success
|
||||
}
|
||||
commandCenter.previousTrackCommand.isEnabled = true
|
||||
commandCenter.previousTrackCommand.removeTarget(nil)
|
||||
commandCenter.previousTrackCommand.addTarget { [weak self] _ in
|
||||
guard let currentTime = self?.getCurrentTime() else {
|
||||
return .commandFailed
|
||||
@@ -634,17 +665,26 @@ class AudioPlayer: NSObject {
|
||||
return .success
|
||||
}
|
||||
|
||||
commandCenter.changePlaybackPositionCommand.isEnabled = true
|
||||
commandCenter.changePlaybackPositionCommand.isEnabled = deviceSettings.allowSeekingOnMediaControls
|
||||
commandCenter.changePlaybackPositionCommand.removeTarget(nil)
|
||||
commandCenter.changePlaybackPositionCommand.addTarget { [weak self] event in
|
||||
guard let event = event as? MPChangePlaybackPositionCommandEvent else {
|
||||
return .noSuchContent
|
||||
}
|
||||
|
||||
self?.seek(event.positionTime, from: "remote")
|
||||
// Adjust seek time if chapter track is being used
|
||||
var seekTime = event.positionTime
|
||||
if PlayerSettings.main().chapterTrack {
|
||||
if let session = self?.getPlaybackSession(), let currentChapter = session.getCurrentChapter() {
|
||||
seekTime += currentChapter.start
|
||||
}
|
||||
}
|
||||
self?.seek(seekTime, from: "remote")
|
||||
return .success
|
||||
}
|
||||
|
||||
commandCenter.changePlaybackRateCommand.isEnabled = true
|
||||
commandCenter.changePlaybackRateCommand.removeTarget(nil)
|
||||
commandCenter.changePlaybackRateCommand.supportedPlaybackRates = [0.5, 0.75, 1.0, 1.25, 1.5, 2]
|
||||
commandCenter.changePlaybackRateCommand.addTarget { [weak self] event in
|
||||
guard let event = event as? MPChangePlaybackRateCommandEvent else {
|
||||
@@ -657,18 +697,25 @@ class AudioPlayer: NSObject {
|
||||
}
|
||||
private func updateNowPlaying() {
|
||||
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.update.rawValue), object: nil)
|
||||
if let duration = self.getDuration(), let currentTime = self.getCurrentTime() {
|
||||
NowPlayingInfo.shared.update(duration: duration, currentTime: currentTime, rate: rate)
|
||||
if let session = self.getPlaybackSession(), let currentChapter = session.getCurrentChapter(), PlayerSettings.main().chapterTrack {
|
||||
NowPlayingInfo.shared.update(
|
||||
duration: currentChapter.getRelativeChapterEndTime(),
|
||||
currentTime: currentChapter.getRelativeChapterCurrentTime(sessionCurrentTime: session.currentTime),
|
||||
rate: self.rateManager.rate,
|
||||
defaultRate: self.rateManager.defaultRate,
|
||||
chapterName: currentChapter.title,
|
||||
chapterNumber: (session.chapters.firstIndex(of: currentChapter) ?? 0) + 1,
|
||||
chapterCount: session.chapters.count
|
||||
)
|
||||
} else if let duration = self.getDuration(), let currentTime = self.getCurrentTime() {
|
||||
NowPlayingInfo.shared.update(duration: duration, currentTime: currentTime, rate: self.rateManager.rate, defaultRate: self.rateManager.defaultRate)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Observer
|
||||
public override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
|
||||
if context == &playerContext {
|
||||
if keyPath == #keyPath(AVPlayer.rate) {
|
||||
logger.log("playerContext observer player rate")
|
||||
self.setPlaybackRate(change?[.newKey] as? Float ?? 1.0, observed: true)
|
||||
} else if keyPath == #keyPath(AVPlayer.currentItem) {
|
||||
if keyPath == #keyPath(AVPlayer.currentItem) {
|
||||
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.update.rawValue), object: nil)
|
||||
logger.log("WARNING: Item ended")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
//
|
||||
// AudioPlayerRateManager.swift
|
||||
// Audiobookshelf
|
||||
//
|
||||
// Created by Marke Hallowell on 4/14/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
|
||||
protocol AudioPlayerRateManager {
|
||||
var rate: Float { get }
|
||||
var defaultRate: Float { get }
|
||||
var rateChangedCompletion: () -> Void { get set }
|
||||
var defaultRateChangedCompletion: () -> Void { get set }
|
||||
|
||||
init(audioPlayer: AVPlayer, defaultRate: Float)
|
||||
|
||||
func setPlaybackRate(_ rate: Float)
|
||||
|
||||
// Callback for play events (e.g. LegacyAudioPlayerRateManager uses this set rate immediately after playback resumes)
|
||||
func handlePlayEvent() -> Void
|
||||
}
|
||||
@@ -22,7 +22,7 @@ extension AudioPlayer {
|
||||
// Return the player time until sleep
|
||||
var sleepTimeRemaining: Double? = nil
|
||||
if let chapterStopAt = self.sleepTimeChapterStopAt {
|
||||
sleepTimeRemaining = (chapterStopAt - currentTime) / Double(self.rate > 0 ? self.rate : 1.0)
|
||||
sleepTimeRemaining = (chapterStopAt - currentTime) / Double(self.rateManager.rate > 0 ? self.rateManager.rate : 1.0)
|
||||
} else if self.isCountdownSleepTimerSet() {
|
||||
sleepTimeRemaining = self.sleepTimeRemaining
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
//
|
||||
// DefaultedAudioPlayerRateManager.swift
|
||||
// Audiobookshelf
|
||||
//
|
||||
// Created by Marke Hallowell on 4/14/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
class DefaultedAudioPlayerRateManager: NSObject, AudioPlayerRateManager {
|
||||
internal let logger = AppLogger(category: "DefaultedAudioPlayerRateManager")
|
||||
|
||||
internal var audioPlayer: AVPlayer
|
||||
|
||||
// MARK: - AudioPlayerRateManager
|
||||
public private(set) var defaultRate: Float
|
||||
public private(set) var rate: Float
|
||||
public var rateChangedCompletion: () -> Void
|
||||
public var defaultRateChangedCompletion: () -> Void
|
||||
|
||||
required init(audioPlayer: AVPlayer, defaultRate: Float) {
|
||||
self.audioPlayer = audioPlayer
|
||||
self.rateChangedCompletion = {}
|
||||
self.defaultRateChangedCompletion = {}
|
||||
self.rate = self.audioPlayer.rate
|
||||
self.defaultRate = defaultRate
|
||||
self.audioPlayer.defaultRate = defaultRate
|
||||
|
||||
super.init()
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(handleObservedRateChange), name: AVPlayer.rateDidChangeNotification, object: self.audioPlayer)
|
||||
}
|
||||
|
||||
public func setPlaybackRate(_ rate: Float) {
|
||||
self.handlePlaybackRateChange(rate, observed: false)
|
||||
}
|
||||
|
||||
// No-op (player automatically resumes at last-known defaultRate)
|
||||
public func handlePlayEvent() { }
|
||||
|
||||
// MARK: - Destructor
|
||||
public func destroy() {
|
||||
NotificationCenter.default.removeObserver(self, name: AVPlayer.rateDidChangeNotification, object: self.audioPlayer)
|
||||
}
|
||||
|
||||
// MARK: - Internal
|
||||
internal func handlePlaybackRateChange(_ rate: Float, observed: Bool = false) {
|
||||
let playbackSpeedChanged = rate > 0.0 && rate != self.defaultRate
|
||||
|
||||
if playbackSpeedChanged {
|
||||
self.defaultRate = rate
|
||||
self.audioPlayer.defaultRate = rate
|
||||
self.defaultRateChangedCompletion()
|
||||
|
||||
// Check to see if we also need to make a temporary rate change to player
|
||||
if self.audioPlayer.rate > 0.0 {
|
||||
if self.audioPlayer.rate != rate {
|
||||
self.rate = rate
|
||||
self.audioPlayer.rate = rate
|
||||
self.rateChangedCompletion()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.rate = rate
|
||||
self.rateChangedCompletion()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - iOS rate change notification handler
|
||||
@objc internal func handleObservedRateChange(notification: Notification) {
|
||||
// TODO: Consider handling cases individually (e.g. overall session impact?)
|
||||
/*
|
||||
guard let reason = notification.userInfo?[AVPlayer.rateDidChangeReasonKey] as? AVPlayer.RateDidChangeReason else {
|
||||
return
|
||||
}
|
||||
|
||||
switch reason {
|
||||
case .appBackgrounded:
|
||||
// App transitioned to the background.
|
||||
case .audioSessionInterrupted:
|
||||
// The system interrupts the app's audio session.
|
||||
case .setRateCalled:
|
||||
// The app set the player's rate.
|
||||
case .setRateFailed:
|
||||
// An attempt to change the player's rate failed.
|
||||
default:
|
||||
break
|
||||
}
|
||||
*/
|
||||
self.handlePlaybackRateChange(self.audioPlayer.rate, observed: true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
//
|
||||
// LegacyAudioPlayerRateManager.swift
|
||||
// Audiobookshelf
|
||||
//
|
||||
// Created by Marke Hallowell on 4/14/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
|
||||
class LegacyAudioPlayerRateManager: NSObject, AudioPlayerRateManager {
|
||||
internal let logger = AppLogger(category: "AudioPlayer")
|
||||
|
||||
internal var audioPlayer: AVPlayer
|
||||
|
||||
internal var managerContext = 0
|
||||
|
||||
// MARK: - AudioPlayerRateManager
|
||||
public private(set) var defaultRate: Float
|
||||
public private(set) var rate: Float
|
||||
public var rateChangedCompletion: () -> Void
|
||||
public var defaultRateChangedCompletion: () -> Void
|
||||
|
||||
required init(audioPlayer: AVPlayer, defaultRate: Float) {
|
||||
self.rate = 0.0
|
||||
self.defaultRate = defaultRate
|
||||
self.audioPlayer = audioPlayer
|
||||
self.rateChangedCompletion = {}
|
||||
self.defaultRateChangedCompletion = {}
|
||||
|
||||
super.init()
|
||||
|
||||
self.audioPlayer.addObserver(self, forKeyPath: #keyPath(AVPlayer.rate), options: .new, context: &managerContext)
|
||||
}
|
||||
|
||||
public func setPlaybackRate(_ rate: Float) {
|
||||
self.handlePlaybackRateChange(rate, observed: false)
|
||||
}
|
||||
|
||||
public func handlePlayEvent() {
|
||||
DispatchQueue.runOnMainQueue {
|
||||
self.audioPlayer.rate = self.defaultRate
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Destructor
|
||||
public func destroy() {
|
||||
// Remove Observer
|
||||
self.audioPlayer.removeObserver(self, forKeyPath: #keyPath(AVPlayer.rate), context: &managerContext)
|
||||
}
|
||||
|
||||
// MARK: - Internal
|
||||
internal func handlePlaybackRateChange(_ rate: Float, observed: Bool = false) {
|
||||
let playbackSpeedChanged = rate > 0.0 && rate != self.defaultRate && !(observed && rate == 1)
|
||||
|
||||
if self.audioPlayer.rate != rate {
|
||||
logger.log("setPlaybakRate rate changed from \(self.audioPlayer.rate) to \(rate)")
|
||||
DispatchQueue.runOnMainQueue {
|
||||
self.audioPlayer.rate = rate
|
||||
}
|
||||
}
|
||||
|
||||
self.rate = rate
|
||||
self.rateChangedCompletion()
|
||||
|
||||
if playbackSpeedChanged {
|
||||
self.defaultRate = rate
|
||||
self.defaultRateChangedCompletion()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Observer
|
||||
public override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
|
||||
if context == &managerContext {
|
||||
if keyPath == #keyPath(AVPlayer.rate) {
|
||||
logger.log("playerContext observer player rate")
|
||||
self.handlePlaybackRateChange(change?[.newKey] as? Float ?? 1.0, observed: true)
|
||||
}
|
||||
} else {
|
||||
super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@ class PlayerHandler {
|
||||
public static var paused: Bool {
|
||||
get {
|
||||
guard let player = player else { return true }
|
||||
return player.rate == 0.0
|
||||
return player.rateManager.rate == 0.0
|
||||
}
|
||||
set(paused) {
|
||||
if paused {
|
||||
@@ -61,6 +61,10 @@ class PlayerHandler {
|
||||
self.player?.setPlaybackRate(speed)
|
||||
}
|
||||
|
||||
public static func setChapterTrack() {
|
||||
self.player?.setChapterTrack()
|
||||
}
|
||||
|
||||
public static func getSleepTimeRemaining() -> Double? {
|
||||
return self.player?.getSleepTimeRemaining()
|
||||
}
|
||||
@@ -131,6 +135,10 @@ class PlayerHandler {
|
||||
)
|
||||
}
|
||||
|
||||
public static func updateRemoteTransportControls() {
|
||||
self.player?.setupRemoteTransportControls()
|
||||
}
|
||||
|
||||
// MARK: - Helper logic
|
||||
|
||||
private static func cleanupOldSessions(currentSessionId: String?) {
|
||||
|
||||
@@ -190,10 +190,10 @@ class ApiClient {
|
||||
}
|
||||
|
||||
public static func reportAllLocalPlaybackSessions(_ sessions: [PlaybackSession]) async -> Bool {
|
||||
return await postResource(endpoint: "api/session/local-all", parameters: LocalPlaybackSessionSyncAllPayload(sessions: sessions))
|
||||
return await postResource(endpoint: "api/session/local-all", parameters: LocalPlaybackSessionSyncAllPayload(sessions: sessions, deviceInfo: sessions.first?.deviceInfo))
|
||||
}
|
||||
|
||||
public static func syncLocalSessionsWithServer() async {
|
||||
public static func syncLocalSessionsWithServer(isFirstSync: Bool) async {
|
||||
do {
|
||||
// Sync server progress with local media progress
|
||||
let localMediaProgressList = Database.shared.getAllLocalMediaProgress().filter {
|
||||
@@ -232,13 +232,15 @@ class ApiClient {
|
||||
let playbackSessions = Database.shared.getAllPlaybackSessions().filter {
|
||||
$0.serverConnectionConfigId == Store.serverConfig?.id
|
||||
}.map { $0.freeze() }
|
||||
logger.log("syncLocalSessionsWithServer: Found \(playbackSessions.count) playback sessions for server")
|
||||
logger.log("syncLocalSessionsWithServer: Found \(playbackSessions.count) playback sessions for server (first sync: \(isFirstSync))")
|
||||
if (!playbackSessions.isEmpty) {
|
||||
let success = await ApiClient.reportAllLocalPlaybackSessions(playbackSessions)
|
||||
if (success) {
|
||||
// Remove sessions from db
|
||||
try playbackSessions.forEach { session in
|
||||
if (!session.isActiveSession) {
|
||||
logger.log("syncLocalSessionsWithServer: Handling \(session.displayTitle ?? "") (\(session.id)) \(session.isActiveSession)")
|
||||
// On first sync then remove all sessions
|
||||
if (!session.isActiveSession || isFirstSync) {
|
||||
if let session = session.thaw() {
|
||||
try session.delete()
|
||||
}
|
||||
@@ -327,6 +329,7 @@ struct LocalMediaProgressSyncResultsPayload: Codable {
|
||||
|
||||
struct LocalPlaybackSessionSyncAllPayload: Codable {
|
||||
var sessions: [PlaybackSession]
|
||||
var deviceInfo: [String: String?]?
|
||||
}
|
||||
|
||||
struct Connectivity {
|
||||
|
||||
@@ -53,15 +53,27 @@ class NowPlayingInfo {
|
||||
self.setMetadata(artwork: artwork, metadata: metadata)
|
||||
}
|
||||
}
|
||||
public func update(duration: Double, currentTime: Double, rate: Float) {
|
||||
public func update(duration: Double, currentTime: Double, rate: Float, defaultRate: Float, chapterName: String? = nil, chapterNumber: Int? = nil, chapterCount: Int? = nil) {
|
||||
// Update on the main to prevent access collisions
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
if let self = self {
|
||||
self.nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = duration
|
||||
self.nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTime
|
||||
self.nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = rate
|
||||
self.nowPlayingInfo[MPNowPlayingInfoPropertyDefaultPlaybackRate] = 1.0
|
||||
self.nowPlayingInfo[MPNowPlayingInfoPropertyDefaultPlaybackRate] = defaultRate
|
||||
|
||||
|
||||
if let chapterName = chapterName, let chapterNumber = chapterNumber, let chapterCount = chapterCount {
|
||||
self.nowPlayingInfo[MPMediaItemPropertyTitle] = chapterName
|
||||
self.nowPlayingInfo[MPNowPlayingInfoPropertyChapterNumber] = chapterNumber
|
||||
self.nowPlayingInfo[MPNowPlayingInfoPropertyChapterCount] = chapterCount
|
||||
} else {
|
||||
// Set the title back to the book title
|
||||
self.nowPlayingInfo[MPMediaItemPropertyTitle] = self.nowPlayingInfo[MPMediaItemPropertyAlbumTitle]
|
||||
self.nowPlayingInfo[MPNowPlayingInfoPropertyChapterNumber] = nil
|
||||
self.nowPlayingInfo[MPNowPlayingInfoPropertyChapterCount] = nil
|
||||
}
|
||||
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = self.nowPlayingInfo
|
||||
}
|
||||
}
|
||||
@@ -89,7 +101,7 @@ class NowPlayingInfo {
|
||||
|
||||
nowPlayingInfo[MPMediaItemPropertyTitle] = metadata!.title
|
||||
nowPlayingInfo[MPMediaItemPropertyArtist] = metadata!.author ?? "unknown"
|
||||
nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = metadata!.series
|
||||
nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = metadata!.title
|
||||
}
|
||||
|
||||
private func shouldFetchCover(id: String) -> Bool {
|
||||
|
||||
+6
-9
@@ -45,7 +45,7 @@ export default {
|
||||
if (timeSinceDisconnect > 5000) {
|
||||
console.log('Time since disconnect was', timeSinceDisconnect, 'sync with server')
|
||||
setTimeout(() => {
|
||||
this.syncLocalSessions()
|
||||
this.syncLocalSessions(false)
|
||||
}, 4000)
|
||||
}
|
||||
}
|
||||
@@ -139,7 +139,7 @@ export default {
|
||||
|
||||
console.log(`[default] Got server config, attempt authorize ${serverConfig.address}`)
|
||||
|
||||
const authRes = await this.postRequest(`${serverConfig.address}/api/authorize`, null, { Authorization: `Bearer ${serverConfig.token}` }, 10000).catch((error) => {
|
||||
const authRes = await this.postRequest(`${serverConfig.address}/api/authorize`, null, { Authorization: `Bearer ${serverConfig.token}` }, 6000).catch((error) => {
|
||||
console.error('[default] Server auth failed', error)
|
||||
return false
|
||||
})
|
||||
@@ -195,14 +195,14 @@ export default {
|
||||
this.$eventBus.$emit('library-changed')
|
||||
this.inittingLibraries = false
|
||||
},
|
||||
async syncLocalSessions() {
|
||||
async syncLocalSessions(isFirstSync) {
|
||||
if (!this.user) {
|
||||
console.log('[default] No need to sync local sessions - not connected to server')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[default] Calling syncLocalSessions')
|
||||
const response = await this.$db.syncLocalSessionsWithServer()
|
||||
const response = await this.$db.syncLocalSessionsWithServer(isFirstSync)
|
||||
if (response?.error) {
|
||||
console.error('[default] Failed to sync local sessions', response.error)
|
||||
} else {
|
||||
@@ -212,8 +212,7 @@ export default {
|
||||
}
|
||||
},
|
||||
userUpdated(user) {
|
||||
// console.log('[default] userUpdated:', JSON.stringify(user))
|
||||
if (this.user && this.user.id == user.id) {
|
||||
if (this.user?.id == user.id) {
|
||||
this.$store.commit('user/setUser', user)
|
||||
}
|
||||
},
|
||||
@@ -326,8 +325,6 @@ export default {
|
||||
|
||||
await this.$store.dispatch('setupNetworkListener')
|
||||
|
||||
await this.$store.dispatch('globals/loadLocalMediaProgress')
|
||||
|
||||
if (this.$store.state.user.serverConnectionConfig) {
|
||||
await this.initLibraries()
|
||||
} else {
|
||||
@@ -335,7 +332,7 @@ export default {
|
||||
}
|
||||
|
||||
console.log(`[default] finished connection attempt or already connected ${!!this.user}`)
|
||||
await this.syncLocalSessions()
|
||||
await this.syncLocalSessions(true)
|
||||
|
||||
this.hasMounted = true
|
||||
|
||||
|
||||
+9
-5
@@ -2,7 +2,8 @@ const pkg = require('./package.json')
|
||||
|
||||
export default {
|
||||
ssr: false,
|
||||
|
||||
target: 'static',
|
||||
telemetry: false,
|
||||
env: {
|
||||
PROD: '1',
|
||||
ANDROID_APP_URL: 'https://play.google.com/store/apps/details?id=com.audiobookshelf.app',
|
||||
@@ -35,6 +36,7 @@ export default {
|
||||
},
|
||||
|
||||
css: [
|
||||
'@/assets/tailwind.css',
|
||||
'@/assets/app.css'
|
||||
],
|
||||
|
||||
@@ -55,10 +57,6 @@ export default {
|
||||
|
||||
components: true,
|
||||
|
||||
buildModules: [
|
||||
'@nuxtjs/tailwindcss',
|
||||
],
|
||||
|
||||
modules: [
|
||||
'@nuxtjs/axios'
|
||||
],
|
||||
@@ -66,6 +64,12 @@ export default {
|
||||
axios: {},
|
||||
|
||||
build: {
|
||||
postcss: {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
},
|
||||
babel: {
|
||||
plugins: [['@babel/plugin-proposal-private-property-in-object', { loose: true }]],
|
||||
},
|
||||
|
||||
Generated
+2375
-17836
File diff suppressed because it is too large
Load Diff
+6
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf-app",
|
||||
"version": "0.9.70-beta",
|
||||
"version": "0.9.74-beta",
|
||||
"author": "advplyr",
|
||||
"scripts": {
|
||||
"dev": "nuxt --hostname 0.0.0.0 --port 1337",
|
||||
@@ -13,6 +13,7 @@
|
||||
"ionic:serve": "npm run start"
|
||||
},
|
||||
"dependencies": {
|
||||
"@byteowls/capacitor-filesharer": "^5.0.0",
|
||||
"@capacitor/android": "^5.0.0",
|
||||
"@capacitor/app": "^5.0.6",
|
||||
"@capacitor/browser": "^5.1.0",
|
||||
@@ -43,8 +44,8 @@
|
||||
"@babel/core": "7.13.15",
|
||||
"@babel/preset-env": "7.13.15",
|
||||
"@capacitor/cli": "^5.0.0",
|
||||
"@nuxtjs/tailwindcss": "^4.2.1",
|
||||
"postcss": "^8.3.5",
|
||||
"tailwindcss": "^3.3.2"
|
||||
"autoprefixer": "^10.4.17",
|
||||
"postcss": "^8.4.33",
|
||||
"tailwindcss": "^3.4.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div id="bookshelf" class="w-full h-full p-4 overflow-y-auto">
|
||||
<div class="flex flex-wrap justify-center">
|
||||
<template v-for="author in authors">
|
||||
<cards-author-card :key="author.id" :author="author" :width="96" :height="120" class="p-2" />
|
||||
<cards-author-card :key="author.id" :author="author" :width="cardWidth" :height="cardHeight" class="p-2" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@@ -16,16 +16,21 @@ export default {
|
||||
return {
|
||||
loading: true,
|
||||
authors: [],
|
||||
loadedLibraryId: null
|
||||
loadedLibraryId: null,
|
||||
cardWidth: 200
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
cardHeight() {
|
||||
return this.cardWidth * 1.25
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async init() {
|
||||
this.cardWidth = (window.innerWidth - 64) / 2
|
||||
if (!this.currentLibraryId) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -236,7 +236,7 @@ export default {
|
||||
console.log('[categories] Local shelves set', this.shelves.length, this.lastLocalFetch)
|
||||
|
||||
if (isConnectedToServerWithInternet) {
|
||||
const categories = await this.$nativeHttp.get(`/api/libraries/${this.currentLibraryId}/personalized?minified=1&include=rssfeed,numEpisodesIncomplete`).catch((error) => {
|
||||
const categories = await this.$nativeHttp.get(`/api/libraries/${this.currentLibraryId}/personalized?minified=1&include=rssfeed,numEpisodesIncomplete`, { connectTimeout: 10000 }).catch((error) => {
|
||||
console.error('[categories] Failed to fetch categories', error)
|
||||
return []
|
||||
})
|
||||
@@ -327,8 +327,13 @@ export default {
|
||||
this.$eventBus.$off('library-changed', this.libraryChanged)
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
async mounted() {
|
||||
if (this.$route.query.error) {
|
||||
this.$toast.error(this.$route.query.error)
|
||||
}
|
||||
|
||||
this.initListeners()
|
||||
await this.$store.dispatch('globals/loadLocalMediaProgress')
|
||||
console.log(`[categories] mounted so fetching categories`)
|
||||
this.fetchCategories()
|
||||
},
|
||||
|
||||
+244
-183
@@ -1,149 +1,166 @@
|
||||
<template>
|
||||
<div id="item-page" class="w-full h-full px-3 pb-4 overflow-y-auto overflow-x-hidden relative bg-bg">
|
||||
<div class="fixed top-0 left-0 w-full h-full pointer-events-none p-px z-10">
|
||||
<div class="w-full h-full" :style="{ backgroundColor: coverRgb }" />
|
||||
<div class="w-full h-full absolute top-0 left-0" style="background: var(--gradient-item-page)" />
|
||||
</div>
|
||||
|
||||
<div class="z-10 relative">
|
||||
<!-- cover -->
|
||||
<div class="w-full flex justify-center relative mb-4">
|
||||
<div style="width: 0; transform: translateX(-50vw); overflow: visible">
|
||||
<div style="width: 150vw; overflow: hidden">
|
||||
<div id="coverBg" style="filter: blur(5vw)">
|
||||
<covers-book-cover :library-item="libraryItem" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" @imageLoaded="coverImageLoaded" />
|
||||
</div>
|
||||
<div v-if="!libraryItem" class="w-full h-full relative flex items-center justify-center bg-bg">
|
||||
<ui-loading-indicator />
|
||||
</div>
|
||||
<div v-else id="item-page" class="w-full h-full overflow-y-auto overflow-x-hidden relative bg-bg">
|
||||
<!-- cover -->
|
||||
<div class="w-full flex justify-center relative">
|
||||
<div style="width: 0; transform: translateX(-50vw); overflow: visible">
|
||||
<div style="width: 150vw; overflow: hidden">
|
||||
<div id="coverBg" style="filter: blur(5vw)">
|
||||
<covers-book-cover :library-item="libraryItem" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" @imageLoaded="coverImageLoaded" />
|
||||
</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" />
|
||||
<div v-if="!isPodcast" class="absolute bottom-0 left-0 h-1 shadow-sm z-10" :class="userIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: coverWidth * progressPercent + 'px' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- title -->
|
||||
<div class="text-center mb-2">
|
||||
<h1 class="text-xl font-semibold">{{ title }}</h1>
|
||||
<p v-if="subtitle" class="text-fg text-base">{{ subtitle }}</p>
|
||||
<div class="relative" @click="showFullscreenCover = true">
|
||||
<covers-book-cover :library-item="libraryItem" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" no-bg raw @imageLoaded="coverImageLoaded" />
|
||||
<div v-if="!isPodcast" class="absolute bottom-0 left-0 h-1 shadow-sm z-10" :class="userIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: coverWidth * progressPercent + 'px' }"></div>
|
||||
</div>
|
||||
|
||||
<div v-if="hasLocal" class="mx-1">
|
||||
<div v-if="isLocalOnly" class="w-full rounded-md bg-warning/10 border border-warning p-4">
|
||||
<p class="text-sm">{{ $strings.MessageMediaNotLinkedToServer }}</p>
|
||||
</div>
|
||||
<div v-else-if="currentServerConnectionConfigId && !isLocalMatchingServerAddress" class="w-full rounded-md bg-warning/10 border border-warning p-4">
|
||||
<p class="text-sm">{{ $getString('MessageMediaLinkedToADifferentServer', [localLibraryItem.serverAddress]) }}</p>
|
||||
</div>
|
||||
<div v-else-if="currentServerConnectionConfigId && !isLocalMatchingUser" class="w-full rounded-md bg-warning/10 border border-warning p-4">
|
||||
<p class="text-sm">{{ $strings.MessageMediaLinkedToADifferentUser }}</p>
|
||||
</div>
|
||||
<div v-else-if="currentServerConnectionConfigId && !isLocalMatchingConnectionConfig" class="w-full rounded-md bg-warning/10 border border-warning p-4">
|
||||
<p class="text-sm">Media is linked to a different server connection config. Downloaded User Id: {{ localLibraryItem.serverUserId }}. Downloaded Server Address: {{ localLibraryItem.serverAddress }}. Currently connected User Id: {{ user.id }}. Currently connected server address: {{ currentServerAddress }}.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- action buttons -->
|
||||
<div class="col-span-full">
|
||||
<div v-if="showPlay || showRead" class="flex mt-4 -mx-1">
|
||||
<ui-btn v-if="showPlay" color="success" class="flex items-center justify-center flex-grow mx-1" :loading="playerIsStartingForThisMedia" :padding-x="4" @click="playClick">
|
||||
<span class="material-icons">{{ playerIsPlaying ? 'pause' : 'play_arrow' }}</span>
|
||||
<span class="px-1 text-sm">{{ playerIsPlaying ? $strings.ButtonPause : isPodcast ? $strings.ButtonNextEpisode : hasLocal ? $strings.ButtonPlay : $strings.ButtonStream }}</span>
|
||||
</ui-btn>
|
||||
<ui-btn v-if="showRead" color="info" class="flex items-center justify-center mx-1" :class="showPlay ? '' : 'flex-grow'" :padding-x="2" @click="readBook">
|
||||
<span class="material-icons">auto_stories</span>
|
||||
<span v-if="!showPlay" class="px-2 text-base">{{ $strings.ButtonRead }} {{ ebookFormat }}</span>
|
||||
</ui-btn>
|
||||
<ui-btn v-if="showDownload" :color="downloadItem ? 'warning' : 'primary'" class="flex items-center justify-center mx-1" :padding-x="2" @click="downloadClick">
|
||||
<span class="material-icons" :class="(downloadItem || startingDownload) ? 'animate-pulse' : ''">{{ (downloadItem || startingDownload) ? 'downloading' : 'download' }}</span>
|
||||
</ui-btn>
|
||||
<ui-btn color="primary" class="flex items-center justify-center mx-1" :padding-x="2" @click="moreButtonPress">
|
||||
<span class="material-icons">more_vert</span>
|
||||
</ui-btn>
|
||||
</div>
|
||||
|
||||
<div v-if="!isPodcast && progressPercent > 0" class="px-4 py-2 bg-primary text-sm font-semibold rounded-md text-fg mt-4 text-center">
|
||||
<p>{{ $strings.LabelYourProgress }}: {{ Math.round(progressPercent * 100) }}%</p>
|
||||
<p v-if="!useEBookProgress && !userIsFinished" class="text-fg-muted text-xs">{{ $getString('LabelTimeRemaining', [$elapsedPretty(userTimeRemaining)]) }}</p>
|
||||
<p v-else-if="userIsFinished" class="text-fg-muted text-xs">{{ $strings.LabelFinished }} {{ $formatDate(userProgressFinishedAt) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="downloadItem" class="py-3">
|
||||
<p v-if="downloadItem.itemProgress == 1" class="text-center text-lg">{{ $strings.MessageDownloadCompleteProcessing }}</p>
|
||||
<p v-else class="text-center text-lg">{{ $strings.MessageDownloading }} ({{ Math.round(downloadItem.itemProgress * 100) }}%)</p>
|
||||
</div>
|
||||
|
||||
<!-- metadata -->
|
||||
<div id="metadata" class="grid gap-2 my-2" style>
|
||||
<div v-if="podcastAuthor || (bookAuthors && bookAuthors.length)" class="text-fg-muted uppercase text-sm">{{ $strings.LabelAuthor }}</div>
|
||||
<div v-if="podcastAuthor" class="text-sm">{{ podcastAuthor }}</div>
|
||||
<div v-else-if="bookAuthors && bookAuthors.length" class="text-sm">
|
||||
<template v-for="(author, index) in bookAuthors">
|
||||
<nuxt-link :key="author.id" :to="`/bookshelf/library?filter=authors.${$encode(author.id)}`" class="underline">{{ author.name }}</nuxt-link>
|
||||
<span :key="`${author.id}-comma`" v-if="index < bookAuthors.length - 1">,</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="podcastType" class="text-fg-muted uppercase text-sm">{{ $strings.LabelType }}</div>
|
||||
<div v-if="podcastType" class="text-sm capitalize">{{ podcastType }}</div>
|
||||
|
||||
<div v-if="series && series.length" class="text-fg-muted uppercase text-sm">{{ $strings.LabelSeries }}</div>
|
||||
<div v-if="series && series.length" class="truncate text-sm">
|
||||
<template v-for="(series, index) in seriesList">
|
||||
<nuxt-link :key="series.id" :to="`/bookshelf/series/${series.id}`" class="underline">{{ series.text }}</nuxt-link>
|
||||
<span :key="`${series.id}-comma`" v-if="index < seriesList.length - 1">,</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="numTracks" class="text-fg-muted uppercase text-sm">{{ $strings.LabelDuration }}</div>
|
||||
<div v-if="numTracks" class="text-sm">{{ $elapsedPretty(duration) }}</div>
|
||||
|
||||
<div v-if="narrators && narrators.length" class="text-fg-muted uppercase text-sm">{{ $strings.LabelNarrators }}</div>
|
||||
<div v-if="narrators && narrators.length" class="truncate text-sm">
|
||||
<template v-for="(narrator, index) in narrators">
|
||||
<nuxt-link :key="narrator" :to="`/bookshelf/library?filter=narrators.${$encode(narrator)}`" class="underline">{{ narrator }}</nuxt-link>
|
||||
<span :key="index" v-if="index < narrators.length - 1">,</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="genres.length" class="text-fg-muted uppercase text-sm">{{ $strings.LabelGenres }}</div>
|
||||
<div v-if="genres.length" class="truncate text-sm">
|
||||
<template v-for="(genre, index) in genres">
|
||||
<nuxt-link :key="genre" :to="`/bookshelf/library?filter=genres.${$encode(genre)}`" class="underline">{{ genre }}</nuxt-link>
|
||||
<span :key="index" v-if="index < genres.length - 1">,</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="publishedYear" class="text-fg-muted uppercase text-sm">{{ $strings.LabelPublishYear }}</div>
|
||||
<div v-if="publishedYear" class="text-sm">{{ publishedYear }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="description" class="w-full py-2">
|
||||
<p ref="description" class="text-sm text-justify whitespace-pre-line font-light" :class="{ 'line-clamp-4': !showFullDescription }" style="hyphens: auto">{{ description }}</p>
|
||||
|
||||
<div v-if="descriptionClamped" class="text-fg text-sm py-2" @click="showFullDescription = !showFullDescription">
|
||||
{{ showFullDescription ? 'Read less' : 'Read more' }}
|
||||
<span class="material-icons align-middle text-base -mt-px">{{ showFullDescription ? 'expand_less' : 'expand_more' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- tables -->
|
||||
<tables-podcast-episodes-table v-if="isPodcast" :library-item="libraryItem" :local-library-item-id="localLibraryItemId" :episodes="episodes" :local-episodes="localLibraryItemEpisodes" :is-local="isLocal" />
|
||||
|
||||
<tables-chapters-table v-if="numChapters" :library-item="libraryItem" @playAtTimestamp="playAtTimestamp" />
|
||||
|
||||
<tables-tracks-table v-if="numTracks" :tracks="tracks" :library-item-id="libraryItemId" />
|
||||
|
||||
<tables-ebook-files-table v-if="ebookFiles.length" :library-item="libraryItem" />
|
||||
|
||||
<!-- modals -->
|
||||
<modals-item-more-menu-modal v-model="showMoreMenu" :library-item="libraryItem" :rss-feed="rssFeed" :processing.sync="processing" />
|
||||
|
||||
<modals-select-local-folder-modal v-model="showSelectLocalFolder" :media-type="mediaType" @select="selectedLocalFolder" />
|
||||
|
||||
<modals-fullscreen-cover v-model="showFullscreenCover" :library-item="libraryItem" />
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<!-- background gradient -->
|
||||
<div id="item-page-bg-gradient" class="absolute top-0 left-0 w-full pointer-events-none z-0" :style="{ opacity: coverRgb ? 1 : 0 }">
|
||||
<div class="w-full h-full" :style="{ backgroundColor: coverRgb }" />
|
||||
<div class="w-full h-full absolute top-0 left-0" style="background: var(--gradient-item-page)" />
|
||||
</div>
|
||||
|
||||
<div class="relative z-10 px-3 py-4">
|
||||
<!-- title -->
|
||||
<div class="text-center mb-2">
|
||||
<div class="flex items-center justify-center">
|
||||
<h1 class="text-xl font-semibold">{{ title }}</h1>
|
||||
<widgets-explicit-indicator v-if="isExplicit" />
|
||||
</div>
|
||||
<p v-if="subtitle" class="text-fg text-base">{{ subtitle }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="hasLocal" class="mx-1">
|
||||
<div v-if="isLocalOnly" class="w-full rounded-md bg-warning/10 border border-warning p-4">
|
||||
<p class="text-sm">{{ $strings.MessageMediaNotLinkedToServer }}</p>
|
||||
</div>
|
||||
<div v-else-if="currentServerConnectionConfigId && !isLocalMatchingServerAddress" class="w-full rounded-md bg-warning/10 border border-warning p-4">
|
||||
<p class="text-sm">{{ $getString('MessageMediaLinkedToADifferentServer', [localLibraryItem.serverAddress]) }}</p>
|
||||
</div>
|
||||
<div v-else-if="currentServerConnectionConfigId && !isLocalMatchingUser" class="w-full rounded-md bg-warning/10 border border-warning p-4">
|
||||
<p class="text-sm">{{ $strings.MessageMediaLinkedToADifferentUser }}</p>
|
||||
</div>
|
||||
<div v-else-if="currentServerConnectionConfigId && !isLocalMatchingConnectionConfig" class="w-full rounded-md bg-warning/10 border border-warning p-4">
|
||||
<p class="text-sm">Media is linked to a different server connection config. Downloaded User Id: {{ localLibraryItem.serverUserId }}. Downloaded Server Address: {{ localLibraryItem.serverAddress }}. Currently connected User Id: {{ user.id }}. Currently connected server address: {{ currentServerAddress }}.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- action buttons -->
|
||||
<div class="col-span-full">
|
||||
<div v-if="showPlay || showRead" class="flex mt-4 -mx-1">
|
||||
<ui-btn v-if="showPlay" color="success" class="flex items-center justify-center flex-grow mx-1" :loading="playerIsStartingForThisMedia" :padding-x="4" @click="playClick">
|
||||
<span class="material-icons">{{ playerIsPlaying ? 'pause' : 'play_arrow' }}</span>
|
||||
<span class="px-1 text-sm">{{ playerIsPlaying ? $strings.ButtonPause : isPodcast ? $strings.ButtonNextEpisode : hasLocal ? $strings.ButtonPlay : $strings.ButtonStream }}</span>
|
||||
</ui-btn>
|
||||
<ui-btn v-if="showRead" color="info" class="flex items-center justify-center mx-1" :class="showPlay ? '' : 'flex-grow'" :padding-x="2" @click="readBook">
|
||||
<span class="material-icons">auto_stories</span>
|
||||
<span v-if="!showPlay" class="px-2 text-base">{{ $strings.ButtonRead }} {{ ebookFormat }}</span>
|
||||
</ui-btn>
|
||||
<ui-btn v-if="showDownload" :color="downloadItem ? 'warning' : 'primary'" class="flex items-center justify-center mx-1" :padding-x="2" @click="downloadClick">
|
||||
<span class="material-icons" :class="downloadItem || startingDownload ? 'animate-pulse' : ''">{{ downloadItem || startingDownload ? 'downloading' : 'download' }}</span>
|
||||
</ui-btn>
|
||||
<ui-btn color="primary" class="flex items-center justify-center mx-1" :padding-x="2" @click="moreButtonPress">
|
||||
<span class="material-icons">more_vert</span>
|
||||
</ui-btn>
|
||||
</div>
|
||||
|
||||
<div v-if="!isPodcast && progressPercent > 0" class="px-4 py-2 bg-primary text-sm font-semibold rounded-md text-fg mt-4 text-center">
|
||||
<p>{{ $strings.LabelYourProgress }}: {{ Math.round(progressPercent * 100) }}%</p>
|
||||
<p v-if="!useEBookProgress && !userIsFinished" class="text-fg-muted text-xs">{{ $getString('LabelTimeRemaining', [$elapsedPretty(userTimeRemaining)]) }}</p>
|
||||
<p v-else-if="userIsFinished" class="text-fg-muted text-xs">{{ $strings.LabelFinished }} {{ $formatDate(userProgressFinishedAt) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="downloadItem" class="py-3">
|
||||
<p v-if="downloadItem.itemProgress == 1" class="text-center text-lg">{{ $strings.MessageDownloadCompleteProcessing }}</p>
|
||||
<p v-else class="text-center text-lg">{{ $strings.MessageDownloading }} ({{ Math.round(downloadItem.itemProgress * 100) }}%)</p>
|
||||
</div>
|
||||
|
||||
<!-- metadata -->
|
||||
<div id="metadata" class="grid gap-2 my-2" style>
|
||||
<div v-if="podcastAuthor || bookAuthors?.length" class="text-fg-muted uppercase text-sm">{{ $strings.LabelAuthor }}</div>
|
||||
<div v-if="podcastAuthor" class="text-sm">{{ podcastAuthor }}</div>
|
||||
<div v-else-if="bookAuthors?.length" class="text-sm">
|
||||
<template v-for="(author, index) in bookAuthors">
|
||||
<nuxt-link :key="author.id" :to="`/bookshelf/library?filter=authors.${$encode(author.id)}`" class="underline whitespace-nowrap">{{ author.name }}</nuxt-link
|
||||
><span :key="`${author.id}-comma`" v-if="index < bookAuthors.length - 1">, </span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="podcastType" class="text-fg-muted uppercase text-sm">{{ $strings.LabelType }}</div>
|
||||
<div v-if="podcastType" class="text-sm capitalize">{{ podcastType }}</div>
|
||||
|
||||
<div v-if="series?.length" class="text-fg-muted uppercase text-sm">{{ $strings.LabelSeries }}</div>
|
||||
<div v-if="series?.length" class="text-sm">
|
||||
<template v-for="(series, index) in seriesList">
|
||||
<nuxt-link :key="series.id" :to="`/bookshelf/series/${series.id}`" class="underline whitespace-nowrap">{{ series.text }}</nuxt-link
|
||||
><span :key="`${series.id}-comma`" v-if="index < seriesList.length - 1">, </span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="numTracks" class="text-fg-muted uppercase text-sm">{{ $strings.LabelDuration }}</div>
|
||||
<div v-if="numTracks" class="text-sm">{{ $elapsedPretty(duration) }}</div>
|
||||
|
||||
<div v-if="narrators?.length" class="text-fg-muted uppercase text-sm">{{ $strings.LabelNarrators }}</div>
|
||||
<div v-if="narrators?.length" class="text-sm">
|
||||
<template v-for="(narrator, index) in narrators">
|
||||
<nuxt-link :key="narrator" :to="`/bookshelf/library?filter=narrators.${$encode(narrator)}`" class="underline whitespace-nowrap">{{ narrator }}</nuxt-link
|
||||
><span :key="index" v-if="index < narrators.length - 1">, </span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="genres.length" class="text-fg-muted uppercase text-sm">{{ $strings.LabelGenres }}</div>
|
||||
<div v-if="genres.length" class="text-sm">
|
||||
<template v-for="(genre, index) in genres">
|
||||
<nuxt-link :key="genre" :to="`/bookshelf/library?filter=genres.${$encode(genre)}`" class="underline whitespace-nowrap">{{ genre }}</nuxt-link
|
||||
><span :key="index" v-if="index < genres.length - 1">, </span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="tags.length" class="text-fg-muted uppercase text-sm">{{ $strings.LabelTags }}</div>
|
||||
<div v-if="tags.length" class="text-sm">
|
||||
<template v-for="(tag, index) in tags">
|
||||
<nuxt-link :key="tag" :to="`/bookshelf/library?filter=tags.${$encode(tag)}`" class="underline whitespace-nowrap">{{ tag }}</nuxt-link
|
||||
><span :key="index" v-if="index < tags.length - 1">, </span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="publishedYear" class="text-fg-muted uppercase text-sm">{{ $strings.LabelPublishYear }}</div>
|
||||
<div v-if="publishedYear" class="text-sm">{{ publishedYear }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="description" class="w-full py-2">
|
||||
<p ref="description" class="text-sm text-justify whitespace-pre-line font-light" :class="{ 'line-clamp-4': !showFullDescription }" style="hyphens: auto">{{ description }}</p>
|
||||
|
||||
<div v-if="descriptionClamped" class="text-fg text-sm py-2" @click="showFullDescription = !showFullDescription">
|
||||
{{ showFullDescription ? 'Read less' : 'Read more' }}
|
||||
<span class="material-icons align-middle text-base -mt-px">{{ showFullDescription ? 'expand_less' : 'expand_more' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- tables -->
|
||||
<tables-podcast-episodes-table v-if="isPodcast" :library-item="libraryItem" :local-library-item-id="localLibraryItemId" :episodes="episodes" :local-episodes="localLibraryItemEpisodes" :is-local="isLocal" />
|
||||
|
||||
<tables-chapters-table v-if="numChapters" :library-item="libraryItem" @playAtTimestamp="playAtTimestamp" />
|
||||
|
||||
<tables-tracks-table v-if="numTracks" :tracks="tracks" :library-item-id="libraryItemId" />
|
||||
|
||||
<tables-ebook-files-table v-if="ebookFiles.length" :library-item="libraryItem" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- modals -->
|
||||
<modals-item-more-menu-modal v-model="showMoreMenu" :library-item="libraryItem" :rss-feed="rssFeed" :processing.sync="processing" />
|
||||
|
||||
<modals-select-local-folder-modal v-model="showSelectLocalFolder" :media-type="mediaType" @select="selectedLocalFolder" />
|
||||
|
||||
<modals-fullscreen-cover v-model="showFullscreenCover" :library-item="libraryItem" />
|
||||
|
||||
<div v-show="processing" class="fixed top-0 left-0 w-screen h-screen flex items-center justify-center bg-black/50 z-50">
|
||||
<ui-loading-indicator />
|
||||
</div>
|
||||
@@ -156,43 +173,37 @@ import { AbsFileSystem, AbsDownloader } from '@/plugins/capacitor'
|
||||
import { FastAverageColor } from 'fast-average-color'
|
||||
|
||||
export default {
|
||||
async asyncData({ store, params, redirect, app }) {
|
||||
async asyncData({ store, params, redirect, app, query }) {
|
||||
const libraryItemId = params.id
|
||||
let libraryItem = null
|
||||
|
||||
if (libraryItemId.startsWith('local')) {
|
||||
libraryItem = await app.$db.getLocalLibraryItem(libraryItemId)
|
||||
console.log('Got lli', libraryItemId)
|
||||
if (!libraryItem) {
|
||||
return redirect('/?error=Failed to get downloaded library item')
|
||||
}
|
||||
|
||||
// If library item is linked to the currently connected server then redirect to the page using the server library item id
|
||||
if (libraryItem?.libraryItemId?.startsWith('li_')) {
|
||||
// Detect old library item id
|
||||
console.error('Local library item has old server library item id', libraryItem.libraryItemId)
|
||||
} else if (libraryItem?.libraryItemId && libraryItem?.serverAddress === store.getters['user/getServerAddress'] && store.state.networkConnected) {
|
||||
let query = ''
|
||||
if (libraryItem.mediaType === 'podcast') query = '?episodefilter=downloaded' // Filter by downloaded when redirecting from the local copy
|
||||
return redirect(`/item/${libraryItem.libraryItemId}${query}`)
|
||||
}
|
||||
} else if (store.state.user.serverConnectionConfig) {
|
||||
libraryItem = await app.$nativeHttp.get(`/api/items/${libraryItemId}?expanded=1&include=rssfeed`).catch((error) => {
|
||||
console.error('Failed', error)
|
||||
return false
|
||||
})
|
||||
|
||||
if (libraryItem) {
|
||||
const localLibraryItem = await app.$db.getLocalLibraryItemByLId(libraryItemId)
|
||||
if (localLibraryItem) {
|
||||
console.log('Library item has local library item also', localLibraryItem.id)
|
||||
libraryItem.localLibraryItem = localLibraryItem
|
||||
} else if (query.noredirect !== '1' && libraryItem?.libraryItemId && libraryItem?.serverAddress === store.getters['user/getServerAddress'] && store.state.networkConnected) {
|
||||
const queryParams = new URLSearchParams()
|
||||
queryParams.set('localLibraryItemId', libraryItemId)
|
||||
if (libraryItem.mediaType === 'podcast') {
|
||||
// Filter by downloaded when redirecting from the local copy
|
||||
queryParams.set('episodefilter', 'downloaded')
|
||||
}
|
||||
return redirect(`/item/${libraryItem.libraryItemId}?${queryParams.toString()}`)
|
||||
}
|
||||
} else if (!store.state.user.serverConnectionConfig) {
|
||||
// Not connected to server
|
||||
return redirect('/?error=No server connection to get library item')
|
||||
}
|
||||
|
||||
if (!libraryItem) {
|
||||
console.error('No item...', params.id)
|
||||
return redirect('/')
|
||||
}
|
||||
return {
|
||||
libraryItem,
|
||||
rssFeed: libraryItem.rssFeed || null
|
||||
libraryItemId
|
||||
}
|
||||
},
|
||||
data() {
|
||||
@@ -201,7 +212,7 @@ export default {
|
||||
showSelectLocalFolder: false,
|
||||
showMoreMenu: false,
|
||||
showFullscreenCover: false,
|
||||
coverRgb: 'rgb(55, 56, 56)',
|
||||
coverRgb: null,
|
||||
coverBgIsLight: false,
|
||||
windowWidth: 0,
|
||||
descriptionClamped: false,
|
||||
@@ -285,8 +296,8 @@ export default {
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
libraryItemId() {
|
||||
return this.libraryItem.id
|
||||
rssFeed() {
|
||||
return this.libraryItem?.rssFeed
|
||||
},
|
||||
mediaType() {
|
||||
return this.libraryItem.mediaType
|
||||
@@ -297,6 +308,9 @@ export default {
|
||||
media() {
|
||||
return this.libraryItem.media || {}
|
||||
},
|
||||
tags() {
|
||||
return this.media.tags || []
|
||||
},
|
||||
mediaMetadata() {
|
||||
return this.media.metadata || {}
|
||||
},
|
||||
@@ -425,6 +439,9 @@ export default {
|
||||
isInvalid() {
|
||||
return this.libraryItem.isInvalid
|
||||
},
|
||||
isExplicit() {
|
||||
return !!this.mediaMetadata.explicit
|
||||
},
|
||||
showPlay() {
|
||||
return !this.isMissing && !this.isInvalid && (this.numTracks || this.episodes.length)
|
||||
},
|
||||
@@ -464,6 +481,9 @@ export default {
|
||||
|
||||
if (width * this.bookCoverAspectRatio > 325) width = 325 / this.bookCoverAspectRatio
|
||||
return width
|
||||
},
|
||||
coverHeight() {
|
||||
return this.coverWidth * this.bookCoverAspectRatio
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -705,27 +725,59 @@ export default {
|
||||
if (!this.libraryItem.libraryId) return
|
||||
await this.$store.dispatch('libraries/fetch', this.libraryItem.libraryId)
|
||||
this.$localStore.setLastLibraryId(this.libraryItem.libraryId)
|
||||
},
|
||||
init() {
|
||||
// If library of this item is different from current library then switch libraries
|
||||
if (this.$store.state.libraries.currentLibraryId !== this.libraryItem.libraryId) {
|
||||
this.setLibrary()
|
||||
}
|
||||
|
||||
this.windowWidth = window.innerWidth
|
||||
window.addEventListener('resize', this.windowResized)
|
||||
this.$eventBus.$on('library-changed', this.libraryChanged)
|
||||
this.$eventBus.$on('new-local-library-item', this.newLocalLibraryItem)
|
||||
this.$socket.$on('item_updated', this.itemUpdated)
|
||||
this.$socket.$on('rss_feed_open', this.rssFeedOpen)
|
||||
this.$socket.$on('rss_feed_closed', this.rssFeedClosed)
|
||||
this.checkDescriptionClamped()
|
||||
|
||||
// Set height of page below cover image
|
||||
const itemPageBgGradientHeight = window.outerHeight - 64 - this.coverHeight
|
||||
document.documentElement.style.setProperty('--item-page-bg-gradient-height', itemPageBgGradientHeight + 'px')
|
||||
|
||||
// Set last scroll position if was set for this item
|
||||
if (this.$store.state.lastItemScrollData.id === this.libraryItemId && window['item-page']) {
|
||||
window['item-page'].scrollTop = this.$store.state.lastItemScrollData.scrollTop || 0
|
||||
}
|
||||
},
|
||||
async loadServerLibraryItem() {
|
||||
console.log(`Fetching library item "${this.libraryItemId}" from server`)
|
||||
const libraryItem = await this.$nativeHttp.get(`/api/items/${this.libraryItemId}?expanded=1&include=rssfeed`, { connectTimeout: 5000 }).catch((error) => {
|
||||
console.error('Failed', error)
|
||||
return null
|
||||
})
|
||||
|
||||
if (libraryItem) {
|
||||
const localLibraryItem = await this.$db.getLocalLibraryItemByLId(this.libraryItemId)
|
||||
if (localLibraryItem) {
|
||||
console.log('Library item has local library item also', localLibraryItem.id)
|
||||
libraryItem.localLibraryItem = localLibraryItem
|
||||
}
|
||||
this.libraryItem = libraryItem
|
||||
} else if (this.$route.query.localLibraryItemId) {
|
||||
// Failed to get server library item but is local library item so redirect
|
||||
return this.$router.replace(`/item/${this.$route.query.localLibraryItemId}?noredirect=1`)
|
||||
} else {
|
||||
this.$toast.error('Failed to get library item from server')
|
||||
return this.$router.replace('/bookshelf')
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
// If library of this item is different from current library then switch libraries
|
||||
if (this.$store.state.libraries.currentLibraryId !== this.libraryItem.libraryId) {
|
||||
this.setLibrary()
|
||||
}
|
||||
|
||||
this.windowWidth = window.innerWidth
|
||||
window.addEventListener('resize', this.windowResized)
|
||||
this.$eventBus.$on('library-changed', this.libraryChanged)
|
||||
this.$eventBus.$on('new-local-library-item', this.newLocalLibraryItem)
|
||||
this.$socket.$on('item_updated', this.itemUpdated)
|
||||
this.$socket.$on('rss_feed_open', this.rssFeedOpen)
|
||||
this.$socket.$on('rss_feed_closed', this.rssFeedClosed)
|
||||
this.checkDescriptionClamped()
|
||||
|
||||
// Set last scroll position if was set for this item
|
||||
if (this.$store.state.lastItemScrollData.id === this.libraryItemId && window['item-page']) {
|
||||
window['item-page'].scrollTop = this.$store.state.lastItemScrollData.scrollTop || 0
|
||||
async mounted() {
|
||||
if (!this.libraryItem) {
|
||||
await this.loadServerLibraryItem()
|
||||
}
|
||||
this.init()
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('resize', this.windowResized)
|
||||
@@ -744,6 +796,15 @@ export default {
|
||||
</script>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--item-page-bg-gradient-height: 100%;
|
||||
}
|
||||
|
||||
#item-page-bg-gradient {
|
||||
transition: opacity 0.5s ease-in-out;
|
||||
height: var(--item-page-bg-gradient-height);
|
||||
}
|
||||
|
||||
.title-container {
|
||||
width: calc(100% - 64px);
|
||||
max-width: calc(100% - 64px);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="w-full h-full py-6 px-2">
|
||||
<div v-if="localLibraryItem" class="w-full h-full">
|
||||
<div class="px-2 flex items-center mb-2">
|
||||
<p class="text-base font-semibold">{{ mediaMetadata.title }}</p>
|
||||
<p class="text-base font-semibold truncate">{{ mediaMetadata.title }}</p>
|
||||
<div class="flex-grow" />
|
||||
|
||||
<button v-if="audioTracks.length && !isPodcast" class="shadow-sm text-success flex items-center justify-center rounded-full mx-2" @click.stop="play">
|
||||
|
||||
+18
-2
@@ -24,7 +24,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<modals-item-more-menu-modal v-model="showMoreMenu" :library-item="selectedLibraryItem" :episode="selectedEpisode" hide-rss-feed-option :processing.sync="processing" />
|
||||
<modals-item-more-menu-modal v-model="showMoreMenu" :library-item="selectedLibraryItem" :episode="selectedEpisode" :playlist="playlist" hide-rss-feed-option :processing.sync="processing" />
|
||||
<div v-show="processing" class="fixed top-0 left-0 w-screen h-screen flex items-center justify-center bg-black/50 z-50">
|
||||
<ui-loading-indicator />
|
||||
</div>
|
||||
@@ -140,8 +140,24 @@ export default {
|
||||
this.$eventBus.$emit('play-item', { libraryItemId: nextItem.libraryItemId, episodeId: nextItem.episodeId })
|
||||
}
|
||||
}
|
||||
},
|
||||
playlistUpdated(playlist) {
|
||||
if (this.playlist.id !== playlist.id) return
|
||||
this.playlist = playlist
|
||||
},
|
||||
playlistRemoved(playlist) {
|
||||
if (this.playlist.id === playlist.id) {
|
||||
this.$router.replace('/bookshelf/playlists')
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
mounted() {
|
||||
this.$socket.$on('playlist_updated', this.playlistUpdated)
|
||||
this.$socket.$on('playlist_removed', this.playlistRemoved)
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$socket.$off('playlist_updated', this.playlistUpdated)
|
||||
this.$socket.$off('playlist_removed', this.playlistRemoved)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
+35
-23
@@ -2,16 +2,16 @@
|
||||
<div class="w-full h-full px-4 py-8 overflow-y-auto">
|
||||
<!-- Display settings -->
|
||||
<p class="uppercase text-xs font-semibold text-fg-muted mb-2">{{ $strings.HeaderUserInterfaceSettings }}</p>
|
||||
<div class="flex items-center py-3" @click="toggleEnableAltView">
|
||||
<div class="w-10 flex justify-center">
|
||||
<div class="flex items-center py-3">
|
||||
<div class="w-10 flex justify-center" @click="toggleEnableAltView">
|
||||
<ui-toggle-switch v-model="enableBookshelfView" @input="saveSettings" />
|
||||
</div>
|
||||
<p class="pl-4">{{ $strings.LabelUseBookshelfView }}</p>
|
||||
</div>
|
||||
<!-- screen.orientation.lock not supported on iOS webview -->
|
||||
<div v-if="!isiOS" class="flex items-center py-3" @click.stop="toggleLockOrientation">
|
||||
<div class="w-10 flex justify-center pointer-events-none">
|
||||
<ui-toggle-switch v-model="lockCurrentOrientation" />
|
||||
<div v-if="!isiOS" class="flex items-center py-3">
|
||||
<div class="w-10 flex justify-center" @click.stop="toggleLockOrientation">
|
||||
<ui-toggle-switch v-model="lockCurrentOrientation" class="pointer-events-none" />
|
||||
</div>
|
||||
<p class="pl-4">{{ $strings.LabelLockOrientation }}</p>
|
||||
</div>
|
||||
@@ -36,37 +36,43 @@
|
||||
|
||||
<!-- Playback settings -->
|
||||
<p class="uppercase text-xs font-semibold text-fg-muted mb-2 mt-10">{{ $strings.HeaderPlaybackSettings }}</p>
|
||||
<div v-if="!isiOS" class="flex items-center py-3" @click="toggleDisableAutoRewind">
|
||||
<div class="w-10 flex justify-center">
|
||||
<div v-if="!isiOS" class="flex items-center py-3">
|
||||
<div class="w-10 flex justify-center" @click="toggleDisableAutoRewind">
|
||||
<ui-toggle-switch v-model="settings.disableAutoRewind" @input="saveSettings" />
|
||||
</div>
|
||||
<p class="pl-4">{{ $strings.LabelDisableAutoRewind }}</p>
|
||||
</div>
|
||||
<div class="flex items-center py-3" @click="toggleJumpBackwards">
|
||||
<div class="w-10 flex justify-center">
|
||||
<div class="flex items-center py-3">
|
||||
<div class="w-10 flex justify-center" @click="toggleJumpBackwards">
|
||||
<span class="material-icons text-4xl">{{ currentJumpBackwardsTimeIcon }}</span>
|
||||
</div>
|
||||
<p class="pl-4">{{ $strings.LabelJumpBackwardsTime }}</p>
|
||||
</div>
|
||||
<div class="flex items-center py-3" @click="toggleJumpForward">
|
||||
<div class="w-10 flex justify-center">
|
||||
<div class="flex items-center py-3">
|
||||
<div class="w-10 flex justify-center" @click="toggleJumpForward">
|
||||
<span class="material-icons text-4xl">{{ currentJumpForwardTimeIcon }}</span>
|
||||
</div>
|
||||
<p class="pl-4">{{ $strings.LabelJumpForwardsTime }}</p>
|
||||
</div>
|
||||
<div v-if="!isiOS" class="flex items-center py-3" @click="toggleEnableMp3IndexSeeking">
|
||||
<div class="w-10 flex justify-center">
|
||||
<div v-if="!isiOS" class="flex items-center py-3">
|
||||
<div class="w-10 flex justify-center" @click="toggleEnableMp3IndexSeeking">
|
||||
<ui-toggle-switch v-model="settings.enableMp3IndexSeeking" @input="saveSettings" />
|
||||
</div>
|
||||
<p class="pl-4">{{ $strings.LabelEnableMp3IndexSeeking }}</p>
|
||||
<span class="material-icons-outlined ml-2" @click.stop="showConfirmMp3IndexSeeking">info</span>
|
||||
</div>
|
||||
<div class="flex items-center py-3">
|
||||
<div class="w-10 flex justify-center" @click="toggleAllowSeekingOnMediaControls">
|
||||
<ui-toggle-switch v-model="settings.allowSeekingOnMediaControls" @input="saveSettings" />
|
||||
</div>
|
||||
<p class="pl-4">{{ $strings.LabelAllowSeekingOnMediaControls }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Sleep timer settings -->
|
||||
<template v-if="!isiOS">
|
||||
<p class="uppercase text-xs font-semibold text-fg-muted mb-2 mt-10">{{ $strings.HeaderSleepTimerSettings }}</p>
|
||||
<div class="flex items-center py-3" @click="toggleDisableShakeToResetSleepTimer">
|
||||
<div class="w-10 flex justify-center">
|
||||
<div class="flex items-center py-3">
|
||||
<div class="w-10 flex justify-center" @click="toggleDisableShakeToResetSleepTimer">
|
||||
<ui-toggle-switch v-model="settings.disableShakeToResetSleepTimer" @input="saveSettings" />
|
||||
</div>
|
||||
<p class="pl-4">{{ $strings.LabelDisableShakeToReset }}</p>
|
||||
@@ -78,22 +84,22 @@
|
||||
<ui-text-input :value="shakeSensitivityOption" readonly append-icon="expand_more" style="width: 145px; max-width: 145px" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center py-3" @click="toggleDisableSleepTimerFadeOut">
|
||||
<div class="w-10 flex justify-center">
|
||||
<div class="flex items-center py-3">
|
||||
<div class="w-10 flex justify-center" @click="toggleDisableSleepTimerFadeOut">
|
||||
<ui-toggle-switch v-model="settings.disableSleepTimerFadeOut" @input="saveSettings" />
|
||||
</div>
|
||||
<p class="pl-4">{{ $strings.LabelDisableAudioFadeOut }}</p>
|
||||
<span class="material-icons-outlined ml-2" @click.stop="showInfo('disableSleepTimerFadeOut')">info</span>
|
||||
</div>
|
||||
<div class="flex items-center py-3" @click="toggleDisableSleepTimerResetFeedback">
|
||||
<div class="w-10 flex justify-center">
|
||||
<div class="flex items-center py-3">
|
||||
<div class="w-10 flex justify-center" @click="toggleDisableSleepTimerResetFeedback">
|
||||
<ui-toggle-switch v-model="settings.disableSleepTimerResetFeedback" @input="saveSettings" />
|
||||
</div>
|
||||
<p class="pl-4">{{ $strings.LabelDisableVibrateOnReset }}</p>
|
||||
<span class="material-icons-outlined ml-2" @click.stop="showInfo('disableSleepTimerResetFeedback')">info</span>
|
||||
</div>
|
||||
<div class="flex items-center py-3" @click="toggleAutoSleepTimer">
|
||||
<div class="w-10 flex justify-center">
|
||||
<div class="flex items-center py-3">
|
||||
<div class="w-10 flex justify-center" @click="toggleAutoSleepTimer">
|
||||
<ui-toggle-switch v-model="settings.autoSleepTimer" @input="saveSettings" />
|
||||
</div>
|
||||
<p class="pl-4">{{ $strings.LabelAutoSleepTimer }}</p>
|
||||
@@ -115,8 +121,8 @@
|
||||
<ui-text-input :value="sleepTimerLengthOption" readonly append-icon="expand_more" style="width: 145px; max-width: 145px" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="settings.autoSleepTimer" class="flex items-center py-3" @click="toggleAutoSleepTimerAutoRewind">
|
||||
<div class="w-10 flex justify-center">
|
||||
<div v-if="settings.autoSleepTimer" class="flex items-center py-3">
|
||||
<div class="w-10 flex justify-center" @click="toggleAutoSleepTimerAutoRewind">
|
||||
<ui-toggle-switch v-model="settings.autoSleepTimerAutoRewind" @input="saveSettings" />
|
||||
</div>
|
||||
<p class="pl-4">{{ $strings.LabelAutoSleepTimerAutoRewind }}</p>
|
||||
@@ -154,6 +160,7 @@ export default {
|
||||
settings: {
|
||||
disableAutoRewind: false,
|
||||
enableAltView: true,
|
||||
allowSeekingOnMediaControls: false,
|
||||
jumpForwardTime: 10,
|
||||
jumpBackwardsTime: 10,
|
||||
enableMp3IndexSeeking: false,
|
||||
@@ -429,6 +436,10 @@ export default {
|
||||
this.settings.enableAltView = !this.settings.enableAltView
|
||||
this.saveSettings()
|
||||
},
|
||||
toggleAllowSeekingOnMediaControls() {
|
||||
this.settings.allowSeekingOnMediaControls = !this.settings.allowSeekingOnMediaControls
|
||||
this.saveSettings()
|
||||
},
|
||||
getCurrentOrientation() {
|
||||
const orientation = window.screen?.orientation || {}
|
||||
const type = orientation.type || ''
|
||||
@@ -471,6 +482,7 @@ export default {
|
||||
const deviceSettings = this.deviceData.deviceSettings || {}
|
||||
this.settings.disableAutoRewind = !!deviceSettings.disableAutoRewind
|
||||
this.settings.enableAltView = !!deviceSettings.enableAltView
|
||||
this.settings.allowSeekingOnMediaControls = !!deviceSettings.allowSeekingOnMediaControls
|
||||
this.settings.jumpForwardTime = deviceSettings.jumpForwardTime || 10
|
||||
this.settings.jumpBackwardsTime = deviceSettings.jumpBackwardsTime || 10
|
||||
this.settings.enableMp3IndexSeeking = !!deviceSettings.enableMp3IndexSeeking
|
||||
|
||||
+14
-2
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<div class="w-full h-full px-0 py-4 overflow-y-auto">
|
||||
<!-- Year in review banner shown at the top in December and January -->
|
||||
<stats-year-in-review-banner v-if="showYearInReviewBanner" />
|
||||
|
||||
<h1 class="text-xl px-4">
|
||||
{{ $strings.HeaderYourStats }}
|
||||
</h1>
|
||||
@@ -50,6 +53,9 @@
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Year in review banner shown at the bottom Feb - Nov -->
|
||||
<stats-year-in-review-banner v-if="!showYearInReviewBanner" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -58,7 +64,8 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
listeningStats: null,
|
||||
windowWidth: 0
|
||||
windowWidth: 0,
|
||||
showYearInReviewBanner: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -103,7 +110,12 @@ export default {
|
||||
console.error('Failed to load listening sesions', err)
|
||||
return []
|
||||
})
|
||||
console.log('Loaded user listening data', this.listeningStats)
|
||||
|
||||
let month = new Date().getMonth()
|
||||
// January and December show year in review banner
|
||||
if (month === 11 || month === 0) {
|
||||
this.showYearInReviewBanner = true
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
|
||||
@@ -141,6 +141,11 @@ class AbsAudioPlayerWeb extends WebPlugin {
|
||||
if (this.player) this.player.playbackRate = value
|
||||
}
|
||||
|
||||
// PluginMethod
|
||||
setChapterTrack({ enabled }) {
|
||||
this.useChapterTrack = enabled
|
||||
}
|
||||
|
||||
// PluginMethod
|
||||
async getCurrentTime() {
|
||||
return {
|
||||
@@ -262,4 +267,4 @@ export { AbsAudioPlayer }
|
||||
export default ({ app, store }, inject) => {
|
||||
$axios = app.$axios
|
||||
vuexStore = store
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,7 +196,7 @@ class AbsDatabaseWeb extends WebPlugin {
|
||||
return null
|
||||
}
|
||||
|
||||
async syncLocalSessionsWithServer() {
|
||||
async syncLocalSessionsWithServer({ isFirstSync }) {
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -70,8 +70,8 @@ class DbService {
|
||||
return AbsDatabase.removeLocalMediaProgress({ localMediaProgressId })
|
||||
}
|
||||
|
||||
syncLocalSessionsWithServer() {
|
||||
return AbsDatabase.syncLocalSessionsWithServer()
|
||||
syncLocalSessionsWithServer(isFirstSync) {
|
||||
return AbsDatabase.syncLocalSessionsWithServer({ isFirstSync })
|
||||
}
|
||||
|
||||
syncServerMediaProgressWithLocalMediaProgress(payload) {
|
||||
|
||||
+5
-1
@@ -14,11 +14,15 @@ const languageCodeMap = {
|
||||
'hr': { label: 'Hrvatski', dateFnsLocale: 'hr' },
|
||||
'it': { label: 'Italiano', dateFnsLocale: 'it' },
|
||||
'lt': { label: 'Lietuvių', dateFnsLocale: 'lt' },
|
||||
'hu': { label: 'Magyar', dateFnsLocale: 'hu' },
|
||||
'nl': { label: 'Nederlands', dateFnsLocale: 'nl' },
|
||||
'no': { label: 'Norsk', dateFnsLocale: 'no' },
|
||||
'pl': { label: 'Polski', dateFnsLocale: 'pl' },
|
||||
'pt-br': { label: 'Português (Brasil)', dateFnsLocale: 'ptBR' },
|
||||
'ru': { label: 'Русский', dateFnsLocale: 'ru' },
|
||||
'sv': { label: 'Svenska', dateFnsLocale: 'sv' },
|
||||
'uk': { label: 'Українська', dateFnsLocale: 'uk' },
|
||||
'vi-vn': { label: 'Tiếng Việt', dateFnsLocale: 'vi' },
|
||||
'zh-cn': { label: '简体中文 (Simplified Chinese)', dateFnsLocale: 'zhCN' },
|
||||
}
|
||||
|
||||
@@ -131,4 +135,4 @@ async function initialize() {
|
||||
export default ({ app, store }, inject) => {
|
||||
$localStore = app.$localStore
|
||||
initialize()
|
||||
}
|
||||
}
|
||||
|
||||
+11
-6
@@ -94,26 +94,31 @@ Vue.prototype.$elapsedPretty = (seconds, useFullNames = false) => {
|
||||
return `${hours} ${useFullNames ? `hour${hours === 1 ? '' : 's'}` : 'hr'} ${minutes} ${useFullNames ? `minute${minutes === 1 ? '' : 's'}` : 'min'}`
|
||||
}
|
||||
|
||||
Vue.prototype.$elapsedPrettyExtended = (seconds, useDays = true) => {
|
||||
Vue.prototype.$elapsedPrettyExtended = (seconds, useDays = true, showSeconds = true) => {
|
||||
if (isNaN(seconds) || seconds === null) return ''
|
||||
seconds = Math.round(seconds)
|
||||
|
||||
var minutes = Math.floor(seconds / 60)
|
||||
let minutes = Math.floor(seconds / 60)
|
||||
seconds -= minutes * 60
|
||||
var hours = Math.floor(minutes / 60)
|
||||
let hours = Math.floor(minutes / 60)
|
||||
minutes -= hours * 60
|
||||
|
||||
var days = 0
|
||||
let days = 0
|
||||
if (useDays || Math.floor(hours / 24) >= 100) {
|
||||
days = Math.floor(hours / 24)
|
||||
hours -= days * 24
|
||||
}
|
||||
|
||||
var strs = []
|
||||
// If not showing seconds then round minutes up
|
||||
if (minutes && seconds && !showSeconds) {
|
||||
if (seconds >= 30) minutes++
|
||||
}
|
||||
|
||||
const strs = []
|
||||
if (days) strs.push(`${days}d`)
|
||||
if (hours) strs.push(`${hours}h`)
|
||||
if (minutes) strs.push(`${minutes}m`)
|
||||
if (seconds) strs.push(`${seconds}s`)
|
||||
if (seconds && showSeconds) strs.push(`${seconds}s`)
|
||||
return strs.join(' ')
|
||||
}
|
||||
|
||||
|
||||
+24
-44
@@ -42,56 +42,20 @@ class LocalStorage {
|
||||
}
|
||||
}
|
||||
|
||||
async setUseChapterTrack(useChapterTrack) {
|
||||
async setPlayerSettings(playerSettings) {
|
||||
try {
|
||||
await Preferences.set({ key: 'useChapterTrack', value: useChapterTrack ? '1' : '0' })
|
||||
await Preferences.set({ key: 'playerSettings', value: JSON.stringify(playerSettings) })
|
||||
} catch (error) {
|
||||
console.error('[LocalStorage] Failed to set use chapter track', error)
|
||||
console.error('[LocalStorage] Failed to set player settings', error)
|
||||
}
|
||||
}
|
||||
|
||||
async getUseChapterTrack() {
|
||||
async getPlayerSettings() {
|
||||
try {
|
||||
var obj = await Preferences.get({ key: 'useChapterTrack' }) || {}
|
||||
return obj.value === '1'
|
||||
const playerSettingsObj = await Preferences.get({ key: 'playerSettings' }) || {}
|
||||
return playerSettingsObj.value ? JSON.parse(playerSettingsObj.value) : null
|
||||
} catch (error) {
|
||||
console.error('[LocalStorage] Failed to get use chapter track', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async setUseTotalTrack(useTotalTrack) {
|
||||
try {
|
||||
await Preferences.set({ key: 'useTotalTrack', value: useTotalTrack ? '1' : '0' })
|
||||
} catch (error) {
|
||||
console.error('[LocalStorage] Failed to set use total track', error)
|
||||
}
|
||||
}
|
||||
|
||||
async getUseTotalTrack() {
|
||||
try {
|
||||
var obj = await Preferences.get({ key: 'useTotalTrack' }) || {}
|
||||
return obj.value === '1'
|
||||
} catch (error) {
|
||||
console.error('[LocalStorage] Failed to get use total track', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async setPlayerLock(lock) {
|
||||
try {
|
||||
await Preferences.set({ key: 'playerLock', value: lock ? '1' : '0' })
|
||||
} catch (error) {
|
||||
console.error('[LocalStorage] Failed to set player lock', error)
|
||||
}
|
||||
}
|
||||
|
||||
async getPlayerLock() {
|
||||
try {
|
||||
var obj = await Preferences.get({ key: 'playerLock' }) || {}
|
||||
return obj.value === '1'
|
||||
} catch (error) {
|
||||
console.error('[LocalStorage] Failed to get player lock', error)
|
||||
console.error('[LocalStorage] Failed to get player settings', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -179,9 +143,25 @@ class LocalStorage {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get preference value by key
|
||||
*
|
||||
* @param {string} key
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async getPreferenceByKey(key) {
|
||||
try {
|
||||
const obj = await Preferences.get({ key }) || {}
|
||||
return obj.value || null
|
||||
} catch (error) {
|
||||
console.error(`[LocalStorage] Failed to get preference "${key}"`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export default ({ app, store }, inject) => {
|
||||
inject('localStore', new LocalStorage(store))
|
||||
}
|
||||
}
|
||||
|
||||
+8
-5
@@ -48,11 +48,7 @@ class ServerSocket extends EventEmitter {
|
||||
this.socket.on('init', this.onInit.bind(this))
|
||||
this.socket.on('user_updated', this.onUserUpdated.bind(this))
|
||||
this.socket.on('user_item_progress_updated', this.onUserItemProgressUpdated.bind(this))
|
||||
|
||||
// Good for testing socket requests
|
||||
// this.socket.onAny((evt, args) => {
|
||||
// console.log(`[SOCKET] onAny: ${this.socket.id}: ${evt} ${JSON.stringify(args)}`)
|
||||
// })
|
||||
this.socket.on('playlist_added', this.onPlaylistAdded.bind(this))
|
||||
}
|
||||
|
||||
removeListeners() {
|
||||
@@ -93,6 +89,13 @@ class ServerSocket extends EventEmitter {
|
||||
this.$store.commit('user/updateUserMediaProgress', payload.data)
|
||||
this.emit('user_media_progress_updated', payload)
|
||||
}
|
||||
|
||||
onPlaylistAdded() {
|
||||
// Currently numUserPlaylists is only used for showing the playlist tab or not. Precise number is not necessary
|
||||
if (!this.$store.state.libraries.numUserPlaylists) {
|
||||
this.$store.commit('libraries/setNumUserPlaylists', 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default ({ app, store }, inject) => {
|
||||
|
||||
@@ -6,10 +6,9 @@ Audiobookshelf is a self-hosted audiobook and podcast server.
|
||||
Get the Android app on the [Google Play Store](https://play.google.com/store/apps/details?id=com.audiobookshelf.app)
|
||||
|
||||
### iOS (early beta)
|
||||
**Beta is currently full. Apple has a hard limit of 10k beta testers. Updates will be posted in Discord/Matrix.**
|
||||
|
||||
Available to beta testers through Test Flight
|
||||
|
||||
Join the beta testers and install the iOS app: https://testflight.apple.com/join/wiic7QIW
|
||||
Using Test Flight: https://testflight.apple.com/join/wiic7QIW ***(beta is full)***
|
||||
|
||||
---
|
||||
|
||||
@@ -26,12 +25,14 @@ Join us on [discord](https://discord.gg/pJsjuNCKRq) or [Matrix](https://matrix.t
|
||||
|
||||
This application is built using [NuxtJS](https://nuxtjs.org/) and [Capacitor](https://capacitorjs.com/) in order to run on both iOS and Android on the same code base.
|
||||
|
||||
Information on helping with translations of the apps [here](https://www.audiobookshelf.org/faq#how-do-i-help-with-translations).
|
||||
|
||||
### Windows Environment Setup for Android
|
||||
|
||||
Required Software:
|
||||
|
||||
* [Git](https://git-scm.com/downloads)
|
||||
* [Node.js](https://nodejs.org/en/)
|
||||
* [Node.js](https://nodejs.org/en/) (version 20)
|
||||
* Code editor of choice([VSCode](https://code.visualstudio.com/download), etc)
|
||||
* [Android Studio](https://developer.android.com/studio)
|
||||
* [Android SDK](https://developer.android.com/studio)
|
||||
@@ -46,7 +47,7 @@ Note: This requires a PowerShell prompt with winget installed. You should be ab
|
||||
winget install -e --id Git.Git; `
|
||||
winget install -e --id Microsoft.VisualStudioCode; `
|
||||
winget install -e --id Google.AndroidStudio; `
|
||||
winget install -e --id OpenJS.NodeJS --version 16.12.0; #v17 has issues with openssl
|
||||
winget install -e --id OpenJS.NodeJS --version 20.11.0;
|
||||
```
|
||||
|
||||

|
||||
@@ -62,7 +63,7 @@ Your Windows environment should now be set up and ready to proceed!
|
||||
Required Software:
|
||||
|
||||
* [Android Studio](https://developer.android.com/studio)
|
||||
* [Node.js](https://nodejs.org/en/)
|
||||
* [Node.js](https://nodejs.org/en/) (version 20)
|
||||
* [Cocoapods](https://guides.cocoapods.org/using/getting-started.html#installation)
|
||||
* [Android SDK](https://developer.android.com/studio)
|
||||
|
||||
|
||||
+3
-3
@@ -64,15 +64,15 @@ export const getters = {
|
||||
return state.serverSettings[key]
|
||||
},
|
||||
getJumpForwardTime: state => {
|
||||
if (!state.deviceData || !state.deviceData.deviceSettings) return 10
|
||||
if (!state.deviceData?.deviceSettings) return 10
|
||||
return state.deviceData.deviceSettings.jumpForwardTime || 10
|
||||
},
|
||||
getJumpBackwardsTime: state => {
|
||||
if (!state.deviceData || !state.deviceData.deviceSettings) return 10
|
||||
if (!state.deviceData?.deviceSettings) return 10
|
||||
return state.deviceData.deviceSettings.jumpBackwardsTime || 10
|
||||
},
|
||||
getAltViewEnabled: state => {
|
||||
if (!state.deviceData || !state.deviceData.deviceSettings) return false
|
||||
if (!state.deviceData?.deviceSettings) return true
|
||||
return state.deviceData.deviceSettings.enableAltView
|
||||
},
|
||||
getOrientationLockSetting: state => {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Browser } from '@capacitor/browser'
|
||||
|
||||
export const state = () => ({
|
||||
user: null,
|
||||
serverConnectionConfig: null,
|
||||
@@ -115,6 +117,21 @@ export const actions = {
|
||||
commit('setSettings', userSettings)
|
||||
this.$eventBus.$emit('user-settings', state.settings)
|
||||
}
|
||||
},
|
||||
async openWebClient({ getters }, path = null) {
|
||||
const serverAddress = getters.getServerAddress
|
||||
if (!serverAddress) {
|
||||
console.error('openWebClient: No server address')
|
||||
return
|
||||
}
|
||||
try {
|
||||
let url = serverAddress.replace(/\/$/, '') // Remove trailing slash
|
||||
if (path?.startsWith('/')) url += path
|
||||
|
||||
await Browser.open({ url })
|
||||
} catch (error) {
|
||||
console.error('Error opening browser', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+162
-151
@@ -1,52 +1,53 @@
|
||||
{
|
||||
"ButtonAdd": "Přidat",
|
||||
"ButtonAddNewServer": "Add New Server",
|
||||
"ButtonAddNewServer": "Přidat nový server",
|
||||
"ButtonAuthors": "Autoři",
|
||||
"ButtonBack": "Back",
|
||||
"ButtonBack": "Zpět",
|
||||
"ButtonCancel": "Zrušit",
|
||||
"ButtonCancelTimer": "Cancel Timer",
|
||||
"ButtonCancelTimer": "Zrušit časovač",
|
||||
"ButtonClearFilter": "Vymazat filtr",
|
||||
"ButtonCloseFeed": "Zavřít kanál",
|
||||
"ButtonCollections": "Kolekce",
|
||||
"ButtonConnect": "Connect",
|
||||
"ButtonConnectToServer": "Connect to Server",
|
||||
"ButtonConnect": "Připojit",
|
||||
"ButtonConnectToServer": "Připojit k serveru",
|
||||
"ButtonCreate": "Vytvořit",
|
||||
"ButtonCreateBookmark": "Create Bookmark",
|
||||
"ButtonCreateNewPlaylist": "Create New Playlist",
|
||||
"ButtonCreateBookmark": "Vytvořit záložku",
|
||||
"ButtonCreateNewPlaylist": "Vytvořit nový seznam skladeb",
|
||||
"ButtonDelete": "Smazat",
|
||||
"ButtonDeleteLocalEpisode": "Delete local episode",
|
||||
"ButtonDeleteLocalFile": "Delete local file",
|
||||
"ButtonDeleteLocalItem": "Delete local item",
|
||||
"ButtonDisableAutoTimer": "Disable Auto Timer",
|
||||
"ButtonDisconnect": "Disconnect",
|
||||
"ButtonHistory": "History",
|
||||
"ButtonDeleteLocalEpisode": "Smazat místní epizodu",
|
||||
"ButtonDeleteLocalFile": "Smazat místní soubor",
|
||||
"ButtonDeleteLocalItem": "Smazat místní položku",
|
||||
"ButtonDisableAutoTimer": "Zakázat automatický časovač",
|
||||
"ButtonDisconnect": "Odpojit",
|
||||
"ButtonGoToWebClient": "Go to Web Client",
|
||||
"ButtonHistory": "Historie",
|
||||
"ButtonHome": "Domů",
|
||||
"ButtonIssues": "Problémy",
|
||||
"ButtonLatest": "Nejnovější",
|
||||
"ButtonLibrary": "Knihovna",
|
||||
"ButtonLocalMedia": "Local Media",
|
||||
"ButtonManageLocalFiles": "Manage Local Files",
|
||||
"ButtonNewFolder": "New Folder",
|
||||
"ButtonNextEpisode": "Next Episode",
|
||||
"ButtonLocalMedia": "Místní média",
|
||||
"ButtonManageLocalFiles": "Spravovat místní soubory",
|
||||
"ButtonNewFolder": "Nová složka",
|
||||
"ButtonNextEpisode": "Další epizoda",
|
||||
"ButtonOpenFeed": "Otevřít kanál",
|
||||
"ButtonOverride": "Override",
|
||||
"ButtonPause": "Pause",
|
||||
"ButtonOverride": "Přepsat",
|
||||
"ButtonPause": "Pozastavit",
|
||||
"ButtonPlay": "Přehrát",
|
||||
"ButtonPlaying": "Hraje",
|
||||
"ButtonPlaying": "Přehrává se",
|
||||
"ButtonPlaylists": "Seznamy skladeb",
|
||||
"ButtonRead": "Číst",
|
||||
"ButtonRemove": "Odstranit",
|
||||
"ButtonRemoveFromServer": "Remove from Server",
|
||||
"ButtonRemoveFromServer": "Odstranit ze serveru",
|
||||
"ButtonSave": "Uložit",
|
||||
"ButtonSaveOrder": "Save Order",
|
||||
"ButtonSaveOrder": "Uložit pořadí",
|
||||
"ButtonSearch": "Hledat",
|
||||
"ButtonSendEbookToDevice": "Send Ebook to Device",
|
||||
"ButtonSendEbookToDevice": "Odeslat e-knihu na zařízení",
|
||||
"ButtonSeries": "Série",
|
||||
"ButtonSetTimer": "Set Timer",
|
||||
"ButtonStream": "Stream",
|
||||
"ButtonSetTimer": "Nastavit časovač",
|
||||
"ButtonStream": "Streamovat",
|
||||
"ButtonSubmit": "Odeslat",
|
||||
"ButtonSwitchServerUser": "Switch Server/User",
|
||||
"ButtonUserStats": "User Stats",
|
||||
"ButtonSwitchServerUser": "Přepnout server/uživatele",
|
||||
"ButtonUserStats": "Statistiky uživatele",
|
||||
"ButtonYes": "Ano",
|
||||
"HeaderAccount": "Účet",
|
||||
"HeaderAdvanced": "Pokročilé",
|
||||
@@ -54,127 +55,133 @@
|
||||
"HeaderChapters": "Kapitoly",
|
||||
"HeaderCollection": "Kolekce",
|
||||
"HeaderCollectionItems": "Položky kolekce",
|
||||
"HeaderConnectionStatus": "Connection Status",
|
||||
"HeaderConnectionStatus": "Stav připojení",
|
||||
"HeaderDetails": "Podrobnosti",
|
||||
"HeaderDownloads": "Downloads",
|
||||
"HeaderEbookFiles": "Soubory elektronických knih",
|
||||
"HeaderDownloads": "Stahování",
|
||||
"HeaderEbookFiles": "Soubory e-knih",
|
||||
"HeaderEpisodes": "Epizody",
|
||||
"HeaderEreaderSettings": "Nastavení čtečky elektronických knih",
|
||||
"HeaderLatestEpisodes": "Poslední epizody",
|
||||
"HeaderEreaderSettings": "Nastavení čtečky e-knih",
|
||||
"HeaderLatestEpisodes": "Nejnovější epizody",
|
||||
"HeaderLibraries": "Knihovny",
|
||||
"HeaderLocalFolders": "Local Folders",
|
||||
"HeaderLocalLibraryItems": "Local Library Items",
|
||||
"HeaderNewPlaylist": "New Playlist",
|
||||
"HeaderLocalFolders": "Místní složky",
|
||||
"HeaderLocalLibraryItems": "Místní položky knihovny",
|
||||
"HeaderNewPlaylist": "Nový seznam skladeb",
|
||||
"HeaderOpenRSSFeed": "Otevřít RSS kanál",
|
||||
"HeaderPlaybackSettings": "Playback Settings",
|
||||
"HeaderPlaybackSettings": "Nastavení přehrávání",
|
||||
"HeaderPlaylist": "Seznam skladeb",
|
||||
"HeaderPlaylistItems": "Položky seznamu přehrávání",
|
||||
"HeaderRSSFeed": "RSS Feed",
|
||||
"HeaderRSSFeedGeneral": "Podrobnosti o RSS",
|
||||
"HeaderRSSFeedIsOpen": "Informační kanál RSS je otevřený",
|
||||
"HeaderSelectDownloadLocation": "Select Download Location",
|
||||
"HeaderRSSFeed": "RSS kanál",
|
||||
"HeaderRSSFeedGeneral": "Detaily RSS",
|
||||
"HeaderRSSFeedIsOpen": "Kanál RSS je otevřen",
|
||||
"HeaderSelectDownloadLocation": "Vyberte umístění pro stažení",
|
||||
"HeaderSettings": "Nastavení",
|
||||
"HeaderSleepTimer": "Časovač vypnutí",
|
||||
"HeaderSleepTimerSettings": "Sleep Timer Settings",
|
||||
"HeaderStatsMinutesListeningChart": "Počet minut poslechu (posledních 7 dní)",
|
||||
"HeaderStatsRecentSessions": "Poslední relace",
|
||||
"HeaderSleepTimer": "Časovač spánku",
|
||||
"HeaderSleepTimerSettings": "Nastavení časovače spánku",
|
||||
"HeaderStatsMinutesListeningChart": "Minuty poslechu (posledních 7 dní)",
|
||||
"HeaderStatsRecentSessions": "Poslední sezení",
|
||||
"HeaderTableOfContents": "Obsah",
|
||||
"HeaderUserInterfaceSettings": "User Interface Settings",
|
||||
"HeaderUserInterfaceSettings": "Nastavení uživatelského rozhraní",
|
||||
"HeaderYourStats": "Vaše statistiky",
|
||||
"LabelAdded": "Přidáno",
|
||||
"LabelAddedAt": "Přidáno v",
|
||||
"LabelAddToPlaylist": "Přidat do seznamu přehrávání",
|
||||
"LabelAddToPlaylist": "Přidat do seznamu skladeb",
|
||||
"LabelAll": "Vše",
|
||||
"LabelAllowSeekingOnMediaControls": "Allow position seeking on media notification controls",
|
||||
"LabelAuthor": "Autor",
|
||||
"LabelAuthorFirstLast": "Autor (jméno a příjmení)",
|
||||
"LabelAuthorLastFirst": "Autor (příjmení a jméno)",
|
||||
"LabelAuthors": "Autoři",
|
||||
"LabelAutoDownloadEpisodes": "Automaticky stahovat epizody",
|
||||
"LabelAutoRewindTime": "Auto rewind time",
|
||||
"LabelAutoSleepTimer": "Auto sleep timer",
|
||||
"LabelAutoSleepTimerAutoRewind": "Auto sleep timer auto rewind",
|
||||
"LabelAutoSleepTimerAutoRewindHelp": "When the auto sleep timer finishes, playing the item again will automatically rewind your position.",
|
||||
"LabelAutoSleepTimerHelp": "When playing media between the specified start and end times a sleep timer will automatically start.",
|
||||
"LabelAutoRewindTime": "Přetočit zpět o",
|
||||
"LabelAutoSleepTimer": "Automatický časovač spánku",
|
||||
"LabelAutoSleepTimerAutoRewind": "Přetočit zpět po uspání",
|
||||
"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",
|
||||
"LabelChapters": "Kapitoly",
|
||||
"LabelChapterTrack": "Chapter Track",
|
||||
"LabelChapterTrack": "Stopa kapitoly",
|
||||
"LabelClosePlayer": "Zavřít přehrávač",
|
||||
"LabelCollapseSeries": "Sbalit sérii",
|
||||
"LabelComplete": "Dokončeno",
|
||||
"LabelContinueBooks": "Continue Books",
|
||||
"LabelContinueEpisodes": "Continue Episodes",
|
||||
"LabelContinueListening": "Continue Listening",
|
||||
"LabelContinueSeries": "Continue Series",
|
||||
"LabelCustomTime": "Custom time",
|
||||
"LabelContinueBooks": "Pokračovat v knihách",
|
||||
"LabelContinueEpisodes": "Pokračovat v epizodách",
|
||||
"LabelContinueListening": "Pokračovat v poslechu",
|
||||
"LabelContinueReading": "Pokračovat ve čtení",
|
||||
"LabelContinueSeries": "Pokračovat v sériích",
|
||||
"LabelCustomTime": "Vlastní čas",
|
||||
"LabelDescription": "Popis",
|
||||
"LabelDisableAudioFadeOut": "Disable audio fade out",
|
||||
"LabelDisableAudioFadeOutHelp": "Audio volume will start decreasing when there is less than 1 minute remaining on the sleep timer. Enable this setting to not fade out.",
|
||||
"LabelDisableAutoRewind": "Disable auto rewind",
|
||||
"LabelDisableShakeToReset": "Disable shake to reset",
|
||||
"LabelDisableShakeToResetHelp": "Shaking your device while the timer is running OR within 2 minutes of the timer expiring will reset the sleep timer. Enable this setting to disable shake to reset.",
|
||||
"LabelDisableVibrateOnReset": "Disable vibrate on reset",
|
||||
"LabelDisableVibrateOnResetHelp": "When the sleep timer gets reset your device will vibrate. Enable this setting to not vibrate when the sleep timer resets.",
|
||||
"LabelDiscover": "Discover",
|
||||
"LabelDisableAudioFadeOut": "Vypnout postupné snižování zvuku",
|
||||
"LabelDisableAudioFadeOutHelp": "Hlasitost zvuku začne klesat, pokud zbývá méně než 1 minuta do vypršení časovače spánku. Tuto možnost povolte, pokud nechcete postupné snižování hlasitosti.",
|
||||
"LabelDisableAutoRewind": "Zakázat automatické přetočení",
|
||||
"LabelDisableShakeToReset": "Zakázat resetování otřesem",
|
||||
"LabelDisableShakeToResetHelp": "Když je časovač spánku spuštěn nebo zbýva 2 minuty do jeho vypršení, zatřesení zařízením se resetuje časovač spánku. Povolte toto nastavení, pokud chcete resetování otřesem zakázat.",
|
||||
"LabelDisableVibrateOnReset": "Zakázat vibraci při resetování",
|
||||
"LabelDisableVibrateOnResetHelp": "Když je časovač spánku resetován, zařízení zavibruje. Tuto možnost povolte, pokud nechcete, aby zařízení vibrace provádělo při resetování časovače spánku.",
|
||||
"LabelDiscover": "Objevit",
|
||||
"LabelDownload": "Stáhnout",
|
||||
"LabelDownloaded": "Downloaded",
|
||||
"LabelDuration": "Doba trvání",
|
||||
"LabelEbook": "Elektronická kniha",
|
||||
"LabelEbooks": "Ebooks",
|
||||
"LabelDownloaded": "Staženo",
|
||||
"LabelDuration": "Trvání",
|
||||
"LabelEbook": "E-kniha",
|
||||
"LabelEbooks": "E-knihy",
|
||||
"LabelEnable": "Povolit",
|
||||
"LabelEnableMp3IndexSeeking": "Enable mp3 index seeking",
|
||||
"LabelEnableMp3IndexSeekingHelp": "This setting should only be enabled if you have mp3 files that are not seeking correctly. Inaccurate seeking is most likely due to Variable birate (VBR) MP3 files. This setting will force index seeking, in which a time-to-byte mapping is built as the file is read. In some cases with large MP3 files there will be a delay when seeking towards the end of the file.",
|
||||
"LabelEnableMp3IndexSeeking": "Povolit indexové vyhledávání mp3",
|
||||
"LabelEnableMp3IndexSeekingHelp": "Toto nastavení by mělo být povoleno pouze v případě, že soubory MP3 nejsou správně vyhledávány. Nepřesné vyhledávání je s největší pravděpodobností způsobeno soubory MP3 s proměnlivým datovým tokem (VBR). Toto nastavení vynutí indexové vyhledávání, při kterém se při čtení souboru vytváří mapování času na bajty. V některých případech u velkých souborů MP3 dochází ke zpoždění při vyhledávání ke konci souboru.",
|
||||
"LabelEnd": "Konec",
|
||||
"LabelEndOfChapter": "End of Chapter",
|
||||
"LabelEndTime": "End time",
|
||||
"LabelEndOfChapter": "Konec kapitoly",
|
||||
"LabelEndTime": "Do",
|
||||
"LabelEpisode": "Epizoda",
|
||||
"LabelFeedURL": "URL zdroje",
|
||||
"LabelFeedURL": "URL kanálu",
|
||||
"LabelFile": "Soubor",
|
||||
"LabelFileBirthtime": "Čas vzniku souboru",
|
||||
"LabelFileModified": "Soubor změněn",
|
||||
"LabelFileBirthtime": "Čas vytvoření souboru",
|
||||
"LabelFileModified": "Soubor upraven",
|
||||
"LabelFilename": "Název souboru",
|
||||
"LabelFinished": "Dokončeno",
|
||||
"LabelFolder": "Složka",
|
||||
"LabelFontScale": "Měřítko písma",
|
||||
"LabelGenre": "Žánr",
|
||||
"LabelGenres": "Žánry",
|
||||
"LabelHapticFeedback": "Haptic feedback",
|
||||
"LabelHasEbook": "Has ebook",
|
||||
"LabelHasSupplementaryEbook": "Has supplementary ebook",
|
||||
"LabelHeavy": "Heavy",
|
||||
"LabelHigh": "High",
|
||||
"LabelHapticFeedback": "Haptická odezva",
|
||||
"LabelHasEbook": "Má e-knihu",
|
||||
"LabelHasSupplementaryEbook": "Má doplňkovou e-knihu",
|
||||
"LabelHeavy": "Těžké",
|
||||
"LabelHigh": "Vysoké",
|
||||
"LabelHost": "Hostitel",
|
||||
"LabelIncomplete": "Neúplné",
|
||||
"LabelInProgress": "Probíhá",
|
||||
"LabelInternalAppStorage": "Internal App Storage",
|
||||
"LabelJumpBackwardsTime": "Jump backwards time",
|
||||
"LabelJumpForwardsTime": "Jump forwards time",
|
||||
"LabelInternalAppStorage": "Interní úložiště aplikace",
|
||||
"LabelJumpBackwardsTime": "Délka skoku zpět v čase",
|
||||
"LabelJumpForwardsTime": "Délka skoku vpřed v čase",
|
||||
"LabelLanguage": "Jazyk",
|
||||
"LabelLight": "Light",
|
||||
"LabelLayout": "Rozvržení",
|
||||
"LabelLayoutAuto": "Automatické",
|
||||
"LabelLayoutSinglePage": "Jedna stránka",
|
||||
"LabelLight": "Světlé",
|
||||
"LabelLineSpacing": "Řádkování",
|
||||
"LabelListenAgain": "Listen Again",
|
||||
"LabelLocalBooks": "Local Books",
|
||||
"LabelLocalPodcasts": "Local Podcasts",
|
||||
"LabelLockOrientation": "Lock orientation",
|
||||
"LabelLockPlayer": "Lock Player",
|
||||
"LabelLow": "Low",
|
||||
"LabelListenAgain": "Poslechnout znovu",
|
||||
"LabelLocalBooks": "Místní knihy",
|
||||
"LabelLocalPodcasts": "Místní podcasty",
|
||||
"LabelLockOrientation": "Zamknout orientaci",
|
||||
"LabelLockPlayer": "Zamknout přehrávač",
|
||||
"LabelLow": "Nízké",
|
||||
"LabelMediaType": "Typ média",
|
||||
"LabelMedium": "Medium",
|
||||
"LabelMedium": "Střední",
|
||||
"LabelMore": "Více",
|
||||
"LabelMoreInfo": "Více informací",
|
||||
"LabelName": "Jméno",
|
||||
"LabelNarrator": "Interpret",
|
||||
"LabelNarrators": "Interpreti",
|
||||
"LabelNewestAuthors": "Newest Authors",
|
||||
"LabelNo": "No",
|
||||
"LabelNarrator": "Vypravěč",
|
||||
"LabelNarrators": "Vypravěči",
|
||||
"LabelNewestAuthors": "Nejnovější autoři",
|
||||
"LabelNewestEpisodes": "Nejnovější epizody",
|
||||
"LabelNo": "Ne",
|
||||
"LabelNotFinished": "Nedokončeno",
|
||||
"LabelNotStarted": "Nezahájeno",
|
||||
"LabelOff": "Off",
|
||||
"LabelOff": "Vypnout",
|
||||
"LabelPassword": "Heslo",
|
||||
"LabelPath": "Cesta",
|
||||
"LabelPlaybackDirect": "Direct",
|
||||
"LabelPlaybackLocal": "Local",
|
||||
"LabelPlaybackSpeed": "Playback Speed",
|
||||
"LabelPlaybackTranscode": "Transcode",
|
||||
"LabelPlaybackDirect": "Přímé",
|
||||
"LabelPlaybackLocal": "Místní",
|
||||
"LabelPlaybackSpeed": "Rychlost přehrávání",
|
||||
"LabelPlaybackTranscode": "Překódovává se",
|
||||
"LabelPodcast": "Podcast",
|
||||
"LabelPodcasts": "Podcasty",
|
||||
"LabelPreventIndexing": "Zabránit indexování vašeho kanálu v adresářích podcastů iTunes a Google",
|
||||
@@ -182,94 +189,98 @@
|
||||
"LabelPubDate": "Datum vydání",
|
||||
"LabelPublishYear": "Rok vydání",
|
||||
"LabelRead": "Číst",
|
||||
"LabelRecentlyAdded": "Recently Added",
|
||||
"LabelRecentSeries": "Recent Series",
|
||||
"LabelReadAgain": "Číst znovu",
|
||||
"LabelRecentlyAdded": "Nedávno přidáno",
|
||||
"LabelRecentSeries": "Nedávné série",
|
||||
"LabelRemoveFromPlaylist": "Remove from Playlist",
|
||||
"LabelRSSFeedCustomOwnerEmail": "Vlastní e-mail vlastníka",
|
||||
"LabelRSSFeedCustomOwnerName": "Vlastní jméno vlastníka",
|
||||
"LabelRSSFeedPreventIndexing": "Zabránit indexování",
|
||||
"LabelRSSFeedSlug": "RSS kanál Slug",
|
||||
"LabelRSSFeedSlug": "Klíčové slovo kanálu RSS",
|
||||
"LabelScaleElapsedTimeBySpeed": "Škálovat uplynulý čas podle rychlosti",
|
||||
"LabelSeason": "Sezóna",
|
||||
"LabelSelectADevice": "Select a device",
|
||||
"LabelSelectADevice": "Vyberte zařízení",
|
||||
"LabelSeries": "Série",
|
||||
"LabelServerAddress": "Server address",
|
||||
"LabelServerAddress": "Adresa serveru",
|
||||
"LabelSetEbookAsPrimary": "Nastavit jako primární",
|
||||
"LabelSetEbookAsSupplementary": "Nastavit jako doplňkové",
|
||||
"LabelShakeSensitivity": "Shake sensitivity",
|
||||
"LabelSetEbookAsSupplementary": "Nastavit jako doplňkový",
|
||||
"LabelShakeSensitivity": "Citlivost na otřes",
|
||||
"LabelShowAll": "Zobrazit vše",
|
||||
"LabelSize": "Velikost",
|
||||
"LabelSleepTimer": "Časovač vypnutí",
|
||||
"LabelStart": "Spustit",
|
||||
"LabelStartTime": "Čas Spuštění",
|
||||
"LabelSleepTimer": "Spánek po",
|
||||
"LabelStart": "Začít",
|
||||
"LabelStartTime": "Od",
|
||||
"LabelStatsBestDay": "Nejlepší den",
|
||||
"LabelStatsDailyAverage": "Denní průměr",
|
||||
"LabelStatsDays": "Dny",
|
||||
"LabelStatsDaysListened": "Dny poslechu",
|
||||
"LabelStatsInARow": "v řadě",
|
||||
"LabelStatsItemsFinished": "Dokončené Položky",
|
||||
"LabelStatsItemsFinished": "Dokončené položky",
|
||||
"LabelStatsMinutes": "minut",
|
||||
"LabelStatsMinutesListening": "Minuty poslechu",
|
||||
"LabelStatsWeekListening": "Týdenní poslech",
|
||||
"LabelTag": "Značka",
|
||||
"LabelTags": "Značky",
|
||||
"LabelStatsWeekListening": "Za týden",
|
||||
"LabelTag": "Štítek",
|
||||
"LabelTags": "Štítky",
|
||||
"LabelTheme": "Téma",
|
||||
"LabelThemeDark": "Tmavé",
|
||||
"LabelThemeLight": "Světlé",
|
||||
"LabelTimeRemaining": "{0} zbývá",
|
||||
"LabelTitle": "Název",
|
||||
"LabelTotalTrack": "Total Track",
|
||||
"LabelTotalTrack": "Celková stopa",
|
||||
"LabelTracks": "Stopy",
|
||||
"LabelType": "Typ",
|
||||
"LabelUnlockPlayer": "Unlock Player",
|
||||
"LabelUseBookshelfView": "Use bookshelf view",
|
||||
"LabelUnlockPlayer": "Odemknout přehrávač",
|
||||
"LabelUseBookshelfView": "Použít vzhled knihovny",
|
||||
"LabelUser": "Uživatel",
|
||||
"LabelUsername": "Uživatelské jméno",
|
||||
"LabelVeryHigh": "Very High",
|
||||
"LabelVeryLow": "Very Low",
|
||||
"LabelVeryHigh": "Velmi vysoké",
|
||||
"LabelVeryLow": "Velmi nízké",
|
||||
"LabelYourBookmarks": "Vaše záložky",
|
||||
"LabelYourProgress": "Váš pokrok",
|
||||
"MessageAndroid10Downloads": "Android 10 and below will use internal app storage for downloads.",
|
||||
"MessageAttemptingServerConnection": "Attempting server connection...",
|
||||
"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",
|
||||
"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.",
|
||||
"MessageConfirmDiscardProgress": "Are you sure you want to reset your progress?",
|
||||
"MessageConfirmMarkAsFinished": "Are you sure you want to mark this item as finished?",
|
||||
"MessageConfirmRemoveBookmark": "Are you sure you want to remove bookmark?",
|
||||
"MessageDiscardProgress": "Discard Progress",
|
||||
"MessageDownloadCompleteProcessing": "Download complete. Processing...",
|
||||
"MessageDownloading": "Downloading...",
|
||||
"MessageAndroid10Downloads": "Android 10 a starší budou používat interní úložiště aplikace pro stahování.",
|
||||
"MessageAttemptingServerConnection": "Pokouším se o připojit k serveru...",
|
||||
"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á",
|
||||
"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.",
|
||||
"MessageConfirmDiscardProgress": "Opravdu chcete zahodit svůj pokrok?",
|
||||
"MessageConfirmMarkAsFinished": "Opravdu chcete tuto položku označit jako dokončenou?",
|
||||
"MessageConfirmRemoveBookmark": "Opravdu chcete odebrat záložku?",
|
||||
"MessageDiscardProgress": "Zahodit pokrok",
|
||||
"MessageDownloadCompleteProcessing": "Stahování dokončeno. Zpracovává se...",
|
||||
"MessageDownloading": "Stahuje se...",
|
||||
"MessageDownloadingEpisode": "Stahuji epizodu",
|
||||
"MessageEpisodesQueuedForDownload": "{0} epizody zařazené do fronty ke stažení",
|
||||
"MessageEpisodesQueuedForDownload": "{0} epizod ve frontě ke stažení",
|
||||
"MessageFeedURLWillBe": "URL zdroje bude {0}",
|
||||
"MessageFetching": "Stahování...",
|
||||
"MessageFollowTheProjectOnGithub": "Follow the project on Github",
|
||||
"MessageItemDownloadCompleteFailedToCreate": "Item download complete but failed to create library item",
|
||||
"MessageFetching": "Získává se...",
|
||||
"MessageFollowTheProjectOnGithub": "Sledujte projekt na GitHubu",
|
||||
"MessageItemDownloadCompleteFailedToCreate": "Stahování položky bylo dokončeno, ale nepodařilo se vytvořit položku v knihovně",
|
||||
"MessageLoading": "Načítá se...",
|
||||
"MessageLoadingServerData": "Loading server data...",
|
||||
"MessageLoadingServerData": "Načítání dat ze serveru...",
|
||||
"MessageMarkAsFinished": "Označit jako dokončené",
|
||||
"MessageMediaLinkedToADifferentServer": "Media is linked to an Audiobookshelf server on a different address ({0}). Progress will be synced when connected to this server address.",
|
||||
"MessageMediaLinkedToADifferentUser": "Media is linked to this server but was downloaded by a different user. Progress will only be synced to the user that downloaded it.",
|
||||
"MessageMediaLinkedToServer": "Linked to server {0}",
|
||||
"MessageMediaLinkedToThisServer": "Downloaded media is linked to this server",
|
||||
"MessageMediaNotLinkedToServer": "Media is not linked to an Audiobookshelf server. No progress will be synced.",
|
||||
"MessageMediaLinkedToADifferentServer": "Média jsou propojena se serverem Audiobookshelf na jiné adrese ({0}). Pokrok bude synchronizován, když budete připojeni k této adrese serveru.",
|
||||
"MessageMediaLinkedToADifferentUser": "Média jsou propojena se serverem, ale byla stažena jiným uživatelem. Pokrok bude synchronizován pouze s uživatelem, který je stáhl.",
|
||||
"MessageMediaLinkedToServer": "Propojeno se serverem {0}",
|
||||
"MessageMediaLinkedToThisServer": "Stažená média jsou propojena s tímto serverem",
|
||||
"MessageMediaNotLinkedToServer": "Média nejsou propojena se serverem Audiobookshelf. Pokrok nebude synchronizován.",
|
||||
"MessageNoBookmarks": "Žádné záložky",
|
||||
"MessageNoChapters": "Žádné kapitoly",
|
||||
"MessageNoItems": "Žádné položky",
|
||||
"MessageNoItemsFound": "Nebyly nalezeny žádné položky",
|
||||
"MessageNoListeningSessions": "Žádné poslechové relace",
|
||||
"MessageNoMediaFolders": "No Media Folders",
|
||||
"MessageNoNetworkConnection": "No network connection",
|
||||
"MessageNoMediaFolders": "Žádné složky médií",
|
||||
"MessageNoNetworkConnection": "Žádné připojení k síti",
|
||||
"MessageNoPodcastsFound": "Nebyly nalezeny žádné podcasty",
|
||||
"MessageNoSeries": "No series",
|
||||
"MessageNoUpdatesWereNecessary": "Nebyly nutné žádné aktualizace",
|
||||
"MessageNoUserPlaylists": "Nemáte žádné seznamy skladeb",
|
||||
"MessageReportBugsAndContribute": "Hlásit chyby, žádat o funkce a přispívat",
|
||||
"MessageSocketConnectedOverMeteredCellular": "Socket connected over metered cellular",
|
||||
"MessageSocketConnectedOverMeteredWifi": "Socket connected over metered wifi",
|
||||
"MessageSocketConnectedOverUnmeteredCellular": "Socket connected over unmetered cellular",
|
||||
"MessageSocketConnectedOverUnmeteredWifi": "Socket connected over unmetered wifi",
|
||||
"MessageSocketNotConnected": "Socket not connected",
|
||||
"MessageReportBugsAndContribute": "Nahlašte chyby, vyžádejte si funkce a přispěte na",
|
||||
"MessageSocketConnectedOverMeteredCellular": "Socket připojen přes měřenou mobilní síť",
|
||||
"MessageSocketConnectedOverMeteredWifi": "Socket připojen přes měřenou wifi",
|
||||
"MessageSocketConnectedOverUnmeteredCellular": "Socket připojen přes neměřenou mobilní síť",
|
||||
"MessageSocketConnectedOverUnmeteredWifi": "Socket připojen přes neměřenou wifi",
|
||||
"MessageSocketNotConnected": "Socket není připojen",
|
||||
"NoteRSSFeedPodcastAppsHttps": "Upozornění: Většina aplikací pro podcasty bude vyžadovat, aby adresa URL kanálu RSS používala protokol HTTPS",
|
||||
"NoteRSSFeedPodcastAppsPubDate": "Upozornění: 1 nebo více epizod nemá datum vydání. Některé podcastové aplikace to vyžadují.",
|
||||
"ToastBookmarkCreateFailed": "Vytvoření záložky se nezdařilo",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"ButtonDeleteLocalItem": "Delete local item",
|
||||
"ButtonDisableAutoTimer": "Disable Auto Timer",
|
||||
"ButtonDisconnect": "Disconnect",
|
||||
"ButtonGoToWebClient": "Go to Web Client",
|
||||
"ButtonHistory": "History",
|
||||
"ButtonHome": "Hjem",
|
||||
"ButtonIssues": "Problemer",
|
||||
@@ -85,6 +86,7 @@
|
||||
"LabelAddedAt": "Tilføjet Kl.",
|
||||
"LabelAddToPlaylist": "Tilføj til Afspilningsliste",
|
||||
"LabelAll": "Alle",
|
||||
"LabelAllowSeekingOnMediaControls": "Allow position seeking on media notification controls",
|
||||
"LabelAuthor": "Forfatter",
|
||||
"LabelAuthorFirstLast": "Forfatter (Fornavn Efternavn)",
|
||||
"LabelAuthorLastFirst": "Forfatter (Efternavn, Fornavn)",
|
||||
@@ -104,6 +106,7 @@
|
||||
"LabelContinueBooks": "Continue Books",
|
||||
"LabelContinueEpisodes": "Continue Episodes",
|
||||
"LabelContinueListening": "Continue Listening",
|
||||
"LabelContinueReading": "Continue Reading",
|
||||
"LabelContinueSeries": "Continue Series",
|
||||
"LabelCustomTime": "Custom time",
|
||||
"LabelDescription": "Beskrivelse",
|
||||
@@ -149,6 +152,9 @@
|
||||
"LabelJumpBackwardsTime": "Jump backwards time",
|
||||
"LabelJumpForwardsTime": "Jump forwards time",
|
||||
"LabelLanguage": "Sprog",
|
||||
"LabelLayout": "Layout",
|
||||
"LabelLayoutAuto": "Auto",
|
||||
"LabelLayoutSinglePage": "Single page",
|
||||
"LabelLight": "Light",
|
||||
"LabelLineSpacing": "Linjeafstand",
|
||||
"LabelListenAgain": "Listen Again",
|
||||
@@ -165,6 +171,7 @@
|
||||
"LabelNarrator": "Fortæller",
|
||||
"LabelNarrators": "Fortællere",
|
||||
"LabelNewestAuthors": "Newest Authors",
|
||||
"LabelNewestEpisodes": "Newest Episodes",
|
||||
"LabelNo": "No",
|
||||
"LabelNotFinished": "Ikke færdig",
|
||||
"LabelNotStarted": "Ikke påbegyndt",
|
||||
@@ -182,12 +189,15 @@
|
||||
"LabelPubDate": "Udgivelsesdato",
|
||||
"LabelPublishYear": "Udgivelsesår",
|
||||
"LabelRead": "Læst",
|
||||
"LabelReadAgain": "Read Again",
|
||||
"LabelRecentlyAdded": "Recently Added",
|
||||
"LabelRecentSeries": "Recent Series",
|
||||
"LabelRemoveFromPlaylist": "Remove from Playlist",
|
||||
"LabelRSSFeedCustomOwnerEmail": "Brugerdefineret ejerens e-mail",
|
||||
"LabelRSSFeedCustomOwnerName": "Brugerdefineret ejerens navn",
|
||||
"LabelRSSFeedPreventIndexing": "Forhindrer indeksering",
|
||||
"LabelRSSFeedSlug": "RSS-feed-slug",
|
||||
"LabelScaleElapsedTimeBySpeed": "Scale Elapsed Time by Speed",
|
||||
"LabelSeason": "Sæson",
|
||||
"LabelSelectADevice": "Select a device",
|
||||
"LabelSeries": "Serie",
|
||||
@@ -262,6 +272,7 @@
|
||||
"MessageNoMediaFolders": "No Media Folders",
|
||||
"MessageNoNetworkConnection": "No network connection",
|
||||
"MessageNoPodcastsFound": "Ingen podcasts fundet",
|
||||
"MessageNoSeries": "No series",
|
||||
"MessageNoUpdatesWereNecessary": "Ingen opdateringer var nødvendige",
|
||||
"MessageNoUserPlaylists": "Du har ingen afspilningslister",
|
||||
"MessageReportBugsAndContribute": "Rapporter fejl, anmod om funktioner og bidrag på",
|
||||
|
||||
+64
-53
@@ -17,36 +17,37 @@
|
||||
"ButtonDeleteLocalEpisode": "Lösche lokale Episode",
|
||||
"ButtonDeleteLocalFile": "Lösche lokale Datei",
|
||||
"ButtonDeleteLocalItem": "Lösche lokales Element",
|
||||
"ButtonDisableAutoTimer": "Deaktiviere Automatischer-Timer",
|
||||
"ButtonDisableAutoTimer": "Deaktiviere automatischen Timer",
|
||||
"ButtonDisconnect": "Trennen",
|
||||
"ButtonGoToWebClient": "Go to Web Client",
|
||||
"ButtonHistory": "Historie",
|
||||
"ButtonHome": "Startseite",
|
||||
"ButtonIssues": "Probleme",
|
||||
"ButtonLatest": "Neuste",
|
||||
"ButtonLatest": "Neueste",
|
||||
"ButtonLibrary": "Bibliothek",
|
||||
"ButtonLocalMedia": "Lokale Medien",
|
||||
"ButtonManageLocalFiles": "Verwalte lokale Dateien",
|
||||
"ButtonNewFolder": "Neuer Ordner",
|
||||
"ButtonNextEpisode": "Nächste Episode",
|
||||
"ButtonOpenFeed": "Feed öffnen",
|
||||
"ButtonOverride": "Überschreieben",
|
||||
"ButtonOverride": "Überschreiben",
|
||||
"ButtonPause": "Pause",
|
||||
"ButtonPlay": "Abspielen",
|
||||
"ButtonPlaying": "Spielt",
|
||||
"ButtonPlaylists": "Wiedergabelisten",
|
||||
"ButtonRead": "Lese",
|
||||
"ButtonRead": "Lesen",
|
||||
"ButtonRemove": "Löschen",
|
||||
"ButtonRemoveFromServer": "Vom Server entfernen",
|
||||
"ButtonSave": "Speichern",
|
||||
"ButtonSaveOrder": "Speicher Reihenfolge",
|
||||
"ButtonSaveOrder": "Reihenfolge speichern",
|
||||
"ButtonSearch": "Suchen",
|
||||
"ButtonSendEbookToDevice": "Sende Ebook an Gerät",
|
||||
"ButtonSendEbookToDevice": "Sende E-Book an Gerät",
|
||||
"ButtonSeries": "Serien",
|
||||
"ButtonSetTimer": "Setze Timer",
|
||||
"ButtonStream": "Stream",
|
||||
"ButtonSubmit": "Ok",
|
||||
"ButtonSwitchServerUser": "Wechsle Server/Benutzer",
|
||||
"ButtonUserStats": "Nutzer Statistiken",
|
||||
"ButtonUserStats": "Nutzerstatistiken",
|
||||
"ButtonYes": "Ja",
|
||||
"HeaderAccount": "Konto",
|
||||
"HeaderAdvanced": "Erweitert",
|
||||
@@ -54,13 +55,13 @@
|
||||
"HeaderChapters": "Kapitel",
|
||||
"HeaderCollection": "Sammlungen",
|
||||
"HeaderCollectionItems": "Sammlungseinträge",
|
||||
"HeaderConnectionStatus": "Verbindungs Status",
|
||||
"HeaderDetails": "Einzelheiten",
|
||||
"HeaderConnectionStatus": "Verbindungsstatus",
|
||||
"HeaderDetails": "Details",
|
||||
"HeaderDownloads": "Downloads",
|
||||
"HeaderEbookFiles": "E-Book Dateien",
|
||||
"HeaderEpisodes": "Episoden",
|
||||
"HeaderEreaderSettings": "Ereader Einstellungen",
|
||||
"HeaderLatestEpisodes": "Letzte Episoden",
|
||||
"HeaderEreaderSettings": "E-Reader Einstellungen",
|
||||
"HeaderLatestEpisodes": "Neueste Episoden",
|
||||
"HeaderLibraries": "Bibliotheken",
|
||||
"HeaderLocalFolders": "Lokale Ordner",
|
||||
"HeaderLocalLibraryItems": "Lokale Bibliothek Elemente",
|
||||
@@ -74,8 +75,8 @@
|
||||
"HeaderRSSFeedIsOpen": "RSS-Feed ist geöffnet",
|
||||
"HeaderSelectDownloadLocation": "Wähle Download-Speicherort",
|
||||
"HeaderSettings": "Einstellungen",
|
||||
"HeaderSleepTimer": "Einschlaf-Timer",
|
||||
"HeaderSleepTimerSettings": "Einschlaf-Timer Einstellungen",
|
||||
"HeaderSleepTimer": "Sleep-Timer",
|
||||
"HeaderSleepTimerSettings": "Sleep-Timer Einstellungen",
|
||||
"HeaderStatsMinutesListeningChart": "Hörminuten (letzte 7 Tage)",
|
||||
"HeaderStatsRecentSessions": "Neueste Ereignisse",
|
||||
"HeaderTableOfContents": "Inhaltsverzeichnis",
|
||||
@@ -85,16 +86,17 @@
|
||||
"LabelAddedAt": "Hinzugefügt am",
|
||||
"LabelAddToPlaylist": "Zur Wiedergabeliste hinzufügen",
|
||||
"LabelAll": "Alle",
|
||||
"LabelAllowSeekingOnMediaControls": "Erlaube Vor- und Zurückspulen auf dem Medienkontrollelement bei den Benachrichtigungen",
|
||||
"LabelAuthor": "Autor",
|
||||
"LabelAuthorFirstLast": "Autor (Vorname Nachname)",
|
||||
"LabelAuthorLastFirst": "Autor (Nachname, Vorname)",
|
||||
"LabelAuthors": "Autoren",
|
||||
"LabelAutoDownloadEpisodes": "Episoden automatisch herunterladen",
|
||||
"LabelAutoRewindTime": "Automatische Rückspulzeit",
|
||||
"LabelAutoSleepTimer": "Automatischer Einschlaf-Timer",
|
||||
"LabelAutoSleepTimerAutoRewind": "Automatischer Einschlaf-Timer automatische Rückspulzeit",
|
||||
"LabelAutoSleepTimerAutoRewindHelp": "Wenn die Einschlaf-Timer abgelaufen ist, wird bei der erneuten Wiedergabe des Titels die Position automatisch zurückgespult.",
|
||||
"LabelAutoSleepTimerHelp": "Bei der Wiedergabe von Medien zwischen der angegebenen Start- und Endzeit wird automatisch ein Einschlaf-Timer gestartet.",
|
||||
"LabelAutoSleepTimer": "Automatischer Sleep-Timer",
|
||||
"LabelAutoSleepTimerAutoRewind": "Automatischer Sleep-Timer automatische Rückspulzeit",
|
||||
"LabelAutoSleepTimerAutoRewindHelp": "Wenn der Sleep-Timer abgelaufen ist, wird bei der erneuten Wiedergabe des Titels die Position automatisch zurückgespult.",
|
||||
"LabelAutoSleepTimerHelp": "Bei der Wiedergabe von Medien zwischen der angegebenen Start- und Endzeit wird automatisch ein Sleep-Timer gestartet.",
|
||||
"LabelBooks": "Bücher",
|
||||
"LabelChapters": "Kapitel",
|
||||
"LabelChapterTrack": "Kapitel Spur",
|
||||
@@ -104,25 +106,26 @@
|
||||
"LabelContinueBooks": "Bücher fortfahren",
|
||||
"LabelContinueEpisodes": "Episoden fortfahren",
|
||||
"LabelContinueListening": "Weiterhören",
|
||||
"LabelContinueReading": "Weiterlesen",
|
||||
"LabelContinueSeries": "Serie fortsetzen",
|
||||
"LabelCustomTime": "Benutzerdefinierte zeit",
|
||||
"LabelCustomTime": "Benutzerdefinierte Zeit",
|
||||
"LabelDescription": "Beschreibung",
|
||||
"LabelDisableAudioFadeOut": "Deaktiviere Audio abklingen",
|
||||
"LabelDisableAudioFadeOutHelp": "Die Audiolautstärke wird verringert, wenn weniger als 1 Minute für den Einschlaf-Timer verbleibt. Aktivieren Sie diese Einstellung, um nicht abzuklingen.",
|
||||
"LabelDisableAutoRewind": "Deaktiviere automatisches Rückspulen",
|
||||
"LabelDisableAudioFadeOutHelp": "Die Audiolautstärke wird verringert, wenn weniger als 1 Minute für den Sleep-Timer verbleibt. Aktiviere diese Einstellung, um nicht abzuklingen.",
|
||||
"LabelDisableAutoRewind": "Deaktiviere automatisches Zurückspulen",
|
||||
"LabelDisableShakeToReset": "Deaktiviere Schütteln zum Zurücksetzen",
|
||||
"LabelDisableShakeToResetHelp": "Wenn Sie Ihr Gerät schütteln, während der Timer läuft ODER innerhalb von 2 Minuten nach Ablauf des Timers, wird der Sleep-Timer zurückgesetzt. Aktivieren Sie diese Einstellung, um das Schütteln zum Zurücksetzen zu deaktivieren.",
|
||||
"LabelDisableVibrateOnReset": "Deaktiviere vibrieren beim Zurücksetzen",
|
||||
"LabelDisableVibrateOnResetHelp": "Wenn der Einschlaf-Timer zurückgesetzt wird, vibriert Ihr Gerät. Aktivieren Sie diese Einstellung, um nicht zu vibrieren, wenn der Einschlaf-Timer zurückgesetzt wird.",
|
||||
"LabelDisableShakeToResetHelp": "Wenn du dein Gerät schüttelst, während der Timer läuft ODER innerhalb von 2 Minuten nach Ablauf des Timers, wird der Sleep-Timer zurückgesetzt. Aktiviere diese Einstellung, um das Schütteln zum Zurücksetzen zu deaktivieren.",
|
||||
"LabelDisableVibrateOnReset": "Deaktiviere Vibrieren beim Zurücksetzen",
|
||||
"LabelDisableVibrateOnResetHelp": "Wenn der Sleep-Timer zurückgesetzt wird, vibriert dein Gerät. Aktiviere diese Einstellung, um nicht zu vibrieren, wenn der Sleep-Timer zurückgesetzt wird.",
|
||||
"LabelDiscover": "Entdecken",
|
||||
"LabelDownload": "Herunterladen",
|
||||
"LabelDownloaded": "Heruntergeladen",
|
||||
"LabelDuration": "Laufzeit",
|
||||
"LabelEbook": "E-Book",
|
||||
"LabelEbooks": "Ebooks",
|
||||
"LabelEbooks": "E-Books",
|
||||
"LabelEnable": "Aktivieren",
|
||||
"LabelEnableMp3IndexSeeking": "Aktiviere MP3-Indexsuche",
|
||||
"LabelEnableMp3IndexSeekingHelp": "Diese Einstellung sollte nur aktiviert werden, wenn Sie MP3-Dateien haben, bei denen das Navigieren (Seeking) nicht korrekt funktioniert. Ungenaues Navigieren ist höchstwahrscheinlich auf MP3-Dateien mit variabler Bitrate (VBR) zurückzuführen. Diese Einstellung erzwingt das Index-Navigieren (Index Seeking), bei dem eine Zeit-zu-Byte-Zuordnung erstellt wird, während die Datei gelesen wird. In einigen Fällen kann es bei großen MP3-Dateien zu einer Verzögerung kommen, wenn gegen Ende der Datei navigiert wird.",
|
||||
"LabelEnableMp3IndexSeeking": "MP3-Indexsuche",
|
||||
"LabelEnableMp3IndexSeekingHelp": "Diese Einstellung sollte nur aktiviert werden, wenn du MP3-Dateien hast, bei denen das Navigieren (Seeking) nicht korrekt funktioniert. Ungenaues Navigieren ist höchstwahrscheinlich auf MP3-Dateien mit variabler Bitrate (VBR) zurückzuführen. Diese Einstellung erzwingt das Index-Navigieren (Index Seeking), bei dem eine Zeit-zu-Byte-Zuordnung erstellt wird, während die Datei gelesen wird. In einigen Fällen kann es bei großen MP3-Dateien zu einer Verzögerung kommen, wenn gegen Ende der Datei navigiert wird.",
|
||||
"LabelEnd": "Ende",
|
||||
"LabelEndOfChapter": "Kapitelende",
|
||||
"LabelEndTime": "Endzeit",
|
||||
@@ -132,15 +135,15 @@
|
||||
"LabelFileBirthtime": "Datei erstellt",
|
||||
"LabelFileModified": "Datei geändert",
|
||||
"LabelFilename": "Dateiname",
|
||||
"LabelFinished": "beendet",
|
||||
"LabelFinished": "Beendet",
|
||||
"LabelFolder": "Ordner",
|
||||
"LabelFontScale": "Schriftgröße",
|
||||
"LabelGenre": "Kategorie",
|
||||
"LabelGenres": "Kategorien",
|
||||
"LabelHapticFeedback": "Haptische Rückmeldung",
|
||||
"LabelHasEbook": "Hat Ebook",
|
||||
"LabelHasSupplementaryEbook": "Hat zusätzliches Ebook",
|
||||
"LabelHeavy": "Schwer",
|
||||
"LabelHasEbook": "E-Book verfügbar",
|
||||
"LabelHasSupplementaryEbook": "Ergänzendes E-Book verfügbar",
|
||||
"LabelHeavy": "Stark",
|
||||
"LabelHigh": "Hoch",
|
||||
"LabelHost": "Host",
|
||||
"LabelIncomplete": "Unvollständig",
|
||||
@@ -149,24 +152,28 @@
|
||||
"LabelJumpBackwardsTime": "Rückspulzeit",
|
||||
"LabelJumpForwardsTime": "Vorwärtsspulzeit",
|
||||
"LabelLanguage": "Sprache",
|
||||
"LabelLight": "Licht",
|
||||
"LabelLayout": "Layout",
|
||||
"LabelLayoutAuto": "Auto",
|
||||
"LabelLayoutSinglePage": "Einzelne Seite",
|
||||
"LabelLight": "Leicht",
|
||||
"LabelLineSpacing": "Zeilenabstand",
|
||||
"LabelListenAgain": "Erneut anhören",
|
||||
"LabelLocalBooks": "Lokale Bücher",
|
||||
"LabelLocalPodcasts": "Lokale Podcasts",
|
||||
"LabelLockOrientation": "Ausrichtung sperren",
|
||||
"LabelLockOrientation": "Automatische Bildschirmausrichtung sperren",
|
||||
"LabelLockPlayer": "Mediaplayer sperren",
|
||||
"LabelLow": "Niedrig",
|
||||
"LabelLow": "Wenig",
|
||||
"LabelMediaType": "Medientyp",
|
||||
"LabelMedium": "Mittel",
|
||||
"LabelMore": "Mehr",
|
||||
"LabelMoreInfo": "Mehr Info",
|
||||
"LabelMoreInfo": "Mehr Infos",
|
||||
"LabelName": "Name",
|
||||
"LabelNarrator": "Erzähler",
|
||||
"LabelNarrators": "Erzähler",
|
||||
"LabelNewestAuthors": "Neueste Autoren",
|
||||
"LabelNewestEpisodes": "Neueste Episoden",
|
||||
"LabelNo": "Nein",
|
||||
"LabelNotFinished": "nicht beendet",
|
||||
"LabelNotFinished": "Nicht beendet",
|
||||
"LabelNotStarted": "Nicht begonnen",
|
||||
"LabelOff": "Aus",
|
||||
"LabelPassword": "Passwort",
|
||||
@@ -182,29 +189,32 @@
|
||||
"LabelPubDate": "Veröffentlichungsdatum",
|
||||
"LabelPublishYear": "Jahr",
|
||||
"LabelRead": "Lesen",
|
||||
"LabelReadAgain": "Erneut lesen",
|
||||
"LabelRecentlyAdded": "Kürzlich hinzugefügt",
|
||||
"LabelRecentSeries": "Aktuelle Serien",
|
||||
"LabelRemoveFromPlaylist": "Remove from Playlist",
|
||||
"LabelRSSFeedCustomOwnerEmail": "Benutzerdefinierte Eigentümer-E-Mail",
|
||||
"LabelRSSFeedCustomOwnerName": "Benutzerdefinierter Name des Eigentümers",
|
||||
"LabelRSSFeedPreventIndexing": "Indizierung verhindern",
|
||||
"LabelRSSFeedSlug": "RSS Feed Schlagwort",
|
||||
"LabelScaleElapsedTimeBySpeed": "Vergangene Zeit anhand der Geschwindigkeit skalieren",
|
||||
"LabelSeason": "Staffel",
|
||||
"LabelSelectADevice": "Wähle ein Gerät",
|
||||
"LabelSeries": "Serien",
|
||||
"LabelServerAddress": "Server Adresse",
|
||||
"LabelSetEbookAsPrimary": "Setzen als Hauptbuch",
|
||||
"LabelSetEbookAsSupplementary": "Setzen als Ergänzung",
|
||||
"LabelSetEbookAsPrimary": "Als Hauptbuch setzen",
|
||||
"LabelSetEbookAsSupplementary": "Als Ergänzung setzen",
|
||||
"LabelShakeSensitivity": "Schüttel-Empfindlichkeit",
|
||||
"LabelShowAll": "Alles anzeigen",
|
||||
"LabelSize": "Größe",
|
||||
"LabelSleepTimer": "Einschlaf-Timer",
|
||||
"LabelSleepTimer": "Sleep-Timer",
|
||||
"LabelStart": "Start",
|
||||
"LabelStartTime": "Startzeit",
|
||||
"LabelStatsBestDay": "Bester Tag",
|
||||
"LabelStatsDailyAverage": "Tagesdurchschnitt",
|
||||
"LabelStatsDays": "Tage",
|
||||
"LabelStatsDaysListened": "Gehörte Tage",
|
||||
"LabelStatsInARow": "nacheinander",
|
||||
"LabelStatsInARow": "Nacheinander",
|
||||
"LabelStatsItemsFinished": "Gehörte Medien",
|
||||
"LabelStatsMinutes": "Minuten",
|
||||
"LabelStatsMinutesListening": "Gehörte Minuten",
|
||||
@@ -213,11 +223,11 @@
|
||||
"LabelTags": "Schlagwörter",
|
||||
"LabelTheme": "Theme",
|
||||
"LabelThemeDark": "Dunkel",
|
||||
"LabelThemeLight": "Licht",
|
||||
"LabelThemeLight": "Hell",
|
||||
"LabelTimeRemaining": "{0} verbleibend",
|
||||
"LabelTitle": "Titel",
|
||||
"LabelTotalTrack": "Total Track",
|
||||
"LabelTracks": "Dateien",
|
||||
"LabelTotalTrack": "Gesamtanzahl Titel",
|
||||
"LabelTracks": "Titel",
|
||||
"LabelType": "Typ",
|
||||
"LabelUnlockPlayer": "Mediaplayer entsperren",
|
||||
"LabelUseBookshelfView": "Benutze Bücherregalansicht",
|
||||
@@ -230,16 +240,16 @@
|
||||
"MessageAndroid10Downloads": "Bei Android 10 und darunter wird der interne App-Speicher für Downloads verwendet.",
|
||||
"MessageAttemptingServerConnection": "Versuch einer Serververbindung...",
|
||||
"MessageAudiobookshelfServerNotConnected": "Audiobookshelf Server nicht verbunden",
|
||||
"MessageAudiobookshelfServerRequired": "<strong>Wichtig!</strong> Diese App wurde entwickelt, um mit einem Audiobookshelf-Server zu arbeiten, den Sie oder jemand, den Sie kennen, hostet. Diese App stellt keine Inhalte zur Verfügung.",
|
||||
"MessageAudiobookshelfServerRequired": "<strong>Wichtig!</strong> Diese App wurde entwickelt, um mit einem Audiobookshelf-Server zu arbeiten, den du oder jemand, den du kennst, hostet. Diese App stellt keine Inhalte zur Verfügung.",
|
||||
"MessageBookshelfEmpty": "Bücherregal leer",
|
||||
"MessageConfirmDeleteLocalEpisode": "Entferne lokale Episode \"{0}\" von Ihrem Gerät? Die Datei auf dem Server bleibt davon unberührt.",
|
||||
"MessageConfirmDeleteLocalFiles": "Entfernen Sie lokale Dateien dieses Elements von Ihrem Gerät? Die Dateien auf dem Server und Ihr Fortschritt bleiben davon unberührt.",
|
||||
"MessageConfirmDiscardProgress": "Sind Sie sicher, dass Sie Ihren Fortschritt zurücksetzen wollen?",
|
||||
"MessageConfirmMarkAsFinished": "Sind Sie sicher, dass Sie diesen Artikel als beendet markieren wollen?",
|
||||
"MessageConfirmRemoveBookmark": "Sind Sie sicher, dass Sie das Lesezeichen entfernen möchten?",
|
||||
"MessageDiscardProgress": "Verwerfe Fortschritt",
|
||||
"MessageConfirmDeleteLocalEpisode": "Soll die lokale Episode \"{0}\" von deinem Gerät entfernt werden? Die Datei auf dem Server bleibt davon unberührt.",
|
||||
"MessageConfirmDeleteLocalFiles": "Sollen lokale Dateien dieses Elements von deinem Gerät entfernt werden? Die Dateien auf dem Server und Ihr Fortschritt bleiben davon unberührt.",
|
||||
"MessageConfirmDiscardProgress": "Bist du sicher, dass du deinen Fortschritt zurücksetzen willst?",
|
||||
"MessageConfirmMarkAsFinished": "Bist du sicher, dass du diesen Artikel als beendet markieren willst?",
|
||||
"MessageConfirmRemoveBookmark": "Bist du sicher, dass du das Lesezeichen entfernen willst?",
|
||||
"MessageDiscardProgress": "Fortschritt verwerfen",
|
||||
"MessageDownloadCompleteProcessing": "Download abgeschlossen. Verarbeite...",
|
||||
"MessageDownloading": "Downloaded...",
|
||||
"MessageDownloading": "Herunterladen...",
|
||||
"MessageDownloadingEpisode": "Episode herunterladen",
|
||||
"MessageEpisodesQueuedForDownload": "{0} Episode(n) in der Warteschlange zum Herunterladen",
|
||||
"MessageFeedURLWillBe": "Feed-URL wird {0} sein",
|
||||
@@ -262,16 +272,17 @@
|
||||
"MessageNoMediaFolders": "Keine Medien Ordner",
|
||||
"MessageNoNetworkConnection": "Keine Netzwerkverbindung",
|
||||
"MessageNoPodcastsFound": "Keine Podcasts gefunden",
|
||||
"MessageNoSeries": "No series",
|
||||
"MessageNoUpdatesWereNecessary": "Keine Aktualisierungen waren notwendig",
|
||||
"MessageNoUserPlaylists": "Keine Wiedergabelisten vorhanden",
|
||||
"MessageReportBugsAndContribute": "Fehler melden, Funktionen anfordern und Beiträge leisten auf",
|
||||
"MessageReportBugsAndContribute": "Fehler melden, Funktionen anfordern und mitwirken",
|
||||
"MessageSocketConnectedOverMeteredCellular": "Socket verbunden über getaktetes Mobilfunknetz",
|
||||
"MessageSocketConnectedOverMeteredWifi": "Socket verbunden über getaktetes WLAN",
|
||||
"MessageSocketConnectedOverUnmeteredCellular": "Socket verbunden über ungetaktetes Mobilfunknetz",
|
||||
"MessageSocketConnectedOverUnmeteredWifi": "Socket verbunden über ungetaktetes Mobilfunknetz",
|
||||
"MessageSocketNotConnected": "Socket nicht verbunden",
|
||||
"NoteRSSFeedPodcastAppsHttps": "Warnung: Die meisten Podcast-Apps verlangen, dass die URL des RSS-Feeds HTTPS verwendet.",
|
||||
"NoteRSSFeedPodcastAppsPubDate": "Warnung: 1 oder mehrere Ihrer Episoden haben kein Veröffentlichungsdatum. Einige Podcast-Apps verlangen dies.",
|
||||
"NoteRSSFeedPodcastAppsPubDate": "Warnung: 1 oder mehrere deiner Episoden haben kein Veröffentlichungsdatum. Einige Podcast-Apps verlangen dies.",
|
||||
"ToastBookmarkCreateFailed": "Lesezeichen konnte nicht erstellt werden",
|
||||
"ToastBookmarkRemoveFailed": "Lesezeichen konnte nicht gelöscht werden",
|
||||
"ToastBookmarkUpdateFailed": "Lesezeichenaktualisierung fehlgeschlagen",
|
||||
@@ -282,4 +293,4 @@
|
||||
"ToastPodcastCreateSuccess": "Podcast erstellt",
|
||||
"ToastRSSFeedCloseFailed": "RSS-Feed konnte nicht geschlossen werden",
|
||||
"ToastRSSFeedCloseSuccess": "RSS-Feed geschlossen"
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@
|
||||
"ButtonDeleteLocalItem": "Delete local item",
|
||||
"ButtonDisableAutoTimer": "Disable Auto Timer",
|
||||
"ButtonDisconnect": "Disconnect",
|
||||
"ButtonGoToWebClient": "Go to Web Client",
|
||||
"ButtonHistory": "History",
|
||||
"ButtonHome": "Home",
|
||||
"ButtonIssues": "Issues",
|
||||
@@ -85,6 +86,7 @@
|
||||
"LabelAddedAt": "Added At",
|
||||
"LabelAddToPlaylist": "Add to Playlist",
|
||||
"LabelAll": "All",
|
||||
"LabelAllowSeekingOnMediaControls": "Allow position seeking on media notification controls",
|
||||
"LabelAuthor": "Author",
|
||||
"LabelAuthorFirstLast": "Author (First Last)",
|
||||
"LabelAuthorLastFirst": "Author (Last, First)",
|
||||
@@ -104,6 +106,7 @@
|
||||
"LabelContinueBooks": "Continue Books",
|
||||
"LabelContinueEpisodes": "Continue Episodes",
|
||||
"LabelContinueListening": "Continue Listening",
|
||||
"LabelContinueReading": "Continue Reading",
|
||||
"LabelContinueSeries": "Continue Series",
|
||||
"LabelCustomTime": "Custom time",
|
||||
"LabelDescription": "Description",
|
||||
@@ -149,6 +152,9 @@
|
||||
"LabelJumpBackwardsTime": "Jump backwards time",
|
||||
"LabelJumpForwardsTime": "Jump forwards time",
|
||||
"LabelLanguage": "Language",
|
||||
"LabelLayout": "Layout",
|
||||
"LabelLayoutAuto": "Auto",
|
||||
"LabelLayoutSinglePage": "Single page",
|
||||
"LabelLight": "Light",
|
||||
"LabelLineSpacing": "Line spacing",
|
||||
"LabelListenAgain": "Listen Again",
|
||||
@@ -165,6 +171,7 @@
|
||||
"LabelNarrator": "Narrator",
|
||||
"LabelNarrators": "Narrators",
|
||||
"LabelNewestAuthors": "Newest Authors",
|
||||
"LabelNewestEpisodes": "Newest Episodes",
|
||||
"LabelNo": "No",
|
||||
"LabelNotFinished": "Not Finished",
|
||||
"LabelNotStarted": "Not Started",
|
||||
@@ -182,12 +189,15 @@
|
||||
"LabelPubDate": "Pub Date",
|
||||
"LabelPublishYear": "Publish Year",
|
||||
"LabelRead": "Read",
|
||||
"LabelReadAgain": "Read Again",
|
||||
"LabelRecentlyAdded": "Recently Added",
|
||||
"LabelRecentSeries": "Recent Series",
|
||||
"LabelRemoveFromPlaylist": "Remove from Playlist",
|
||||
"LabelRSSFeedCustomOwnerEmail": "Custom owner Email",
|
||||
"LabelRSSFeedCustomOwnerName": "Custom owner Name",
|
||||
"LabelRSSFeedPreventIndexing": "Prevent Indexing",
|
||||
"LabelRSSFeedSlug": "RSS Feed Slug",
|
||||
"LabelScaleElapsedTimeBySpeed": "Scale Elapsed Time by Speed",
|
||||
"LabelSeason": "Season",
|
||||
"LabelSelectADevice": "Select a device",
|
||||
"LabelSeries": "Series",
|
||||
@@ -262,6 +272,7 @@
|
||||
"MessageNoMediaFolders": "No Media Folders",
|
||||
"MessageNoNetworkConnection": "No network connection",
|
||||
"MessageNoPodcastsFound": "No podcasts found",
|
||||
"MessageNoSeries": "No series",
|
||||
"MessageNoUpdatesWereNecessary": "No updates were necessary",
|
||||
"MessageNoUserPlaylists": "You have no playlists",
|
||||
"MessageReportBugsAndContribute": "Report bugs, request features, and contribute on",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"ButtonDeleteLocalItem": "Delete local item",
|
||||
"ButtonDisableAutoTimer": "Disable Auto Timer",
|
||||
"ButtonDisconnect": "Disconnect",
|
||||
"ButtonGoToWebClient": "Go to Web Client",
|
||||
"ButtonHistory": "History",
|
||||
"ButtonHome": "Inicio",
|
||||
"ButtonIssues": "Problemas",
|
||||
@@ -85,6 +86,7 @@
|
||||
"LabelAddedAt": "Añadido",
|
||||
"LabelAddToPlaylist": "Añadido a la Lista de Reproducción",
|
||||
"LabelAll": "Todos",
|
||||
"LabelAllowSeekingOnMediaControls": "Allow position seeking on media notification controls",
|
||||
"LabelAuthor": "Autor",
|
||||
"LabelAuthorFirstLast": "Autor (Nombre Apellido)",
|
||||
"LabelAuthorLastFirst": "Autor (Apellido, Nombre)",
|
||||
@@ -104,6 +106,7 @@
|
||||
"LabelContinueBooks": "Continue Books",
|
||||
"LabelContinueEpisodes": "Continue Episodes",
|
||||
"LabelContinueListening": "Continue Listening",
|
||||
"LabelContinueReading": "Continue Reading",
|
||||
"LabelContinueSeries": "Continue Series",
|
||||
"LabelCustomTime": "Custom time",
|
||||
"LabelDescription": "Descripción",
|
||||
@@ -149,6 +152,9 @@
|
||||
"LabelJumpBackwardsTime": "Jump backwards time",
|
||||
"LabelJumpForwardsTime": "Jump forwards time",
|
||||
"LabelLanguage": "Lenguaje",
|
||||
"LabelLayout": "Layout",
|
||||
"LabelLayoutAuto": "Auto",
|
||||
"LabelLayoutSinglePage": "Single page",
|
||||
"LabelLight": "Light",
|
||||
"LabelLineSpacing": "Interlineado",
|
||||
"LabelListenAgain": "Listen Again",
|
||||
@@ -165,6 +171,7 @@
|
||||
"LabelNarrator": "Narrador",
|
||||
"LabelNarrators": "Narradores",
|
||||
"LabelNewestAuthors": "Newest Authors",
|
||||
"LabelNewestEpisodes": "Newest Episodes",
|
||||
"LabelNo": "No",
|
||||
"LabelNotFinished": "No Terminado",
|
||||
"LabelNotStarted": "Sin Iniciar",
|
||||
@@ -182,12 +189,15 @@
|
||||
"LabelPubDate": "Fecha de Publicación",
|
||||
"LabelPublishYear": "Año de Publicación",
|
||||
"LabelRead": "Leído",
|
||||
"LabelReadAgain": "Read Again",
|
||||
"LabelRecentlyAdded": "Recently Added",
|
||||
"LabelRecentSeries": "Recent Series",
|
||||
"LabelRemoveFromPlaylist": "Remove from Playlist",
|
||||
"LabelRSSFeedCustomOwnerEmail": "Email de dueño personalizado",
|
||||
"LabelRSSFeedCustomOwnerName": "Nombre de dueño personalizado",
|
||||
"LabelRSSFeedPreventIndexing": "Prevenir Indexado",
|
||||
"LabelRSSFeedSlug": "Fuente RSS Slug",
|
||||
"LabelScaleElapsedTimeBySpeed": "Scale Elapsed Time by Speed",
|
||||
"LabelSeason": "Temporada",
|
||||
"LabelSelectADevice": "Select a device",
|
||||
"LabelSeries": "Series",
|
||||
@@ -262,6 +272,7 @@
|
||||
"MessageNoMediaFolders": "No Media Folders",
|
||||
"MessageNoNetworkConnection": "No network connection",
|
||||
"MessageNoPodcastsFound": "Ningún podcast encontrado",
|
||||
"MessageNoSeries": "No series",
|
||||
"MessageNoUpdatesWereNecessary": "No fue necesario actualizar",
|
||||
"MessageNoUserPlaylists": "No tienes lista de reproducciones",
|
||||
"MessageReportBugsAndContribute": "Reporte erres, solicite funciones y contribuya en",
|
||||
|
||||
+145
-134
@@ -1,52 +1,53 @@
|
||||
{
|
||||
"ButtonAdd": "Ajouter",
|
||||
"ButtonAddNewServer": "Add New Server",
|
||||
"ButtonAddNewServer": "Ajouter un Serveur",
|
||||
"ButtonAuthors": "Auteurs",
|
||||
"ButtonBack": "Back",
|
||||
"ButtonBack": "Retour",
|
||||
"ButtonCancel": "Annuler",
|
||||
"ButtonCancelTimer": "Cancel Timer",
|
||||
"ButtonClearFilter": "Effacer le filtre",
|
||||
"ButtonCloseFeed": "Fermer le flux",
|
||||
"ButtonCancelTimer": "Annuler le Minuteur",
|
||||
"ButtonClearFilter": "Effacer le Filtre",
|
||||
"ButtonCloseFeed": "Fermer le Flux",
|
||||
"ButtonCollections": "Collections",
|
||||
"ButtonConnect": "Connect",
|
||||
"ButtonConnectToServer": "Connect to Server",
|
||||
"ButtonConnect": "Connexion",
|
||||
"ButtonConnectToServer": "Se Connecter au Serveur",
|
||||
"ButtonCreate": "Créer",
|
||||
"ButtonCreateBookmark": "Create Bookmark",
|
||||
"ButtonCreateNewPlaylist": "Create New Playlist",
|
||||
"ButtonCreateBookmark": "Créer un Marque-pages",
|
||||
"ButtonCreateNewPlaylist": "Créer une Nouvelle Liste de Lecture",
|
||||
"ButtonDelete": "Effacer",
|
||||
"ButtonDeleteLocalEpisode": "Delete local episode",
|
||||
"ButtonDeleteLocalFile": "Delete local file",
|
||||
"ButtonDeleteLocalItem": "Delete local item",
|
||||
"ButtonDisableAutoTimer": "Disable Auto Timer",
|
||||
"ButtonDisconnect": "Disconnect",
|
||||
"ButtonHistory": "History",
|
||||
"ButtonDeleteLocalEpisode": "Supprimer l'Épisode de l'Appareil",
|
||||
"ButtonDeleteLocalFile": "Supprimer le Fichier de l'Appareil",
|
||||
"ButtonDeleteLocalItem": "Supprimer l'Élément de l'Appareil",
|
||||
"ButtonDisableAutoTimer": "Désactiver le Minuteur Automatique",
|
||||
"ButtonDisconnect": "Déconnexion",
|
||||
"ButtonGoToWebClient": "Ouvrir dans le Navigateur",
|
||||
"ButtonHistory": "Historique",
|
||||
"ButtonHome": "Accueil",
|
||||
"ButtonIssues": "Parutions",
|
||||
"ButtonLatest": "Dernière version",
|
||||
"ButtonLatest": "Dernière Version",
|
||||
"ButtonLibrary": "Bibliothèque",
|
||||
"ButtonLocalMedia": "Local Media",
|
||||
"ButtonManageLocalFiles": "Manage Local Files",
|
||||
"ButtonNewFolder": "New Folder",
|
||||
"ButtonNextEpisode": "Next Episode",
|
||||
"ButtonOpenFeed": "Ouvrir le flux",
|
||||
"ButtonOverride": "Override",
|
||||
"ButtonLocalMedia": "Medias Locaux",
|
||||
"ButtonManageLocalFiles": "Gérer les Fichiers Locaux",
|
||||
"ButtonNewFolder": "Nouveau Dossier",
|
||||
"ButtonNextEpisode": "Prochain Épisode",
|
||||
"ButtonOpenFeed": "Ouvrir le Flux",
|
||||
"ButtonOverride": "Remplacer",
|
||||
"ButtonPause": "Pause",
|
||||
"ButtonPlay": "Écouter",
|
||||
"ButtonPlaying": "En lecture",
|
||||
"ButtonPlaylists": "Listes de lecture",
|
||||
"ButtonPlaying": "En Lecture",
|
||||
"ButtonPlaylists": "Listes de Lecture",
|
||||
"ButtonRead": "Lire",
|
||||
"ButtonRemove": "Supprimer",
|
||||
"ButtonRemoveFromServer": "Remove from Server",
|
||||
"ButtonRemoveFromServer": "Supprimer du Serveur",
|
||||
"ButtonSave": "Sauvegarder",
|
||||
"ButtonSaveOrder": "Save Order",
|
||||
"ButtonSaveOrder": "Sauvegarder l'Ordre",
|
||||
"ButtonSearch": "Chercher",
|
||||
"ButtonSendEbookToDevice": "Send Ebook to Device",
|
||||
"ButtonSendEbookToDevice": "Envoyer le Livre a l'Appareil",
|
||||
"ButtonSeries": "Séries",
|
||||
"ButtonSetTimer": "Set Timer",
|
||||
"ButtonStream": "Stream",
|
||||
"ButtonSetTimer": "Lancer",
|
||||
"ButtonStream": "Streamer",
|
||||
"ButtonSubmit": "Soumettre",
|
||||
"ButtonSwitchServerUser": "Switch Server/User",
|
||||
"ButtonUserStats": "User Stats",
|
||||
"ButtonSwitchServerUser": "Changer de Serveur/Utilisateur",
|
||||
"ButtonUserStats": "Statistiques Utilisateur",
|
||||
"ButtonYes": "Oui",
|
||||
"HeaderAccount": "Compte",
|
||||
"HeaderAdvanced": "Avancé",
|
||||
@@ -54,78 +55,80 @@
|
||||
"HeaderChapters": "Chapitres",
|
||||
"HeaderCollection": "Collection",
|
||||
"HeaderCollectionItems": "Entrées de la Collection",
|
||||
"HeaderConnectionStatus": "Connection Status",
|
||||
"HeaderConnectionStatus": "Status de Connexion",
|
||||
"HeaderDetails": "Détails",
|
||||
"HeaderDownloads": "Downloads",
|
||||
"HeaderDownloads": "Téléchargements",
|
||||
"HeaderEbookFiles": "Fichier des livres numériques",
|
||||
"HeaderEpisodes": "Épisodes",
|
||||
"HeaderEreaderSettings": "Options Ereader",
|
||||
"HeaderLatestEpisodes": "Dernier épisodes",
|
||||
"HeaderLibraries": "Bibliothèque",
|
||||
"HeaderLocalFolders": "Local Folders",
|
||||
"HeaderLocalLibraryItems": "Local Library Items",
|
||||
"HeaderNewPlaylist": "New Playlist",
|
||||
"HeaderLocalFolders": "Dossiers Locaux",
|
||||
"HeaderLocalLibraryItems": "Éléments de la Bibliothèque Local",
|
||||
"HeaderNewPlaylist": "Nouvelle Liste de Lecture",
|
||||
"HeaderOpenRSSFeed": "Ouvrir Flux RSS",
|
||||
"HeaderPlaybackSettings": "Playback Settings",
|
||||
"HeaderPlaybackSettings": "Paramètres de Lecture",
|
||||
"HeaderPlaylist": "Liste de lecture",
|
||||
"HeaderPlaylistItems": "Éléments de la liste de lecture",
|
||||
"HeaderPlaylistItems": "Éléments de la Liste de Lecture",
|
||||
"HeaderRSSFeed": "RSS Feed",
|
||||
"HeaderRSSFeedGeneral": "Détails de flux RSS",
|
||||
"HeaderRSSFeedGeneral": "Détails du Flux RSS",
|
||||
"HeaderRSSFeedIsOpen": "Le Flux RSS est actif",
|
||||
"HeaderSelectDownloadLocation": "Select Download Location",
|
||||
"HeaderSelectDownloadLocation": "Sélectionner le Dossier de Téléchargement",
|
||||
"HeaderSettings": "Paramètres",
|
||||
"HeaderSleepTimer": "Minuterie",
|
||||
"HeaderSleepTimerSettings": "Sleep Timer Settings",
|
||||
"HeaderSleepTimerSettings": "Paramètres du Minuteur Nocturne",
|
||||
"HeaderStatsMinutesListeningChart": "Minutes d’écoute (7 derniers jours)",
|
||||
"HeaderStatsRecentSessions": "Sessions récentes",
|
||||
"HeaderTableOfContents": "Table des matières",
|
||||
"HeaderUserInterfaceSettings": "User Interface Settings",
|
||||
"HeaderYourStats": "Vos statistiques",
|
||||
"HeaderTableOfContents": "Table des Matières",
|
||||
"HeaderUserInterfaceSettings": "Paramètres de l'Interface",
|
||||
"HeaderYourStats": "Vos Statistiques",
|
||||
"LabelAdded": "Ajouté",
|
||||
"LabelAddedAt": "Date d’ajout",
|
||||
"LabelAddToPlaylist": "Ajouter à la liste de lecture",
|
||||
"LabelAddToPlaylist": "Ajouter à la Liste de Lecture",
|
||||
"LabelAll": "Tout",
|
||||
"LabelAllowSeekingOnMediaControls": "Autoriser la Recherche de Position depuis la Notification du Lecteur Multimédia",
|
||||
"LabelAuthor": "Auteur",
|
||||
"LabelAuthorFirstLast": "Auteur (Prénom Nom)",
|
||||
"LabelAuthorLastFirst": "Auteur (Nom, Prénom)",
|
||||
"LabelAuthors": "Auteurs",
|
||||
"LabelAutoDownloadEpisodes": "Téléchargement automatique d’épisode",
|
||||
"LabelAutoRewindTime": "Auto rewind time",
|
||||
"LabelAutoSleepTimer": "Auto sleep timer",
|
||||
"LabelAutoSleepTimerAutoRewind": "Auto sleep timer auto rewind",
|
||||
"LabelAutoSleepTimerAutoRewindHelp": "When the auto sleep timer finishes, playing the item again will automatically rewind your position.",
|
||||
"LabelAutoSleepTimerHelp": "When playing media between the specified start and end times a sleep timer will automatically start.",
|
||||
"LabelAutoDownloadEpisodes": "Téléchargement Automatique d’Épisode",
|
||||
"LabelAutoRewindTime": "Saut arrière Automatique",
|
||||
"LabelAutoSleepTimer": "Minuteur Nocturne Automatique",
|
||||
"LabelAutoSleepTimerAutoRewind": "Saut arrière Automatique du Minuteur Nocturne",
|
||||
"LabelAutoSleepTimerAutoRewindHelp": "Lorsque le minuteur nocturne de termine, relire l'élément fera un saut en arrière.",
|
||||
"LabelAutoSleepTimerHelp": "Lorsqu'un éléments est lu entre l'heure de début et de fin, un minuteur nocturne se lance automatiquement.",
|
||||
"LabelBooks": "Livres",
|
||||
"LabelChapters": "Chapitres",
|
||||
"LabelChapterTrack": "Chapter Track",
|
||||
"LabelChapterTrack": "Piste des Chapitres",
|
||||
"LabelClosePlayer": "Fermer le lecteur",
|
||||
"LabelCollapseSeries": "Réduire les séries",
|
||||
"LabelComplete": "Complet",
|
||||
"LabelContinueBooks": "Continue Books",
|
||||
"LabelContinueEpisodes": "Continue Episodes",
|
||||
"LabelContinueListening": "Continue Listening",
|
||||
"LabelContinueSeries": "Continue Series",
|
||||
"LabelCustomTime": "Custom time",
|
||||
"LabelContinueBooks": "Continuer les Livres",
|
||||
"LabelContinueEpisodes": "Continuer les Épisodes",
|
||||
"LabelContinueListening": "Continuer d'Écouter",
|
||||
"LabelContinueReading": "Continuer la lecture",
|
||||
"LabelContinueSeries": "Continuer les series",
|
||||
"LabelCustomTime": "Temps personalisé",
|
||||
"LabelDescription": "Description",
|
||||
"LabelDisableAudioFadeOut": "Disable audio fade out",
|
||||
"LabelDisableAudioFadeOutHelp": "Audio volume will start decreasing when there is less than 1 minute remaining on the sleep timer. Enable this setting to not fade out.",
|
||||
"LabelDisableAutoRewind": "Disable auto rewind",
|
||||
"LabelDisableShakeToReset": "Disable shake to reset",
|
||||
"LabelDisableShakeToResetHelp": "Shaking your device while the timer is running OR within 2 minutes of the timer expiring will reset the sleep timer. Enable this setting to disable shake to reset.",
|
||||
"LabelDisableVibrateOnReset": "Disable vibrate on reset",
|
||||
"LabelDisableVibrateOnResetHelp": "When the sleep timer gets reset your device will vibrate. Enable this setting to not vibrate when the sleep timer resets.",
|
||||
"LabelDiscover": "Discover",
|
||||
"LabelDisableAudioFadeOut": "Désactiver la diminution automatique du volume",
|
||||
"LabelDisableAudioFadeOutHelp": "Le volume va diminuer lorsqu'il restera moins d'une minute sur le minuteur nocture. Sélectionner pour désactiver la diminution",
|
||||
"LabelDisableAutoRewind": "Désactiver le saut arrière automatique",
|
||||
"LabelDisableShakeToReset": "Désactiver secouer pour redémarrer",
|
||||
"LabelDisableShakeToResetHelp": "Secouer l'appareil pendant le minuteur OU lorsqu'il ne reste que 2 minutes au minuteur va redémarrer le minuteur. Sélectionner pour désactiver ce paramètre",
|
||||
"LabelDisableVibrateOnReset": "Désactiver la vibration au redémarrage",
|
||||
"LabelDisableVibrateOnResetHelp": "Lorsque le minuteur est redémarré, l'appareil vibre. Sélectionner pour désactiver les vibrations..",
|
||||
"LabelDiscover": "Découvrir",
|
||||
"LabelDownload": "Téléchargement",
|
||||
"LabelDownloaded": "Downloaded",
|
||||
"LabelDownloaded": "Téléchargé",
|
||||
"LabelDuration": "Durée",
|
||||
"LabelEbook": "Livre numérique",
|
||||
"LabelEbooks": "Ebooks",
|
||||
"LabelEnable": "Activer",
|
||||
"LabelEnableMp3IndexSeeking": "Enable mp3 index seeking",
|
||||
"LabelEnableMp3IndexSeekingHelp": "This setting should only be enabled if you have mp3 files that are not seeking correctly. Inaccurate seeking is most likely due to Variable birate (VBR) MP3 files. This setting will force index seeking, in which a time-to-byte mapping is built as the file is read. In some cases with large MP3 files there will be a delay when seeking towards the end of the file.",
|
||||
"LabelEnableMp3IndexSeeking": "Activer la recherche par index pour mp3",
|
||||
"LabelEnableMp3IndexSeekingHelp": "Ce paramètres ne devrai être activer que si vous avez des problèmes avec des fichiers mp3 que vous ne pouvez pas chercher. La mauvaise indexation est probablement du au Bitrate Variable (VBR) du fichier MP3. Ce paramètre va forcer l'indexation. Une correspondance Temps-Octet est construit durant la lecture. Dans certains cas, avec de gros fichier MP3, il y aura du délais lors de la recherche vers la fin du fichier.",
|
||||
"LabelEnd": "Fin",
|
||||
"LabelEndOfChapter": "End of Chapter",
|
||||
"LabelEndTime": "End time",
|
||||
"LabelEndOfChapter": "Fin du Chapitre",
|
||||
"LabelEndTime": "Heure de Fin",
|
||||
"LabelEpisode": "Épisode",
|
||||
"LabelFeedURL": "URL du flux",
|
||||
"LabelFile": "Fichier",
|
||||
@@ -137,44 +140,48 @@
|
||||
"LabelFontScale": "Taille de la police de caractère",
|
||||
"LabelGenre": "Genre",
|
||||
"LabelGenres": "Genres",
|
||||
"LabelHapticFeedback": "Haptic feedback",
|
||||
"LabelHasEbook": "Has ebook",
|
||||
"LabelHapticFeedback": "Retour haptique",
|
||||
"LabelHasEbook": "Contient un ebook",
|
||||
"LabelHasSupplementaryEbook": "Has supplementary ebook",
|
||||
"LabelHeavy": "Heavy",
|
||||
"LabelHigh": "High",
|
||||
"LabelHeavy": "Puissant",
|
||||
"LabelHigh": "Importante",
|
||||
"LabelHost": "Hôte",
|
||||
"LabelIncomplete": "Incomplet",
|
||||
"LabelInProgress": "En cours",
|
||||
"LabelInternalAppStorage": "Internal App Storage",
|
||||
"LabelJumpBackwardsTime": "Jump backwards time",
|
||||
"LabelJumpForwardsTime": "Jump forwards time",
|
||||
"LabelInternalAppStorage": "Stockage Interne de l'application",
|
||||
"LabelJumpBackwardsTime": "Durée du saut arrière",
|
||||
"LabelJumpForwardsTime": "Durée du saut avant",
|
||||
"LabelLanguage": "Langue",
|
||||
"LabelLight": "Light",
|
||||
"LabelLayout": "Arrangement",
|
||||
"LabelLayoutAuto": "Automatique",
|
||||
"LabelLayoutSinglePage": "Page seul",
|
||||
"LabelLight": "Légé",
|
||||
"LabelLineSpacing": "Interligne",
|
||||
"LabelListenAgain": "Listen Again",
|
||||
"LabelLocalBooks": "Local Books",
|
||||
"LabelLocalPodcasts": "Local Podcasts",
|
||||
"LabelLockOrientation": "Lock orientation",
|
||||
"LabelLockPlayer": "Lock Player",
|
||||
"LabelLow": "Low",
|
||||
"LabelListenAgain": "Ré-écouter",
|
||||
"LabelLocalBooks": "Livres Locaux",
|
||||
"LabelLocalPodcasts": "Podcasts Locaux",
|
||||
"LabelLockOrientation": "Verrouiller l'orientation",
|
||||
"LabelLockPlayer": "Verrouiller le lecteur",
|
||||
"LabelLow": "Basse",
|
||||
"LabelMediaType": "Type de média",
|
||||
"LabelMedium": "Medium",
|
||||
"LabelMedium": "Moyenne",
|
||||
"LabelMore": "Plus",
|
||||
"LabelMoreInfo": "Plus d’info",
|
||||
"LabelName": "Nom",
|
||||
"LabelNarrator": "Narrateur",
|
||||
"LabelNarrators": "Narrateurs",
|
||||
"LabelNewestAuthors": "Newest Authors",
|
||||
"LabelNo": "No",
|
||||
"LabelNewestAuthors": "Auteurs Recents",
|
||||
"LabelNewestEpisodes": "Épisodes Récents",
|
||||
"LabelNo": "Non",
|
||||
"LabelNotFinished": "Non terminé(e)",
|
||||
"LabelNotStarted": "Non Démarré(e)",
|
||||
"LabelOff": "Off",
|
||||
"LabelOff": "Éteint",
|
||||
"LabelPassword": "Mot de passe",
|
||||
"LabelPath": "Chemin",
|
||||
"LabelPlaybackDirect": "Direct",
|
||||
"LabelPlaybackLocal": "Local",
|
||||
"LabelPlaybackSpeed": "Playback Speed",
|
||||
"LabelPlaybackTranscode": "Transcode",
|
||||
"LabelPlaybackSpeed": "Vitesse de Lecture",
|
||||
"LabelPlaybackTranscode": "Transcoder",
|
||||
"LabelPodcast": "Podcast",
|
||||
"LabelPodcasts": "Podcasts",
|
||||
"LabelPreventIndexing": "Empêcher l’indexation de votre flux par les bases de données iTunes et Google podcast",
|
||||
@@ -182,19 +189,22 @@
|
||||
"LabelPubDate": "Date de publication",
|
||||
"LabelPublishYear": "Année d’édition",
|
||||
"LabelRead": "Lire",
|
||||
"LabelRecentlyAdded": "Recently Added",
|
||||
"LabelRecentSeries": "Recent Series",
|
||||
"LabelReadAgain": "Re-lire",
|
||||
"LabelRecentlyAdded": "Ajouts Récents",
|
||||
"LabelRecentSeries": "Series Recentes",
|
||||
"LabelRemoveFromPlaylist": "Supprimer de la Liste de Lecture",
|
||||
"LabelRSSFeedCustomOwnerEmail": "Courriel du propriétaire personnalisé",
|
||||
"LabelRSSFeedCustomOwnerName": "Nom propriétaire personnalisé",
|
||||
"LabelRSSFeedPreventIndexing": "Empêcher l’indexation",
|
||||
"LabelRSSFeedSlug": "Identificateur d’adresse du Flux RSS ",
|
||||
"LabelScaleElapsedTimeBySpeed": "Traduire le temps restant en fonction de la vitesse de lecture",
|
||||
"LabelSeason": "Saison",
|
||||
"LabelSelectADevice": "Select a device",
|
||||
"LabelSelectADevice": "Sélectionner un Appareil",
|
||||
"LabelSeries": "Séries",
|
||||
"LabelServerAddress": "Server address",
|
||||
"LabelServerAddress": "Adresse du Serveur",
|
||||
"LabelSetEbookAsPrimary": "Définir comme principale",
|
||||
"LabelSetEbookAsSupplementary": "Définir comme supplémentaire",
|
||||
"LabelShakeSensitivity": "Shake sensitivity",
|
||||
"LabelShakeSensitivity": "Sensibilité de la secousse",
|
||||
"LabelShowAll": "Afficher Tout",
|
||||
"LabelSize": "Taille",
|
||||
"LabelSleepTimer": "Minuterie",
|
||||
@@ -216,65 +226,66 @@
|
||||
"LabelThemeLight": "Clair",
|
||||
"LabelTimeRemaining": "{0} restantes",
|
||||
"LabelTitle": "Titre",
|
||||
"LabelTotalTrack": "Total Track",
|
||||
"LabelTotalTrack": "Pistes totale",
|
||||
"LabelTracks": "Pistes",
|
||||
"LabelType": "Type",
|
||||
"LabelUnlockPlayer": "Unlock Player",
|
||||
"LabelUseBookshelfView": "Use bookshelf view",
|
||||
"LabelUnlockPlayer": "Déverrouiller le Lecteur",
|
||||
"LabelUseBookshelfView": "Utiliser l'interface Bibliothèque",
|
||||
"LabelUser": "Utilisateur",
|
||||
"LabelUsername": "Nom d’utilisateur",
|
||||
"LabelVeryHigh": "Very High",
|
||||
"LabelVeryLow": "Very Low",
|
||||
"LabelYourBookmarks": "Vos signets",
|
||||
"LabelVeryHigh": "Très importante",
|
||||
"LabelVeryLow": "Très peu importante",
|
||||
"LabelYourBookmarks": "Vos marque-pages",
|
||||
"LabelYourProgress": "Votre progression",
|
||||
"MessageAndroid10Downloads": "Android 10 and below will use internal app storage for downloads.",
|
||||
"MessageAttemptingServerConnection": "Attempting server connection...",
|
||||
"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",
|
||||
"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.",
|
||||
"MessageConfirmDiscardProgress": "Are you sure you want to reset your progress?",
|
||||
"MessageConfirmMarkAsFinished": "Are you sure you want to mark this item as finished?",
|
||||
"MessageConfirmRemoveBookmark": "Are you sure you want to remove bookmark?",
|
||||
"MessageDiscardProgress": "Discard Progress",
|
||||
"MessageDownloadCompleteProcessing": "Download complete. Processing...",
|
||||
"MessageDownloading": "Downloading...",
|
||||
"MessageAndroid10Downloads": "Android 10 et en dessous utiliseront le stockage interne de l'application pour les téléchargements.",
|
||||
"MessageAttemptingServerConnection": "Tentative de connexion...",
|
||||
"MessageAudiobookshelfServerNotConnected": "Serveur Audiobookshelf non connecté",
|
||||
"MessageAudiobookshelfServerRequired": "<strong>Important!</strong> Cette application est faite pour fonctionner avec un serveur Audiobookshelf. L'application ne fournit aucun contenu.",
|
||||
"MessageBookshelfEmpty": "Bibliothèque vide",
|
||||
"MessageConfirmDeleteLocalEpisode": "Supprimer l'épisode local \"{0}\" de votre appareil ? Le fichier sur le serveur ne sera pas affecté.",
|
||||
"MessageConfirmDeleteLocalFiles": "Supprimer les fichiers locaux de cet élément de votre appareil ? Les fichiers sur le serveur ainsi que votre progression ne serons pas affectés.",
|
||||
"MessageConfirmDiscardProgress": "Êtes vous sûre de vouloir supprimer votre progression ?",
|
||||
"MessageConfirmMarkAsFinished": "Êtes vous sûre de vouloir marquer cette élement comme terminé ?",
|
||||
"MessageConfirmRemoveBookmark": "Êtes vous sûre de vouloir supprimer le marque-page ?",
|
||||
"MessageDiscardProgress": "Supprimer la progression",
|
||||
"MessageDownloadCompleteProcessing": "Téléchargements terminé. Analyse..",
|
||||
"MessageDownloading": "Téléchargement...",
|
||||
"MessageDownloadingEpisode": "Téléchargement de l’épisode",
|
||||
"MessageEpisodesQueuedForDownload": "{0} épisode(s) mis en file pour téléchargement",
|
||||
"MessageFeedURLWillBe": "l’URL du flux sera {0}",
|
||||
"MessageFetching": "Récupération…",
|
||||
"MessageFollowTheProjectOnGithub": "Follow the project on Github",
|
||||
"MessageItemDownloadCompleteFailedToCreate": "Item download complete but failed to create library item",
|
||||
"MessageLoading": "Chargement…",
|
||||
"MessageLoadingServerData": "Loading server data...",
|
||||
"MessageFollowTheProjectOnGithub": "Suivre le project sur Github",
|
||||
"MessageItemDownloadCompleteFailedToCreate": "L'élément à été téléchargé avec succès, mais impossible de créer l'entré dans la bibliothèque.",
|
||||
"MessageLoading": "Chargement...",
|
||||
"MessageLoadingServerData": "Chargement des données du serveur...",
|
||||
"MessageMarkAsFinished": "Marquer comme terminé",
|
||||
"MessageMediaLinkedToADifferentServer": "Media is linked to an Audiobookshelf server on a different address ({0}). Progress will be synced when connected to this server address.",
|
||||
"MessageMediaLinkedToADifferentUser": "Media is linked to this server but was downloaded by a different user. Progress will only be synced to the user that downloaded it.",
|
||||
"MessageMediaLinkedToServer": "Linked to server {0}",
|
||||
"MessageMediaLinkedToThisServer": "Downloaded media is linked to this server",
|
||||
"MessageMediaNotLinkedToServer": "Media is not linked to an Audiobookshelf server. No progress will be synced.",
|
||||
"MessageNoBookmarks": "Aucun signet",
|
||||
"MessageMediaLinkedToADifferentServer": "L'élément est lié à un serveur Audiobookshelf sur une autre adresse ({0}). La progression sera synchroniser lors de la connexion au serveur à cette adresse.",
|
||||
"MessageMediaLinkedToADifferentUser": "L'élément est lié a ce serveur, mais a ete téléchargé par un autre utilisateur. La progression ne sera synchronisé que pour l'utilisateur qui l'a téléchargé..",
|
||||
"MessageMediaLinkedToServer": "Lié au serveur {0}",
|
||||
"MessageMediaLinkedToThisServer": "L'élément téléchargé est lié à a ce serveur",
|
||||
"MessageMediaNotLinkedToServer": "L'élément n'est lié à aucun serveur. La progression ne sera pas synchronisé.",
|
||||
"MessageNoBookmarks": "Aucun marque-page",
|
||||
"MessageNoChapters": "Aucun chapitre",
|
||||
"MessageNoItems": "Aucun article",
|
||||
"MessageNoItemsFound": "Aucun article trouvé",
|
||||
"MessageNoListeningSessions": "Aucune session d’écoute en cours",
|
||||
"MessageNoMediaFolders": "No Media Folders",
|
||||
"MessageNoNetworkConnection": "No network connection",
|
||||
"MessageNoMediaFolders": "Aucun dossier multimédia",
|
||||
"MessageNoNetworkConnection": "Pas de connexion réseau",
|
||||
"MessageNoPodcastsFound": "Aucun podcast trouvé",
|
||||
"MessageNoSeries": "No series",
|
||||
"MessageNoUpdatesWereNecessary": "Aucune mise à jour n’était nécessaire",
|
||||
"MessageNoUserPlaylists": "Vous n’avez aucune liste de lecture",
|
||||
"MessageReportBugsAndContribute": "Remonter des anomalies, demander des fonctionnalités et contribuer sur",
|
||||
"MessageSocketConnectedOverMeteredCellular": "Socket connected over metered cellular",
|
||||
"MessageSocketConnectedOverMeteredWifi": "Socket connected over metered wifi",
|
||||
"MessageSocketConnectedOverUnmeteredCellular": "Socket connected over unmetered cellular",
|
||||
"MessageSocketConnectedOverUnmeteredWifi": "Socket connected over unmetered wifi",
|
||||
"MessageSocketNotConnected": "Socket not connected",
|
||||
"MessageSocketConnectedOverMeteredCellular": "Socket connecté via données cellulaire limitée",
|
||||
"MessageSocketConnectedOverMeteredWifi": "Socket connecté via wifi limitée",
|
||||
"MessageSocketConnectedOverUnmeteredCellular": "Socket connecté via données cellulaire",
|
||||
"MessageSocketConnectedOverUnmeteredWifi": "Socket connecté via wifi",
|
||||
"MessageSocketNotConnected": "Socket non connecté",
|
||||
"NoteRSSFeedPodcastAppsHttps": "Attention : la majorité des application de podcast nécessite une adresse de flux en HTTPS.",
|
||||
"NoteRSSFeedPodcastAppsPubDate": "Attention : un ou plusieurs de vos épisodes ne possèdent pas de date de publication. Certaines applications de podcast le requièrent.",
|
||||
"ToastBookmarkCreateFailed": "Échec de la création de signet",
|
||||
"ToastBookmarkRemoveFailed": "Échec de la suppression de signet",
|
||||
"ToastBookmarkUpdateFailed": "Échec de la mise à jour de signet",
|
||||
"ToastBookmarkCreateFailed": "Échec de la création de marque-page",
|
||||
"ToastBookmarkRemoveFailed": "Échec de la suppression de marque-page",
|
||||
"ToastBookmarkUpdateFailed": "Échec de la mise à jour de marsue-page",
|
||||
"ToastItemMarkedAsFinishedFailed": "Échec de l’annotation terminée",
|
||||
"ToastItemMarkedAsNotFinishedFailed": "Échec de l’annotation non-terminée",
|
||||
"ToastPlaylistCreateFailed": "Échec de la création de la liste de lecture",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"ButtonDeleteLocalItem": "Delete local item",
|
||||
"ButtonDisableAutoTimer": "Disable Auto Timer",
|
||||
"ButtonDisconnect": "Disconnect",
|
||||
"ButtonGoToWebClient": "Go to Web Client",
|
||||
"ButtonHistory": "History",
|
||||
"ButtonHome": "ઘર",
|
||||
"ButtonIssues": "સમસ્યાઓ",
|
||||
@@ -85,6 +86,7 @@
|
||||
"LabelAddedAt": "Added At",
|
||||
"LabelAddToPlaylist": "Add to Playlist",
|
||||
"LabelAll": "All",
|
||||
"LabelAllowSeekingOnMediaControls": "Allow position seeking on media notification controls",
|
||||
"LabelAuthor": "Author",
|
||||
"LabelAuthorFirstLast": "Author (First Last)",
|
||||
"LabelAuthorLastFirst": "Author (Last, First)",
|
||||
@@ -104,6 +106,7 @@
|
||||
"LabelContinueBooks": "Continue Books",
|
||||
"LabelContinueEpisodes": "Continue Episodes",
|
||||
"LabelContinueListening": "Continue Listening",
|
||||
"LabelContinueReading": "Continue Reading",
|
||||
"LabelContinueSeries": "Continue Series",
|
||||
"LabelCustomTime": "Custom time",
|
||||
"LabelDescription": "Description",
|
||||
@@ -149,6 +152,9 @@
|
||||
"LabelJumpBackwardsTime": "Jump backwards time",
|
||||
"LabelJumpForwardsTime": "Jump forwards time",
|
||||
"LabelLanguage": "Language",
|
||||
"LabelLayout": "Layout",
|
||||
"LabelLayoutAuto": "Auto",
|
||||
"LabelLayoutSinglePage": "Single page",
|
||||
"LabelLight": "Light",
|
||||
"LabelLineSpacing": "Line spacing",
|
||||
"LabelListenAgain": "Listen Again",
|
||||
@@ -165,6 +171,7 @@
|
||||
"LabelNarrator": "Narrator",
|
||||
"LabelNarrators": "Narrators",
|
||||
"LabelNewestAuthors": "Newest Authors",
|
||||
"LabelNewestEpisodes": "Newest Episodes",
|
||||
"LabelNo": "No",
|
||||
"LabelNotFinished": "Not Finished",
|
||||
"LabelNotStarted": "Not Started",
|
||||
@@ -182,12 +189,15 @@
|
||||
"LabelPubDate": "Pub Date",
|
||||
"LabelPublishYear": "Publish Year",
|
||||
"LabelRead": "Read",
|
||||
"LabelReadAgain": "Read Again",
|
||||
"LabelRecentlyAdded": "Recently Added",
|
||||
"LabelRecentSeries": "Recent Series",
|
||||
"LabelRemoveFromPlaylist": "Remove from Playlist",
|
||||
"LabelRSSFeedCustomOwnerEmail": "Custom owner Email",
|
||||
"LabelRSSFeedCustomOwnerName": "Custom owner Name",
|
||||
"LabelRSSFeedPreventIndexing": "Prevent Indexing",
|
||||
"LabelRSSFeedSlug": "RSS Feed Slug",
|
||||
"LabelScaleElapsedTimeBySpeed": "Scale Elapsed Time by Speed",
|
||||
"LabelSeason": "Season",
|
||||
"LabelSelectADevice": "Select a device",
|
||||
"LabelSeries": "Series",
|
||||
@@ -262,6 +272,7 @@
|
||||
"MessageNoMediaFolders": "No Media Folders",
|
||||
"MessageNoNetworkConnection": "No network connection",
|
||||
"MessageNoPodcastsFound": "No podcasts found",
|
||||
"MessageNoSeries": "No series",
|
||||
"MessageNoUpdatesWereNecessary": "No updates were necessary",
|
||||
"MessageNoUserPlaylists": "You have no playlists",
|
||||
"MessageReportBugsAndContribute": "Report bugs, request features, and contribute on",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"ButtonDeleteLocalItem": "Delete local item",
|
||||
"ButtonDisableAutoTimer": "Disable Auto Timer",
|
||||
"ButtonDisconnect": "Disconnect",
|
||||
"ButtonGoToWebClient": "Go to Web Client",
|
||||
"ButtonHistory": "History",
|
||||
"ButtonHome": "घर",
|
||||
"ButtonIssues": "समस्याएं",
|
||||
@@ -85,6 +86,7 @@
|
||||
"LabelAddedAt": "Added At",
|
||||
"LabelAddToPlaylist": "Add to Playlist",
|
||||
"LabelAll": "All",
|
||||
"LabelAllowSeekingOnMediaControls": "Allow position seeking on media notification controls",
|
||||
"LabelAuthor": "Author",
|
||||
"LabelAuthorFirstLast": "Author (First Last)",
|
||||
"LabelAuthorLastFirst": "Author (Last, First)",
|
||||
@@ -104,6 +106,7 @@
|
||||
"LabelContinueBooks": "Continue Books",
|
||||
"LabelContinueEpisodes": "Continue Episodes",
|
||||
"LabelContinueListening": "Continue Listening",
|
||||
"LabelContinueReading": "Continue Reading",
|
||||
"LabelContinueSeries": "Continue Series",
|
||||
"LabelCustomTime": "Custom time",
|
||||
"LabelDescription": "Description",
|
||||
@@ -149,6 +152,9 @@
|
||||
"LabelJumpBackwardsTime": "Jump backwards time",
|
||||
"LabelJumpForwardsTime": "Jump forwards time",
|
||||
"LabelLanguage": "Language",
|
||||
"LabelLayout": "Layout",
|
||||
"LabelLayoutAuto": "Auto",
|
||||
"LabelLayoutSinglePage": "Single page",
|
||||
"LabelLight": "Light",
|
||||
"LabelLineSpacing": "Line spacing",
|
||||
"LabelListenAgain": "Listen Again",
|
||||
@@ -165,6 +171,7 @@
|
||||
"LabelNarrator": "Narrator",
|
||||
"LabelNarrators": "Narrators",
|
||||
"LabelNewestAuthors": "Newest Authors",
|
||||
"LabelNewestEpisodes": "Newest Episodes",
|
||||
"LabelNo": "No",
|
||||
"LabelNotFinished": "Not Finished",
|
||||
"LabelNotStarted": "Not Started",
|
||||
@@ -182,12 +189,15 @@
|
||||
"LabelPubDate": "Pub Date",
|
||||
"LabelPublishYear": "Publish Year",
|
||||
"LabelRead": "Read",
|
||||
"LabelReadAgain": "Read Again",
|
||||
"LabelRecentlyAdded": "Recently Added",
|
||||
"LabelRecentSeries": "Recent Series",
|
||||
"LabelRemoveFromPlaylist": "Remove from Playlist",
|
||||
"LabelRSSFeedCustomOwnerEmail": "Custom owner Email",
|
||||
"LabelRSSFeedCustomOwnerName": "Custom owner Name",
|
||||
"LabelRSSFeedPreventIndexing": "Prevent Indexing",
|
||||
"LabelRSSFeedSlug": "RSS Feed Slug",
|
||||
"LabelScaleElapsedTimeBySpeed": "Scale Elapsed Time by Speed",
|
||||
"LabelSeason": "Season",
|
||||
"LabelSelectADevice": "Select a device",
|
||||
"LabelSeries": "Series",
|
||||
@@ -262,6 +272,7 @@
|
||||
"MessageNoMediaFolders": "No Media Folders",
|
||||
"MessageNoNetworkConnection": "No network connection",
|
||||
"MessageNoPodcastsFound": "No podcasts found",
|
||||
"MessageNoSeries": "No series",
|
||||
"MessageNoUpdatesWereNecessary": "No updates were necessary",
|
||||
"MessageNoUserPlaylists": "You have no playlists",
|
||||
"MessageReportBugsAndContribute": "Report bugs, request features, and contribute on",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"ButtonDeleteLocalItem": "Delete local item",
|
||||
"ButtonDisableAutoTimer": "Disable Auto Timer",
|
||||
"ButtonDisconnect": "Disconnect",
|
||||
"ButtonGoToWebClient": "Go to Web Client",
|
||||
"ButtonHistory": "History",
|
||||
"ButtonHome": "Početna stranica",
|
||||
"ButtonIssues": "Problemi",
|
||||
@@ -85,6 +86,7 @@
|
||||
"LabelAddedAt": "Added At",
|
||||
"LabelAddToPlaylist": "Add to Playlist",
|
||||
"LabelAll": "All",
|
||||
"LabelAllowSeekingOnMediaControls": "Allow position seeking on media notification controls",
|
||||
"LabelAuthor": "Autor",
|
||||
"LabelAuthorFirstLast": "Author (First Last)",
|
||||
"LabelAuthorLastFirst": "Author (Last, First)",
|
||||
@@ -104,6 +106,7 @@
|
||||
"LabelContinueBooks": "Continue Books",
|
||||
"LabelContinueEpisodes": "Continue Episodes",
|
||||
"LabelContinueListening": "Continue Listening",
|
||||
"LabelContinueReading": "Continue Reading",
|
||||
"LabelContinueSeries": "Continue Series",
|
||||
"LabelCustomTime": "Custom time",
|
||||
"LabelDescription": "Opis",
|
||||
@@ -149,6 +152,9 @@
|
||||
"LabelJumpBackwardsTime": "Jump backwards time",
|
||||
"LabelJumpForwardsTime": "Jump forwards time",
|
||||
"LabelLanguage": "Jezik",
|
||||
"LabelLayout": "Layout",
|
||||
"LabelLayoutAuto": "Auto",
|
||||
"LabelLayoutSinglePage": "Single page",
|
||||
"LabelLight": "Light",
|
||||
"LabelLineSpacing": "Line spacing",
|
||||
"LabelListenAgain": "Listen Again",
|
||||
@@ -165,6 +171,7 @@
|
||||
"LabelNarrator": "Narrator",
|
||||
"LabelNarrators": "Naratori",
|
||||
"LabelNewestAuthors": "Newest Authors",
|
||||
"LabelNewestEpisodes": "Newest Episodes",
|
||||
"LabelNo": "No",
|
||||
"LabelNotFinished": "Nedovršeno",
|
||||
"LabelNotStarted": "Not Started",
|
||||
@@ -182,12 +189,15 @@
|
||||
"LabelPubDate": "Datam izdavanja",
|
||||
"LabelPublishYear": "Godina izdavanja",
|
||||
"LabelRead": "Read",
|
||||
"LabelReadAgain": "Read Again",
|
||||
"LabelRecentlyAdded": "Recently Added",
|
||||
"LabelRecentSeries": "Recent Series",
|
||||
"LabelRemoveFromPlaylist": "Remove from Playlist",
|
||||
"LabelRSSFeedCustomOwnerEmail": "Custom owner Email",
|
||||
"LabelRSSFeedCustomOwnerName": "Custom owner Name",
|
||||
"LabelRSSFeedPreventIndexing": "Prevent Indexing",
|
||||
"LabelRSSFeedSlug": "RSS Feed Slug",
|
||||
"LabelScaleElapsedTimeBySpeed": "Scale Elapsed Time by Speed",
|
||||
"LabelSeason": "Sezona",
|
||||
"LabelSelectADevice": "Select a device",
|
||||
"LabelSeries": "Serije",
|
||||
@@ -262,6 +272,7 @@
|
||||
"MessageNoMediaFolders": "No Media Folders",
|
||||
"MessageNoNetworkConnection": "No network connection",
|
||||
"MessageNoPodcastsFound": "Nijedan podcast pronađen",
|
||||
"MessageNoSeries": "No series",
|
||||
"MessageNoUpdatesWereNecessary": "Aktualiziranje nije bilo potrebno",
|
||||
"MessageNoUserPlaylists": "You have no playlists",
|
||||
"MessageReportBugsAndContribute": "Prijavte bugove, zatržite featurese i doprinosite na",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user