mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-01-24 07:21:06 -05:00
Compare commits
395 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b27c726d5 | ||
|
|
68418c1d3b | ||
|
|
a8af6db3d6 | ||
|
|
af856ce1ec | ||
|
|
aae8e7535a | ||
|
|
359a2752d8 | ||
|
|
9102a0045f | ||
|
|
b124d61826 | ||
|
|
8e6ead59ce | ||
|
|
f74d741821 | ||
|
|
15f83986e7 | ||
|
|
a57fe42dff | ||
|
|
b03198abd9 | ||
|
|
ad30977781 | ||
|
|
dbe10382fd | ||
|
|
f0caf1a933 | ||
|
|
0f7c99d989 | ||
|
|
60c65008dc | ||
|
|
c4fd4ff9de | ||
|
|
29fc503503 | ||
|
|
bca49616e1 | ||
|
|
cb49c17fc5 | ||
|
|
9e1686232b | ||
|
|
f702358bbd | ||
|
|
9a0b8de354 | ||
|
|
6ed6fff6bd | ||
|
|
75007bb371 | ||
|
|
df9da095ef | ||
|
|
64c98722c3 | ||
|
|
36c1a8b2df | ||
|
|
710d6af4b3 | ||
|
|
cd7ecb9933 | ||
|
|
f75f0b8cc8 | ||
|
|
e60d2a9858 | ||
|
|
04993dd63d | ||
|
|
41af913280 | ||
|
|
8dc0f2c67c | ||
|
|
fc196180b3 | ||
|
|
4a127d35b9 | ||
|
|
1525fdf4f6 | ||
|
|
8a29c998da | ||
|
|
f56d9f128f | ||
|
|
c5785e9c20 | ||
|
|
0ca91ecfff | ||
|
|
304d0f6d43 | ||
|
|
6c9a811472 | ||
|
|
116a7fb994 | ||
|
|
8e46181ba0 | ||
|
|
a336686e42 | ||
|
|
c8957fe373 | ||
|
|
ca7eaf9750 | ||
|
|
74dd24febf | ||
|
|
7b856474af | ||
|
|
c7ac12a67a | ||
|
|
3264359771 | ||
|
|
c7cc994532 | ||
|
|
afe40be957 | ||
|
|
a9c9c447f1 | ||
|
|
aa1aeacc09 | ||
|
|
fc595bd799 | ||
|
|
a5d7a81519 | ||
|
|
7e8fd91fc5 | ||
|
|
c2ed0b7d3d | ||
|
|
aefda8bd51 | ||
|
|
93bec282d2 | ||
|
|
1396a432a4 | ||
|
|
90e1283058 | ||
|
|
8cd50d5684 | ||
|
|
50bd2648aa | ||
|
|
33254654d5 | ||
|
|
617b8f4487 | ||
|
|
f9b95bb003 | ||
|
|
740640884f | ||
|
|
86fea5c667 | ||
|
|
33e4b51aee | ||
|
|
1cf0bd0f01 | ||
|
|
8ce5a5cdbd | ||
|
|
fc26b7af0a | ||
|
|
2d68fa2c27 | ||
|
|
f241cb2280 | ||
|
|
125346bb5c | ||
|
|
b60f62cebf | ||
|
|
51ff62356d | ||
|
|
f827aa97f8 | ||
|
|
68276fe30b | ||
|
|
961533765f | ||
|
|
c1bbec22f0 | ||
|
|
7d0eb215d6 | ||
|
|
ff5226fa93 | ||
|
|
8d7530254c | ||
|
|
6957b4baf6 | ||
|
|
01c8d42291 | ||
|
|
1e21847852 | ||
|
|
1bee082720 | ||
|
|
b0a9bed15a | ||
|
|
1d7434cbbb | ||
|
|
1646f0ebc2 | ||
|
|
50330b0a60 | ||
|
|
f661e0835c | ||
|
|
9511122bae | ||
|
|
56f1bfef50 | ||
|
|
8e5b7504ae | ||
|
|
0a0006f949 | ||
|
|
5b836dfa28 | ||
|
|
8396900178 | ||
|
|
8f80948211 | ||
|
|
4ad09ec3d8 | ||
|
|
be4eb28b21 | ||
|
|
f938fca2c7 | ||
|
|
d562f6a69f | ||
|
|
166454ef43 | ||
|
|
d5c854d606 | ||
|
|
eace46bf55 | ||
|
|
b9ffce166e | ||
|
|
9713e94aed | ||
|
|
d71bc89c9d | ||
|
|
a2b2a2d060 | ||
|
|
752268effb | ||
|
|
9e3b3f3e12 | ||
|
|
88f9533b37 | ||
|
|
630ece82ad | ||
|
|
5777184cae | ||
|
|
a76da14fb0 | ||
|
|
0c612b4836 | ||
|
|
a1af672c7c | ||
|
|
5fcd23409a | ||
|
|
99f0799a11 | ||
|
|
316aeba1b0 | ||
|
|
bfd4a378f3 | ||
|
|
521db90ae0 | ||
|
|
d02fc2debe | ||
|
|
e6c21c5be1 | ||
|
|
91248b496e | ||
|
|
f7ae7783bd | ||
|
|
ae395497a5 | ||
|
|
8826d3af62 | ||
|
|
65153fae9d | ||
|
|
d4c1bc5dfc | ||
|
|
d6f13513ae | ||
|
|
2584c3b432 | ||
|
|
b54421412d | ||
|
|
e2451a3281 | ||
|
|
dbf4bd5c3d | ||
|
|
2a722ab163 | ||
|
|
c83399c7b5 | ||
|
|
a814e45150 | ||
|
|
29e9216bb1 | ||
|
|
94d1732b0d | ||
|
|
7610084627 | ||
|
|
d840905a97 | ||
|
|
7b1b448795 | ||
|
|
77559d29bb | ||
|
|
c14f9accaf | ||
|
|
76a1f48c62 | ||
|
|
ae0a9bcf86 | ||
|
|
9e44fe5524 | ||
|
|
727dad7e19 | ||
|
|
0c2de91097 | ||
|
|
450fa45360 | ||
|
|
e0dddae2c2 | ||
|
|
daa9fccc14 | ||
|
|
ad45dadc15 | ||
|
|
0e8148001e | ||
|
|
fa71f9db2e | ||
|
|
0d9d2fa4be | ||
|
|
c34e9cde05 | ||
|
|
b934a755b5 | ||
|
|
a5772f6b66 | ||
|
|
153f149d58 | ||
|
|
e50b06183e | ||
|
|
305689d513 | ||
|
|
4dd140585d | ||
|
|
cd60d0219f | ||
|
|
8ec18e8d7b | ||
|
|
15545654ea | ||
|
|
8a0fab2b20 | ||
|
|
6e8c6aa740 | ||
|
|
5005aabe5e | ||
|
|
abc2d28617 | ||
|
|
7569a14510 | ||
|
|
b52341dbcf | ||
|
|
b4eed3bad2 | ||
|
|
4fe672f09d | ||
|
|
49af7eb7b0 | ||
|
|
c93c863d82 | ||
|
|
763bb1b829 | ||
|
|
79d32274aa | ||
|
|
987842ed04 | ||
|
|
d2b006b909 | ||
|
|
f4a19e48ad | ||
|
|
38f12f4795 | ||
|
|
7a4f4b1586 | ||
|
|
20ec54e085 | ||
|
|
655bebfec4 | ||
|
|
71e1abd263 | ||
|
|
72172dcb33 | ||
|
|
def2988e12 | ||
|
|
b47793c365 | ||
|
|
3a99cc56b7 | ||
|
|
24c35dede5 | ||
|
|
8c4400dff1 | ||
|
|
af8dffaa33 | ||
|
|
4a36a3c8e6 | ||
|
|
e6735e042e | ||
|
|
c799379a54 | ||
|
|
d8b9f08e5a | ||
|
|
608b25de45 | ||
|
|
2db8869908 | ||
|
|
9500737bbe | ||
|
|
def2b6425b | ||
|
|
0f4b11494e | ||
|
|
46448ce1e9 | ||
|
|
fbe12b393f | ||
|
|
ccf59b2c1a | ||
|
|
d7af3b7788 | ||
|
|
682aca0b2a | ||
|
|
3328ffe1b9 | ||
|
|
c07b7840e2 | ||
|
|
9f848b2c64 | ||
|
|
3d66ec0761 | ||
|
|
f50920be69 | ||
|
|
d31add9d5a | ||
|
|
a4dcb4f92e | ||
|
|
2c589c1dbd | ||
|
|
60ea386c6d | ||
|
|
24be1a0ec5 | ||
|
|
e71a14756b | ||
|
|
85fecbd1b9 | ||
|
|
335d39f317 | ||
|
|
973a18d346 | ||
|
|
a43b93d796 | ||
|
|
acf75abdf1 | ||
|
|
58598bfcf2 | ||
|
|
7a570439db | ||
|
|
6e769d1c20 | ||
|
|
d9e7f5d133 | ||
|
|
a119b05d85 | ||
|
|
7bf7b6bcf9 | ||
|
|
e47ea98cdd | ||
|
|
bf66e13377 | ||
|
|
d7aba5629e | ||
|
|
a5c200ac79 | ||
|
|
fdc1fc1b2a | ||
|
|
42a4b762bd | ||
|
|
180c328ed1 | ||
|
|
2ec52a7a45 | ||
|
|
aacf37e32b | ||
|
|
52323b7eb5 | ||
|
|
5b5613a762 | ||
|
|
de6df0c029 | ||
|
|
e180b3c171 | ||
|
|
1364b79cbf | ||
|
|
ef96f3102f | ||
|
|
06ce3b08f7 | ||
|
|
a13217dddf | ||
|
|
ce528d4012 | ||
|
|
89207b6d2a | ||
|
|
e9591caf81 | ||
|
|
24f1aae6b6 | ||
|
|
04fbc9a22b | ||
|
|
14e31d5690 | ||
|
|
a9e9808183 | ||
|
|
af7cb2432b | ||
|
|
e0c1364916 | ||
|
|
04d16fc535 | ||
|
|
44135b3fed | ||
|
|
6111e8f0da | ||
|
|
4e3e7b10ce | ||
|
|
ce7f81d676 | ||
|
|
0cf2f8885e | ||
|
|
ddf4b2646c | ||
|
|
fe1e0749a2 | ||
|
|
2093468c92 | ||
|
|
19af7454f2 | ||
|
|
d24427aad8 | ||
|
|
e2bb0cfb7c | ||
|
|
2ebdb44826 | ||
|
|
432e25565e | ||
|
|
ebe511404a | ||
|
|
e0a79fb86c | ||
|
|
295ca3d9a2 | ||
|
|
dbad8bdb96 | ||
|
|
8c703859a0 | ||
|
|
bedb260b00 | ||
|
|
b49592301f | ||
|
|
c6c67078b8 | ||
|
|
9e45ad10f1 | ||
|
|
24da859975 | ||
|
|
0b6a8a9641 | ||
|
|
e43c4f082e | ||
|
|
0b334cf957 | ||
|
|
ae387ab397 | ||
|
|
056e62dce8 | ||
|
|
47999214bd | ||
|
|
68473ee345 | ||
|
|
455f27d443 | ||
|
|
ba996c3b55 | ||
|
|
d43a1109c8 | ||
|
|
c3ba7daa16 | ||
|
|
82048cd4f3 | ||
|
|
71b0a5cc81 | ||
|
|
edb5ff1e33 | ||
|
|
d4ed6348ee | ||
|
|
f12ac685e8 | ||
|
|
b9ec4068ee | ||
|
|
02aabb8f97 | ||
|
|
dcec2154c0 | ||
|
|
bbc1d20396 | ||
|
|
e682213681 | ||
|
|
0153c0faae | ||
|
|
87ebf4722b | ||
|
|
3906dca04e | ||
|
|
399ba314a3 | ||
|
|
19e1803633 | ||
|
|
71048c7ff0 | ||
|
|
7f350279fa | ||
|
|
90f4833c9e | ||
|
|
c0cb3a176f | ||
|
|
7b0fa48e2e | ||
|
|
b51853b3df | ||
|
|
f5545cd3f4 | ||
|
|
e76af3bfc2 | ||
|
|
6ef4944d89 | ||
|
|
850397e4c1 | ||
|
|
3b531144cf | ||
|
|
6ca684603c | ||
|
|
cf85d66b2f | ||
|
|
e8fa029df7 | ||
|
|
1a361c91f1 | ||
|
|
4a76059608 | ||
|
|
da25eff5c1 | ||
|
|
69e23ef9f2 | ||
|
|
48a08e9659 | ||
|
|
4608f91ec6 | ||
|
|
e88c1fa329 | ||
|
|
935e545caa | ||
|
|
a426da534c | ||
|
|
eaf6bf29cc | ||
|
|
a0eb6bd3dc | ||
|
|
fbe228a4f8 | ||
|
|
578a59063f | ||
|
|
81020ff34d | ||
|
|
fea78898a5 | ||
|
|
ffa7cc0d22 | ||
|
|
4f9969cd9b | ||
|
|
1be34564f2 | ||
|
|
56eff7a236 | ||
|
|
9f909b0d85 | ||
|
|
baa65b8155 | ||
|
|
12c6a1baa0 | ||
|
|
5ea423072b | ||
|
|
08a41e37b4 | ||
|
|
8027c4a06f | ||
|
|
a1e321b153 | ||
|
|
8c6a2ac5dd | ||
|
|
b489bf9236 | ||
|
|
aa63aa6cf3 | ||
|
|
9a2b93fb37 | ||
|
|
e8ea7efc98 | ||
|
|
81a76593da | ||
|
|
5336864f7d | ||
|
|
d38058e1d2 | ||
|
|
fececd4651 | ||
|
|
021adf3104 | ||
|
|
160c83df4a | ||
|
|
456bb87a00 | ||
|
|
707451309c | ||
|
|
269676e8a5 | ||
|
|
e4effebc19 | ||
|
|
fbbceddba8 | ||
|
|
9a634e0de5 | ||
|
|
21d0d43edc | ||
|
|
3051b963ef | ||
|
|
0d0bdce337 | ||
|
|
bdb5dc8c28 | ||
|
|
209847d98a | ||
|
|
14f42e15d1 | ||
|
|
7402e4811d | ||
|
|
6de0465b86 | ||
|
|
cd7c4baaaf | ||
|
|
8f7a420cca | ||
|
|
6f6395bad7 | ||
|
|
6afb8de3dd | ||
|
|
0e62ccc7aa | ||
|
|
09282a9a62 | ||
|
|
18b3ab5610 | ||
|
|
699a658df9 | ||
|
|
67ccd2c1fb | ||
|
|
898b072e68 | ||
|
|
61a0126278 | ||
|
|
1ce1904c89 | ||
|
|
c074c835d4 | ||
|
|
2e989fbe83 | ||
|
|
b1b325d00b | ||
|
|
679bdf36b1 |
@@ -1,5 +1,5 @@
|
||||
# [Choice] Node.js version (use -bullseye variants on local arm64/Apple Silicon): 18, 16, 14, 18-bullseye, 16-bullseye, 14-bullseye, 18-buster, 16-buster, 14-buster
|
||||
ARG VARIANT=16
|
||||
ARG VARIANT=20
|
||||
FROM mcr.microsoft.com/devcontainers/javascript-node:0-${VARIANT} as base
|
||||
|
||||
# Setup the node environment
|
||||
|
||||
@@ -5,5 +5,6 @@ module.exports.config = {
|
||||
ConfigPath: Path.resolve('config'),
|
||||
MetadataPath: Path.resolve('metadata'),
|
||||
FFmpegPath: '/usr/bin/ffmpeg',
|
||||
FFProbePath: '/usr/bin/ffprobe'
|
||||
FFProbePath: '/usr/bin/ffprobe',
|
||||
SkipBinariesCheck: false
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
// Append -bullseye or -buster to pin to an OS version.
|
||||
// Use -bullseye variants on local arm64/Apple Silicon.
|
||||
"args": {
|
||||
"VARIANT": "16"
|
||||
"VARIANT": "20"
|
||||
}
|
||||
},
|
||||
"mounts": [
|
||||
|
||||
3
.github/ISSUE_TEMPLATE/bug.yaml
vendored
3
.github/ISSUE_TEMPLATE/bug.yaml
vendored
@@ -11,7 +11,7 @@ body:
|
||||
value: "### Mobile app issues report [here](https://github.com/advplyr/audiobookshelf-app/issues/new/choose)."
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: "### Join the [discord server](https://discord.gg/pJsjuNCKRq) for questions or if you are not sure about a bug."
|
||||
value: "### Join the [discord server](https://discord.gg/HQgCbd6E75) for questions or if you are not sure about a bug."
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: "## Be as descriptive as you can. Include screenshots, error logs, browser, file types, everything you can think of that might be relevant."
|
||||
@@ -44,6 +44,7 @@ body:
|
||||
options:
|
||||
- Docker
|
||||
- Debian/PPA
|
||||
- Windows Tray App
|
||||
- Built from source
|
||||
- Other
|
||||
validations:
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/config.yml
vendored
2
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,7 +1,7 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Discord
|
||||
url: https://discord.gg/pJsjuNCKRq
|
||||
url: https://discord.gg/HQgCbd6E75
|
||||
about: Ask questions, get help troubleshooting, and join the Abs community here.
|
||||
- name: Matrix
|
||||
url: https://matrix.to/#/#audiobookshelf:matrix.org
|
||||
|
||||
65
.github/workflows/codeql.yml
vendored
Normal file
65
.github/workflows/codeql.yml
vendored
Normal file
@@ -0,0 +1,65 @@
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ 'master' ]
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [ 'master' ]
|
||||
schedule:
|
||||
- cron: '16 5 * * 4'
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [ 'javascript' ]
|
||||
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
|
||||
# Use only 'java' to analyze code written in Java, Kotlin or both
|
||||
# Use only 'javascript' to analyze code written in JavaScript, TypeScript or both
|
||||
# Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
|
||||
# For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
|
||||
# queries: security-extended,security-and-quality
|
||||
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v2
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
|
||||
# If the Autobuild fails above, remove it and uncomment the following three lines.
|
||||
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
|
||||
|
||||
# - run: |
|
||||
# echo "Run, Build Application using script"
|
||||
# ./location_of_script_within_repo/buildscript.sh
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
2
.github/workflows/docker-build.yml
vendored
2
.github/workflows/docker-build.yml
vendored
@@ -71,7 +71,7 @@ jobs:
|
||||
with:
|
||||
tags: ${{ github.event.inputs.tags || steps.meta.outputs.tags }}
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
cache-from: type=local,src=/tmp/.buildx-cache
|
||||
cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
|
||||
|
||||
30
.github/workflows/i18n-integration.yml
vendored
Normal file
30
.github/workflows/i18n-integration.yml
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
name: Verify all i18n files are alphabetized
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- client/strings/** # Should only check if any strings changed
|
||||
push:
|
||||
paths:
|
||||
- client/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: "client/strings/" # Adjust the directory path as needed
|
||||
2
.github/workflows/integration-test.yml
vendored
2
.github/workflows/integration-test.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
- name: setup nade
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16
|
||||
node-version: 20
|
||||
|
||||
- name: install pkg
|
||||
run: npm install -g pkg
|
||||
|
||||
17
.github/workflows/notify-abs-windows.yml
vendored
Normal file
17
.github/workflows/notify-abs-windows.yml
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
name: Dispatch an abs-windows event
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
abs-windows-dispatch:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Send a remote repository dispatch event
|
||||
uses: peter-evans/repository-dispatch@v3
|
||||
with:
|
||||
token: ${{ secrets.ABS_WINDOWS_PAT }}
|
||||
repository: mikiher/audiobookshelf-windows
|
||||
event-type: build-windows
|
||||
37
.github/workflows/unit-tests.yml
vendored
Normal file
37
.github/workflows/unit-tests.yml
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
name: Run Unit Tests
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: 'Branch/Tag/SHA to test'
|
||||
required: true
|
||||
pull_request:
|
||||
push:
|
||||
|
||||
jobs:
|
||||
run-unit-tests:
|
||||
name: Run Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout (push/pull request)
|
||||
uses: actions/checkout@v4
|
||||
if: github.event_name != 'workflow_dispatch'
|
||||
|
||||
- name: Checkout (workflow_dispatch)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run tests
|
||||
run: npm test
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -13,6 +13,8 @@
|
||||
/deploy/
|
||||
/coverage/
|
||||
/.nyc_output/
|
||||
/ffmpeg*
|
||||
/ffprobe*
|
||||
|
||||
sw.*
|
||||
.DS_STORE
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
### STAGE 0: Build client ###
|
||||
FROM node:16-alpine AS build
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /client
|
||||
COPY /client /client
|
||||
RUN npm ci && npm cache clean --force
|
||||
@@ -7,7 +7,7 @@ RUN npm run generate
|
||||
|
||||
### STAGE 1: Build server ###
|
||||
FROM sandreas/tone:v0.1.5 AS tone
|
||||
FROM node:16-alpine
|
||||
FROM node:20-alpine
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
@@ -18,7 +18,8 @@ RUN apk update && \
|
||||
ffmpeg \
|
||||
make \
|
||||
python3 \
|
||||
g++
|
||||
g++ \
|
||||
tini
|
||||
|
||||
COPY --from=tone /usr/local/bin/tone /usr/local/bin/
|
||||
COPY --from=build /client/dist /client/dist
|
||||
@@ -31,4 +32,5 @@ RUN apk del make python3 g++
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
ENTRYPOINT ["tini", "--"]
|
||||
CMD ["node", "index.js"]
|
||||
|
||||
@@ -48,11 +48,10 @@ Description: $DESCRIPTION"
|
||||
echo "$controlfile" > dist/debian/DEBIAN/control;
|
||||
|
||||
# Package debian
|
||||
pkg -t node16-linux-x64 -o dist/debian/usr/share/audiobookshelf/audiobookshelf .
|
||||
pkg -t node18-linux-x64 -o dist/debian/usr/share/audiobookshelf/audiobookshelf .
|
||||
|
||||
fakeroot dpkg-deb --build dist/debian
|
||||
fakeroot dpkg-deb -Zxz --build dist/debian
|
||||
|
||||
mv dist/debian.deb "dist/$OUTPUT_FILE"
|
||||
chmod +x "dist/$OUTPUT_FILE"
|
||||
|
||||
echo "Finished! Filename: $OUTPUT_FILE"
|
||||
|
||||
@@ -30,8 +30,7 @@
|
||||
}
|
||||
|
||||
.bookshelf-row {
|
||||
/* Sidebar width + scrollbar width */
|
||||
width: calc(100vw - 88px);
|
||||
width: calc(100vw - (100vw - 100%));
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@@ -217,36 +216,6 @@ Bookshelf Label
|
||||
filter: blur(20px);
|
||||
}
|
||||
|
||||
|
||||
.episode-subtitle {
|
||||
word-break: break-word;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
line-height: 16px;
|
||||
/* fallback */
|
||||
max-height: 32px;
|
||||
/* fallback */
|
||||
-webkit-line-clamp: 2;
|
||||
/* number of lines to show */
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.episode-subtitle-long {
|
||||
word-break: break-word;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
line-height: 16px;
|
||||
/* fallback */
|
||||
max-height: 72px;
|
||||
/* fallback */
|
||||
-webkit-line-clamp: 6;
|
||||
/* number of lines to show */
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
|
||||
/* Padding for toastification toasts in the top right to not cover appbar/toolbar */
|
||||
.app-bar-and-toolbar .Vue-Toastification__container.top-right {
|
||||
padding-top: 104px;
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
word-wrap: normal;
|
||||
direction: ltr;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.material-icons:not([class*="text-"]) {
|
||||
|
||||
3
client/assets/tailwind.css
Normal file
3
client/assets/tailwind.css
Normal file
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -4,7 +4,7 @@
|
||||
<div class="w-full h-full pt-6">
|
||||
<div v-if="shelf.type === 'book' || shelf.type === 'podcast'" class="flex items-center">
|
||||
<template v-for="(entity, index) in shelf.entities">
|
||||
<cards-lazy-book-card :key="entity.id" :ref="`shelf-book-${entity.id}`" :index="index" :width="bookCoverWidth" :height="bookCoverHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" :book-mount="entity" :continue-listening-shelf="continueListeningShelf" class="relative mx-2" @hook:updated="updatedBookCard" @select="selectItem" @edit="editItem" />
|
||||
<cards-lazy-book-card :key="`${entity.id}-${index}`" :ref="`shelf-book-${entity.id}`" :index="index" :width="bookCoverWidth" :height="bookCoverHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" :book-mount="entity" :continue-listening-shelf="continueListeningShelf" class="relative mx-2" @hook:updated="updatedBookCard" @select="selectItem" @edit="editItem" />
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="shelf.type === 'episode'" class="flex items-center">
|
||||
|
||||
@@ -22,6 +22,10 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2" />
|
||||
</svg>
|
||||
</nuxt-link>
|
||||
<nuxt-link v-if="showPlaylists" :to="`/library/${currentLibraryId}/bookshelf/playlists`" class="flex-grow h-full flex justify-center items-center" :class="isPlaylistsPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
|
||||
<p v-if="isPlaylistsPage || isPodcastLibrary" class="text-sm">{{ $strings.ButtonPlaylists }}</p>
|
||||
<span v-else class="material-icons-outlined text-lg">queue_music</span>
|
||||
</nuxt-link>
|
||||
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/collections`" class="flex-grow h-full flex justify-center items-center" :class="isCollectionsPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
|
||||
<p v-if="isCollectionsPage" class="text-sm">{{ $strings.ButtonCollections }}</p>
|
||||
<span v-else class="material-icons-outlined text-lg">collections_bookmark</span>
|
||||
@@ -94,6 +98,9 @@
|
||||
<template v-else-if="page === 'authors'">
|
||||
<div class="flex-grow" />
|
||||
<ui-btn v-if="userCanUpdate && authors && authors.length && !isBatchSelecting" :loading="processingAuthors" color="primary" small @click="matchAllAuthors">{{ $strings.ButtonMatchAllAuthors }}</ui-btn>
|
||||
|
||||
<!-- author sort select -->
|
||||
<controls-sort-select v-if="authors && authors.length" v-model="settings.authorSortBy" :descending.sync="settings.authorSortDesc" :items="authorSortItems" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateAuthorSort" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@@ -179,6 +186,30 @@ export default {
|
||||
}
|
||||
]
|
||||
},
|
||||
authorSortItems() {
|
||||
return [
|
||||
{
|
||||
text: this.$strings.LabelAuthorFirstLast,
|
||||
value: 'name'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelAuthorLastFirst,
|
||||
value: 'lastFirst'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelNumberOfBooks,
|
||||
value: 'numBooks'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelAddedAt,
|
||||
value: 'addedAt'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelUpdatedAt,
|
||||
value: 'updatedAt'
|
||||
}
|
||||
]
|
||||
},
|
||||
userIsAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
@@ -293,6 +324,9 @@ export default {
|
||||
}
|
||||
|
||||
return items
|
||||
},
|
||||
showPlaylists() {
|
||||
return this.$store.state.libraries.numUserPlaylists > 0
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -448,6 +482,9 @@ export default {
|
||||
updateCollapseBookSeries() {
|
||||
this.saveSettings()
|
||||
},
|
||||
updateAuthorSort() {
|
||||
this.saveSettings()
|
||||
},
|
||||
saveSettings() {
|
||||
this.$store.dispatch('user/updateUserSettings', this.settings)
|
||||
},
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<div v-if="streamLibraryItem" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-48 md:h-40 z-50 bg-primary px-2 md:px-4 pb-1 md:pb-4 pt-2">
|
||||
<div v-if="streamLibraryItem" id="mediaPlayerContainer" class="w-full fixed bottom-0 left-0 right-0 h-48 lg:h-40 z-50 bg-primary px-2 lg:px-4 pb-1 lg:pb-4 pt-2">
|
||||
<div id="videoDock" />
|
||||
<div class="absolute left-2 top-2 md:left-4 cursor-pointer">
|
||||
<div class="absolute left-2 top-2 lg:left-4 cursor-pointer">
|
||||
<covers-book-cover expand-on-click :library-item="streamLibraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" />
|
||||
</div>
|
||||
<div class="flex items-start mb-6 md:mb-0" :class="playerHandler.isVideo ? 'ml-4 pl-96' : isSquareCover ? 'pl-18 sm:pl-24' : 'pl-12 sm:pl-16'">
|
||||
<div class="flex items-start mb-6 lg:mb-0" :class="playerHandler.isVideo ? 'ml-4 pl-96' : isSquareCover ? 'pl-18 sm:pl-24' : 'pl-12 sm:pl-16'">
|
||||
<div class="min-w-0">
|
||||
<nuxt-link :to="`/item/${streamLibraryItem.id}`" class="hover:underline cursor-pointer text-sm sm:text-lg block truncate">
|
||||
{{ title }}
|
||||
@@ -29,7 +29,7 @@
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<ui-tooltip direction="top" :text="$strings.LabelClosePlayer">
|
||||
<span class="material-icons sm:px-2 py-1 md:p-4 cursor-pointer text-xl sm:text-2xl" @click="closePlayer">close</span>
|
||||
<button :aria-label="$strings.LabelClosePlayer" class="material-icons sm:px-2 py-1 lg:p-4 cursor-pointer text-xl sm:text-2xl" @click="closePlayer">close</button>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
<player-ui
|
||||
@@ -349,7 +349,7 @@ export default {
|
||||
}
|
||||
|
||||
if ('mediaSession' in navigator) {
|
||||
var coverImageSrc = this.$store.getters['globals/getLibraryItemCoverSrc'](this.streamLibraryItem, '/Logo.png')
|
||||
var coverImageSrc = this.$store.getters['globals/getLibraryItemCoverSrc'](this.streamLibraryItem, '/Logo.png', true)
|
||||
const artwork = [
|
||||
{
|
||||
src: coverImageSrc
|
||||
@@ -380,7 +380,7 @@ export default {
|
||||
if (this.playerHandler.isPlayingLocalItem && this.playerHandler.currentStreamId === data.stream) {
|
||||
if (!data.numSegments) return
|
||||
var chunks = data.chunks
|
||||
console.log(`[StreamContainer] Stream Progress ${data.percent}`)
|
||||
console.log(`[MediaPlayerContainer] Stream Progress ${data.percent}`)
|
||||
if (this.$refs.audioPlayer) {
|
||||
this.$refs.audioPlayer.setChunksReady(chunks, data.numSegments)
|
||||
} else {
|
||||
@@ -397,17 +397,17 @@ export default {
|
||||
this.playerHandler.prepareOpenSession(session, this.currentPlaybackRate)
|
||||
},
|
||||
streamOpen(session) {
|
||||
console.log(`[StreamContainer] Stream session open`, session)
|
||||
console.log(`[MediaPlayerContainer] Stream session open`, session)
|
||||
},
|
||||
streamClosed(streamId) {
|
||||
// Stream was closed from the server
|
||||
if (this.playerHandler.isPlayingLocalItem && this.playerHandler.currentStreamId === streamId) {
|
||||
console.warn('[StreamContainer] Closing stream due to request from server')
|
||||
console.warn('[MediaPlayerContainer] Closing stream due to request from server')
|
||||
this.playerHandler.closePlayer()
|
||||
}
|
||||
},
|
||||
streamReady() {
|
||||
console.log(`[StreamContainer] Stream Ready`)
|
||||
console.log(`[MediaPlayerContainer] Stream Ready`)
|
||||
if (this.$refs.audioPlayer) {
|
||||
this.$refs.audioPlayer.setStreamReady()
|
||||
} else {
|
||||
@@ -417,7 +417,7 @@ export default {
|
||||
streamError(streamId) {
|
||||
// Stream had critical error from the server
|
||||
if (this.playerHandler.isPlayingLocalItem && this.playerHandler.currentStreamId === streamId) {
|
||||
console.warn('[StreamContainer] Closing stream due to stream error from server')
|
||||
console.warn('[MediaPlayerContainer] Closing stream due to stream error from server')
|
||||
this.playerHandler.closePlayer()
|
||||
}
|
||||
},
|
||||
@@ -496,7 +496,7 @@ export default {
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#streamContainer {
|
||||
#mediaPlayerContainer {
|
||||
box-shadow: 0px -6px 8px #1111113f;
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8">
|
||||
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-2 sm:p-4 mb-8">
|
||||
<div class="flex items-center mb-2">
|
||||
<slot name="header-prefix"></slot>
|
||||
<h1 class="text-xl">{{ headerText }}</h1>
|
||||
|
||||
<slot name="header-items"></slot>
|
||||
|
||||
@@ -6,12 +6,12 @@
|
||||
</div>
|
||||
|
||||
<!-- Alternative bookshelf title/author/sort -->
|
||||
<div v-if="isAlternativeBookshelfView || isAuthorBookshelfView" class="absolute left-0 z-50 w-full" :style="{ bottom: `-${titleDisplayBottomOffset}rem` }">
|
||||
<div v-if="isAlternativeBookshelfView || isAuthorBookshelfView" dir="auto" class="absolute left-0 z-50 w-full" :style="{ bottom: `-${titleDisplayBottomOffset}rem` }">
|
||||
<div :style="{ fontSize: 0.9 * sizeMultiplier + 'rem' }">
|
||||
<div class="flex items-center">
|
||||
<span class="truncate">{{ displayTitle }}</span>
|
||||
<ui-tooltip v-if="displayTitle" :text="displayTitle" :disabled="!displayTitleTruncated" direction="bottom" :delayOnShow="500" class="flex items-center">
|
||||
<p ref="displayTitle" class="truncate">{{ displayTitle }}</p>
|
||||
<widgets-explicit-indicator :explicit="isExplicit" />
|
||||
</div>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
<p class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displayLineTwo || ' ' }}</p>
|
||||
<p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
|
||||
@@ -164,6 +164,7 @@ export default {
|
||||
imageReady: false,
|
||||
selected: false,
|
||||
isSelectionMode: false,
|
||||
displayTitleTruncated: false,
|
||||
showCoverBg: false
|
||||
}
|
||||
},
|
||||
@@ -357,7 +358,7 @@ export default {
|
||||
},
|
||||
showError() {
|
||||
if (this.recentEpisode) return false // Dont show podcast error on episode card
|
||||
return this.numInvalidAudioFiles || this.numMissingParts || this.isMissing || this.isInvalid
|
||||
return this.isMissing || this.isInvalid
|
||||
},
|
||||
libraryItemIdStreaming() {
|
||||
return this.store.getters['getLibraryItemIdStreaming']
|
||||
@@ -387,29 +388,13 @@ export default {
|
||||
isInvalid() {
|
||||
return this._libraryItem.isInvalid
|
||||
},
|
||||
numMissingParts() {
|
||||
if (this.isPodcast) return 0
|
||||
return this.media.numMissingParts
|
||||
},
|
||||
numInvalidAudioFiles() {
|
||||
if (this.isPodcast) return 0
|
||||
return this.media.numInvalidAudioFiles
|
||||
},
|
||||
errorText() {
|
||||
if (this.isMissing) return 'Item directory is missing!'
|
||||
else if (this.isInvalid) {
|
||||
if (this.isPodcast) return 'Podcast has no episodes'
|
||||
return 'Item has no audio tracks & ebook'
|
||||
}
|
||||
let txt = ''
|
||||
if (this.numMissingParts) {
|
||||
txt += `${this.numMissingParts} missing parts.`
|
||||
}
|
||||
if (this.numInvalidAudioFiles) {
|
||||
if (txt) txt += ' '
|
||||
txt += `${this.numInvalidAudioFiles} invalid audio files.`
|
||||
}
|
||||
return txt || 'Unknown Error'
|
||||
return 'Unknown Error'
|
||||
},
|
||||
overlayWrapperClasslist() {
|
||||
const classes = []
|
||||
@@ -642,6 +627,12 @@ export default {
|
||||
}
|
||||
|
||||
this.libraryItem = libraryItem
|
||||
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.displayTitle) {
|
||||
this.displayTitleTruncated = this.$refs.displayTitle.scrollWidth > this.$refs.displayTitle.clientWidth
|
||||
}
|
||||
})
|
||||
},
|
||||
clickCard(e) {
|
||||
if (this.processing) return
|
||||
|
||||
@@ -89,6 +89,14 @@
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="language" class="flex py-0.5">
|
||||
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
|
||||
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelLanguage }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<nuxt-link :to="`/library/${libraryId}/bookshelf?filter=languages.${$encode(language)}`" class="hover:underline">{{ language }}</nuxt-link>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="tracks.length || audioFile || (isPodcast && totalPodcastDuration)" class="flex py-0.5">
|
||||
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
|
||||
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelDuration }}</span>
|
||||
@@ -182,6 +190,9 @@ export default {
|
||||
narrators() {
|
||||
return this.mediaMetadata.narrators || []
|
||||
},
|
||||
language() {
|
||||
return this.mediaMetadata.language || null
|
||||
},
|
||||
durationPretty() {
|
||||
if (this.isPodcast) return this.$elapsedPrettyExtended(this.totalPodcastDuration)
|
||||
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
<template>
|
||||
<div class="sm:w-80 w-full relative">
|
||||
<form @submit.prevent="submitSearch">
|
||||
<ui-text-input ref="input" v-model="search" :placeholder="$strings.PlaceholderSearch" @input="inputUpdate" @focus="focussed" @blur="blurred" class="w-full h-8 text-sm" />
|
||||
</form>
|
||||
<div class="absolute top-0 right-0 bottom-0 h-full flex items-center px-2 text-gray-400 cursor-pointer" @click="clickClear">
|
||||
<span v-if="!search" class="material-icons" style="font-size: 1.2rem">search</span>
|
||||
<span v-else class="material-icons" style="font-size: 1.2rem">close</span>
|
||||
<div class="">
|
||||
<div class="w-full relative sm:w-80">
|
||||
<form @submit.prevent="submitSearch">
|
||||
<ui-text-input ref="input" v-model="search" :placeholder="$strings.PlaceholderSearch" @input="inputUpdate" @focus="focussed" @blur="blurred" class="w-full h-8 text-sm" />
|
||||
</form>
|
||||
<div class="absolute top-0 right-0 bottom-0 h-full flex items-center px-2 text-gray-400 cursor-pointer" @click="clickClear">
|
||||
<span v-if="!search" class="material-icons" style="font-size: 1.2rem">search</span>
|
||||
<span v-else class="material-icons" style="font-size: 1.2rem">close</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="showMenu && (lastSearch || isTyping)" class="absolute z-40 -mt-px w-40 sm:w-full bg-bg border border-black-200 shadow-lg rounded-md py-1 px-2 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm globalSearchMenu">
|
||||
<div v-show="showMenu && (lastSearch || isTyping)" class="absolute z-40 -mt-px w-full max-w-64 sm:max-w-80 sm:w-80 bg-bg border border-black-200 shadow-lg rounded-md py-1 px-2 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm globalSearchMenu">
|
||||
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||
<li v-if="isTyping" class="py-2 px-2">
|
||||
<p>{{ $strings.MessageThinking }}</p>
|
||||
|
||||
@@ -235,6 +235,11 @@ export default {
|
||||
value: 'tags',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelLanguage,
|
||||
value: 'languages',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: this.$strings.ButtonIssues,
|
||||
value: 'issues',
|
||||
@@ -368,9 +373,17 @@ export default {
|
||||
id: 'ebook',
|
||||
name: this.$strings.LabelHasEbook
|
||||
},
|
||||
{
|
||||
id: 'no-ebook',
|
||||
name: this.$strings.LabelMissingEbook
|
||||
},
|
||||
{
|
||||
id: 'supplementary',
|
||||
name: this.$strings.LabelHasSupplementaryEbook
|
||||
},
|
||||
{
|
||||
id: 'no-supplementary',
|
||||
name: this.$strings.LabelMissingSupplementaryEbook
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div class="relative" v-click-outside="clickOutside" @mouseover="mouseover" @mouseleave="mouseleave">
|
||||
<div class="cursor-pointer text-gray-300 hover:text-white" @mousedown.prevent @mouseup.prevent @click="clickVolumeIcon">
|
||||
<button :aria-label="$strings.LabelVolume" class="text-gray-300 hover:text-white" @mousedown.prevent @mouseup.prevent @click="clickVolumeIcon">
|
||||
<span class="material-icons text-2xl sm:text-3xl">{{ volumeIcon }}</span>
|
||||
</div>
|
||||
</button>
|
||||
<transition name="menux">
|
||||
<div v-show="isOpen" class="volumeMenu h-6 absolute bottom-2 w-28 px-2 bg-bg shadow-sm rounded-lg" style="left: -116px">
|
||||
<div ref="volumeTrack" class="h-1 w-full bg-gray-500 my-2.5 relative cursor-pointer rounded-full" @mousedown="mousedownTrack" @click="clickVolumeTrack">
|
||||
@@ -38,8 +38,8 @@ export default {
|
||||
},
|
||||
set(val) {
|
||||
try {
|
||||
localStorage.setItem("volume", val);
|
||||
} catch(error) {
|
||||
localStorage.setItem('volume', val)
|
||||
} catch (error) {
|
||||
console.error('Failed to store volume', err)
|
||||
}
|
||||
this.$emit('input', val)
|
||||
@@ -146,7 +146,7 @@ export default {
|
||||
if (this.value === 0) {
|
||||
this.isMute = true
|
||||
}
|
||||
const storageVolume = localStorage.getItem("volume")
|
||||
const storageVolume = localStorage.getItem('volume')
|
||||
if (storageVolume) {
|
||||
this.volume = parseFloat(storageVolume)
|
||||
}
|
||||
|
||||
@@ -111,7 +111,8 @@
|
||||
</div>
|
||||
|
||||
<div class="flex pt-4 px-2">
|
||||
<ui-btn v-if="isEditingRoot" to="/account">{{ $strings.ButtonChangeRootPassword }}</ui-btn>
|
||||
<ui-btn v-if="hasOpenIDLink" small :loading="unlinkingFromOpenID" color="primary" type="button" class="mr-2" @click.stop="unlinkOpenID">Unlink OpenID</ui-btn>
|
||||
<ui-btn v-if="isEditingRoot" small class="flex items-center" to="/account">{{ $strings.ButtonChangeRootPassword }}</ui-btn>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn color="success" type="submit">{{ $strings.ButtonSubmit }}</ui-btn>
|
||||
</div>
|
||||
@@ -136,7 +137,8 @@ export default {
|
||||
newUser: {},
|
||||
isNew: true,
|
||||
tags: [],
|
||||
loadingTags: false
|
||||
loadingTags: false,
|
||||
unlinkingFromOpenID: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -180,7 +182,7 @@ export default {
|
||||
return this.isNew ? this.$strings.HeaderNewAccount : this.$strings.HeaderUpdateAccount
|
||||
},
|
||||
isEditingRoot() {
|
||||
return this.account && this.account.type === 'root'
|
||||
return this.account?.type === 'root'
|
||||
},
|
||||
libraries() {
|
||||
return this.$store.state.libraries.libraries
|
||||
@@ -198,6 +200,9 @@ export default {
|
||||
},
|
||||
tagsSelectionText() {
|
||||
return this.newUser.permissions.selectedTagsNotAccessible ? this.$strings.LabelTagsNotAccessibleToUser : this.$strings.LabelTagsAccessibleToUser
|
||||
},
|
||||
hasOpenIDLink() {
|
||||
return !!this.account?.hasOpenIDLink
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -205,6 +210,31 @@ export default {
|
||||
// Force close when navigating - used in UsersTable
|
||||
if (this.$refs.modal) this.$refs.modal.setHide()
|
||||
},
|
||||
unlinkOpenID() {
|
||||
const payload = {
|
||||
message: 'Are you sure you want to unlink this user from OpenID?',
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.unlinkingFromOpenID = true
|
||||
this.$axios
|
||||
.$patch(`/api/users/${this.account.id}/openid-unlink`)
|
||||
.then(() => {
|
||||
this.$toast.success('User unlinked from OpenID')
|
||||
this.show = false
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to unlink user from OpenID', error)
|
||||
this.$toast.error('Failed to unlink user from OpenID')
|
||||
})
|
||||
.finally(() => {
|
||||
this.unlinkingFromOpenID = false
|
||||
})
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
},
|
||||
accessAllTagsToggled(val) {
|
||||
if (val) {
|
||||
if (this.newUser.itemTagsSelected?.length) {
|
||||
|
||||
105
client/components/modals/AddCustomMetadataProviderModal.vue
Normal file
105
client/components/modals/AddCustomMetadataProviderModal.vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<modals-modal ref="modal" v-model="show" name="custom-metadata-provider" :width="600" :height="'unset'" :processing="processing">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||
<p class="text-3xl text-white truncate">Add custom metadata provider</p>
|
||||
</div>
|
||||
</template>
|
||||
<form @submit.prevent="submitForm">
|
||||
<div class="px-4 w-full flex items-center text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 overflow-y-auto overflow-x-hidden" style="min-height: 400px; max-height: 80vh">
|
||||
<div class="w-full p-8">
|
||||
<div class="flex mb-2">
|
||||
<div class="w-3/4 p-1">
|
||||
<ui-text-input-with-label v-model="newName" :label="$strings.LabelName" />
|
||||
</div>
|
||||
<div class="w-1/4 p-1">
|
||||
<ui-text-input-with-label value="Book" readonly :label="$strings.LabelMediaType" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full mb-2 p-1">
|
||||
<ui-text-input-with-label v-model="newUrl" label="URL" />
|
||||
</div>
|
||||
<div class="w-full mb-2 p-1">
|
||||
<ui-text-input-with-label v-model="newAuthHeaderValue" :label="'Authorization Header Value'" type="password" />
|
||||
</div>
|
||||
<div class="flex px-1 pt-4">
|
||||
<div class="flex-grow" />
|
||||
<ui-btn color="success" type="submit">{{ $strings.ButtonAdd }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
processing: false,
|
||||
newName: '',
|
||||
newUrl: '',
|
||||
newAuthHeaderValue: ''
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
show: {
|
||||
handler(newVal) {
|
||||
if (newVal) {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
submitForm() {
|
||||
if (!this.newName || !this.newUrl) {
|
||||
this.$toast.error('Must add name and url')
|
||||
return
|
||||
}
|
||||
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$post('/api/custom-metadata-providers', {
|
||||
name: this.newName,
|
||||
url: this.newUrl,
|
||||
mediaType: 'book', // Currently only supporting book mediaType
|
||||
authHeaderValue: this.newAuthHeaderValue
|
||||
})
|
||||
.then((data) => {
|
||||
this.$emit('added', data.provider)
|
||||
this.$toast.success('New provider added')
|
||||
this.show = false
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorMsg = error.response?.data || 'Unknown error'
|
||||
console.error('Failed to add provider', error)
|
||||
this.$toast.error('Failed to add provider: ' + errorMsg)
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
})
|
||||
},
|
||||
init() {
|
||||
this.processing = false
|
||||
this.newName = ''
|
||||
this.newUrl = ''
|
||||
this.newAuthHeaderValue = ''
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -34,11 +34,6 @@ export default {
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
watch: {
|
||||
value(newVal) {
|
||||
this.$nextTick(this.scrollToChapter)
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
@@ -53,7 +48,7 @@ export default {
|
||||
return this.playbackRate
|
||||
},
|
||||
currentChapterId() {
|
||||
return this.currentChapter ? this.currentChapter.id : null
|
||||
return this.currentChapter?.id || null
|
||||
},
|
||||
currentChapterStart() {
|
||||
return (this.currentChapter?.start || 0) / this._playbackRate
|
||||
@@ -74,6 +69,11 @@ export default {
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
updated() {
|
||||
if (this.value) {
|
||||
this.$nextTick(this.scrollToChapter)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -88,10 +88,11 @@
|
||||
<p class="mb-1">{{ _session.mediaPlayer }}</p>
|
||||
|
||||
<p v-if="hasDeviceInfo" class="font-semibold uppercase text-xs text-gray-400 tracking-wide mt-6 mb-2">{{ $strings.LabelDevice }}</p>
|
||||
<p v-if="clientDisplayName" class="mb-1">{{ clientDisplayName }}</p>
|
||||
<p v-if="deviceInfo.ipAddress" class="mb-1">{{ deviceInfo.ipAddress }}</p>
|
||||
<p v-if="osDisplayName" class="mb-1">{{ osDisplayName }}</p>
|
||||
<p v-if="deviceInfo.browserName" class="mb-1">{{ deviceInfo.browserName }}</p>
|
||||
<p v-if="clientDisplayName" class="mb-1">{{ clientDisplayName }}</p>
|
||||
<p v-if="deviceDisplayName" class="mb-1">{{ deviceDisplayName }}</p>
|
||||
<p v-if="deviceInfo.sdkVersion" class="mb-1">SDK {{ $strings.LabelVersion }}: {{ deviceInfo.sdkVersion }}</p>
|
||||
<p v-if="deviceInfo.deviceType" class="mb-1">{{ $strings.LabelType }}: {{ deviceInfo.deviceType }}</p>
|
||||
</div>
|
||||
@@ -141,10 +142,14 @@ export default {
|
||||
if (!this.deviceInfo.osName) return null
|
||||
return `${this.deviceInfo.osName} ${this.deviceInfo.osVersion}`
|
||||
},
|
||||
clientDisplayName() {
|
||||
deviceDisplayName() {
|
||||
if (!this.deviceInfo.manufacturer || !this.deviceInfo.model) return null
|
||||
return `${this.deviceInfo.manufacturer} ${this.deviceInfo.model}`
|
||||
},
|
||||
clientDisplayName() {
|
||||
if (!this.deviceInfo.clientName) return null
|
||||
return `${this.deviceInfo.clientName} ${this.deviceInfo.clientVersion || ''}`
|
||||
},
|
||||
playMethodName() {
|
||||
const playMethod = this._session.playMethod
|
||||
if (playMethod === this.$constants.PlayMethod.DIRECTPLAY) return 'Direct Play'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="w-full h-full overflow-hidden overflow-y-auto px-2 sm:px-4 py-6 relative">
|
||||
<div class="flex flex-wrap mb-4">
|
||||
<div class="relative">
|
||||
<div class="flex flex-col sm:flex-row mb-4">
|
||||
<div class="relative self-center">
|
||||
<covers-preview-cover :src="$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId, libraryItemUpdatedAt, true)" :width="120" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
|
||||
<!-- book cover overlay -->
|
||||
@@ -14,7 +14,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow sm:pl-2 md:pl-6 sm:pr-2 mt-2 md:mt-0">
|
||||
<div class="flex-grow sm:pl-2 md:pl-6 sm:pr-2 mt-6 md:mt-0">
|
||||
<div class="flex items-center">
|
||||
<div v-if="userCanUpload" class="w-10 md:w-40 pr-2 md:min-w-32">
|
||||
<ui-file-input ref="fileInput" @change="fileUploadSelected">
|
||||
@@ -49,20 +49,20 @@
|
||||
</div>
|
||||
</div>
|
||||
<form @submit.prevent="submitSearchForm">
|
||||
<div class="flex items-center justify-start -mx-1 h-20">
|
||||
<div class="w-48 px-1">
|
||||
<div class="flex flex-wrap sm:flex-nowrap items-center justify-start -mx-1">
|
||||
<div class="w-48 flex-grow p-1">
|
||||
<ui-dropdown v-model="provider" :items="providers" :label="$strings.LabelProvider" small />
|
||||
</div>
|
||||
<div class="w-72 px-1">
|
||||
<div class="w-72 flex-grow p-1">
|
||||
<ui-text-input-with-label v-model="searchTitle" :label="searchTitleLabel" :placeholder="$strings.PlaceholderSearch" />
|
||||
</div>
|
||||
<div v-show="provider != 'itunes' && provider != 'audiobookcovers'" class="w-72 px-1">
|
||||
<div v-show="provider != 'itunes' && provider != 'audiobookcovers'" class="w-72 flex-grow p-1">
|
||||
<ui-text-input-with-label v-model="searchAuthor" :label="$strings.LabelAuthor" />
|
||||
</div>
|
||||
<ui-btn class="mt-5 ml-1" type="submit">{{ $strings.ButtonSearch }}</ui-btn>
|
||||
<ui-btn class="mt-5 ml-1 md:min-w-24" :padding-x="4" type="submit">{{ $strings.ButtonSearch }}</ui-btn>
|
||||
</div>
|
||||
</form>
|
||||
<div v-if="hasSearched" class="flex items-center flex-wrap justify-center max-h-80 overflow-y-scroll mt-2 max-w-full">
|
||||
<div v-if="hasSearched" class="flex items-center flex-wrap justify-center sm:max-h-80 sm:overflow-y-scroll mt-2 max-w-full">
|
||||
<p v-if="!coversFound.length">{{ $strings.MessageNoCoversFound }}</p>
|
||||
<template v-for="cover in coversFound">
|
||||
<div :key="cover" class="m-0.5 mb-5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover === coverPath ? 'border-yellow-300' : ''" @click="updateCover(cover)">
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
<td class="text-center w-20 min-w-20">
|
||||
<p>{{ episode.episode }}</p>
|
||||
</td>
|
||||
<td>
|
||||
<td dir="auto">
|
||||
{{ episode.title }}
|
||||
</td>
|
||||
<td class="font-mono text-center">
|
||||
|
||||
@@ -49,8 +49,8 @@
|
||||
</div>
|
||||
<div v-if="media.coverPath">
|
||||
<p class="text-center text-gray-200">Current</p>
|
||||
<a :href="$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId, null, true)" target="_blank" class="bg-primary">
|
||||
<covers-preview-cover :src="$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId, null, true)" :width="100" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
<a :href="$store.getters['globals/getLibraryItemCoverSrc'](libraryItem, null, true)" target="_blank" class="bg-primary">
|
||||
<covers-preview-cover :src="$store.getters['globals/getLibraryItemCoverSrc'](libraryItem, null, true)" :width="100" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -328,6 +328,17 @@ export default {
|
||||
console.error('PersistProvider', error)
|
||||
}
|
||||
},
|
||||
getDefaultBookProvider() {
|
||||
let provider = localStorage.getItem('book-provider')
|
||||
if (!provider) return 'google'
|
||||
// Validate book provider
|
||||
if (!this.$store.getters['scanners/checkBookProviderExists'](provider)) {
|
||||
console.error('Stored book provider does not exist', provider)
|
||||
localStorage.removeItem('book-provider')
|
||||
return 'google'
|
||||
}
|
||||
return provider
|
||||
},
|
||||
getSearchQuery() {
|
||||
if (this.isPodcast) return `term=${encodeURIComponent(this.searchTitle)}`
|
||||
var searchQuery = `provider=${this.provider}&fallbackTitleOnly=1&title=${encodeURIComponent(this.searchTitle)}`
|
||||
@@ -434,7 +445,9 @@ export default {
|
||||
this.searchTitle = this.libraryItem.media.metadata.title
|
||||
this.searchAuthor = this.libraryItem.media.metadata.authorName || ''
|
||||
if (this.isPodcast) this.provider = 'itunes'
|
||||
else this.provider = localStorage.getItem('book-provider') || 'google'
|
||||
else {
|
||||
this.provider = this.getDefaultBookProvider()
|
||||
}
|
||||
|
||||
// Prefer using ASIN if set and using audible provider
|
||||
if (this.provider.startsWith('audible') && this.libraryItem.media.metadata.asin) {
|
||||
@@ -495,7 +508,10 @@ export default {
|
||||
} else if (key === 'author' && !this.isPodcast) {
|
||||
var authors = this.selectedMatch[key]
|
||||
if (!Array.isArray(authors)) {
|
||||
authors = authors.split(',').map((au) => au.trim())
|
||||
authors = authors
|
||||
.split(',')
|
||||
.map((au) => au.trim())
|
||||
.filter((au) => !!au)
|
||||
}
|
||||
var authorPayload = []
|
||||
authors.forEach((authorName) =>
|
||||
@@ -533,24 +549,11 @@ export default {
|
||||
// Persist in local storage
|
||||
localStorage.setItem('selectedMatchUsage', JSON.stringify(this.selectedMatchUsage))
|
||||
|
||||
if (updatePayload.metadata.cover) {
|
||||
const coverPayload = {
|
||||
url: updatePayload.metadata.cover
|
||||
}
|
||||
const success = await this.$axios.$post(`/api/items/${this.libraryItemId}/cover`, coverPayload).catch((error) => {
|
||||
console.error('Failed to update', error)
|
||||
return false
|
||||
})
|
||||
if (success) {
|
||||
this.$toast.success(this.$strings.ToastItemCoverUpdateSuccess)
|
||||
} else {
|
||||
this.$toast.error(this.$strings.ToastItemCoverUpdateFailed)
|
||||
}
|
||||
console.log('Updated cover')
|
||||
delete updatePayload.metadata.cover
|
||||
}
|
||||
|
||||
if (Object.keys(updatePayload).length) {
|
||||
if (updatePayload.metadata.cover) {
|
||||
updatePayload.url = updatePayload.metadata.cover
|
||||
delete updatePayload.metadata.cover
|
||||
}
|
||||
const mediaUpdatePayload = updatePayload
|
||||
const updateResult = await this.$axios.$patch(`/api/items/${this.libraryItemId}/media`, mediaUpdatePayload).catch((error) => {
|
||||
console.error('Failed to update', error)
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
<div v-if="enableAutoDownloadEpisodes" class="flex items-center py-2">
|
||||
<ui-text-input ref="maxEpisodesInput" type="number" v-model="newMaxNewEpisodesToDownload" no-spinner :padding-x="1" text-center class="w-10 text-base" @change="updateMaxNewEpisodesToDownload" />
|
||||
<ui-text-input ref="maxEpisodesToDownloadInput" type="number" v-model="newMaxNewEpisodesToDownload" no-spinner :padding-x="1" text-center class="w-10 text-base" @change="updateMaxNewEpisodesToDownload" />
|
||||
<ui-tooltip text="Value of 0 sets no max limit. When checking for new episodes this is the max number of episodes that will be downloaded.">
|
||||
<p class="pl-4 text-base">
|
||||
Max new episodes to download per check
|
||||
@@ -129,9 +129,12 @@ export default {
|
||||
return
|
||||
}
|
||||
}
|
||||
if (this.$refs.maxEpisodesInput && this.$refs.maxEpisodesInput.isFocused) {
|
||||
|
||||
if (this.$refs.maxEpisodesInput?.isFocused) {
|
||||
this.$refs.maxEpisodesInput.blur()
|
||||
return
|
||||
}
|
||||
if (this.$refs.maxEpisodesToDownloadInput?.isFocused) {
|
||||
this.$refs.maxEpisodesToDownloadInput.blur()
|
||||
}
|
||||
|
||||
const updatePayload = {
|
||||
@@ -140,9 +143,11 @@ export default {
|
||||
if (this.enableAutoDownloadEpisodes) {
|
||||
updatePayload.autoDownloadSchedule = this.cronExpression
|
||||
}
|
||||
this.newMaxEpisodesToKeep = Number(this.newMaxEpisodesToKeep)
|
||||
if (this.newMaxEpisodesToKeep !== this.maxEpisodesToKeep) {
|
||||
updatePayload.maxEpisodesToKeep = this.newMaxEpisodesToKeep
|
||||
}
|
||||
this.newMaxNewEpisodesToDownload = Number(this.newMaxNewEpisodesToDownload)
|
||||
if (this.newMaxNewEpisodesToDownload !== this.maxNewEpisodesToDownload) {
|
||||
updatePayload.maxNewEpisodesToDownload = this.newMaxNewEpisodesToDownload
|
||||
}
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
<div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6">
|
||||
<p class="text-xl font-semibold mb-2">{{ $strings.HeaderAudiobookTools }}</p>
|
||||
|
||||
<!-- alert for windows install -->
|
||||
<widgets-alert v-if="isWindowsInstall" type="warning" class="my-8 text-base">Not supported for the Windows install yet</widgets-alert>
|
||||
|
||||
<!-- Merge to m4b -->
|
||||
<div v-if="showM4bDownload" class="w-full border border-black-200 p-4 my-8">
|
||||
<div v-if="showM4bDownload && !isWindowsInstall" class="w-full border border-black-200 p-4 my-8">
|
||||
<div class="flex flex-wrap items-center">
|
||||
<div>
|
||||
<p class="text-lg">{{ $strings.LabelToolsMakeM4b }}</p>
|
||||
@@ -19,22 +22,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Split to mp3 -->
|
||||
<!-- <div v-if="showMp3Split" class="w-full border border-black-200 p-4 my-8">
|
||||
<div class="flex items-center">
|
||||
<div>
|
||||
<p class="text-lg">{{ $strings.LabelToolsSplitM4b }}</p>
|
||||
<p class="max-w-sm text-sm pt-2 text-gray-300">{{ $strings.LabelToolsSplitM4bDescription }}</p>
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<div>
|
||||
<ui-btn :disabled="true">{{ $strings.MessageNotYetImplemented }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
<!-- Embed Metadata -->
|
||||
<div v-if="mediaTracks.length" class="w-full border border-black-200 p-4 my-8">
|
||||
<div v-if="mediaTracks.length && !isWindowsInstall" class="w-full border border-black-200 p-4 my-8">
|
||||
<div class="flex items-center">
|
||||
<div>
|
||||
<p class="text-lg">{{ $strings.LabelToolsEmbedMetadata }}</p>
|
||||
@@ -122,6 +111,12 @@ export default {
|
||||
},
|
||||
isEncodeTaskRunning() {
|
||||
return this.encodeTask && !this.encodeTask?.isFinished
|
||||
},
|
||||
isWindowsInstall() {
|
||||
return this.Source == 'windows'
|
||||
},
|
||||
Source() {
|
||||
return this.$store.state.Source
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
<ui-btn class="w-full mt-2" color="primary" @click="browseForFolder">{{ $strings.ButtonBrowseForFolder }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
<modals-libraries-folder-chooser v-else :paths="folderPaths" @back="showDirectoryPicker = false" @select="selectFolder" />
|
||||
<modals-libraries-lazy-folder-chooser v-else :paths="folderPaths" @back="showDirectoryPicker = false" @select="selectFolder" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -127,6 +127,7 @@ export default {
|
||||
skipMatchingMediaWithIsbn: false,
|
||||
autoScanCronExpression: null,
|
||||
hideSingleBookSeries: false,
|
||||
onlyShowLaterBooksInContinueSeries: false,
|
||||
metadataPrecedence: ['folderStructure', 'audioMetatags', 'nfoFile', 'txtFiles', 'opfFile', 'absMetadata']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,35 +4,37 @@
|
||||
<span class="material-icons text-3xl cursor-pointer hover:text-gray-300" @click="$emit('back')">arrow_back</span>
|
||||
<p class="px-4 text-xl">{{ $strings.HeaderChooseAFolder }}</p>
|
||||
</div>
|
||||
<div v-if="allFolders.length" class="w-full bg-primary bg-opacity-70 py-1 px-4 mb-2">
|
||||
<p class="font-mono truncate">{{ selectedPath || '\\' }}</p>
|
||||
<div v-if="rootDirs.length" class="w-full bg-primary bg-opacity-70 py-1 px-4 mb-2">
|
||||
<p class="font-mono truncate">{{ selectedPath || '/' }}</p>
|
||||
</div>
|
||||
<div v-if="allFolders.length" class="flex bg-primary bg-opacity-50 p-4 folder-container">
|
||||
<div v-if="rootDirs.length" class="relative flex bg-primary bg-opacity-50 p-4 folder-container">
|
||||
<div class="w-1/2 border-r border-bg h-full overflow-y-auto">
|
||||
<div v-if="level > 0" class="w-full p-1 cursor-pointer flex items-center" @click="goBack">
|
||||
<div v-if="level > 0" class="w-full p-1 cursor-pointer flex items-center hover:bg-white/10" @click="goBack">
|
||||
<span class="material-icons bg-opacity-50 text-yellow-200" style="font-size: 1.2rem">folder</span>
|
||||
<p class="text-base font-mono px-2">..</p>
|
||||
</div>
|
||||
<div v-for="dir in _directories" :key="dir.path" class="dir-item w-full p-1 cursor-pointer flex items-center hover:text-white text-gray-200" :class="dir.className" @click="selectDir(dir)">
|
||||
<div v-for="dir in _directories" :key="dir.path" class="dir-item w-full p-1 cursor-pointer flex items-center hover:text-white text-gray-200 hover:bg-white/10" :class="dir.className" @click="selectDir(dir)">
|
||||
<span class="material-icons bg-opacity-50 text-yellow-200" style="font-size: 1.2rem">folder</span>
|
||||
<p class="text-base font-mono px-2 truncate">{{ dir.dirname }}</p>
|
||||
<span v-if="dir.dirs && dir.dirs.length && dir.path === selectedPath" class="material-icons" style="font-size: 1.1rem">arrow_right</span>
|
||||
<span v-if="dir.path === selectedPath" class="material-icons" style="font-size: 1.1rem">arrow_right</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-1/2 h-full overflow-y-auto">
|
||||
<div v-for="dir in _subdirs" :key="dir.path" :class="dir.className" class="dir-item w-full p-1 cursor-pointer flex items-center hover:text-white text-gray-200" @click="selectSubDir(dir)">
|
||||
<div v-for="dir in _subdirs" :key="dir.path" :class="dir.className" class="dir-item w-full p-1 cursor-pointer flex items-center hover:text-white text-gray-200 hover:bg-white/10" @click="selectSubDir(dir)">
|
||||
<span class="material-icons bg-opacity-50 text-yellow-200" style="font-size: 1.2rem">folder</span>
|
||||
<p class="text-base font-mono px-2 truncate">{{ dir.dirname }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="loadingDirs" class="absolute inset-0 w-full h-full flex items-center justify-center bg-black/10">
|
||||
<ui-loading-indicator />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="loadingFolders" class="py-12 text-center">
|
||||
<div v-else-if="initialLoad" class="py-12 text-center">
|
||||
<p>{{ $strings.MessageLoadingFolders }}</p>
|
||||
</div>
|
||||
<div v-else class="py-12 text-center max-w-sm mx-auto">
|
||||
<p class="text-lg mb-2">{{ $strings.MessageNoFoldersAvailable }}</p>
|
||||
<p class="text-gray-300 mb-2">{{ $strings.NoteFolderPicker }}</p>
|
||||
<p v-if="isDebian" class="text-red-400">{{ $strings.NoteFolderPickerDebian }}</p>
|
||||
</div>
|
||||
|
||||
<div class="w-full py-2">
|
||||
@@ -51,11 +53,12 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loadingFolders: false,
|
||||
allFolders: [],
|
||||
initialLoad: false,
|
||||
loadingDirs: false,
|
||||
isPosix: true,
|
||||
rootDirs: [],
|
||||
directories: [],
|
||||
selectedPath: '',
|
||||
selectedFullPath: '',
|
||||
subdirs: [],
|
||||
level: 0,
|
||||
currentDir: null,
|
||||
@@ -89,68 +92,91 @@ export default {
|
||||
...d
|
||||
}
|
||||
})
|
||||
},
|
||||
isDebian() {
|
||||
return this.Source == 'debian'
|
||||
},
|
||||
Source() {
|
||||
return this.$store.state.Source
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
goBack() {
|
||||
var splitPaths = this.selectedPath.split('\\').slice(1)
|
||||
var prev = splitPaths.slice(0, -1).join('\\')
|
||||
async goBack() {
|
||||
let selPath = this.selectedPath.replace(/^\//, '')
|
||||
var splitPaths = selPath.split('/')
|
||||
|
||||
var currDirs = this.allFolders
|
||||
for (let i = 0; i < splitPaths.length; i++) {
|
||||
var _dir = currDirs.find((dir) => dir.dirname === splitPaths[i])
|
||||
if (_dir && _dir.path.slice(1) === prev) {
|
||||
this.directories = currDirs
|
||||
this.selectDir(_dir)
|
||||
return
|
||||
} else if (_dir) {
|
||||
currDirs = _dir.dirs
|
||||
}
|
||||
let previousPath = ''
|
||||
let lookupPath = ''
|
||||
|
||||
if (splitPaths.length > 2) {
|
||||
lookupPath = splitPaths.slice(0, -2).join('/')
|
||||
}
|
||||
previousPath = splitPaths.slice(0, -1).join('/')
|
||||
|
||||
if (!this.isPosix) {
|
||||
// For windows drives add a trailing slash. e.g. C:/
|
||||
if (!this.isPosix && lookupPath.endsWith(':')) {
|
||||
lookupPath += '/'
|
||||
}
|
||||
if (!this.isPosix && previousPath.endsWith(':')) {
|
||||
previousPath += '/'
|
||||
}
|
||||
} else {
|
||||
// Add leading slash
|
||||
if (previousPath) previousPath = '/' + previousPath
|
||||
if (lookupPath) lookupPath = '/' + lookupPath
|
||||
}
|
||||
|
||||
this.level--
|
||||
this.subdirs = this.directories
|
||||
this.selectedPath = previousPath
|
||||
this.directories = await this.fetchDirs(lookupPath, this.level)
|
||||
},
|
||||
selectDir(dir) {
|
||||
async selectDir(dir) {
|
||||
if (dir.isUsed) return
|
||||
this.selectedPath = dir.path
|
||||
this.selectedFullPath = dir.fullPath
|
||||
this.level = dir.level
|
||||
this.subdirs = dir.dirs
|
||||
this.subdirs = await this.fetchDirs(dir.path, dir.level + 1)
|
||||
},
|
||||
selectSubDir(dir) {
|
||||
async selectSubDir(dir) {
|
||||
if (dir.isUsed) return
|
||||
this.selectedPath = dir.path
|
||||
this.selectedFullPath = dir.fullPath
|
||||
this.level = dir.level
|
||||
this.directories = this.subdirs
|
||||
this.subdirs = dir.dirs
|
||||
this.subdirs = await this.fetchDirs(dir.path, dir.level + 1)
|
||||
},
|
||||
selectFolder() {
|
||||
if (!this.selectedPath) {
|
||||
console.error('No Selected path')
|
||||
return
|
||||
}
|
||||
if (this.paths.find((p) => p.startsWith(this.selectedFullPath))) {
|
||||
if (this.paths.find((p) => p.startsWith(this.selectedPath))) {
|
||||
this.$toast.error(`Oops, you cannot add a parent directory of a folder already added`)
|
||||
return
|
||||
}
|
||||
this.$emit('select', this.selectedFullPath)
|
||||
this.$emit('select', this.selectedPath)
|
||||
this.selectedPath = ''
|
||||
this.selectedFullPath = ''
|
||||
},
|
||||
fetchDirs(path, level) {
|
||||
this.loadingDirs = true
|
||||
return this.$axios
|
||||
.$get(`/api/filesystem?path=${path}&level=${level}`)
|
||||
.then((data) => {
|
||||
console.log('Fetched directories', data.directories)
|
||||
this.isPosix = !!data.posix
|
||||
return data.directories
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to get filesystem paths', error)
|
||||
this.$toast.error('Failed to get filesystem paths')
|
||||
return []
|
||||
})
|
||||
.finally(() => {
|
||||
this.loadingDirs = false
|
||||
})
|
||||
},
|
||||
async init() {
|
||||
this.loadingFolders = true
|
||||
this.allFolders = await this.$store.dispatch('libraries/loadFolders')
|
||||
this.loadingFolders = false
|
||||
this.initialLoad = true
|
||||
this.rootDirs = await this.fetchDirs('', 0)
|
||||
this.initialLoad = false
|
||||
|
||||
this.directories = this.allFolders
|
||||
this.directories = this.rootDirs
|
||||
this.subdirs = []
|
||||
this.selectedPath = ''
|
||||
this.selectedFullPath = ''
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
@@ -63,7 +63,7 @@ export default {
|
||||
},
|
||||
audioMetatags: {
|
||||
id: 'audioMetatags',
|
||||
name: 'Audio file meta tags',
|
||||
name: 'Audio file meta tags OR ebook metadata',
|
||||
include: true
|
||||
},
|
||||
nfoFile: {
|
||||
|
||||
@@ -49,6 +49,20 @@
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isBookLibrary" class="py-3">
|
||||
<div class="flex items-center">
|
||||
<ui-toggle-switch v-model="onlyShowLaterBooksInContinueSeries" @input="formUpdated" />
|
||||
<ui-tooltip :text="$strings.LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp">
|
||||
<p class="pl-4 text-base">
|
||||
{{ $strings.LabelSettingsOnlyShowLaterBooksInContinueSeries }}
|
||||
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isPodcastLibrary" class="py-3">
|
||||
<ui-dropdown :label="$strings.LabelPodcastSearchRegion" v-model="podcastSearchRegion" :items="$podcastSearchRegionOptions" small class="max-w-52" @input="formUpdated" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -69,7 +83,9 @@ export default {
|
||||
skipMatchingMediaWithAsin: false,
|
||||
skipMatchingMediaWithIsbn: false,
|
||||
audiobooksOnly: false,
|
||||
hideSingleBookSeries: false
|
||||
hideSingleBookSeries: false,
|
||||
onlyShowLaterBooksInContinueSeries: false,
|
||||
podcastSearchRegion: 'us'
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -85,6 +101,9 @@ export default {
|
||||
isBookLibrary() {
|
||||
return this.mediaType === 'book'
|
||||
},
|
||||
isPodcastLibrary() {
|
||||
return this.mediaType === 'podcast'
|
||||
},
|
||||
providers() {
|
||||
if (this.mediaType === 'podcast') return this.$store.state.scanners.podcastProviders
|
||||
return this.$store.state.scanners.providers
|
||||
@@ -99,7 +118,9 @@ export default {
|
||||
skipMatchingMediaWithAsin: !!this.skipMatchingMediaWithAsin,
|
||||
skipMatchingMediaWithIsbn: !!this.skipMatchingMediaWithIsbn,
|
||||
audiobooksOnly: !!this.audiobooksOnly,
|
||||
hideSingleBookSeries: !!this.hideSingleBookSeries
|
||||
hideSingleBookSeries: !!this.hideSingleBookSeries,
|
||||
onlyShowLaterBooksInContinueSeries: !!this.onlyShowLaterBooksInContinueSeries,
|
||||
podcastSearchRegion: this.podcastSearchRegion
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -113,6 +134,8 @@ export default {
|
||||
this.skipMatchingMediaWithIsbn = !!this.librarySettings.skipMatchingMediaWithIsbn
|
||||
this.audiobooksOnly = !!this.librarySettings.audiobooksOnly
|
||||
this.hideSingleBookSeries = !!this.librarySettings.hideSingleBookSeries
|
||||
this.onlyShowLaterBooksInContinueSeries = !!this.librarySettings.onlyShowLaterBooksInContinueSeries
|
||||
this.podcastSearchRegion = this.librarySettings.podcastSearchRegion || 'us'
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
<div class="break-words">{{ episode.title }}</div>
|
||||
<widgets-podcast-type-indicator :type="episode.episodeType" />
|
||||
</div>
|
||||
<p v-if="episode.subtitle" class="break-words mb-1 text-sm text-gray-300 episode-subtitle">{{ episode.subtitle }}</p>
|
||||
<p v-if="episode.subtitle" class="mb-1 text-sm text-gray-300 line-clamp-2">{{ episode.subtitle }}</p>
|
||||
<p class="text-xs text-gray-300">Published {{ episode.publishedAt ? $dateDistanceFromNow(episode.publishedAt) : 'Unknown' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -68,7 +68,9 @@ export default {
|
||||
selectAll: false,
|
||||
search: null,
|
||||
searchTimeout: null,
|
||||
searchText: null
|
||||
searchText: null,
|
||||
downloadedEpisodeGuidMap: {},
|
||||
downloadedEpisodeUrlMap: {}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -122,11 +124,13 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
getIsEpisodeDownloaded(episode) {
|
||||
return this.itemEpisodes.some((downloadedEpisode) => {
|
||||
if (episode.guid && downloadedEpisode.guid === episode.guid) return true
|
||||
if (!downloadedEpisode.enclosure?.url) return false
|
||||
return this.getCleanEpisodeUrl(downloadedEpisode.enclosure.url) === episode.cleanUrl
|
||||
})
|
||||
if (episode.guid && !!this.downloadedEpisodeGuidMap[episode.guid]) {
|
||||
return true
|
||||
}
|
||||
if (this.downloadedEpisodeUrlMap[episode.cleanUrl]) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
/**
|
||||
* UPDATE: As of v2.4.5 guid is used for matching existing downloaded episodes if it is found on the RSS feed.
|
||||
@@ -219,6 +223,14 @@ export default {
|
||||
})
|
||||
},
|
||||
init() {
|
||||
this.downloadedEpisodeGuidMap = {}
|
||||
this.downloadedEpisodeUrlMap = {}
|
||||
|
||||
this.itemEpisodes.forEach((episode) => {
|
||||
if (episode.guid) this.downloadedEpisodeGuidMap[episode.guid] = episode.id
|
||||
if (episode.enclosure?.url) this.downloadedEpisodeUrlMap[this.getCleanEpisodeUrl(episode.enclosure.url)] = episode.id
|
||||
})
|
||||
|
||||
this.episodesCleaned = this.episodes
|
||||
.filter((ep) => ep.enclosure?.url)
|
||||
.map((_ep) => {
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
<p class="text-xs text-gray-300">{{ podcastAuthor }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-lg font-semibold mb-6">{{ title }}</p>
|
||||
<div v-if="description" class="default-style" v-html="description" />
|
||||
<p dir="auto" class="text-lg font-semibold mb-6">{{ title }}</p>
|
||||
<div v-if="description" dir="auto" class="default-style" v-html="description" />
|
||||
<p v-else class="mb-2">{{ $strings.MessageNoDescription }}</p>
|
||||
</div>
|
||||
</modals-modal>
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<div class="w-full p-1">
|
||||
<ui-textarea-with-label v-model="newEpisode.subtitle" :label="$strings.LabelSubtitle" :rows="3" />
|
||||
</div>
|
||||
<div class="w-full p-1 default-style">
|
||||
<div class="w-full p-1">
|
||||
<ui-rich-text-editor :label="$strings.LabelDescription" v-model="newEpisode.description" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<div v-for="(episode, index) in episodesFound" :key="index" class="w-full py-4 border-b border-white border-opacity-5 hover:bg-gray-300 hover:bg-opacity-10 cursor-pointer px-2" @click.stop="selectEpisode(episode)">
|
||||
<p v-if="episode.episode" class="font-semibold text-gray-200">#{{ episode.episode }}</p>
|
||||
<p class="break-words mb-1">{{ episode.title }}</p>
|
||||
<p v-if="episode.subtitle" class="break-words mb-1 text-sm text-gray-300 episode-subtitle">{{ episode.subtitle }}</p>
|
||||
<p v-if="episode.subtitle" class="mb-1 text-sm text-gray-300 line-clamp-2">{{ episode.subtitle }}</p>
|
||||
<p class="text-xs text-gray-400">Published {{ episode.publishedAt ? $dateDistanceFromNow(episode.publishedAt) : 'Unknown' }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,22 +1,30 @@
|
||||
<template>
|
||||
<div class="flex pt-4 pb-2 md:pt-0 md:pb-2">
|
||||
<div class="flex items-center pt-4 pb-2 lg:pt-0 lg:pb-2">
|
||||
<div class="flex-grow" />
|
||||
<template v-if="!loading">
|
||||
<div class="cursor-pointer flex items-center justify-center text-gray-300 mr-4 md:mr-8" @mousedown.prevent @mouseup.prevent @click.stop="prevChapter">
|
||||
<span class="material-icons text-2xl sm:text-3xl">first_page</span>
|
||||
</div>
|
||||
<div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpBackward">
|
||||
<span class="material-icons text-2xl sm:text-3xl">replay_10</span>
|
||||
</div>
|
||||
<div class="cursor-pointer p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-4 md:mx-8" :class="seekLoading ? 'animate-spin' : ''" @mousedown.prevent @mouseup.prevent @click.stop="playPause">
|
||||
<ui-tooltip direction="top" :text="$strings.ButtonPreviousChapter" class="mr-4 lg:mr-8">
|
||||
<button :aria-label="$strings.ButtonPreviousChapter" class="text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="prevChapter">
|
||||
<span class="material-icons text-2xl sm:text-3xl">first_page</span>
|
||||
</button>
|
||||
</ui-tooltip>
|
||||
<ui-tooltip direction="top" :text="$strings.ButtonJumpBackward">
|
||||
<button :aria-label="$strings.ButtonJumpBackward" class="text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpBackward">
|
||||
<span class="material-icons text-2xl sm:text-3xl">replay_10</span>
|
||||
</button>
|
||||
</ui-tooltip>
|
||||
<button :aria-label="paused ? $strings.ButtonPlay : $strings.ButtonPause" class="p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-4 lg:mx-8" :class="seekLoading ? 'animate-spin' : ''" @mousedown.prevent @mouseup.prevent @click.stop="playPause">
|
||||
<span class="material-icons text-2xl">{{ seekLoading ? 'autorenew' : paused ? 'play_arrow' : 'pause' }}</span>
|
||||
</div>
|
||||
<div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpForward">
|
||||
<span class="material-icons text-2xl sm:text-3xl">forward_10</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-center ml-4 md:ml-8" :class="hasNextChapter ? 'text-gray-300 cursor-pointer' : 'text-gray-500'" @mousedown.prevent @mouseup.prevent @click.stop="nextChapter">
|
||||
<span class="material-icons text-2xl sm:text-3xl">last_page</span>
|
||||
</div>
|
||||
</button>
|
||||
<ui-tooltip direction="top" :text="$strings.ButtonJumpForward">
|
||||
<button :aria-label="$strings.ButtonJumpForward" class="text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpForward">
|
||||
<span class="material-icons text-2xl sm:text-3xl">forward_10</span>
|
||||
</button>
|
||||
</ui-tooltip>
|
||||
<ui-tooltip direction="top" :text="$strings.ButtonNextChapter" class="ml-4 lg:ml-8">
|
||||
<button :aria-label="$strings.ButtonNextChapter" :disabled="!hasNextChapter" :class="hasNextChapter ? 'text-gray-300' : 'text-gray-500'" @mousedown.prevent @mouseup.prevent @click.stop="nextChapter">
|
||||
<span class="material-icons text-2xl sm:text-3xl">last_page</span>
|
||||
</button>
|
||||
</ui-tooltip>
|
||||
<controls-playback-speed-control v-model="playbackRateInput" @input="playbackRateUpdated" @change="playbackRateChanged" />
|
||||
</template>
|
||||
<template v-else>
|
||||
|
||||
@@ -57,7 +57,6 @@ export default {
|
||||
},
|
||||
watch: {
|
||||
duration: {
|
||||
immediate: true,
|
||||
handler() {
|
||||
this.setChapterTicks()
|
||||
}
|
||||
@@ -205,10 +204,14 @@ export default {
|
||||
},
|
||||
windowResize() {
|
||||
this.setTrackWidth()
|
||||
this.setChapterTicks()
|
||||
this.updatePlayedTrackWidth()
|
||||
this.updateBufferTrack()
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.setTrackWidth()
|
||||
this.setChapterTicks()
|
||||
window.addEventListener('resize', this.windowResize)
|
||||
},
|
||||
beforeDestroy() {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="w-full -mt-6">
|
||||
<div class="w-full relative mb-1">
|
||||
<div class="absolute -top-10 md:top-0 right-0 lg:right-2 flex items-center h-full">
|
||||
<div class="absolute -top-10 lg:top-0 right-0 lg:right-2 flex items-center h-full">
|
||||
<!-- <span class="material-icons text-2xl cursor-pointer" @click="toggleFullscreen(true)">expand_less</span> -->
|
||||
|
||||
<ui-tooltip direction="top" :text="$strings.LabelVolume">
|
||||
@@ -9,37 +9,37 @@
|
||||
</ui-tooltip>
|
||||
|
||||
<ui-tooltip direction="top" :text="$strings.LabelSleepTimer">
|
||||
<div class="cursor-pointer text-gray-300 hover:text-white mx-1 lg:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showSleepTimer')">
|
||||
<button :aria-label="$strings.LabelSleepTimer" class="text-gray-300 hover:text-white mx-1 lg:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showSleepTimer')">
|
||||
<span v-if="!sleepTimerSet" class="material-icons text-2xl">snooze</span>
|
||||
<div v-else class="flex items-center">
|
||||
<span class="material-icons text-lg text-warning">snooze</span>
|
||||
<p class="text-xl text-warning font-mono font-semibold text-center px-0.5 pb-0.5" style="min-width: 30px">{{ sleepTimerRemainingString }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</ui-tooltip>
|
||||
|
||||
<ui-tooltip v-if="!isPodcast" direction="top" :text="$strings.LabelViewBookmarks">
|
||||
<div class="cursor-pointer text-gray-300 hover:text-white mx-1 lg:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showBookmarks')">
|
||||
<button :aria-label="$strings.LabelViewBookmarks" class="text-gray-300 hover:text-white mx-1 lg:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showBookmarks')">
|
||||
<span class="material-icons text-2xl">{{ bookmarks.length ? 'bookmarks' : 'bookmark_border' }}</span>
|
||||
</div>
|
||||
</button>
|
||||
</ui-tooltip>
|
||||
|
||||
<ui-tooltip v-if="chapters.length" direction="top" :text="$strings.LabelViewChapters">
|
||||
<div class="cursor-pointer text-gray-300 hover:text-white mx-1 lg:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="showChapters">
|
||||
<button :aria-label="$strings.LabelViewChapters" class="text-gray-300 hover:text-white mx-1 lg:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="showChapters">
|
||||
<span class="material-icons text-2xl">format_list_bulleted</span>
|
||||
</div>
|
||||
</button>
|
||||
</ui-tooltip>
|
||||
|
||||
<ui-tooltip v-if="playerQueueItems.length" direction="top" :text="$strings.LabelViewQueue">
|
||||
<button class="outline-none text-gray-300 mx-1 lg:mx-2 hover:text-white" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showPlayerQueueItems')">
|
||||
<button :aria-label="$strings.LabelViewQueue" class="outline-none text-gray-300 mx-1 lg:mx-2 hover:text-white" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showPlayerQueueItems')">
|
||||
<span class="material-icons text-2.5xl sm:text-3xl">playlist_play</span>
|
||||
</button>
|
||||
</ui-tooltip>
|
||||
|
||||
<ui-tooltip v-if="chapters.length" direction="top" :text="useChapterTrack ? $strings.LabelUseFullTrack : $strings.LabelUseChapterTrack">
|
||||
<div class="cursor-pointer text-gray-300 mx-1 lg:mx-2 hover:text-white" @mousedown.prevent @mouseup.prevent @click.stop="setUseChapterTrack">
|
||||
<button :aria-label="useChapterTrack ? $strings.LabelUseFullTrack : $strings.LabelUseChapterTrack" class="text-gray-300 mx-1 lg:mx-2 hover:text-white" @mousedown.prevent @mouseup.prevent @click.stop="setUseChapterTrack">
|
||||
<span class="material-icons text-2xl sm:text-3xl transform transition-transform" :class="useChapterTrack ? 'rotate-180' : ''">timelapse</span>
|
||||
</div>
|
||||
</button>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="w-full h-full">
|
||||
<div v-show="showPageMenu" v-click-outside="clickOutside" class="pagemenu absolute top-9 left-8 rounded-md overflow-y-auto bg-bg shadow-lg z-20 border border-gray-400" :style="{ width: pageMenuWidth + 'px' }">
|
||||
<div v-for="(file, index) in cleanedPageNames" :key="file" class="w-full cursor-pointer hover:bg-black-200 px-2 py-1" :class="page === index ? 'bg-black-200' : ''" @click="setPage(index + 1)">
|
||||
<div v-for="(file, index) in cleanedPageNames" :key="file" class="w-full cursor-pointer hover:bg-black-200 px-2 py-1" :class="page === index + 1 ? 'bg-black-200' : ''" @click="setPage(index + 1)">
|
||||
<p class="text-sm truncate">{{ file }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -334,7 +334,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())
|
||||
})
|
||||
|
||||
@@ -316,6 +316,7 @@ export default {
|
||||
reader.rendition = reader.book.renderTo('viewer', {
|
||||
width: this.readerWidth,
|
||||
height: this.readerHeight * 0.8,
|
||||
allowScriptedContent: true,
|
||||
spread: 'auto',
|
||||
snap: true,
|
||||
manager: 'continuous',
|
||||
|
||||
@@ -1,45 +1,45 @@
|
||||
<template>
|
||||
<div class="flex flex-wrap justify-center mt-6">
|
||||
<div class="flex px-2">
|
||||
<svg class="h-14 w-14 md:h-18 md:w-18" viewBox="0 0 24 24">
|
||||
<div class="flex p-2">
|
||||
<svg class="h-14 w-14" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M9 3V18H12V3H9M12 5L16 18L19 17L15 4L12 5M5 5V18H8V5H5M3 19V21H21V19H3Z" />
|
||||
</svg>
|
||||
<div class="px-2">
|
||||
<p class="text-4xl md:text-5xl font-bold">{{ totalItems }}</p>
|
||||
<div class="px-1">
|
||||
<p class="text-4.5xl leading-none font-bold">{{ $formatNumber(totalItems) }}</p>
|
||||
<p class="text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelStatsItemsInLibrary }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex px-4">
|
||||
<span class="material-icons text-7xl">show_chart</span>
|
||||
<div class="flex p-2">
|
||||
<span class="material-icons text-5xl py-1">show_chart</span>
|
||||
<div class="px-1">
|
||||
<p class="text-4xl md:text-5xl font-bold">{{ totalTime }}</p>
|
||||
<p class="text-4.5xl leading-none font-bold">{{ $formatNumber(totalTime) }}</p>
|
||||
<p class="text-xs md:text-sm text-white text-opacity-80">{{ useOverallHours ? $strings.LabelStatsOverallHours : $strings.LabelStatsOverallDays }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isBookLibrary" class="flex px-4">
|
||||
<svg class="h-14 w-14 md:h-18 md:w-18" viewBox="0 0 24 24">
|
||||
<div v-if="isBookLibrary" class="flex p-2">
|
||||
<svg class="h-14 w-14" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M12,4A4,4 0 0,1 16,8A4,4 0 0,1 12,12A4,4 0 0,1 8,8A4,4 0 0,1 12,4M12,6A2,2 0 0,0 10,8A2,2 0 0,0 12,10A2,2 0 0,0 14,8A2,2 0 0,0 12,6M12,13C14.67,13 20,14.33 20,17V20H4V17C4,14.33 9.33,13 12,13M12,14.9C9.03,14.9 5.9,16.36 5.9,17V18.1H18.1V17C18.1,16.36 14.97,14.9 12,14.9Z" />
|
||||
</svg>
|
||||
<div class="px-1">
|
||||
<p class="text-4xl md:text-5xl font-bold">{{ totalAuthors }}</p>
|
||||
<p class="text-4.5xl leading-none font-bold">{{ $formatNumber(totalAuthors) }}</p>
|
||||
<p class="text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelStatsAuthors }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex px-4">
|
||||
<span class="material-icons-outlined text-6xl pt-1">insert_drive_file</span>
|
||||
<div class="flex p-2">
|
||||
<span class="material-icons-outlined text-5xl pt-1">insert_drive_file</span>
|
||||
<div class="px-1">
|
||||
<p class="text-4xl md:text-5xl font-bold">{{ totalSizeNum }}</p>
|
||||
<p class="text-4.5xl leading-none font-bold">{{ $formatNumber(totalSizeNum) }}</p>
|
||||
<p class="text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelSize }} ({{ totalSizeMod }})</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex px-4">
|
||||
<span class="material-icons-outlined text-6xl pt-1">audio_file</span>
|
||||
<div class="flex p-2">
|
||||
<span class="material-icons-outlined text-5xl pt-1">audio_file</span>
|
||||
<div class="px-1">
|
||||
<p class="text-4xl md:text-5xl font-bold">{{ numAudioTracks }}</p>
|
||||
<p class="text-4.5xl leading-none font-bold">{{ $formatNumber(numAudioTracks) }}</p>
|
||||
<p class="text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelStatsAudioTracks }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,9 +7,10 @@
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<p class="hidden md:block text-xl font-semibold">{{ yearInReviewYear }} Year in Review</p>
|
||||
<p class="hidden md:block text-xl font-semibold">{{ $getString('HeaderYearReview', [yearInReviewYear]) }}</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 Year in Review' }}</ui-btn>
|
||||
<ui-btn class="w-full md:w-auto" @click.stop="clickShowYearInReview">{{ showYearInReview ? $strings.LabelYearReviewHide :
|
||||
$strings.LabelYearReviewShow }}</ui-btn>
|
||||
</div>
|
||||
|
||||
<!-- your year in review -->
|
||||
@@ -20,24 +21,27 @@
|
||||
<!-- 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>
|
||||
<span class="hidden sm:inline-block pr-2">{{ $strings.ButtonPrevious }}</span>
|
||||
</ui-btn>
|
||||
<!-- share button -->
|
||||
<ui-btn v-if="showShareButton" small :disabled="processingYearInReview" class="inline-flex sm:hidden items-center font-semibold ml-1 sm:ml-2" @click="shareYearInReview"> Share </ui-btn>
|
||||
<ui-btn v-if="showShareButton" small :disabled="processingYearInReview" class="inline-flex sm:hidden items-center font-semibold ml-1 sm:ml-2" @click="shareYearInReview">{{
|
||||
$strings.ButtonShare }}
|
||||
</ui-btn>
|
||||
|
||||
<div class="flex-grow" />
|
||||
<p class="hidden sm:block text-lg font-semibold">Your Year in Review ({{ yearInReviewVariant + 1 }})</p>
|
||||
<p class="hidden sm:block text-lg font-semibold">{{ $getString('LabelPersonalYearReview', [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="hidden sm:inline-block">{{ $strings.ButtonRefresh }}</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="hidden sm:inline-block pl-2">{{ $strings.ButtonNext }}</span>
|
||||
<span class="material-icons-outlined text-lg sm:pl-1 py-px sm:py-0">chevron_right</span>
|
||||
</ui-btn>
|
||||
</div>
|
||||
@@ -46,7 +50,7 @@
|
||||
<!-- your year in review short -->
|
||||
<div class="w-full max-w-[800px] mx-auto my-4">
|
||||
<!-- share button -->
|
||||
<ui-btn v-if="showShareButton" small :disabled="processingYearInReviewShort" class="inline-flex sm:hidden items-center font-semibold mb-1" @click="shareYearInReviewShort"> Share </ui-btn>
|
||||
<ui-btn v-if="showShareButton" small :disabled="processingYearInReviewShort" class="inline-flex sm:hidden items-center font-semibold mb-1" @click="shareYearInReviewShort">{{ $strings.ButtonShare }}</ui-btn>
|
||||
<stats-year-in-review-short ref="yearInReviewShort" :year="yearInReviewYear" :processing.sync="processingYearInReviewShort" />
|
||||
</div>
|
||||
|
||||
@@ -56,24 +60,25 @@
|
||||
<!-- 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>
|
||||
<span class="hidden sm:inline-block pr-2">{{ $strings.ButtonPrevious }}</span>
|
||||
</ui-btn>
|
||||
<!-- share button -->
|
||||
<ui-btn v-if="showShareButton" small :disabled="processingYearInReviewServer" class="inline-flex sm:hidden items-center font-semibold ml-1 sm:ml-2" @click="shareYearInReviewServer"> Share </ui-btn>
|
||||
<ui-btn v-if="showShareButton" small :disabled="processingYearInReviewServer" class="inline-flex sm:hidden items-center font-semibold ml-1 sm:ml-2" @click="shareYearInReviewServer">{{ $strings.ButtonShare }}
|
||||
</ui-btn>
|
||||
|
||||
<div class="flex-grow" />
|
||||
<p class="hidden sm:block text-lg font-semibold">Server Year in Review ({{ yearInReviewServerVariant + 1 }})</p>
|
||||
<p class="hidden sm:block text-lg font-semibold">{{ $getString('LabelServerYearReview', [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="hidden sm:inline-block">{{ $strings.ButtonRefresh }}</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="hidden sm:inline-block pl-2">{{ $strings.ButtonNext }}</span>
|
||||
<span class="material-icons-outlined text-lg sm:pl-1 py-px sm:py-0">chevron_right</span>
|
||||
</ui-btn>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="text-center mt-4">
|
||||
<div class="text-center mt-4 relative">
|
||||
<div class="flex py-4">
|
||||
<ui-file-input ref="fileInput" class="mr-2" accept=".audiobookshelf" @change="backupUploaded">{{ $strings.ButtonUploadBackup }}</ui-file-input>
|
||||
<div class="flex-grow" />
|
||||
@@ -54,6 +54,10 @@
|
||||
</div>
|
||||
</div>
|
||||
</prompt-dialog>
|
||||
|
||||
<div v-if="isApplyingBackup" class="absolute inset-0 w-full h-full flex items-center justify-center bg-black/20 rounded-md">
|
||||
<ui-loading-indicator />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -64,6 +68,7 @@ export default {
|
||||
showConfirmApply: false,
|
||||
selectedBackup: null,
|
||||
isBackingUp: false,
|
||||
isApplyingBackup: false,
|
||||
processing: false,
|
||||
backups: []
|
||||
}
|
||||
@@ -85,19 +90,21 @@ export default {
|
||||
},
|
||||
confirm() {
|
||||
this.showConfirmApply = false
|
||||
this.isApplyingBackup = true
|
||||
|
||||
this.$axios
|
||||
.$get(`/api/backups/${this.selectedBackup.id}/apply`)
|
||||
.then(() => {
|
||||
this.isBackingUp = false
|
||||
location.replace('/config/backups?backup=1')
|
||||
})
|
||||
.catch((error) => {
|
||||
this.isBackingUp = false
|
||||
console.error('Failed to apply backup', error)
|
||||
const errorMsg = error.response.data || this.$strings.ToastBackupRestoreFailed
|
||||
this.$toast.error(errorMsg)
|
||||
})
|
||||
.finally(() => {
|
||||
this.isApplyingBackup = false
|
||||
})
|
||||
},
|
||||
deleteBackupClick(backup) {
|
||||
if (confirm(this.$getString('MessageConfirmDeleteBackup', [this.$formatDatetime(backup.createdAt, this.dateFormat, this.timeFormat)]))) {
|
||||
@@ -180,7 +187,6 @@ export default {
|
||||
this.loadBackups()
|
||||
if (this.$route.query.backup) {
|
||||
this.$toast.success('Backup applied successfully')
|
||||
this.$router.replace('/config')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
<td class="text-left">
|
||||
<p class="px-4">{{ chapter.id }}</p>
|
||||
</td>
|
||||
<td>
|
||||
<td dir="auto">
|
||||
{{ chapter.title }}
|
||||
</td>
|
||||
<td class="font-mono text-center hover:underline cursor-pointer" @click.stop="goToTimestamp(chapter.start)">
|
||||
|
||||
127
client/components/tables/CustomMetadataProviderTable.vue
Normal file
127
client/components/tables/CustomMetadataProviderTable.vue
Normal file
@@ -0,0 +1,127 @@
|
||||
<template>
|
||||
<div class="min-h-40">
|
||||
<table v-if="providers.length" id="providers">
|
||||
<tr>
|
||||
<th>{{ $strings.LabelName }}</th>
|
||||
<th>URL</th>
|
||||
<th>Authorization Header Value</th>
|
||||
<th class="w-12"></th>
|
||||
</tr>
|
||||
<tr v-for="provider in providers" :key="provider.id">
|
||||
<td class="text-sm">{{ provider.name }}</td>
|
||||
<td class="text-sm">{{ provider.url }}</td>
|
||||
<td class="text-sm">
|
||||
<span v-if="provider.authHeaderValue" class="custom-provider-api-key">{{ provider.authHeaderValue }}</span>
|
||||
</td>
|
||||
<td class="py-0">
|
||||
<div class="h-8 w-8 flex items-center justify-center text-white text-opacity-50 hover:text-error cursor-pointer" @click.stop="removeProvider(provider)">
|
||||
<button type="button" :aria-label="$strings.ButtonDelete" class="material-icons text-base">delete</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div v-else-if="!processing" class="text-center py-8">
|
||||
<p class="text-lg">No custom metadata providers</p>
|
||||
</div>
|
||||
|
||||
<div v-if="processing" class="absolute inset-0 h-full flex items-center justify-center bg-black/40 rounded-md">
|
||||
<ui-loading-indicator />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
providers: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
processing: Boolean
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
methods: {
|
||||
removeProvider(provider) {
|
||||
const payload = {
|
||||
message: `Are you sure you want remove custom metadata provider "${provider.name}"?`,
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.$emit('update:processing', true)
|
||||
|
||||
this.$axios
|
||||
.$delete(`/api/custom-metadata-providers/${provider.id}`)
|
||||
.then(() => {
|
||||
this.$toast.success('Provider removed')
|
||||
this.$emit('removed', provider.id)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to remove provider', error)
|
||||
this.$toast.error('Failed to remove provider')
|
||||
})
|
||||
.finally(() => {
|
||||
this.$emit('update:processing', false)
|
||||
})
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#providers {
|
||||
table-layout: fixed;
|
||||
border-collapse: collapse;
|
||||
border: 1px solid #474747;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#providers td,
|
||||
#providers th {
|
||||
/* border: 1px solid #2e2e2e; */
|
||||
padding: 8px 8px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
#providers td.py-0 {
|
||||
padding: 0px 8px;
|
||||
}
|
||||
|
||||
#providers tr:nth-child(even) {
|
||||
background-color: #373838;
|
||||
}
|
||||
|
||||
#providers tr:nth-child(odd) {
|
||||
background-color: #2f2f2f;
|
||||
}
|
||||
|
||||
#providers tr:hover {
|
||||
background-color: #444;
|
||||
}
|
||||
|
||||
#providers th {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
padding-top: 5px;
|
||||
padding-bottom: 5px;
|
||||
background-color: #272727;
|
||||
}
|
||||
|
||||
.custom-provider-api-key {
|
||||
padding: 1px;
|
||||
background-color: #272727;
|
||||
border-radius: 4px;
|
||||
color: transparent;
|
||||
transition: color, background-color 0.5s ease;
|
||||
}
|
||||
|
||||
.custom-provider-api-key:hover {
|
||||
background-color: transparent;
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
@@ -12,7 +12,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<transition name="slide">
|
||||
<div class="w-full" v-show="showFiles">
|
||||
<div class="w-full" v-if="showFiles">
|
||||
<table class="text-sm tracksTable">
|
||||
<tr>
|
||||
<th class="text-left px-4">{{ $strings.LabelPath }}</th>
|
||||
@@ -70,7 +70,7 @@ export default {
|
||||
},
|
||||
audioFiles() {
|
||||
if (this.libraryItem.mediaType === 'podcast') {
|
||||
return this.libraryItem.media?.episodes.map((ep) => ep.audioFile) || []
|
||||
return this.libraryItem.media?.episodes.map((ep) => ep.audioFile).filter((af) => af) || []
|
||||
}
|
||||
return this.libraryItem.media?.audioFiles || []
|
||||
},
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<widgets-podcast-type-indicator :type="downloadQueued.episodeType" />
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4">
|
||||
<td dir="auto" class="px-4">
|
||||
{{ downloadQueued.episodeDisplayTitle }}
|
||||
</td>
|
||||
<td class="text-xs">
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
<template>
|
||||
<div class="w-full px-2 py-3 overflow-hidden relative border-b border-white border-opacity-10" @mouseover="mouseover" @mouseleave="mouseleave">
|
||||
<div v-if="episode" class="flex items-center cursor-pointer" :class="{ 'opacity-70': isSelected || selectionMode }" @click="clickedEpisode">
|
||||
<div class="flex-grow px-2">
|
||||
<div class="flex items-center">
|
||||
<span class="text-sm font-semibold">{{ title }}</span>
|
||||
<widgets-podcast-type-indicator :type="episode.episodeType" />
|
||||
<div :id="`lazy-episode-${index}`" class="w-full h-full cursor-pointer" @mouseover="mouseover" @mouseleave="mouseleave">
|
||||
<div class="flex" @click="clickedEpisode">
|
||||
<div class="flex-grow">
|
||||
<div dir="auto" class="flex items-center">
|
||||
<span class="text-sm font-semibold">{{ episodeTitle }}</span>
|
||||
<widgets-podcast-type-indicator :type="episodeType" />
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-gray-200 episode-subtitle mt-1.5 mb-0.5" v-html="subtitle"></p>
|
||||
<div class="flex justify-between pt-2 max-w-xl">
|
||||
<p v-if="episode.season" class="text-sm text-gray-300">Season #{{ episode.season }}</p>
|
||||
<p v-if="episode.episode" class="text-sm text-gray-300">Episode #{{ episode.episode }}</p>
|
||||
<p v-if="episode.chapters?.length" class="text-sm text-gray-300">{{ episode.chapters.length }} Chapters</p>
|
||||
<p v-if="publishedAt" class="text-sm text-gray-300">Published {{ $formatDate(publishedAt, dateFormat) }}</p>
|
||||
<div class="h-10 flex items-center mt-1.5 mb-0.5 overflow-hidden">
|
||||
<p class="text-sm text-gray-200 line-clamp-2" v-html="episodeSubtitle"></p>
|
||||
</div>
|
||||
<div class="h-8 flex items-center">
|
||||
<div class="w-full inline-flex justify-between max-w-xl">
|
||||
<p v-if="episode?.season" class="text-sm text-gray-300">Season #{{ episode.season }}</p>
|
||||
<p v-if="episode?.episode" class="text-sm text-gray-300">Episode #{{ episode.episode }}</p>
|
||||
<p v-if="episode?.chapters?.length" class="text-sm text-gray-300">{{ episode.chapters.length }} Chapters</p>
|
||||
<p v-if="publishedAt" class="text-sm text-gray-300">Published {{ $formatDate(publishedAt, dateFormat) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center pt-2">
|
||||
@@ -37,10 +41,11 @@
|
||||
<ui-icon-btn v-if="userCanDelete" icon="close" borderless @click="removeClick" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isHovering || isSelected || selectionMode" class="hidden md:block w-12 min-w-12" />
|
||||
<div v-if="isHovering || isSelected || isSelectionMode" class="hidden md:block w-12 min-w-12" />
|
||||
</div>
|
||||
<div v-if="isSelected || selectionMode" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-10 z-10 cursor-pointer" @click.stop="clickedSelectionBg" />
|
||||
<div class="hidden md:block md:w-12 md:min-w-12 md:-right-0 md:absolute md:top-0 h-full transform transition-transform z-20" :class="!isHovering && !isSelected && !selectionMode ? 'translate-x-24' : 'translate-x-0'">
|
||||
|
||||
<div v-if="isSelected || isSelectionMode" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-10 z-10 cursor-pointer" @click.stop="clickedSelectionBg" />
|
||||
<div class="hidden md:block md:w-12 md:min-w-12 md:-right-0 md:absolute md:top-0 h-full transform transition-transform z-20" :class="!isHovering && !isSelected && !isSelectionMode ? 'translate-x-24' : 'translate-x-0'">
|
||||
<div class="flex h-full items-center">
|
||||
<div class="mx-1">
|
||||
<ui-checkbox v-model="isSelected" @input="selectedUpdated" checkbox-bg="bg" />
|
||||
@@ -55,84 +60,91 @@
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
index: Number,
|
||||
libraryItemId: String,
|
||||
episode: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
selectionMode: Boolean
|
||||
default: () => null
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isProcessingReadUpdate: false,
|
||||
processingRemove: false,
|
||||
isHovering: false,
|
||||
isSelected: false
|
||||
isSelected: false,
|
||||
isSelectionMode: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
store() {
|
||||
return this.$store || this.$nuxt.$store
|
||||
},
|
||||
axios() {
|
||||
return this.$axios || this.$nuxt.$axios
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
return this.store.getters['user/getUserCanUpdate']
|
||||
},
|
||||
userCanDelete() {
|
||||
return this.$store.getters['user/getUserCanDelete']
|
||||
return this.store.getters['user/getUserCanDelete']
|
||||
},
|
||||
audioFile() {
|
||||
return this.episode.audioFile
|
||||
episodeId() {
|
||||
return this.episode?.id || ''
|
||||
},
|
||||
title() {
|
||||
return this.episode.title || ''
|
||||
episodeTitle() {
|
||||
return this.episode?.title || ''
|
||||
},
|
||||
subtitle() {
|
||||
return this.episode.subtitle || this.description
|
||||
episodeSubtitle() {
|
||||
return this.episode?.subtitle || ''
|
||||
},
|
||||
description() {
|
||||
return this.episode.description || ''
|
||||
episodeType() {
|
||||
return this.episode?.episodeType || ''
|
||||
},
|
||||
duration() {
|
||||
return this.$secondsToTimestamp(this.episode.duration)
|
||||
publishedAt() {
|
||||
return this.episode?.publishedAt
|
||||
},
|
||||
libraryItemIdStreaming() {
|
||||
return this.$store.getters['getLibraryItemIdStreaming']
|
||||
},
|
||||
isStreamingFromDifferentLibrary() {
|
||||
return this.$store.getters['getIsStreamingFromDifferentLibrary']
|
||||
},
|
||||
isStreaming() {
|
||||
return this.$store.getters['getIsMediaStreaming'](this.libraryItemId, this.episode.id)
|
||||
},
|
||||
isQueued() {
|
||||
return this.$store.getters['getIsMediaQueued'](this.libraryItemId, this.episode.id)
|
||||
},
|
||||
streamIsPlaying() {
|
||||
return this.$store.state.streamIsPlaying && this.isStreaming
|
||||
dateFormat() {
|
||||
return this.store.state.serverSettings.dateFormat
|
||||
},
|
||||
itemProgress() {
|
||||
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId, this.episode.id)
|
||||
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId, this.episodeId)
|
||||
},
|
||||
itemProgressPercent() {
|
||||
return this.itemProgress ? this.itemProgress.progress : 0
|
||||
return this.itemProgress?.progress || 0
|
||||
},
|
||||
userIsFinished() {
|
||||
return this.itemProgress ? !!this.itemProgress.isFinished : false
|
||||
return !!this.itemProgress?.isFinished
|
||||
},
|
||||
libraryItemIdStreaming() {
|
||||
return this.store.getters['getLibraryItemIdStreaming']
|
||||
},
|
||||
isStreamingFromDifferentLibrary() {
|
||||
return this.store.getters['getIsStreamingFromDifferentLibrary']
|
||||
},
|
||||
isStreaming() {
|
||||
return this.store.getters['getIsMediaStreaming'](this.libraryItemId, this.episodeId)
|
||||
},
|
||||
isQueued() {
|
||||
return this.store.getters['getIsMediaQueued'](this.libraryItemId, this.episodeId)
|
||||
},
|
||||
streamIsPlaying() {
|
||||
return this.store.state.streamIsPlaying && this.isStreaming
|
||||
},
|
||||
timeRemaining() {
|
||||
if (this.streamIsPlaying) return 'Playing'
|
||||
if (!this.itemProgress) return this.$elapsedPretty(this.episode.duration)
|
||||
if (!this.itemProgress) return this.$elapsedPretty(this.episode?.duration || 0)
|
||||
if (this.userIsFinished) return 'Finished'
|
||||
var remaining = Math.floor(this.itemProgress.duration - this.itemProgress.currentTime)
|
||||
|
||||
const duration = this.itemProgress.duration || this.episode?.duration || 0
|
||||
const remaining = Math.floor(duration - this.itemProgress.currentTime)
|
||||
return `${this.$elapsedPretty(remaining)} left`
|
||||
},
|
||||
publishedAt() {
|
||||
return this.episode.publishedAt
|
||||
},
|
||||
dateFormat() {
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickAddToPlaylist() {
|
||||
this.$emit('addToPlaylist', this.episode)
|
||||
setSelectionMode(isSelectionMode) {
|
||||
this.isSelectionMode = isSelectionMode
|
||||
if (!this.isSelectionMode) this.isSelected = false
|
||||
},
|
||||
clickedEpisode() {
|
||||
this.$emit('view', this.episode)
|
||||
@@ -150,16 +162,23 @@ export default {
|
||||
mouseleave() {
|
||||
this.isHovering = false
|
||||
},
|
||||
clickEdit() {
|
||||
this.$emit('edit', this.episode)
|
||||
},
|
||||
playClick() {
|
||||
if (this.streamIsPlaying) {
|
||||
this.$eventBus.$emit('pause-item')
|
||||
const eventBus = this.$eventBus || this.$nuxt.$eventBus
|
||||
eventBus.$emit('pause-item')
|
||||
} else {
|
||||
this.$emit('play', this.episode)
|
||||
}
|
||||
},
|
||||
queueBtnClick() {
|
||||
if (this.isQueued) {
|
||||
// Remove from queue
|
||||
this.store.commit('removeItemFromQueue', { libraryItemId: this.libraryItemId, episodeId: this.episodeId })
|
||||
} else {
|
||||
// Add to queue
|
||||
this.$emit('addToQueue', this.episode)
|
||||
}
|
||||
},
|
||||
toggleFinished(confirmed = false) {
|
||||
if (!this.userIsFinished && this.itemProgressPercent > 0 && !confirmed) {
|
||||
const payload = {
|
||||
@@ -171,37 +190,47 @@ export default {
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
this.store.commit('globals/setConfirmPrompt', payload)
|
||||
return
|
||||
}
|
||||
|
||||
var updatePayload = {
|
||||
const updatePayload = {
|
||||
isFinished: !this.userIsFinished
|
||||
}
|
||||
this.isProcessingReadUpdate = true
|
||||
this.$axios
|
||||
.$patch(`/api/me/progress/${this.libraryItemId}/${this.episode.id}`, updatePayload)
|
||||
this.axios
|
||||
.$patch(`/api/me/progress/${this.libraryItemId}/${this.episodeId}`, updatePayload)
|
||||
.then(() => {
|
||||
this.isProcessingReadUpdate = false
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
this.isProcessingReadUpdate = false
|
||||
this.$toast.error(updatePayload.isFinished ? this.$strings.ToastItemMarkedAsFinishedFailed : this.$strings.ToastItemMarkedAsNotFinishedFailed)
|
||||
const toast = this.$toast || this.$nuxt.$toast
|
||||
toast.error(updatePayload.isFinished ? this.$strings.ToastItemMarkedAsFinishedFailed : this.$strings.ToastItemMarkedAsNotFinishedFailed)
|
||||
})
|
||||
},
|
||||
clickAddToPlaylist() {
|
||||
this.$emit('addToPlaylist', this.episode)
|
||||
},
|
||||
clickEdit() {
|
||||
this.$emit('edit', this.episode)
|
||||
},
|
||||
removeClick() {
|
||||
this.$emit('remove', this.episode)
|
||||
},
|
||||
queueBtnClick() {
|
||||
if (this.isQueued) {
|
||||
// Remove from queue
|
||||
this.$store.commit('removeItemFromQueue', { libraryItemId: this.libraryItemId, episodeId: this.episode.id })
|
||||
} else {
|
||||
// Add to queue
|
||||
this.$emit('addToQueue', this.episode)
|
||||
destroy() {
|
||||
// destroy the vue listeners, etc
|
||||
this.$destroy()
|
||||
|
||||
// remove the element from the DOM
|
||||
if (this.$el && this.$el.parentNode) {
|
||||
this.$el.parentNode.removeChild(this.$el)
|
||||
} else if (this.$el && this.$el.remove) {
|
||||
this.$el.remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="w-full py-6">
|
||||
<div id="lazy-episodes-table" class="w-full py-6">
|
||||
<div class="flex flex-wrap flex-col md:flex-row md:items-center mb-4">
|
||||
<div class="flex items-center flex-nowrap whitespace-nowrap mb-2 md:mb-0">
|
||||
<p class="text-lg mb-0 font-semibold">{{ $strings.HeaderEpisodes }}</p>
|
||||
@@ -18,28 +18,41 @@
|
||||
<ui-btn :disabled="processing" small class="ml-2 h-9" @click="clearSelected">{{ $strings.ButtonCancel }}</ui-btn>
|
||||
</template>
|
||||
<template v-else>
|
||||
<controls-filter-select v-model="filterKey" :items="filterItems" class="w-36 h-9 md:ml-4" />
|
||||
<controls-sort-select v-model="sortKey" :descending.sync="sortDesc" :items="sortItems" class="w-44 md:w-48 h-9 ml-1 sm:ml-4" />
|
||||
<controls-filter-select v-model="filterKey" :items="filterItems" class="w-36 h-9 md:ml-4" @change="filterSortChanged" />
|
||||
<controls-sort-select v-model="sortKey" :descending.sync="sortDesc" :items="sortItems" class="w-44 md:w-48 h-9 ml-1 sm:ml-4" @change="filterSortChanged" />
|
||||
<div class="flex-grow md:hidden" />
|
||||
<ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" class="ml-1" @action="contextMenuAction" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="!episodes.length" class="py-4 text-center text-lg">{{ $strings.MessageNoEpisodes }}</p>
|
||||
<!-- <p v-if="!episodes.length" class="py-4 text-center text-lg">{{ $strings.MessageNoEpisodes }}</p> -->
|
||||
<div v-if="episodes.length" class="w-full py-3 mx-auto flex">
|
||||
<form @submit.prevent="submit" class="flex flex-grow">
|
||||
<ui-text-input v-model="search" @input="inputUpdate" type="search" :placeholder="$strings.PlaceholderSearchEpisode" class="flex-grow mr-2 text-sm md:text-base" />
|
||||
</form>
|
||||
</div>
|
||||
<template v-for="episode in episodesList">
|
||||
<tables-podcast-episode-table-row ref="episodeRow" :key="episode.id" :episode="episode" :library-item-id="libraryItem.id" :selection-mode="isSelectionMode" class="item" @play="playEpisode" @remove="removeEpisode" @edit="editEpisode" @view="viewEpisode" @selected="episodeSelected" @addToQueue="addEpisodeToQueue" @addToPlaylist="addToPlaylist" />
|
||||
</template>
|
||||
<div class="relative min-h-[176px]">
|
||||
<template v-for="episode in totalEpisodes">
|
||||
<div :key="episode" :id="`episode-${episode - 1}`" class="w-full h-44 px-2 py-3 overflow-hidden relative border-b border-white/10">
|
||||
<!-- episode is mounted here -->
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="isSearching" class="w-full h-full absolute inset-0 flex justify-center py-12" :class="{ 'bg-black/50': totalEpisodes }">
|
||||
<ui-loading-indicator />
|
||||
</div>
|
||||
<div v-else-if="!totalEpisodes" class="h-44 flex items-center justify-center">
|
||||
<p class="text-lg">{{ $strings.MessageNoEpisodes }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<modals-podcast-remove-episode v-model="showPodcastRemoveModal" @input="removeEpisodeModalToggled" :library-item="libraryItem" :episodes="episodesToRemove" @clearSelected="clearSelected" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Vue from 'vue'
|
||||
import LazyEpisodeRow from './LazyEpisodeRow.vue'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
libraryItem: {
|
||||
@@ -60,13 +73,21 @@ export default {
|
||||
processing: false,
|
||||
search: null,
|
||||
searchTimeout: null,
|
||||
searchText: null
|
||||
searchText: null,
|
||||
isSearching: false,
|
||||
totalEpisodes: 0,
|
||||
episodesPerPage: null,
|
||||
episodeIndexesMounted: [],
|
||||
episodeComponentRefs: {},
|
||||
windowHeight: 0,
|
||||
episodesTableOffsetTop: 0,
|
||||
episodeRowHeight: 176
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
libraryItem: {
|
||||
handler() {
|
||||
this.init()
|
||||
this.refresh()
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -194,13 +215,19 @@ export default {
|
||||
submit() {},
|
||||
inputUpdate() {
|
||||
clearTimeout(this.searchTimeout)
|
||||
this.isSearching = true
|
||||
let searchStart = this.searchText
|
||||
this.searchTimeout = setTimeout(() => {
|
||||
if (!this.search || !this.search.trim()) {
|
||||
this.isSearching = false
|
||||
if (!this.search?.trim()) {
|
||||
this.searchText = ''
|
||||
return
|
||||
} else {
|
||||
this.searchText = this.search.toLowerCase().trim()
|
||||
}
|
||||
this.searchText = this.search.toLowerCase().trim()
|
||||
}, 500)
|
||||
if (searchStart !== this.searchText) {
|
||||
this.init()
|
||||
}
|
||||
}, 750)
|
||||
},
|
||||
contextMenuAction({ action }) {
|
||||
if (action === 'quick-match-episodes') {
|
||||
@@ -304,24 +331,30 @@ export default {
|
||||
if (!val) this.episodesToRemove = []
|
||||
},
|
||||
clearSelected() {
|
||||
const episodeRows = this.$refs.episodeRow
|
||||
if (episodeRows && episodeRows.length) {
|
||||
for (const epRow of episodeRows) {
|
||||
if (epRow) epRow.isSelected = false
|
||||
}
|
||||
}
|
||||
this.selectedEpisodes = []
|
||||
this.setSelectionModeForEpisodes()
|
||||
},
|
||||
removeSelectedEpisodes() {
|
||||
this.episodesToRemove = this.selectedEpisodes
|
||||
this.showPodcastRemoveModal = true
|
||||
},
|
||||
episodeSelected({ isSelected, episode }) {
|
||||
let isSelectionModeBefore = this.isSelectionMode
|
||||
if (isSelected) {
|
||||
this.selectedEpisodes.push(episode)
|
||||
} else {
|
||||
this.selectedEpisodes = this.selectedEpisodes.filter((ep) => ep.id !== episode.id)
|
||||
}
|
||||
if (this.isSelectionMode !== isSelectionModeBefore) {
|
||||
this.setSelectionModeForEpisodes()
|
||||
}
|
||||
},
|
||||
setSelectionModeForEpisodes() {
|
||||
for (const key in this.episodeComponentRefs) {
|
||||
if (this.episodeComponentRefs[key]?.setSelectionMode) {
|
||||
this.episodeComponentRefs[key].setSelectionMode(this.isSelectionMode)
|
||||
}
|
||||
}
|
||||
},
|
||||
playEpisode(episode) {
|
||||
const queueItems = []
|
||||
@@ -367,12 +400,147 @@ export default {
|
||||
this.$store.commit('globals/setSelectedEpisode', episode)
|
||||
this.$store.commit('globals/setShowViewPodcastEpisodeModal', true)
|
||||
},
|
||||
init() {
|
||||
destroyEpisodeComponents() {
|
||||
for (const key in this.episodeComponentRefs) {
|
||||
if (this.episodeComponentRefs[key]?.destroy) {
|
||||
this.episodeComponentRefs[key].destroy()
|
||||
}
|
||||
}
|
||||
this.episodeComponentRefs = {}
|
||||
this.episodeIndexesMounted = []
|
||||
},
|
||||
mountEpisode(index) {
|
||||
const episodeEl = document.getElementById(`episode-${index}`)
|
||||
if (!episodeEl) {
|
||||
console.warn('Episode row el not found at ' + index)
|
||||
return
|
||||
}
|
||||
|
||||
this.episodeIndexesMounted.push(index)
|
||||
|
||||
if (this.episodeComponentRefs[index]) {
|
||||
const episodeComponent = this.episodeComponentRefs[index]
|
||||
episodeEl.appendChild(episodeComponent.$el)
|
||||
if (this.isSelectionMode) {
|
||||
episodeComponent.setSelectionMode(true)
|
||||
if (this.selectedEpisodes.some((i) => i.id === episodeComponent.episodeId)) {
|
||||
episodeComponent.isSelected = true
|
||||
} else {
|
||||
episodeComponent.isSelected = false
|
||||
}
|
||||
} else {
|
||||
episodeComponent.setSelectionMode(false)
|
||||
}
|
||||
} else {
|
||||
const _this = this
|
||||
const ComponentClass = Vue.extend(LazyEpisodeRow)
|
||||
const instance = new ComponentClass({
|
||||
propsData: {
|
||||
index,
|
||||
libraryItemId: this.libraryItem.id,
|
||||
episode: this.episodesList[index]
|
||||
},
|
||||
created() {
|
||||
this.$on('selected', (payload) => {
|
||||
_this.episodeSelected(payload)
|
||||
})
|
||||
this.$on('view', (payload) => {
|
||||
_this.viewEpisode(payload)
|
||||
})
|
||||
this.$on('play', (payload) => {
|
||||
_this.playEpisode(payload)
|
||||
})
|
||||
this.$on('addToQueue', (payload) => {
|
||||
_this.addEpisodeToQueue(payload)
|
||||
})
|
||||
this.$on('remove', (payload) => {
|
||||
_this.removeEpisode(payload)
|
||||
})
|
||||
this.$on('edit', (payload) => {
|
||||
_this.editEpisode(payload)
|
||||
})
|
||||
this.$on('addToPlaylist', (payload) => {
|
||||
_this.addToPlaylist(payload)
|
||||
})
|
||||
}
|
||||
})
|
||||
this.episodeComponentRefs[index] = instance
|
||||
instance.$mount()
|
||||
episodeEl.appendChild(instance.$el)
|
||||
|
||||
if (this.isSelectionMode) {
|
||||
instance.setSelectionMode(true)
|
||||
if (this.selectedEpisodes.some((i) => i.id === this.episodesList[index].id)) {
|
||||
instance.isSelected = true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
mountEpisodes(startIndex, endIndex) {
|
||||
for (let i = startIndex; i < endIndex; i++) {
|
||||
if (!this.episodeIndexesMounted.includes(i)) {
|
||||
this.mountEpisode(i)
|
||||
}
|
||||
}
|
||||
},
|
||||
scroll(evt) {
|
||||
if (!evt?.target?.scrollTop) return
|
||||
const scrollTop = Math.max(evt.target.scrollTop - this.episodesTableOffsetTop, 0)
|
||||
let firstEpisodeIndex = Math.floor(scrollTop / this.episodeRowHeight)
|
||||
let lastEpisodeIndex = Math.ceil((scrollTop + this.windowHeight) / this.episodeRowHeight)
|
||||
lastEpisodeIndex = Math.min(this.totalEpisodes - 1, lastEpisodeIndex)
|
||||
|
||||
this.episodeIndexesMounted = this.episodeIndexesMounted.filter((_index) => {
|
||||
if (_index < firstEpisodeIndex || _index >= lastEpisodeIndex) {
|
||||
const el = document.getElementById(`lazy-episode-${_index}`)
|
||||
if (el) el.remove()
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
this.mountEpisodes(firstEpisodeIndex, lastEpisodeIndex + 1)
|
||||
},
|
||||
initListeners() {
|
||||
const itemPageWrapper = document.getElementById('item-page-wrapper')
|
||||
if (itemPageWrapper) {
|
||||
itemPageWrapper.addEventListener('scroll', this.scroll)
|
||||
}
|
||||
},
|
||||
removeListeners() {
|
||||
const itemPageWrapper = document.getElementById('item-page-wrapper')
|
||||
if (itemPageWrapper) {
|
||||
itemPageWrapper.removeEventListener('scroll', this.scroll)
|
||||
}
|
||||
},
|
||||
filterSortChanged() {
|
||||
this.init()
|
||||
},
|
||||
refresh() {
|
||||
this.episodesCopy = this.episodes.map((ep) => ({ ...ep }))
|
||||
this.init()
|
||||
},
|
||||
init() {
|
||||
this.destroyEpisodeComponents()
|
||||
this.totalEpisodes = this.episodesList.length
|
||||
|
||||
const lazyEpisodesTableEl = document.getElementById('lazy-episodes-table')
|
||||
this.episodesTableOffsetTop = (lazyEpisodesTableEl?.offsetTop || 0) + 64
|
||||
|
||||
this.windowHeight = window.innerHeight
|
||||
this.episodesPerPage = Math.ceil(this.windowHeight / this.episodeRowHeight)
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.mountEpisodes(0, Math.min(this.episodesPerPage, this.totalEpisodes))
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.episodesCopy = this.episodes.map((ep) => ({ ...ep }))
|
||||
this.initListeners()
|
||||
this.init()
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.removeListeners()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div>
|
||||
<input ref="fileInput" type="file" :accept="accept" class="hidden" @change="inputChanged" />
|
||||
<ui-btn @click="clickUpload" color="primary" class="hidden md:block" type="text"><slot /></ui-btn>
|
||||
<ui-btn @click="clickUpload" color="primary" class="hidden md:block w-full" type="text"><slot /></ui-btn>
|
||||
<ui-icon-btn @click="clickUpload" icon="upload" class="block md:hidden" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -11,13 +11,13 @@
|
||||
</div>
|
||||
{{ item }}
|
||||
</div>
|
||||
<input v-show="!readonly" ref="input" v-model="textInput" :disabled="disabled" style="min-width: 40px; width: 40px" class="h-full bg-primary focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" @paste="inputPaste" />
|
||||
<input v-show="!readonly" ref="input" v-model="textInput" :disabled="disabled" class="h-full bg-primary focus:outline-none px-1 w-6" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" @paste="inputPaste" />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<ul ref="menu" v-show="showMenu" class="absolute z-60 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
|
||||
<template v-for="item in itemsToShow">
|
||||
<li :key="item" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent>
|
||||
<li :key="item" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" :class="itemsToShow[selectedMenuItemIndex] === item ? 'text-yellow-300' : ''" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent>
|
||||
<div class="flex items-center">
|
||||
<span class="font-normal ml-3 block truncate">{{ item }}</span>
|
||||
</div>
|
||||
@@ -54,7 +54,7 @@ export default {
|
||||
menuDisabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -62,7 +62,9 @@ export default {
|
||||
currentSearch: null,
|
||||
typingTimeout: null,
|
||||
isFocused: false,
|
||||
menu: null
|
||||
menu: null,
|
||||
filteredItems: null,
|
||||
selectedMenuItemIndex: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -91,24 +93,63 @@ export default {
|
||||
return classes.join(' ')
|
||||
},
|
||||
itemsToShow() {
|
||||
if (!this.currentSearch || !this.textInput) {
|
||||
if (!this.currentSearch || !this.textInput || !this.filteredItems) {
|
||||
return this.items
|
||||
}
|
||||
|
||||
return this.items.filter((i) => {
|
||||
var iValue = String(i).toLowerCase()
|
||||
return iValue.includes(this.currentSearch.toLowerCase())
|
||||
})
|
||||
return this.filteredItems
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
editItem(item) {
|
||||
this.$emit('edit', item)
|
||||
},
|
||||
keydownInput() {
|
||||
search() {
|
||||
if (!this.textInput) {
|
||||
this.filteredItems = null
|
||||
return
|
||||
}
|
||||
this.currentSearch = this.textInput
|
||||
|
||||
const results = this.items.filter((i) => {
|
||||
var iValue = String(i).toLowerCase()
|
||||
return iValue.includes(this.currentSearch.toLowerCase())
|
||||
})
|
||||
|
||||
this.filteredItems = results || []
|
||||
},
|
||||
keydownInput(event) {
|
||||
let items = this.itemsToShow
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
if (!items.length) return
|
||||
if (event.key === 'ArrowDown') {
|
||||
if (this.selectedMenuItemIndex === null) {
|
||||
this.selectedMenuItemIndex = 0
|
||||
} else {
|
||||
this.selectedMenuItemIndex = Math.min(this.selectedMenuItemIndex + 1, items.length - 1)
|
||||
}
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
if (this.selectedMenuItemIndex === null) {
|
||||
this.selectedMenuItemIndex = items.length - 1
|
||||
} else {
|
||||
this.selectedMenuItemIndex = Math.max(this.selectedMenuItemIndex - 1, 0)
|
||||
}
|
||||
}
|
||||
this.recalcScroll()
|
||||
return
|
||||
} else if (event.key === 'Enter') {
|
||||
if (this.selectedMenuItemIndex !== null) {
|
||||
this.clickedOption(event, items[this.selectedMenuItemIndex])
|
||||
} else {
|
||||
this.submitForm()
|
||||
}
|
||||
return
|
||||
}
|
||||
this.selectedMenuItemIndex = null
|
||||
clearTimeout(this.typingTimeout)
|
||||
this.typingTimeout = setTimeout(() => {
|
||||
this.currentSearch = this.textInput
|
||||
this.search()
|
||||
}, 100)
|
||||
this.setInputWidth()
|
||||
},
|
||||
@@ -120,6 +161,24 @@ export default {
|
||||
this.recalcMenuPos()
|
||||
}, 50)
|
||||
},
|
||||
recalcScroll() {
|
||||
if (!this.menu) return
|
||||
var menuItems = this.menu.querySelectorAll('li')
|
||||
if (!menuItems.length) return
|
||||
var selectedItem = menuItems[this.selectedMenuItemIndex]
|
||||
if (!selectedItem) return
|
||||
var menuHeight = this.menu.offsetHeight
|
||||
var itemHeight = selectedItem.offsetHeight
|
||||
var itemTop = selectedItem.offsetTop
|
||||
var itemBottom = itemTop + itemHeight
|
||||
if (itemBottom > this.menu.scrollTop + menuHeight) {
|
||||
let menuPaddingBottom = parseFloat(window.getComputedStyle(this.menu).paddingBottom)
|
||||
this.menu.scrollTop = itemBottom - menuHeight + menuPaddingBottom
|
||||
} else if (itemTop < this.menu.scrollTop) {
|
||||
let menuPaddingTop = parseFloat(window.getComputedStyle(this.menu).paddingTop)
|
||||
this.menu.scrollTop = itemTop - menuPaddingTop
|
||||
}
|
||||
},
|
||||
recalcMenuPos() {
|
||||
if (!this.menu || !this.$refs.inputWrapper) return
|
||||
var boundingBox = this.$refs.inputWrapper.getBoundingClientRect()
|
||||
@@ -208,7 +267,10 @@ export default {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
}
|
||||
if (this.$refs.input) this.$refs.input.focus()
|
||||
if (this.$refs.input) {
|
||||
this.$refs.input.style.width = '24px'
|
||||
this.$refs.input.focus()
|
||||
}
|
||||
|
||||
var newSelected = null
|
||||
if (this.selected.includes(itemValue)) {
|
||||
@@ -219,6 +281,7 @@ export default {
|
||||
}
|
||||
this.textInput = null
|
||||
this.currentSearch = null
|
||||
this.selectedMenuItemIndex = null
|
||||
this.$emit('input', newSelected)
|
||||
this.$nextTick(() => {
|
||||
this.recalcMenuPos()
|
||||
@@ -245,6 +308,7 @@ export default {
|
||||
this.$emit('newItem', item)
|
||||
this.textInput = null
|
||||
this.currentSearch = null
|
||||
this.selectedMenuItemIndex = null
|
||||
this.$nextTick(() => {
|
||||
this.blur()
|
||||
})
|
||||
@@ -261,6 +325,7 @@ export default {
|
||||
} else {
|
||||
this.insertNewItem(this.textInput)
|
||||
}
|
||||
if (this.$refs.input) this.$refs.input.style.width = '24px'
|
||||
},
|
||||
scroll() {
|
||||
this.recalcMenuPos()
|
||||
|
||||
@@ -14,13 +14,13 @@
|
||||
<div v-if="showEdit && !disabled" class="rounded-full cursor-pointer w-6 h-6 mx-0.5 bg-bg flex items-center justify-center">
|
||||
<span class="material-icons text-white hover:text-success pt-px pr-px" style="font-size: 1.1rem" @click.stop="addItem">add</span>
|
||||
</div>
|
||||
<input v-show="!readonly" ref="input" v-model="textInput" :disabled="disabled" style="min-width: 40px; width: 40px" class="h-full bg-primary focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" @paste="inputPaste" />
|
||||
<input v-show="!readonly" ref="input" v-model="textInput" :disabled="disabled" class="h-full bg-primary focus:outline-none px-1 w-6" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" @paste="inputPaste" />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<ul ref="menu" v-show="showMenu" class="absolute z-60 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
|
||||
<template v-for="item in itemsToShow">
|
||||
<li :key="item.id" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent>
|
||||
<li :key="item.id" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" :class="itemsToShow[selectedMenuItemIndex] === item ? 'text-yellow-300' : ''" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent>
|
||||
<div class="flex items-center">
|
||||
<span class="font-normal ml-3 block truncate">{{ item.name }}</span>
|
||||
</div>
|
||||
@@ -63,7 +63,8 @@ export default {
|
||||
typingTimeout: null,
|
||||
isFocused: false,
|
||||
menu: null,
|
||||
items: []
|
||||
items: [],
|
||||
selectedMenuItemIndex: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -122,7 +123,35 @@ export default {
|
||||
|
||||
this.items = results || []
|
||||
},
|
||||
keydownInput() {
|
||||
keydownInput(event) {
|
||||
let items = this.itemsToShow
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
if (!items.length) return
|
||||
if (event.key === 'ArrowDown') {
|
||||
if (this.selectedMenuItemIndex === null) {
|
||||
this.selectedMenuItemIndex = 0
|
||||
} else {
|
||||
this.selectedMenuItemIndex = Math.min(this.selectedMenuItemIndex + 1, items.length - 1)
|
||||
}
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
if (this.selectedMenuItemIndex === null) {
|
||||
this.selectedMenuItemIndex = items.length - 1
|
||||
} else {
|
||||
this.selectedMenuItemIndex = Math.max(this.selectedMenuItemIndex - 1, 0)
|
||||
}
|
||||
}
|
||||
this.recalcScroll()
|
||||
return
|
||||
} else if (event.key === 'Enter') {
|
||||
if (this.selectedMenuItemIndex !== null) {
|
||||
this.clickedOption(event, items[this.selectedMenuItemIndex])
|
||||
} else {
|
||||
this.submitForm()
|
||||
}
|
||||
return
|
||||
}
|
||||
this.selectedMenuItemIndex = null
|
||||
clearTimeout(this.typingTimeout)
|
||||
this.typingTimeout = setTimeout(() => {
|
||||
this.search()
|
||||
@@ -137,6 +166,24 @@ export default {
|
||||
this.recalcMenuPos()
|
||||
}, 50)
|
||||
},
|
||||
recalcScroll() {
|
||||
if (!this.menu) return
|
||||
var menuItems = this.menu.querySelectorAll('li')
|
||||
if (!menuItems.length) return
|
||||
var selectedItem = menuItems[this.selectedMenuItemIndex]
|
||||
if (!selectedItem) return
|
||||
var menuHeight = this.menu.offsetHeight
|
||||
var itemHeight = selectedItem.offsetHeight
|
||||
var itemTop = selectedItem.offsetTop
|
||||
var itemBottom = itemTop + itemHeight
|
||||
if (itemBottom > this.menu.scrollTop + menuHeight) {
|
||||
let menuPaddingBottom = parseFloat(window.getComputedStyle(this.menu).paddingBottom)
|
||||
this.menu.scrollTop = itemBottom - menuHeight + menuPaddingBottom
|
||||
} else if (itemTop < this.menu.scrollTop) {
|
||||
let menuPaddingTop = parseFloat(window.getComputedStyle(this.menu).paddingTop)
|
||||
this.menu.scrollTop = itemTop - menuPaddingTop
|
||||
}
|
||||
},
|
||||
recalcMenuPos() {
|
||||
if (!this.menu || !this.$refs.inputWrapper) return
|
||||
var boundingBox = this.$refs.inputWrapper.getBoundingClientRect()
|
||||
@@ -228,7 +275,10 @@ export default {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
}
|
||||
if (this.$refs.input) this.$refs.input.focus()
|
||||
if (this.$refs.input) {
|
||||
this.$refs.input.style.width = '24px'
|
||||
this.$refs.input.focus()
|
||||
}
|
||||
|
||||
let newSelected = null
|
||||
if (this.getIsSelected(item.id)) {
|
||||
@@ -244,6 +294,7 @@ export default {
|
||||
}
|
||||
this.textInput = null
|
||||
this.currentSearch = null
|
||||
this.selectedMenuItemIndex = null
|
||||
|
||||
this.$emit('input', newSelected)
|
||||
this.$nextTick(() => {
|
||||
@@ -271,6 +322,7 @@ export default {
|
||||
this.$emit('newItem', item)
|
||||
this.textInput = null
|
||||
this.currentSearch = null
|
||||
this.selectedMenuItemIndex = null
|
||||
this.$nextTick(() => {
|
||||
this.blur()
|
||||
})
|
||||
@@ -291,6 +343,7 @@ export default {
|
||||
name: this.textInput
|
||||
})
|
||||
}
|
||||
if (this.$refs.input) this.$refs.input.style.width = '24px'
|
||||
},
|
||||
scroll() {
|
||||
this.recalcMenuPos()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="default-style">
|
||||
<p v-if="label" class="px-1 text-sm font-semibold" :class="{ 'text-gray-400': disabled }">
|
||||
{{ label }}
|
||||
</p>
|
||||
@@ -29,31 +29,31 @@ export default {
|
||||
config() {
|
||||
return {
|
||||
toolbar: {
|
||||
getDefaultHTML: () => ` <div class="trix-button-row">
|
||||
getDefaultHTML: () => `<div class="trix-button-row">
|
||||
<span class="trix-button-group trix-button-group--text-tools" data-trix-button-group="text-tools">
|
||||
<button type="button" class="trix-button trix-button--icon trix-button--icon-bold" data-trix-attribute="bold" data-trix-key="b" title="#{lang.bold}" tabindex="-1">#{lang.bold}</button>
|
||||
<button type="button" class="trix-button trix-button--icon trix-button--icon-italic" data-trix-attribute="italic" data-trix-key="i" title="#{lang.italic}" tabindex="-1">#{lang.italic}</button>
|
||||
<button type="button" class="trix-button trix-button--icon trix-button--icon-strike" data-trix-attribute="strike" title="#{lang.strike}" tabindex="-1">#{lang.strike}</button>
|
||||
<button type="button" class="trix-button trix-button--icon trix-button--icon-link" data-trix-attribute="href" data-trix-action="link" data-trix-key="k" title="#{lang.link}" tabindex="-1">#{lang.link}</button>
|
||||
<button type="button" class="trix-button trix-button--icon trix-button--icon-bold" data-trix-attribute="bold" data-trix-key="b" title="${this.$strings.LabelFontBold}" tabindex="-1">${this.$strings.LabelFontBold}</button>
|
||||
<button type="button" class="trix-button trix-button--icon trix-button--icon-italic" data-trix-attribute="italic" data-trix-key="i" title="${this.$strings.LabelFontItalic}" tabindex="-1">${this.$strings.LabelFontItalic}</button>
|
||||
<button type="button" class="trix-button trix-button--icon trix-button--icon-strike" data-trix-attribute="strike" title="${this.$strings.LabelFontStrikethrough}" tabindex="-1">${this.$strings.LabelFontStrikethrough}</button>
|
||||
<button type="button" class="trix-button trix-button--icon trix-button--icon-link" data-trix-attribute="href" data-trix-action="link" data-trix-key="k" title="${this.$strings.LabelTextEditorLink}" tabindex="-1">${this.$strings.LabelTextEditorLink}</button>
|
||||
</span>
|
||||
<span class="trix-button-group trix-button-group--block-tools" data-trix-button-group="block-tools">
|
||||
<button type="button" class="trix-button trix-button--icon trix-button--icon-bullet-list" data-trix-attribute="bullet" title="#{lang.bullets}" tabindex="-1">#{lang.bullets}</button>
|
||||
<button type="button" class="trix-button trix-button--icon trix-button--icon-number-list" data-trix-attribute="number" title="#{lang.numbers}" tabindex="-1">#{lang.numbers}</button>
|
||||
<button type="button" class="trix-button trix-button--icon trix-button--icon-bullet-list" data-trix-attribute="bullet" title="${this.$strings.LabelTextEditorBulletedList}" tabindex="-1">${this.$strings.LabelTextEditorBulletedList}</button>
|
||||
<button type="button" class="trix-button trix-button--icon trix-button--icon-number-list" data-trix-attribute="number" title="${this.$strings.LabelTextEditorNumberedList}" tabindex="-1">${this.$strings.LabelTextEditorNumberedList}</button>
|
||||
</span>
|
||||
|
||||
<span class="trix-button-group-spacer"></span>
|
||||
<span class="trix-button-group trix-button-group--history-tools" data-trix-button-group="history-tools">
|
||||
<button type="button" class="trix-button trix-button--icon trix-button--icon-undo" data-trix-action="undo" data-trix-key="z" title="#{lang.undo}" tabindex="-1">#{lang.undo}</button>
|
||||
<button type="button" class="trix-button trix-button--icon trix-button--icon-redo" data-trix-action="redo" data-trix-key="shift+z" title="#{lang.redo}" tabindex="-1">#{lang.redo}</button>
|
||||
<button type="button" class="trix-button trix-button--icon trix-button--icon-undo" data-trix-action="undo" data-trix-key="z" title="${this.$strings.LabelUndo}" tabindex="-1">${this.$strings.LabelUndo}</button>
|
||||
<button type="button" class="trix-button trix-button--icon trix-button--icon-redo" data-trix-action="redo" data-trix-key="shift+z" title="${this.$strings.LabelRedo}" tabindex="-1">${this.$strings.LabelRedo}</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="trix-dialogs" data-trix-dialogs>
|
||||
<div class="trix-dialog trix-dialog--link" data-trix-dialog="href" data-trix-dialog-attribute="href">
|
||||
<div class="trix-dialog__link-fields">
|
||||
<input type="url" name="href" class="trix-input trix-input--dialog" placeholder="#{lang.urlPlaceholder}" aria-label="#{lang.url}" required data-trix-input>
|
||||
<input type="url" name="href" class="trix-input trix-input--dialog" placeholder="" aria-label="URL" required data-trix-input>
|
||||
<div class="trix-button-group">
|
||||
<input type="button" class="trix-button trix-button--dialog" value="#{lang.link}" data-trix-method="setAttribute">
|
||||
<input type="button" class="trix-button trix-button--dialog" value="#{lang.unlink}" data-trix-method="removeAttribute">
|
||||
<input type="button" class="trix-button trix-button--dialog" value="${this.$strings.LabelTextEditorLink}" data-trix-method="setAttribute">
|
||||
<input type="button" class="trix-button trix-button--dialog" value="${this.$strings.LabelTextEditorUnlink}" data-trix-method="removeAttribute">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div ref="wrapper" class="relative">
|
||||
<input :id="inputId" ref="input" v-model="inputValue" :type="actualType" :step="step" :min="min" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" class="rounded bg-primary text-gray-200 focus:border-gray-300 focus:bg-bg focus:outline-none border border-gray-600 h-full w-full" :class="classList" @keyup="keyup" @change="change" @focus="focused" @blur="blurred" />
|
||||
<input :id="inputId" :name="inputName" ref="input" v-model="inputValue" :type="actualType" :step="step" :min="min" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" dir="auto" class="rounded bg-primary text-gray-200 focus:border-gray-300 focus:bg-bg focus:outline-none border border-gray-600 h-full w-full" :class="classList" @keyup="keyup" @change="change" @focus="focused" @blur="blurred" />
|
||||
<div v-if="clearable && inputValue" class="absolute top-0 right-0 h-full px-2 flex items-center justify-center">
|
||||
<span class="material-icons text-gray-300 cursor-pointer" style="font-size: 1.1rem" @click.stop.prevent="clear">close</span>
|
||||
</div>
|
||||
@@ -33,6 +33,7 @@ export default {
|
||||
textCenter: Boolean,
|
||||
clearable: Boolean,
|
||||
inputId: String,
|
||||
inputName: String,
|
||||
step: [String, Number],
|
||||
min: [String, Number]
|
||||
},
|
||||
@@ -117,4 +118,4 @@ input:read-only {
|
||||
input::-webkit-calendar-picker-indicator {
|
||||
filter: invert(1);
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
>{{ label }}<em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em></label
|
||||
>
|
||||
</slot>
|
||||
<ui-text-input :placeholder="label" :inputId="identifier" ref="input" v-model="inputValue" :disabled="disabled" :readonly="readonly" :type="type" class="w-full" :class="inputClass" @blur="inputBlurred" />
|
||||
<ui-text-input :placeholder="placeholder || label" :inputId="identifier" ref="input" v-model="inputValue" :disabled="disabled" :readonly="readonly" :type="type" class="w-full" :class="inputClass" @blur="inputBlurred" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -14,6 +14,7 @@ export default {
|
||||
props: {
|
||||
value: [String, Number],
|
||||
label: String,
|
||||
placeholder: String,
|
||||
note: String,
|
||||
type: {
|
||||
type: String,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<textarea ref="input" v-model="inputValue" :rows="rows" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" class="py-2 px-3 rounded bg-primary text-gray-200 focus:border-gray-500 focus:outline-none" :class="transparent ? '' : 'border border-gray-600'" @change="change" />
|
||||
<textarea ref="input" v-model="inputValue" :rows="rows" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" dir="auto" class="py-2 px-3 rounded bg-primary text-gray-200 focus:border-gray-500 focus:outline-none" :class="transparent ? '' : 'border border-gray-600'" @change="change" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
@@ -15,6 +15,13 @@ export default {
|
||||
type: String,
|
||||
default: 'right'
|
||||
},
|
||||
/**
|
||||
* Delay showing the tooltip after X milliseconds of hovering
|
||||
*/
|
||||
delayOnShow: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
disabled: Boolean
|
||||
},
|
||||
data() {
|
||||
@@ -22,7 +29,8 @@ export default {
|
||||
tooltip: null,
|
||||
tooltipId: null,
|
||||
isShowing: false,
|
||||
hideTimeout: null
|
||||
hideTimeout: null,
|
||||
delayOnShowTimeout: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -59,29 +67,44 @@ export default {
|
||||
this.tooltip = tooltip
|
||||
},
|
||||
setTooltipPosition(tooltip) {
|
||||
var boxChow = this.$refs.box.getBoundingClientRect()
|
||||
const boxRect = this.$refs.box.getBoundingClientRect()
|
||||
|
||||
const shouldMount = !tooltip.isConnected
|
||||
|
||||
var shouldMount = !tooltip.isConnected
|
||||
// Calculate size of tooltip
|
||||
if (shouldMount) document.body.appendChild(tooltip)
|
||||
var { width, height } = tooltip.getBoundingClientRect()
|
||||
const tooltipRect = tooltip.getBoundingClientRect()
|
||||
if (shouldMount) tooltip.remove()
|
||||
|
||||
var top = 0
|
||||
var left = 0
|
||||
// Subtracting scrollbar size
|
||||
const windowHeight = window.innerHeight - 8
|
||||
const windowWidth = window.innerWidth - 8
|
||||
|
||||
let top = 0
|
||||
let left = 0
|
||||
if (this.direction === 'right') {
|
||||
top = boxChow.top - height / 2 + boxChow.height / 2
|
||||
left = boxChow.left + boxChow.width + 4
|
||||
top = Math.max(0, boxRect.top - tooltipRect.height / 2 + boxRect.height / 2)
|
||||
left = Math.max(0, boxRect.left + boxRect.width + 4)
|
||||
} else if (this.direction === 'bottom') {
|
||||
top = boxChow.top + boxChow.height + 4
|
||||
left = boxChow.left - width / 2 + boxChow.width / 2
|
||||
top = Math.max(0, boxRect.top + boxRect.height + 4)
|
||||
left = Math.max(0, boxRect.left - tooltipRect.width / 2 + boxRect.width / 2)
|
||||
} else if (this.direction === 'top') {
|
||||
top = boxChow.top - height - 4
|
||||
left = boxChow.left - width / 2 + boxChow.width / 2
|
||||
top = Math.max(0, boxRect.top - tooltipRect.height - 4)
|
||||
left = Math.max(0, boxRect.left - tooltipRect.width / 2 + boxRect.width / 2)
|
||||
} else if (this.direction === 'left') {
|
||||
top = boxChow.top - height / 2 + boxChow.height / 2
|
||||
left = boxChow.left - width - 4
|
||||
top = Math.max(0, boxRect.top - tooltipRect.height / 2 + boxRect.height / 2)
|
||||
left = Math.max(0, boxRect.left - tooltipRect.width - 4)
|
||||
}
|
||||
|
||||
// Shift left if tooltip would overflow the window on the right
|
||||
if (left + tooltipRect.width > windowWidth) {
|
||||
left -= left + tooltipRect.width - windowWidth
|
||||
}
|
||||
// Shift up if tooltip would overflow the window on the bottom
|
||||
if (top + tooltipRect.height > windowHeight) {
|
||||
top -= top + tooltipRect.height - windowHeight
|
||||
}
|
||||
|
||||
tooltip.style.top = top + 'px'
|
||||
tooltip.style.left = left + 'px'
|
||||
},
|
||||
@@ -107,15 +130,33 @@ export default {
|
||||
this.isShowing = false
|
||||
},
|
||||
cancelHide() {
|
||||
if (this.hideTimeout) clearTimeout(this.hideTimeout)
|
||||
clearTimeout(this.hideTimeout)
|
||||
},
|
||||
mouseover() {
|
||||
if (!this.isShowing) this.showTooltip()
|
||||
if (this.isShowing || this.disabled) return
|
||||
|
||||
if (this.delayOnShow) {
|
||||
if (this.delayOnShowTimeout) {
|
||||
// Delay already running
|
||||
return
|
||||
}
|
||||
|
||||
this.delayOnShowTimeout = setTimeout(() => {
|
||||
this.showTooltip()
|
||||
this.delayOnShowTimeout = null
|
||||
}, this.delayOnShow)
|
||||
} else {
|
||||
this.showTooltip()
|
||||
}
|
||||
},
|
||||
mouseleave() {
|
||||
if (this.isShowing) {
|
||||
this.hideTimeout = setTimeout(this.hideTooltip, 100)
|
||||
if (!this.isShowing) {
|
||||
clearTimeout(this.delayOnShowTimeout)
|
||||
this.delayOnShowTimeout = null
|
||||
return
|
||||
}
|
||||
|
||||
this.hideTimeout = setTimeout(this.hideTooltip, 100)
|
||||
}
|
||||
},
|
||||
beforeDestroy() {
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<div v-if="missingParts.length" class="bg-error border-red-800 shadow-md p-4">
|
||||
<p class="text-sm mb-2">
|
||||
{{ $strings.LabelMissingParts }} <span class="text-sm">({{ missingParts.length }})</span>
|
||||
</p>
|
||||
<p class="text-sm font-mono">{{ missingPartChunks.join(', ') }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="invalidParts.length" class="bg-error border-red-800 shadow-md p-4">
|
||||
<p class="text-sm mb-2">
|
||||
{{ $strings.LabelInvalidParts }} <span class="text-sm">({{ invalidParts.length }})</span>
|
||||
</p>
|
||||
<div>
|
||||
<p v-for="part in invalidParts" :key="part.filename" class="text-sm font-mono">{{ part.filename }}: {{ part.error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<tables-tracks-table :title="$strings.LabelStatsAudioTracks" :tracks="tracksWithAudioFile" :is-file="isFile" :library-item-id="libraryItemId" class="mt-6" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
libraryItemId: String,
|
||||
media: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
isFile: Boolean
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
tracksWithAudioFile() {
|
||||
return this.media.tracks.map((track) => {
|
||||
track.audioFile = this.media.audioFiles.find((af) => af.metadata.path === track.metadata.path)
|
||||
return track
|
||||
})
|
||||
},
|
||||
missingPartChunks() {
|
||||
if (this.missingParts === 1) return this.missingParts[0]
|
||||
var chunks = []
|
||||
|
||||
var currentIndex = this.missingParts[0]
|
||||
var currentChunk = [this.missingParts[0]]
|
||||
|
||||
for (let i = 1; i < this.missingParts.length; i++) {
|
||||
var partIndex = this.missingParts[i]
|
||||
if (currentIndex === partIndex - 1) {
|
||||
currentChunk.push(partIndex)
|
||||
currentIndex = partIndex
|
||||
} else {
|
||||
// console.log('Chunk ended', currentChunk.join(', '), currentIndex, partIndex)
|
||||
if (currentChunk.length === 0) {
|
||||
console.error('How is current chunk 0?', currentChunk.join(', '))
|
||||
}
|
||||
chunks.push(currentChunk)
|
||||
currentChunk = [partIndex]
|
||||
currentIndex = partIndex
|
||||
}
|
||||
}
|
||||
if (currentChunk.length) {
|
||||
chunks.push(currentChunk)
|
||||
}
|
||||
chunks = chunks.map((chunk) => {
|
||||
if (chunk.length === 1) return chunk[0]
|
||||
else return `${chunk[0]}-${chunk[chunk.length - 1]}`
|
||||
})
|
||||
return chunks
|
||||
},
|
||||
missingParts() {
|
||||
return this.media.missingParts || []
|
||||
},
|
||||
invalidParts() {
|
||||
return this.media.invalidParts || []
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -10,10 +10,10 @@
|
||||
<span class="material-icons text-2xl">chevron_right</span>
|
||||
</button>
|
||||
</div>
|
||||
<div ref="slider" class="w-full overflow-y-hidden overflow-x-auto no-scroll -mx-2" style="scroll-behavior: smooth" @scroll="scrolled">
|
||||
<div class="flex" :style="{ height: height + 'px' }">
|
||||
<div ref="slider" class="w-full overflow-y-hidden overflow-x-auto no-scroll" style="scroll-behavior: smooth" @scroll="scrolled">
|
||||
<div class="flex space-x-4" :style="{ height: height + 'px' }">
|
||||
<template v-for="(item, index) in items">
|
||||
<cards-author-card :key="item.id" :ref="`slider-item-${item.id}`" :index="index" :author="item" :height="cardHeight" :width="cardWidth" class="relative mx-2" @edit="editAuthor" @hook:updated="setScrollVars" />
|
||||
<cards-author-card :key="item.id" :ref="`slider-item-${item.id}`" :index="index" :author="item" :height="cardHeight" :width="cardWidth" class="relative" @edit="editAuthor" @hook:updated="setScrollVars" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
<span class="material-icons text-2xl">chevron_right</span>
|
||||
</button>
|
||||
</div>
|
||||
<div ref="slider" class="w-full overflow-y-hidden overflow-x-auto no-scroll -mx-2" style="scroll-behavior: smooth" @scroll="scrolled">
|
||||
<div class="flex" :style="{ height: height + 'px' }">
|
||||
<div ref="slider" class="w-full overflow-y-hidden overflow-x-auto no-scroll" style="scroll-behavior: smooth" @scroll="scrolled">
|
||||
<div class="flex space-x-4" :style="{ height: height + 'px' }">
|
||||
<template v-for="(item, index) in items">
|
||||
<cards-lazy-book-card
|
||||
:key="item.recentEpisode.id"
|
||||
@@ -23,7 +23,7 @@
|
||||
:book-cover-aspect-ratio="bookCoverAspectRatio"
|
||||
:bookshelf-view="bookshelfView"
|
||||
:continue-listening-shelf="continueListeningShelf"
|
||||
class="relative mx-2"
|
||||
class="relative"
|
||||
@edit="editEpisode"
|
||||
@editPodcast="editPodcast"
|
||||
@select="selectItem"
|
||||
|
||||
@@ -10,10 +10,24 @@
|
||||
<span class="material-icons text-2xl">chevron_right</span>
|
||||
</button>
|
||||
</div>
|
||||
<div ref="slider" class="w-full overflow-y-hidden overflow-x-auto no-scroll -mx-2" style="scroll-behavior: smooth" @scroll="scrolled">
|
||||
<div class="flex" :style="{ height: height + 'px' }">
|
||||
<div ref="slider" class="w-full overflow-y-hidden overflow-x-auto no-scroll" style="scroll-behavior: smooth" @scroll="scrolled">
|
||||
<div class="flex space-x-4" :style="{ height: height + 'px' }">
|
||||
<template v-for="(item, index) in items">
|
||||
<cards-lazy-book-card :key="item.id + '-' + shelfId" :ref="`slider-item-${item.id}`" :index="index" :book-mount="item" :height="cardHeight" :width="cardWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" :bookshelf-view="bookshelfView" :continue-listening-shelf="continueListeningShelf" class="relative mx-2" @edit="editItem" @select="selectItem" @hook:updated="setScrollVars" />
|
||||
<cards-lazy-book-card
|
||||
:key="item.id + '-' + shelfId + '-' + index"
|
||||
:ref="`slider-item-${item.id}`"
|
||||
:index="index"
|
||||
:book-mount="item"
|
||||
:height="cardHeight"
|
||||
:width="cardWidth"
|
||||
:book-cover-aspect-ratio="bookCoverAspectRatio"
|
||||
:bookshelf-view="bookshelfView"
|
||||
:continue-listening-shelf="continueListeningShelf"
|
||||
class="relative"
|
||||
@edit="editItem"
|
||||
@select="selectItem"
|
||||
@hook:updated="setScrollVars"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
<span class="material-icons text-2xl">chevron_right</span>
|
||||
</button>
|
||||
</div>
|
||||
<div ref="slider" class="w-full overflow-y-hidden overflow-x-auto no-scroll -mx-2" style="scroll-behavior: smooth" @scroll="scrolled">
|
||||
<div class="flex" :style="{ height: height + 'px' }">
|
||||
<div ref="slider" class="w-full overflow-y-hidden overflow-x-auto no-scroll" style="scroll-behavior: smooth" @scroll="scrolled">
|
||||
<div class="flex space-x-4" :style="{ height: height + 'px' }">
|
||||
<template v-for="item in items">
|
||||
<cards-narrator-card :key="item.name" :ref="`slider-item-${item.name}`" :narrator="item" :height="cardHeight" :width="cardWidth" class="relative mx-2" @hook:updated="setScrollVars" />
|
||||
<cards-narrator-card :key="item.name" :ref="`slider-item-${item.name}`" :narrator="item" :height="cardHeight" :width="cardWidth" class="relative" @hook:updated="setScrollVars" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
<span class="material-icons text-2xl">chevron_right</span>
|
||||
</button>
|
||||
</div>
|
||||
<div ref="slider" class="w-full overflow-y-hidden overflow-x-auto no-scroll -mx-2" style="scroll-behavior: smooth" @scroll="scrolled">
|
||||
<div class="flex" :style="{ height: height + 'px' }">
|
||||
<div ref="slider" class="w-full overflow-y-hidden overflow-x-auto no-scroll" style="scroll-behavior: smooth" @scroll="scrolled">
|
||||
<div class="flex space-x-4" :style="{ height: height + 'px' }">
|
||||
<template v-for="(item, index) in items">
|
||||
<cards-lazy-series-card :key="item.id" :ref="`slider-item-${item.id}`" :index="index" :series-mount="item" :height="cardHeight" :width="cardWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" :bookshelf-view="$constants.BookshelfView.DETAIL" class="relative mx-2" @hook:updated="setScrollVars" />
|
||||
<cards-lazy-series-card :key="item.id" :ref="`slider-item-${item.id}`" :index="index" :series-mount="item" :height="cardHeight" :width="cardWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" :bookshelf-view="$constants.BookshelfView.DETAIL" class="relative" @hook:updated="setScrollVars" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<Nuxt :key="currentLang" />
|
||||
</div>
|
||||
|
||||
<app-stream-container ref="streamContainer" />
|
||||
<app-media-player-container ref="mediaPlayerContainer" />
|
||||
|
||||
<modals-item-edit-modal />
|
||||
<modals-collections-add-create-modal />
|
||||
@@ -129,23 +129,23 @@ export default {
|
||||
this.$eventBus.$emit('socket_init')
|
||||
},
|
||||
streamOpen(stream) {
|
||||
if (this.$refs.streamContainer) this.$refs.streamContainer.streamOpen(stream)
|
||||
if (this.$refs.mediaPlayerContainer) this.$refs.mediaPlayerContainer.streamOpen(stream)
|
||||
},
|
||||
streamClosed(streamId) {
|
||||
if (this.$refs.streamContainer) this.$refs.streamContainer.streamClosed(streamId)
|
||||
if (this.$refs.mediaPlayerContainer) this.$refs.mediaPlayerContainer.streamClosed(streamId)
|
||||
},
|
||||
streamProgress(data) {
|
||||
if (this.$refs.streamContainer) this.$refs.streamContainer.streamProgress(data)
|
||||
if (this.$refs.mediaPlayerContainer) this.$refs.mediaPlayerContainer.streamProgress(data)
|
||||
},
|
||||
streamReady() {
|
||||
if (this.$refs.streamContainer) this.$refs.streamContainer.streamReady()
|
||||
if (this.$refs.mediaPlayerContainer) this.$refs.mediaPlayerContainer.streamReady()
|
||||
},
|
||||
streamReset(payload) {
|
||||
if (this.$refs.streamContainer) this.$refs.streamContainer.streamReset(payload)
|
||||
if (this.$refs.mediaPlayerContainer) this.$refs.mediaPlayerContainer.streamReset(payload)
|
||||
},
|
||||
streamError({ id, errorMessage }) {
|
||||
this.$toast.error(`Stream Failed: ${errorMessage}`)
|
||||
if (this.$refs.streamContainer) this.$refs.streamContainer.streamError(id)
|
||||
if (this.$refs.mediaPlayerContainer) this.$refs.mediaPlayerContainer.streamError(id)
|
||||
},
|
||||
libraryAdded(library) {
|
||||
this.$store.commit('libraries/addUpdate', library)
|
||||
@@ -247,7 +247,7 @@ export default {
|
||||
this.multiSessionCurrentSessionId = null
|
||||
this.$toast.dismiss('multiple-sessions')
|
||||
}
|
||||
if (this.$refs.streamContainer) this.$refs.streamContainer.sessionClosedEvent(sessionId)
|
||||
if (this.$refs.mediaPlayerContainer) this.$refs.mediaPlayerContainer.sessionClosedEvent(sessionId)
|
||||
},
|
||||
userMediaProgressUpdate(payload) {
|
||||
this.$store.commit('user/updateMediaProgress', payload)
|
||||
@@ -328,6 +328,14 @@ export default {
|
||||
|
||||
this.$store.commit('libraries/setEReaderDevices', data.ereaderDevices)
|
||||
},
|
||||
customMetadataProviderAdded(provider) {
|
||||
if (!provider?.id) return
|
||||
this.$store.commit('scanners/addCustomMetadataProvider', provider)
|
||||
},
|
||||
customMetadataProviderRemoved(provider) {
|
||||
if (!provider?.id) return
|
||||
this.$store.commit('scanners/removeCustomMetadataProvider', provider)
|
||||
},
|
||||
initializeSocket() {
|
||||
this.socket = this.$nuxtSocket({
|
||||
name: process.env.NODE_ENV === 'development' ? 'dev' : 'prod',
|
||||
@@ -406,6 +414,10 @@ export default {
|
||||
this.socket.on('batch_quickmatch_complete', this.batchQuickMatchComplete)
|
||||
|
||||
this.socket.on('admin_message', this.adminMessageEvt)
|
||||
|
||||
// Custom metadata provider Listeners
|
||||
this.socket.on('custom_metadata_provider_added', this.customMetadataProviderAdded)
|
||||
this.socket.on('custom_metadata_provider_removed', this.customMetadataProviderRemoved)
|
||||
},
|
||||
showUpdateToast(versionData) {
|
||||
var ignoreVersion = localStorage.getItem('ignoreVersion')
|
||||
|
||||
@@ -25,11 +25,13 @@ module.exports = {
|
||||
meta: [
|
||||
{ charset: 'utf-8' },
|
||||
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
|
||||
{ hid: 'description', name: 'description', content: '' }
|
||||
{ hid: 'description', name: 'description', content: '' },
|
||||
{ hid: 'robots', name: 'robots', content: 'noindex' }
|
||||
],
|
||||
script: [],
|
||||
link: [
|
||||
{ rel: 'icon', type: 'image/x-icon', href: (process.env.ROUTER_BASE_PATH || '') + '/favicon.ico' }
|
||||
{ rel: 'icon', type: 'image/x-icon', href: (process.env.ROUTER_BASE_PATH || '') + '/favicon.ico' },
|
||||
{ rel: 'apple-touch-icon', href: (process.env.ROUTER_BASE_PATH || '') + '/ios_icon.png' }
|
||||
]
|
||||
},
|
||||
|
||||
@@ -39,6 +41,7 @@ module.exports = {
|
||||
|
||||
// Global CSS: https://go.nuxtjs.dev/config-css
|
||||
css: [
|
||||
'@/assets/tailwind.css',
|
||||
'@/assets/app.css'
|
||||
],
|
||||
|
||||
@@ -58,9 +61,7 @@ module.exports = {
|
||||
// Modules for dev and build (recommended): https://go.nuxtjs.dev/config-modules
|
||||
buildModules: [
|
||||
// https://go.nuxtjs.dev/tailwindcss
|
||||
'@nuxtjs/tailwindcss',
|
||||
'@nuxtjs/pwa',
|
||||
'@nuxt/postcss8'
|
||||
'@nuxtjs/pwa'
|
||||
],
|
||||
|
||||
// Modules: https://go.nuxtjs.dev/config-modules
|
||||
@@ -96,7 +97,7 @@ module.exports = {
|
||||
meta: {
|
||||
appleStatusBarStyle: 'black',
|
||||
name: 'Audiobookshelf',
|
||||
theme_color: '#373838',
|
||||
theme_color: '#232323',
|
||||
mobileAppIOS: true,
|
||||
nativeUI: true
|
||||
},
|
||||
@@ -104,16 +105,16 @@ module.exports = {
|
||||
name: 'Audiobookshelf',
|
||||
short_name: 'Audiobookshelf',
|
||||
display: 'standalone',
|
||||
background_color: '#373838',
|
||||
background_color: '#232323',
|
||||
icons: [
|
||||
{
|
||||
src: (process.env.ROUTER_BASE_PATH || '') + '/icon.svg',
|
||||
sizes: "any"
|
||||
sizes: 'any'
|
||||
},
|
||||
{
|
||||
src: (process.env.ROUTER_BASE_PATH || '') + '/icon64.png',
|
||||
type: "image/png",
|
||||
sizes: "64x64"
|
||||
src: (process.env.ROUTER_BASE_PATH || '') + '/icon192.png',
|
||||
type: 'image/png',
|
||||
sizes: 'any'
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
26967
client/package-lock.json
generated
26967
client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.7.0",
|
||||
"version": "2.9.0",
|
||||
"buildNumber": 1,
|
||||
"description": "Self-hosted audiobook and podcast client",
|
||||
"main": "index.js",
|
||||
@@ -21,9 +21,9 @@
|
||||
"cron-parser": "^4.7.1",
|
||||
"date-fns": "^2.25.0",
|
||||
"epubjs": "^0.3.88",
|
||||
"hls.js": "^1.0.7",
|
||||
"hls.js": "^1.5.7",
|
||||
"libarchive.js": "^1.3.0",
|
||||
"nuxt": "^2.15.8",
|
||||
"nuxt": "^2.17.3",
|
||||
"nuxt-socket-io": "^1.1.18",
|
||||
"trix": "^1.3.1",
|
||||
"v-click-outside": "^3.1.2",
|
||||
@@ -31,11 +31,9 @@
|
||||
"vuedraggable": "^2.24.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nuxt/postcss8": "^1.1.3",
|
||||
"@nuxtjs/pwa": "^3.3.5",
|
||||
"@nuxtjs/tailwindcss": "^4.2.1",
|
||||
"autoprefixer": "^10.4.7",
|
||||
"postcss": "^8.3.6",
|
||||
"tailwindcss": "^3.1.4"
|
||||
"tailwindcss": "^3.4.1"
|
||||
}
|
||||
}
|
||||
@@ -82,19 +82,33 @@ export default {
|
||||
this.$setLanguageCode(lang)
|
||||
},
|
||||
logout() {
|
||||
var rootSocket = this.$root.socket || {}
|
||||
const logoutPayload = {
|
||||
socketId: rootSocket.id
|
||||
// Disconnect from socket
|
||||
if (this.$root.socket) {
|
||||
console.log('Disconnecting from socket', this.$root.socket.id)
|
||||
this.$root.socket.removeAllListeners()
|
||||
this.$root.socket.disconnect()
|
||||
}
|
||||
this.$axios.$post('/logout', logoutPayload).catch((error) => {
|
||||
console.error(error)
|
||||
})
|
||||
|
||||
if (localStorage.getItem('token')) {
|
||||
localStorage.removeItem('token')
|
||||
}
|
||||
this.$store.commit('libraries/setUserPlaylists', [])
|
||||
this.$store.commit('libraries/setCollections', [])
|
||||
this.$router.push('/login')
|
||||
|
||||
this.$axios
|
||||
.$post('/logout')
|
||||
.then((logoutPayload) => {
|
||||
const redirect_url = logoutPayload.redirect_url
|
||||
|
||||
if (redirect_url) {
|
||||
window.location.href = redirect_url
|
||||
} else {
|
||||
this.$router.push('/login')
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error)
|
||||
})
|
||||
},
|
||||
resetForm() {
|
||||
this.password = null
|
||||
|
||||
@@ -142,7 +142,7 @@
|
||||
</template>
|
||||
<div class="w-full h-full max-h-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 relative">
|
||||
<div v-if="!chapterData" class="flex p-20">
|
||||
<ui-text-input-with-label v-model="asinInput" label="ASIN" />
|
||||
<ui-text-input-with-label v-model.trim="asinInput" label="ASIN" />
|
||||
<ui-dropdown v-model="regionInput" :label="$strings.LabelRegion" small :items="audibleRegions" class="w-32 mx-1" />
|
||||
<ui-btn small color="primary" class="mt-5" @click="findChapters">{{ $strings.ButtonSearch }}</ui-btn>
|
||||
</div>
|
||||
@@ -281,7 +281,7 @@ export default {
|
||||
return this.media.audioFiles || []
|
||||
},
|
||||
audioTracks() {
|
||||
return this.audioFiles.filter((af) => !af.exclude && !af.invalid)
|
||||
return this.audioFiles.filter((af) => !af.exclude)
|
||||
},
|
||||
selectedChapterId() {
|
||||
return this.selectedChapter ? this.selectedChapter.id : null
|
||||
|
||||
@@ -137,9 +137,6 @@ export default {
|
||||
})
|
||||
return count
|
||||
},
|
||||
missingParts() {
|
||||
return this.media.missingParts || []
|
||||
},
|
||||
libraryItemId() {
|
||||
return this.libraryItem.id
|
||||
},
|
||||
|
||||
@@ -249,7 +249,7 @@ export default {
|
||||
return this.media.metadata || {}
|
||||
},
|
||||
audioFiles() {
|
||||
return (this.media.audioFiles || []).filter((af) => !af.exclude && !af.invalid)
|
||||
return (this.media.audioFiles || []).filter((af) => !af.exclude)
|
||||
},
|
||||
isSingleM4b() {
|
||||
return this.audioFiles.length === 1 && this.audioFiles[0].metadata.ext.toLowerCase() === '.m4b'
|
||||
|
||||
@@ -17,7 +17,10 @@
|
||||
</div>
|
||||
|
||||
<p v-if="author.description" class="text-white text-opacity-60 uppercase text-xs mb-2">{{ $strings.LabelDescription }}</p>
|
||||
<p class="text-white max-w-3xl text-sm leading-5 whitespace-pre-wrap">{{ author.description }}</p>
|
||||
<p ref="description" id="author-description" class="text-white max-w-3xl text-base whitespace-pre-wrap" :class="{ 'show-full': showFullDescription }">{{ author.description }}</p>
|
||||
<button v-if="isDescriptionClamped" class="py-0.5 flex items-center text-slate-300 hover:text-white" @click="showFullDescription = !showFullDescription">
|
||||
{{ showFullDescription ? 'Read less' : 'Read more' }} <span class="material-icons text-xl pl-1">{{ showFullDescription ? 'expand_less' : 'expand_more' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -62,7 +65,10 @@ export default {
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
return {
|
||||
isDescriptionClamped: false,
|
||||
showFullDescription: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
streamLibraryItem() {
|
||||
@@ -82,6 +88,10 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
checkDescriptionClamped() {
|
||||
if (!this.$refs.description) return
|
||||
this.isDescriptionClamped = this.$refs.description.scrollHeight > this.$refs.description.clientHeight
|
||||
},
|
||||
editAuthor() {
|
||||
this.$store.commit('globals/showEditAuthorModal', this.author)
|
||||
},
|
||||
@@ -93,6 +103,7 @@ export default {
|
||||
series: this.authorSeries,
|
||||
libraryItems: this.libraryItems
|
||||
}
|
||||
this.$nextTick(this.checkDescriptionClamped)
|
||||
}
|
||||
},
|
||||
authorRemoved(author) {
|
||||
@@ -104,6 +115,7 @@ export default {
|
||||
},
|
||||
mounted() {
|
||||
if (!this.author) this.$router.replace('/')
|
||||
this.checkDescriptionClamped()
|
||||
|
||||
this.$root.socket.on('author_updated', this.authorUpdated)
|
||||
this.$root.socket.on('author_removed', this.authorRemoved)
|
||||
@@ -113,4 +125,19 @@ export default {
|
||||
this.$root.socket.off('author_removed', this.authorRemoved)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
#author-description {
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 4;
|
||||
max-height: 6.25rem;
|
||||
transition: all 0.3s ease-in-out;
|
||||
}
|
||||
#author-description.show-full {
|
||||
-webkit-line-clamp: unset;
|
||||
max-height: 999rem;
|
||||
}
|
||||
</style>
|
||||
@@ -20,44 +20,44 @@
|
||||
<div class="overflow-hidden">
|
||||
<transition name="slide">
|
||||
<div v-if="openMapOptions" class="flex flex-wrap">
|
||||
<div v-if="!isPodcastLibrary && !isMapAppend" class="flex items-center px-4 w-1/2">
|
||||
<div v-if="!isPodcastLibrary && !isMapAppend" class="flex items-center px-4 h-18 w-1/2">
|
||||
<ui-checkbox v-model="selectedBatchUsage.subtitle" />
|
||||
<ui-text-input-with-label ref="subtitleInput" v-model="batchDetails.subtitle" :disabled="!selectedBatchUsage.subtitle" :label="$strings.LabelSubtitle" class="mb-4 ml-4" />
|
||||
<ui-text-input-with-label ref="subtitleInput" v-model="batchDetails.subtitle" :disabled="!selectedBatchUsage.subtitle" :label="$strings.LabelSubtitle" class="mb-5 ml-4" />
|
||||
</div>
|
||||
<div v-if="!isPodcastLibrary" class="flex items-center px-4 w-1/2">
|
||||
<div v-if="!isPodcastLibrary" class="flex items-center px-4 h-18 w-1/2">
|
||||
<ui-checkbox v-model="selectedBatchUsage.authors" />
|
||||
<!-- Authors filter only contains authors in this library, uses filter data -->
|
||||
<ui-multi-select-query-input ref="authorsSelect" v-model="batchDetails.authors" :disabled="!selectedBatchUsage.authors" :label="$strings.LabelAuthors" filter-key="authors" class="mb-4 ml-4" />
|
||||
<ui-multi-select-query-input ref="authorsSelect" v-model="batchDetails.authors" :disabled="!selectedBatchUsage.authors" :label="$strings.LabelAuthors" filter-key="authors" class="mb-5 ml-4" />
|
||||
</div>
|
||||
<div v-if="!isPodcastLibrary && !isMapAppend" class="flex items-center px-4 w-1/2">
|
||||
<div v-if="!isPodcastLibrary && !isMapAppend" class="flex items-center px-4 h-18 w-1/2">
|
||||
<ui-checkbox v-model="selectedBatchUsage.publishedYear" />
|
||||
<ui-text-input-with-label ref="publishedYearInput" v-model="batchDetails.publishedYear" :disabled="!selectedBatchUsage.publishedYear" :label="$strings.LabelPublishYear" class="mb-4 ml-4" />
|
||||
<ui-text-input-with-label ref="publishedYearInput" v-model="batchDetails.publishedYear" :disabled="!selectedBatchUsage.publishedYear" :label="$strings.LabelPublishYear" class="mb-5 ml-4" />
|
||||
</div>
|
||||
<div v-if="!isPodcastLibrary" class="flex items-center px-4 w-1/2">
|
||||
<div v-if="!isPodcastLibrary" class="flex items-center px-4 h-18 w-1/2">
|
||||
<ui-checkbox v-model="selectedBatchUsage.series" />
|
||||
<ui-multi-select ref="seriesSelect" v-model="batchDetails.series" :disabled="!selectedBatchUsage.series" :label="$strings.LabelSeries" :items="existingSeriesNames" @newItem="newSeriesItem" @removedItem="removedSeriesItem" class="mb-4 ml-4" />
|
||||
<ui-multi-select ref="seriesSelect" v-model="batchDetails.series" :disabled="!selectedBatchUsage.series" :label="$strings.LabelSeries" :items="existingSeriesNames" @newItem="newSeriesItem" @removedItem="removedSeriesItem" class="mb-5 ml-4" />
|
||||
</div>
|
||||
<div class="flex items-center px-4 w-1/2">
|
||||
<div class="flex items-center px-4 h-18 w-1/2">
|
||||
<ui-checkbox v-model="selectedBatchUsage.genres" />
|
||||
<ui-multi-select ref="genresSelect" v-model="batchDetails.genres" :disabled="!selectedBatchUsage.genres" :label="$strings.LabelGenres" :items="genreItems" @newItem="newGenreItem" @removedItem="removedGenreItem" class="mb-4 ml-4" />
|
||||
<ui-multi-select ref="genresSelect" v-model="batchDetails.genres" :disabled="!selectedBatchUsage.genres" :label="$strings.LabelGenres" :items="genreItems" @newItem="newGenreItem" @removedItem="removedGenreItem" class="mb-5 ml-4" />
|
||||
</div>
|
||||
<div class="flex items-center px-4 w-1/2">
|
||||
<div class="flex items-center px-4 h-18 w-1/2">
|
||||
<ui-checkbox v-model="selectedBatchUsage.tags" />
|
||||
<ui-multi-select ref="tagsSelect" v-model="batchDetails.tags" :label="$strings.LabelTags" :disabled="!selectedBatchUsage.tags" :items="tagItems" @newItem="newTagItem" @removedItem="removedTagItem" class="mb-4 ml-4" />
|
||||
<ui-multi-select ref="tagsSelect" v-model="batchDetails.tags" :label="$strings.LabelTags" :disabled="!selectedBatchUsage.tags" :items="tagItems" @newItem="newTagItem" @removedItem="removedTagItem" class="mb-5 ml-4" />
|
||||
</div>
|
||||
<div v-if="!isPodcastLibrary" class="flex items-center px-4 w-1/2">
|
||||
<div v-if="!isPodcastLibrary" class="flex items-center px-4 h-18 w-1/2">
|
||||
<ui-checkbox v-model="selectedBatchUsage.narrators" />
|
||||
<ui-multi-select ref="narratorsSelect" v-model="batchDetails.narrators" :disabled="!selectedBatchUsage.narrators" :label="$strings.LabelNarrators" :items="narratorItems" @newItem="newNarratorItem" @removedItem="removedNarratorItem" class="mb-4 ml-4" />
|
||||
<ui-multi-select ref="narratorsSelect" v-model="batchDetails.narrators" :disabled="!selectedBatchUsage.narrators" :label="$strings.LabelNarrators" :items="narratorItems" @newItem="newNarratorItem" @removedItem="removedNarratorItem" class="mb-5 ml-4" />
|
||||
</div>
|
||||
<div v-if="!isPodcastLibrary && !isMapAppend" class="flex items-center px-4 w-1/2">
|
||||
<div v-if="!isPodcastLibrary && !isMapAppend" class="flex items-center px-4 h-18 w-1/2">
|
||||
<ui-checkbox v-model="selectedBatchUsage.publisher" />
|
||||
<ui-text-input-with-label ref="publisherInput" v-model="batchDetails.publisher" :disabled="!selectedBatchUsage.publisher" :label="$strings.LabelPublisher" class="mb-4 ml-4" />
|
||||
<ui-text-input-with-label ref="publisherInput" v-model="batchDetails.publisher" :disabled="!selectedBatchUsage.publisher" :label="$strings.LabelPublisher" class="mb-5 ml-4" />
|
||||
</div>
|
||||
<div v-if="!isMapAppend" class="flex items-center px-4 w-1/2">
|
||||
<div v-if="!isMapAppend" class="flex items-center px-4 h-18 w-1/2">
|
||||
<ui-checkbox v-model="selectedBatchUsage.language" />
|
||||
<ui-text-input-with-label ref="languageInput" v-model="batchDetails.language" :disabled="!selectedBatchUsage.language" :label="$strings.LabelLanguage" class="mb-4 ml-4" />
|
||||
<ui-text-input-with-label ref="languageInput" v-model="batchDetails.language" :disabled="!selectedBatchUsage.language" :label="$strings.LabelLanguage" class="mb-5 ml-4" />
|
||||
</div>
|
||||
<div v-if="!isMapAppend" class="flex items-center px-4 w-1/2">
|
||||
<div v-if="!isMapAppend" class="flex items-center px-4 h-18 w-1/2">
|
||||
<ui-checkbox v-model="selectedBatchUsage.explicit" />
|
||||
<div class="ml-4">
|
||||
<ui-checkbox
|
||||
@@ -71,6 +71,20 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!isPodcastLibrary && !isMapAppend" class="flex items-center px-4 h-18 w-1/2">
|
||||
<ui-checkbox v-model="selectedBatchUsage.abridged" />
|
||||
<div class="ml-4">
|
||||
<ui-checkbox
|
||||
v-model="batchDetails.abridged"
|
||||
:label="$strings.LabelAbridged"
|
||||
:disabled="!selectedBatchUsage.abridged"
|
||||
:checkbox-bg="!selectedBatchUsage.abridged ? 'bg' : 'primary'"
|
||||
:check-color="!selectedBatchUsage.abridged ? 'gray-600' : 'green-500'"
|
||||
border-color="gray-600"
|
||||
:label-class="!selectedBatchUsage.abridged ? 'pl-2 text-base text-gray-400 font-semibold' : 'pl-2 text-base font-semibold'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full flex items-center justify-end p-4">
|
||||
<ui-btn color="success" :disabled="!hasSelectedBatchUsage" :padding-x="8" small class="text-base" :loading="isProcessing" @click="mapBatchDetails">{{ $strings.ButtonApply }}</ui-btn>
|
||||
@@ -139,7 +153,8 @@ export default {
|
||||
narrators: [],
|
||||
publisher: null,
|
||||
language: null,
|
||||
explicit: false
|
||||
explicit: false,
|
||||
abridged: false
|
||||
},
|
||||
selectedBatchUsage: {
|
||||
subtitle: false,
|
||||
@@ -151,7 +166,8 @@ export default {
|
||||
narrators: false,
|
||||
publisher: false,
|
||||
language: false,
|
||||
explicit: false
|
||||
explicit: false,
|
||||
abridged: false
|
||||
},
|
||||
appendableKeys: ['authors', 'genres', 'tags', 'narrators', 'series'],
|
||||
openMapOptions: false
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
<template>
|
||||
<div id="authentication-settings">
|
||||
<app-settings-content :header-text="$strings.HeaderAuthentication">
|
||||
<div class="w-full border border-white/10 rounded-xl p-4 my-4 bg-primary/25">
|
||||
<div class="flex items-center">
|
||||
<ui-checkbox v-model="showCustomLoginMessage" checkbox-bg="bg" />
|
||||
<p class="text-lg pl-4">Custom Message on Login</p>
|
||||
</div>
|
||||
<transition name="slide">
|
||||
<div v-if="showCustomLoginMessage" class="w-full pt-4">
|
||||
<ui-rich-text-editor v-model="newAuthSettings.authLoginCustomMessage" />
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
|
||||
<div class="w-full border border-white/10 rounded-xl p-4 my-4 bg-primary/25">
|
||||
<div class="flex items-center">
|
||||
<ui-checkbox v-model="enableLocalAuth" checkbox-bg="bg" />
|
||||
@@ -46,29 +58,53 @@
|
||||
|
||||
<ui-text-input-with-label ref="openidClientSecret" v-model="newAuthSettings.authOpenIDClientSecret" :disabled="savingSettings" :label="'Client Secret'" class="mb-2" />
|
||||
|
||||
<ui-dropdown v-if="openIdSigningAlgorithmsSupportedByIssuer.length" v-model="newAuthSettings.authOpenIDTokenSigningAlgorithm" :items="openIdSigningAlgorithmsSupportedByIssuer" :label="'Signing Algorithm'" :disabled="savingSettings" class="mb-2" />
|
||||
<ui-text-input-with-label v-else ref="openidTokenSigningAlgorithm" v-model="newAuthSettings.authOpenIDTokenSigningAlgorithm" :disabled="savingSettings" :label="'Signing Algorithm'" class="mb-2" />
|
||||
|
||||
<ui-multi-select ref="redirectUris" v-model="newAuthSettings.authOpenIDMobileRedirectURIs" :items="newAuthSettings.authOpenIDMobileRedirectURIs" :label="$strings.LabelMobileRedirectURIs" class="mb-2" :menuDisabled="true" :disabled="savingSettings" />
|
||||
<p class="pl-4 text-sm text-gray-300 mb-2" v-html="$strings.LabelMobileRedirectURIsDescription" />
|
||||
<p class="sm:pl-4 text-sm text-gray-300 mb-2" v-html="$strings.LabelMobileRedirectURIsDescription" />
|
||||
|
||||
<ui-text-input-with-label ref="buttonTextInput" v-model="newAuthSettings.authOpenIDButtonText" :disabled="savingSettings" :label="$strings.LabelButtonText" class="mb-2" />
|
||||
|
||||
<div class="flex items-center pt-1 mb-2">
|
||||
<div class="flex sm:items-center flex-col sm:flex-row pt-1 mb-2">
|
||||
<div class="w-44">
|
||||
<ui-dropdown v-model="newAuthSettings.authOpenIDMatchExistingBy" small :items="matchingExistingOptions" :label="$strings.LabelMatchExistingUsersBy" :disabled="savingSettings" />
|
||||
</div>
|
||||
<p class="pl-4 text-sm text-gray-300 mt-5">{{ $strings.LabelMatchExistingUsersByDescription }}</p>
|
||||
<p class="sm:pl-4 text-sm text-gray-300 mt-2 sm:mt-5">{{ $strings.LabelMatchExistingUsersByDescription }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-4 px-1">
|
||||
<div class="flex items-center py-4 px-1 w-full">
|
||||
<ui-toggle-switch labeledBy="auto-redirect-toggle" v-model="newAuthSettings.authOpenIDAutoLaunch" :disabled="savingSettings" />
|
||||
<p id="auto-redirect-toggle" class="pl-4 whitespace-nowrap">{{ $strings.LabelAutoLaunch }}</p>
|
||||
<p class="pl-4 text-sm text-gray-300" v-html="$strings.LabelAutoLaunchDescription" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-4 px-1">
|
||||
<div class="flex items-center py-4 px-1 w-full">
|
||||
<ui-toggle-switch labeledBy="auto-register-toggle" v-model="newAuthSettings.authOpenIDAutoRegister" :disabled="savingSettings" />
|
||||
<p id="auto-register-toggle" class="pl-4 whitespace-nowrap">{{ $strings.LabelAutoRegister }}</p>
|
||||
<p class="pl-4 text-sm text-gray-300">{{ $strings.LabelAutoRegisterDescription }}</p>
|
||||
</div>
|
||||
|
||||
<p class="pt-6 mb-4 px-1">{{ $strings.LabelOpenIDClaims }}</p>
|
||||
|
||||
<div class="flex flex-col sm:flex-row mb-4">
|
||||
<div class="w-44 min-w-44">
|
||||
<ui-text-input-with-label ref="openidGroupClaim" v-model="newAuthSettings.authOpenIDGroupClaim" :disabled="savingSettings" :placeholder="'groups'" :label="'Group Claim'" />
|
||||
</div>
|
||||
<p class="sm:pl-4 pt-2 sm:pt-0 text-sm text-gray-300" v-html="$strings.LabelOpenIDGroupClaimDescription"></p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col sm:flex-row mb-4">
|
||||
<div class="w-44 min-w-44">
|
||||
<ui-text-input-with-label ref="openidAdvancedPermsClaim" v-model="newAuthSettings.authOpenIDAdvancedPermsClaim" :disabled="savingSettings" :placeholder="'abspermissions'" :label="'Advanced Permission Claim'" />
|
||||
</div>
|
||||
<div class="sm:pl-4 pt-2 sm:pt-0 text-sm text-gray-300">
|
||||
<p v-html="$strings.LabelOpenIDAdvancedPermsClaimDescription"></p>
|
||||
<pre class="text-pre-wrap mt-2"
|
||||
>{{ newAuthSettings.authOpenIDSamplePermissions }}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
@@ -103,7 +139,9 @@ export default {
|
||||
return {
|
||||
enableLocalAuth: false,
|
||||
enableOpenIDAuth: false,
|
||||
showCustomLoginMessage: false,
|
||||
savingSettings: false,
|
||||
openIdSigningAlgorithmsSupportedByIssuer: [],
|
||||
newAuthSettings: {}
|
||||
}
|
||||
},
|
||||
@@ -144,6 +182,22 @@ export default {
|
||||
this.newAuthSettings.authOpenIDIssuerURL = this.newAuthSettings.authOpenIDIssuerURL.replace('/.well-known/openid-configuration', '')
|
||||
}
|
||||
|
||||
const setSupportedSigningAlgorithms = (algorithms) => {
|
||||
if (!algorithms?.length || !Array.isArray(algorithms)) {
|
||||
console.warn('Invalid id_token_signing_alg_values_supported from openid-configuration', algorithms)
|
||||
this.openIdSigningAlgorithmsSupportedByIssuer = []
|
||||
return
|
||||
}
|
||||
this.openIdSigningAlgorithmsSupportedByIssuer = algorithms
|
||||
|
||||
// If a signing algorithm is already selected, then keep it, when it is still supported.
|
||||
// But if it is not supported, then select one of the supported ones.
|
||||
let currentAlgorithm = this.newAuthSettings.authOpenIDTokenSigningAlgorithm
|
||||
if (!algorithms.includes(currentAlgorithm)) {
|
||||
this.newAuthSettings.authOpenIDTokenSigningAlgorithm = algorithms[0]
|
||||
}
|
||||
}
|
||||
|
||||
this.$axios
|
||||
.$get(`/auth/openid/config?issuer=${issuerUrl}`)
|
||||
.then((data) => {
|
||||
@@ -153,6 +207,7 @@ export default {
|
||||
if (data.userinfo_endpoint) this.newAuthSettings.authOpenIDUserInfoURL = data.userinfo_endpoint
|
||||
if (data.end_session_endpoint) this.newAuthSettings.authOpenIDLogoutURL = data.end_session_endpoint
|
||||
if (data.jwks_uri) this.newAuthSettings.authOpenIDJwksURL = data.jwks_uri
|
||||
if (data.id_token_signing_alg_values_supported) setSupportedSigningAlgorithms(data.id_token_signing_alg_values_supported)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to receive data', error)
|
||||
@@ -190,10 +245,14 @@ export default {
|
||||
this.$toast.error('Client Secret required')
|
||||
isValid = false
|
||||
}
|
||||
if (!this.newAuthSettings.authOpenIDTokenSigningAlgorithm) {
|
||||
this.$toast.error('Signing Algorithm required')
|
||||
isValid = false
|
||||
}
|
||||
|
||||
function isValidRedirectURI(uri) {
|
||||
// Check for somestring://someother/string
|
||||
const pattern = new RegExp('^\\w+://[\\w\\.-]+$', 'i')
|
||||
const pattern = new RegExp('^\\w+://[\\w\\.-]+(/[\\w\\./-]*)*$', 'i')
|
||||
return pattern.test(uri)
|
||||
}
|
||||
|
||||
@@ -209,6 +268,22 @@ export default {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function isValidClaim(claim) {
|
||||
if (claim === '') return true
|
||||
|
||||
const pattern = new RegExp('^[a-zA-Z][a-zA-Z0-9_-]*$', 'i')
|
||||
return pattern.test(claim)
|
||||
}
|
||||
if (!isValidClaim(this.newAuthSettings.authOpenIDGroupClaim)) {
|
||||
this.$toast.error('Group Claim: Invalid claim name')
|
||||
isValid = false
|
||||
}
|
||||
if (!isValidClaim(this.newAuthSettings.authOpenIDAdvancedPermsClaim)) {
|
||||
this.$toast.error('Advanced Permission Claim: Invalid claim name')
|
||||
isValid = false
|
||||
}
|
||||
|
||||
return isValid
|
||||
},
|
||||
async saveSettings() {
|
||||
@@ -221,6 +296,10 @@ export default {
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.showCustomLoginMessage || !this.newAuthSettings.authLoginCustomMessage?.trim()) {
|
||||
this.newAuthSettings.authLoginCustomMessage = null
|
||||
}
|
||||
|
||||
this.newAuthSettings.authActiveAuthMethods = []
|
||||
if (this.enableLocalAuth) this.newAuthSettings.authActiveAuthMethods.push('local')
|
||||
if (this.enableOpenIDAuth) this.newAuthSettings.authActiveAuthMethods.push('openid')
|
||||
@@ -250,6 +329,7 @@ export default {
|
||||
}
|
||||
this.enableLocalAuth = this.authMethods.includes('local')
|
||||
this.enableOpenIDAuth = this.authMethods.includes('openid')
|
||||
this.showCustomLoginMessage = !!this.authSettings.authLoginCustomMessage
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
|
||||
@@ -178,9 +178,9 @@
|
||||
</a>
|
||||
<p class="pl-4 pr-2 text-sm text-yellow-400">
|
||||
{{ $strings.MessageJoinUsOn }}
|
||||
<a class="underline" href="https://discord.gg/pJsjuNCKRq" target="_blank">discord</a>
|
||||
<a class="underline" href="https://discord.gg/HQgCbd6E75" target="_blank">discord</a>
|
||||
</p>
|
||||
<a href="https://discord.gg/pJsjuNCKRq" target="_blank" class="text-white hover:text-gray-200 hover:scale-150 hover:rotate-6 transform duration-500">
|
||||
<a href="https://discord.gg/HQgCbd6E75" target="_blank" class="text-white hover:text-gray-200 hover:scale-150 hover:rotate-6 transform duration-500">
|
||||
<svg width="31" height="24" viewBox="0 0 71 55" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0)">
|
||||
<path
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<app-settings-content :header-text="$strings.HeaderCustomMetadataProviders">
|
||||
<template #header-prefix>
|
||||
<nuxt-link to="/config/item-metadata-utils" class="w-8 h-8 flex items-center justify-center rounded-full cursor-pointer hover:bg-white hover:bg-opacity-10 text-center mr-2">
|
||||
<span class="material-icons text-2xl">arrow_back</span>
|
||||
</nuxt-link>
|
||||
</template>
|
||||
<template #header-items>
|
||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
||||
<a href="https://www.audiobookshelf.org/guides/custom-metadata-providers" target="_blank" class="inline-flex">
|
||||
<span class="material-icons text-xl w-5 text-gray-200">help_outline</span>
|
||||
</a>
|
||||
</ui-tooltip>
|
||||
<div class="flex-grow" />
|
||||
|
||||
<ui-btn color="primary" small @click="setShowAddModal">{{ $strings.ButtonAdd }}</ui-btn>
|
||||
</template>
|
||||
|
||||
<tables-custom-metadata-provider-table :providers="providers" :processing.sync="processing" class="pt-2" @removed="providerRemoved" />
|
||||
<modals-add-custom-metadata-provider-modal ref="addModal" v-model="showAddModal" @added="providerAdded" />
|
||||
</app-settings-content>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
async asyncData({ store, redirect }) {
|
||||
if (!store.getters['user/getIsAdminOrUp']) {
|
||||
redirect('/')
|
||||
return
|
||||
}
|
||||
return {}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showAddModal: false,
|
||||
processing: false,
|
||||
providers: []
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
providerRemoved(providerId) {
|
||||
this.providers = this.providers.filter((p) => p.id !== providerId)
|
||||
},
|
||||
providerAdded(provider) {
|
||||
this.providers.push(provider)
|
||||
},
|
||||
setShowAddModal() {
|
||||
this.showAddModal = true
|
||||
},
|
||||
loadProviders() {
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$get('/api/custom-metadata-providers')
|
||||
.then((res) => {
|
||||
this.providers = res.providers
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
this.$toast.error('Failed to load custom metadata providers')
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.loadProviders()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style></style>
|
||||
@@ -13,6 +13,12 @@
|
||||
<span class="material-icons">arrow_forward</span>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
<nuxt-link to="/config/item-metadata-utils/custom-metadata-providers" class="block w-full rounded bg-primary/40 hover:bg-primary/60 text-gray-300 hover:text-white p-4 my-2">
|
||||
<div class="flex justify-between">
|
||||
<p>{{ $strings.HeaderCustomMetadataProviders }}</p>
|
||||
<span class="material-icons">arrow_forward</span>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
</app-settings-content>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<div ref="container" class="relative w-full h-full bg-primary border-bg overflow-x-hidden overflow-y-auto text-red shadow-inner rounded-md" style="max-height: 800px; min-height: 550px">
|
||||
<div ref="container" id="log-container" class="relative w-full h-full bg-primary border-bg overflow-x-hidden overflow-y-auto text-red shadow-inner rounded-md" style="min-height: 550px">
|
||||
<template v-for="(log, index) in logs">
|
||||
<div :key="index" class="flex flex-nowrap px-2 py-1 items-start text-sm bg-opacity-10" :class="`bg-${logColors[log.level]}`">
|
||||
<p class="text-gray-400 w-36 font-mono text-xs">{{ log.timestamp }}</p>
|
||||
@@ -136,7 +136,15 @@ export default {
|
||||
this.loadedLogs = this.loadedLogs.slice(-5000)
|
||||
}
|
||||
},
|
||||
init(attempts = 0) {
|
||||
async loadLoggerData() {
|
||||
const loggerData = await this.$axios.$get('/api/logger-data').catch((error) => {
|
||||
console.error('Failed to load logger data', error)
|
||||
this.$toast.error('Failed to load logger data')
|
||||
})
|
||||
|
||||
this.loadedLogs = loggerData?.currentDailyLogs || []
|
||||
},
|
||||
async init(attempts = 0) {
|
||||
if (!this.$root.socket) {
|
||||
if (attempts > 10) {
|
||||
return console.error('Failed to setup socket listeners')
|
||||
@@ -147,14 +155,11 @@ export default {
|
||||
return
|
||||
}
|
||||
|
||||
await this.loadLoggerData()
|
||||
|
||||
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
|
||||
this.$root.socket.on('daily_logs', this.dailyLogsLoaded)
|
||||
this.$root.socket.on('log', this.logEvtReceived)
|
||||
this.$root.socket.emit('set_log_listener', this.newServerSettings.logLevel)
|
||||
this.$root.socket.emit('fetch_daily_logs')
|
||||
},
|
||||
dailyLogsLoaded(lines) {
|
||||
this.loadedLogs = lines
|
||||
}
|
||||
},
|
||||
updated() {
|
||||
@@ -166,13 +171,15 @@ export default {
|
||||
beforeDestroy() {
|
||||
if (!this.$root.socket) return
|
||||
this.$root.socket.emit('remove_log_listener')
|
||||
this.$root.socket.off('daily_logs', this.dailyLogsLoaded)
|
||||
this.$root.socket.off('log', this.logEvtReceived)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
#log-container {
|
||||
height: calc(100vh - 285px);
|
||||
}
|
||||
.logmessage {
|
||||
width: calc(100% - 208px);
|
||||
}
|
||||
|
||||
@@ -64,8 +64,8 @@
|
||||
<td class="hidden md:table-cell w-26 min-w-26">
|
||||
<p class="text-xs">{{ getPlayMethodName(session.playMethod) }}</p>
|
||||
</td>
|
||||
<td class="hidden sm:table-cell w-32 min-w-32">
|
||||
<p class="text-xs" v-html="getDeviceInfoString(session.deviceInfo)" />
|
||||
<td class="hidden sm:table-cell max-w-32 min-w-32">
|
||||
<p class="text-xs truncate" v-html="getDeviceInfoString(session.deviceInfo)" />
|
||||
</td>
|
||||
<td class="text-center w-24 min-w-24 sm:w-32 sm:min-w-32">
|
||||
<p class="text-xs font-mono">{{ $elapsedPretty(session.timeListening) }}</p>
|
||||
@@ -84,7 +84,7 @@
|
||||
<div class="flex items-center my-2">
|
||||
<div class="flex-grow" />
|
||||
<div class="hidden sm:inline-flex items-center">
|
||||
<p class="text-sm">{{ $strings.LabelRowsPerPage }}</p>
|
||||
<p class="text-sm whitespace-nowrap">{{ $strings.LabelRowsPerPage }}</p>
|
||||
<ui-dropdown v-model="itemsPerPage" :items="itemsPerPageOptions" small class="w-24 mx-2" @input="updatedItemsPerPage" />
|
||||
</div>
|
||||
<div class="inline-flex items-center">
|
||||
@@ -127,8 +127,8 @@
|
||||
<td class="hidden md:table-cell">
|
||||
<p class="text-xs">{{ getPlayMethodName(session.playMethod) }}</p>
|
||||
</td>
|
||||
<td class="hidden sm:table-cell">
|
||||
<p class="text-xs" v-html="getDeviceInfoString(session.deviceInfo)" />
|
||||
<td class="hidden sm:table-cell max-w-32 min-w-32">
|
||||
<p class="text-xs truncate" v-html="getDeviceInfoString(session.deviceInfo)" />
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<p class="text-xs font-mono">{{ $elapsedPretty(session.timeListening) }}</p>
|
||||
@@ -394,6 +394,7 @@ export default {
|
||||
getDeviceInfoString(deviceInfo) {
|
||||
if (!deviceInfo) return ''
|
||||
var lines = []
|
||||
if (deviceInfo.clientName) lines.push(`${deviceInfo.clientName} ${deviceInfo.clientVersion || ''}`)
|
||||
if (deviceInfo.osName) lines.push(`${deviceInfo.osName} ${deviceInfo.osVersion}`)
|
||||
if (deviceInfo.browserName) lines.push(deviceInfo.browserName)
|
||||
|
||||
|
||||
@@ -36,8 +36,8 @@
|
||||
<td class="hidden md:table-cell">
|
||||
<p class="text-xs">{{ getPlayMethodName(session.playMethod) }}</p>
|
||||
</td>
|
||||
<td class="hidden sm:table-cell">
|
||||
<p class="text-xs" v-html="getDeviceInfoString(session.deviceInfo)" />
|
||||
<td class="hidden sm:table-cell min-w-32 max-w-32">
|
||||
<p class="text-xs truncate" v-html="getDeviceInfoString(session.deviceInfo)" />
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<p class="text-xs font-mono">{{ $elapsedPretty(session.timeListening) }}</p>
|
||||
@@ -193,6 +193,7 @@ export default {
|
||||
getDeviceInfoString(deviceInfo) {
|
||||
if (!deviceInfo) return ''
|
||||
var lines = []
|
||||
if (deviceInfo.clientName) lines.push(`${deviceInfo.clientName} ${deviceInfo.clientVersion || ''}`)
|
||||
if (deviceInfo.osName) lines.push(`${deviceInfo.osName} ${deviceInfo.osVersion}`)
|
||||
if (deviceInfo.browserName) lines.push(deviceInfo.browserName)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div id="page-wrapper" class="bg-bg page overflow-hidden" :class="streamLibraryItem ? 'streaming' : ''">
|
||||
<div class="w-full h-full overflow-y-auto px-2 py-6 lg:p-8">
|
||||
<div id="item-page-wrapper" class="w-full h-full overflow-y-auto px-2 py-6 lg:p-8">
|
||||
<div class="flex flex-col lg:flex-row max-w-6xl mx-auto">
|
||||
<div class="w-full flex justify-center lg:block lg:w-52" style="min-width: 208px">
|
||||
<div class="relative group" style="height: fit-content">
|
||||
@@ -34,7 +34,10 @@
|
||||
|
||||
<p v-if="bookSubtitle" class="text-gray-200 text-xl md:text-2xl">{{ bookSubtitle }}</p>
|
||||
|
||||
<nuxt-link v-for="_series in seriesList" :key="_series.id" :to="`/library/${libraryId}/series/${_series.id}`" class="hover:underline font-sans text-gray-300 text-lg leading-7"> {{ _series.text }}</nuxt-link>
|
||||
<template v-for="(_series, index) in seriesList">
|
||||
<nuxt-link :key="_series.id" :to="`/library/${libraryId}/series/${_series.id}`" class="hover:underline font-sans text-gray-300 text-lg leading-7">{{ _series.text }}</nuxt-link
|
||||
><span :key="index" v-if="index < seriesList.length - 1">, </span>
|
||||
</template>
|
||||
|
||||
<template v-if="!isVideo">
|
||||
<p v-if="isPodcast" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl">by {{ podcastAuthor || 'Unknown' }}</p>
|
||||
@@ -125,21 +128,18 @@
|
||||
</div>
|
||||
|
||||
<div class="my-4 w-full">
|
||||
<p class="text-base text-gray-100 whitespace-pre-line">{{ description }}</p>
|
||||
<p ref="description" id="item-description" dir="auto" class="text-base text-gray-100 whitespace-pre-line mb-1" :class="{ 'show-full': showFullDescription }">{{ description }}</p>
|
||||
<button v-if="isDescriptionClamped" class="py-0.5 flex items-center text-slate-300 hover:text-white" @click="showFullDescription = !showFullDescription">
|
||||
{{ showFullDescription ? 'Read less' : 'Read more' }} <span class="material-icons text-xl pl-1">{{ showFullDescription ? 'expand_less' : 'expand_more' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="invalidAudioFiles.length" class="bg-error border-red-800 shadow-md p-4">
|
||||
<p class="text-sm mb-2">Invalid audio files</p>
|
||||
|
||||
<p v-for="audioFile in invalidAudioFiles" :key="audioFile.id" class="text-xs pl-2">- {{ audioFile.metadata.filename }} ({{ audioFile.error }})</p>
|
||||
</div>
|
||||
|
||||
<widgets-audiobook-data v-if="tracks.length" :library-item-id="libraryItemId" :is-file="isFile" :media="media" />
|
||||
|
||||
<tables-podcast-episodes-table v-if="isPodcast" :library-item="libraryItem" />
|
||||
|
||||
<tables-chapters-table v-if="chapters.length" :library-item="libraryItem" class="mt-6" />
|
||||
|
||||
<tables-tracks-table v-if="tracks.length" :title="$strings.LabelStatsAudioTracks" :tracks="tracksWithAudioFile" :is-file="isFile" :library-item-id="libraryItemId" class="mt-6" />
|
||||
|
||||
<tables-podcast-lazy-episodes-table v-if="isPodcast" :library-item="libraryItem" />
|
||||
|
||||
<tables-ebook-files-table v-if="ebookFiles.length" :library-item="libraryItem" class="mt-6" />
|
||||
|
||||
<tables-library-files-table v-if="libraryFiles.length" :library-item="libraryItem" class="mt-6" />
|
||||
@@ -182,7 +182,9 @@ export default {
|
||||
podcastFeedEpisodes: [],
|
||||
episodesDownloading: [],
|
||||
episodeDownloadsQueued: [],
|
||||
showBookmarksModal: false
|
||||
showBookmarksModal: false,
|
||||
isDescriptionClamped: false,
|
||||
showFullDescription: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -234,10 +236,6 @@ export default {
|
||||
isAbridged() {
|
||||
return !!this.mediaMetadata.abridged
|
||||
},
|
||||
invalidAudioFiles() {
|
||||
if (!this.isBook) return []
|
||||
return this.libraryItem.media.audioFiles.filter((af) => af.invalid)
|
||||
},
|
||||
showPlayButton() {
|
||||
if (this.isMissing || this.isInvalid) return false
|
||||
if (this.isMusic) return !!this.audioFile
|
||||
@@ -270,6 +268,12 @@ export default {
|
||||
tracks() {
|
||||
return this.media.tracks || []
|
||||
},
|
||||
tracksWithAudioFile() {
|
||||
return this.tracks.map((track) => {
|
||||
track.audioFile = this.media.audioFiles?.find((af) => af.metadata.path === track.metadata.path)
|
||||
return track
|
||||
})
|
||||
},
|
||||
podcastEpisodes() {
|
||||
return this.media.episodes || []
|
||||
},
|
||||
@@ -596,10 +600,15 @@ export default {
|
||||
this.$store.commit('setBookshelfBookIds', [])
|
||||
this.$store.commit('showEditModal', this.libraryItem)
|
||||
},
|
||||
checkDescriptionClamped() {
|
||||
if (!this.$refs.description) return
|
||||
this.isDescriptionClamped = this.$refs.description.scrollHeight > this.$refs.description.clientHeight
|
||||
},
|
||||
libraryItemUpdated(libraryItem) {
|
||||
if (libraryItem.id === this.libraryItemId) {
|
||||
console.log('Item was updated', libraryItem)
|
||||
this.libraryItem = libraryItem
|
||||
this.$nextTick(this.checkDescriptionClamped)
|
||||
}
|
||||
},
|
||||
clearProgressClick() {
|
||||
@@ -756,6 +765,8 @@ export default {
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.checkDescriptionClamped()
|
||||
|
||||
this.episodeDownloadsQueued = this.libraryItem.episodeDownloadsQueued || []
|
||||
this.episodesDownloading = this.libraryItem.episodesDownloading || []
|
||||
|
||||
@@ -782,3 +793,18 @@ export default {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
#item-description {
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 4;
|
||||
max-height: 6.25rem;
|
||||
transition: all 0.3s ease-in-out;
|
||||
}
|
||||
#item-description.show-full {
|
||||
-webkit-line-clamp: unset;
|
||||
max-height: 999rem;
|
||||
}
|
||||
</style>
|
||||
@@ -3,7 +3,7 @@
|
||||
<app-book-shelf-toolbar page="authors" is-home :authors="authors" />
|
||||
<div id="bookshelf" class="w-full h-full p-8 overflow-y-auto">
|
||||
<div class="flex flex-wrap justify-center">
|
||||
<template v-for="author in authors">
|
||||
<template v-for="author in authorsSorted">
|
||||
<cards-author-card :key="author.id" :author="author" :width="160" :height="200" class="p-3" @edit="editAuthor" />
|
||||
</template>
|
||||
</div>
|
||||
@@ -44,6 +44,22 @@ export default {
|
||||
},
|
||||
selectedAuthor() {
|
||||
return this.$store.state.globals.selectedAuthor
|
||||
},
|
||||
authorSortBy() {
|
||||
return this.$store.getters['user/getUserSetting']('authorSortBy') || 'name'
|
||||
},
|
||||
authorSortDesc() {
|
||||
return !!this.$store.getters['user/getUserSetting']('authorSortDesc')
|
||||
},
|
||||
authorsSorted() {
|
||||
const sortProp = this.authorSortBy
|
||||
const bDesc = this.authorSortDesc ? -1 : 1
|
||||
return this.authors.sort((a, b) => {
|
||||
if (typeof a[sortProp] === 'number' && typeof b[sortProp] === 'number') {
|
||||
return a[sortProp] > b[sortProp] ? bDesc : -bDesc
|
||||
}
|
||||
return a[sortProp].localeCompare(b[sortProp], undefined, { sensitivity: 'base' }) * bDesc
|
||||
})
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -8,11 +8,11 @@
|
||||
<p v-if="!recentEpisodes.length && !processing" class="text-center text-xl">{{ $strings.MessageNoEpisodes }}</p>
|
||||
<template v-for="(episode, index) in episodesMapped">
|
||||
<div :key="episode.id" class="flex py-5 cursor-pointer relative" @click.stop="clickEpisode(episode)">
|
||||
<covers-preview-cover :src="$store.getters['globals/getLibraryItemCoverSrcById'](episode.libraryItemId)" :width="96" :book-cover-aspect-ratio="bookCoverAspectRatio" :show-resolution="false" class="hidden md:block" />
|
||||
<covers-preview-cover :src="$store.getters['globals/getLibraryItemCoverSrcById'](episode.libraryItemId, episode.updatedAt)" :width="96" :book-cover-aspect-ratio="bookCoverAspectRatio" :show-resolution="false" class="hidden md:block" />
|
||||
<div class="flex-grow pl-4 max-w-2xl">
|
||||
<!-- mobile -->
|
||||
<div class="flex md:hidden mb-2">
|
||||
<covers-preview-cover :src="$store.getters['globals/getLibraryItemCoverSrcById'](episode.libraryItemId)" :width="48" :book-cover-aspect-ratio="bookCoverAspectRatio" :show-resolution="false" class="md:hidden" />
|
||||
<covers-preview-cover :src="$store.getters['globals/getLibraryItemCoverSrcById'](episode.libraryItemId, episode.updatedAt)" :width="48" :book-cover-aspect-ratio="bookCoverAspectRatio" :show-resolution="false" class="md:hidden" />
|
||||
<div class="flex-grow px-2">
|
||||
<div class="flex items-center">
|
||||
<div class="flex" @click.stop>
|
||||
@@ -40,12 +40,12 @@
|
||||
<div v-if="episode.episode">{{ episode.episode }}</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center mb-2">
|
||||
<div dir="auto" class="flex items-center mb-2">
|
||||
<div class="font-semibold text-sm md:text-base">{{ episode.title }}</div>
|
||||
<widgets-podcast-type-indicator :type="episode.episodeType" />
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-gray-200 mb-4 episode-subtitle-long" v-html="episode.subtitle || episode.description" />
|
||||
<p dir="auto" class="text-sm text-gray-200 mb-4 line-clamp-4" v-html="episode.subtitle || episode.description" />
|
||||
|
||||
<div class="flex items-center">
|
||||
<button class="h-8 px-4 border border-white border-opacity-20 hover:bg-white hover:bg-opacity-10 rounded-full flex items-center justify-center cursor-pointer focus:outline-none" :class="episode.progress && episode.progress.isFinished ? 'text-white text-opacity-40' : ''" @click.stop="playClick(episode)">
|
||||
@@ -54,9 +54,16 @@
|
||||
<p class="pl-2 pr-1 text-sm font-semibold">{{ getButtonText(episode) }}</p>
|
||||
</button>
|
||||
|
||||
<button v-if="libraryItemIdStreaming && !isStreamingFromDifferentLibrary" class="h-8 w-8 flex justify-center items-center mx-2" :class="playerQueueEpisodeIdMap[episode.id] ? 'text-success' : ''" @click.stop="queueBtnClick(episode)">
|
||||
<span class="material-icons-outlined text-2xl">{{ playerQueueEpisodeIdMap[episode.id] ? 'playlist_add_check' : 'playlist_add' }}</span>
|
||||
</button>
|
||||
<ui-tooltip v-if="libraryItemIdStreaming && !isStreamingFromDifferentLibrary" :text="playerQueueEpisodeIdMap[episode.id] ? $strings.MessageRemoveFromPlayerQueue : $strings.MessageAddToPlayerQueue" :class="playerQueueEpisodeIdMap[episode.id] ? 'text-success' : ''" direction="top">
|
||||
<ui-icon-btn :icon="playerQueueEpisodeIdMap[episode.id] ? 'playlist_add_check' : 'playlist_play'" borderless @click="queueBtnClick(episode)" />
|
||||
<!-- <button class="h-8 w-8 flex justify-center items-center mx-2" :class="playerQueueEpisodeIdMap[episode.id] ? 'text-success' : ''" @click.stop="queueBtnClick(episode)">
|
||||
<span class="material-icons-outlined text-2xl">{{ playerQueueEpisodeIdMap[episode.id] ? 'playlist_add_check' : 'playlist_add' }}</span>
|
||||
</button> -->
|
||||
</ui-tooltip>
|
||||
|
||||
<ui-tooltip :text="$strings.LabelYourPlaylists" direction="top">
|
||||
<ui-icon-btn icon="playlist_add" borderless @click="clickAddToPlaylist(episode)" />
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -136,6 +143,15 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickAddToPlaylist(episode) {
|
||||
// Makeshift libraryItem
|
||||
const libraryItem = {
|
||||
id: episode.libraryItemId,
|
||||
media: episode.podcast
|
||||
}
|
||||
this.$store.commit('globals/setSelectedPlaylistItems', [{ libraryItem: libraryItem, episode }])
|
||||
this.$store.commit('globals/setShowPlaylistsModal', true)
|
||||
},
|
||||
async clickEpisode(episode) {
|
||||
if (this.openingItem) return
|
||||
this.openingItem = true
|
||||
@@ -155,7 +171,9 @@ export default {
|
||||
if (this.episodeIdStreaming === episode.id) return this.streamIsPlaying ? 'Streaming' : 'Play'
|
||||
if (!episode.progress) return this.$elapsedPretty(episode.duration)
|
||||
if (episode.progress.isFinished) return 'Finished'
|
||||
var remaining = Math.floor(episode.progress.duration - episode.progress.currentTime)
|
||||
|
||||
const duration = episode.progress.duration || episode.duration
|
||||
const remaining = Math.floor(duration - episode.progress.currentTime)
|
||||
return `${this.$elapsedPretty(remaining)} left`
|
||||
},
|
||||
playClick(episodeToPlay) {
|
||||
|
||||
@@ -86,6 +86,9 @@ export default {
|
||||
},
|
||||
streamLibraryItem() {
|
||||
return this.$store.state.streamLibraryItem
|
||||
},
|
||||
librarySettings() {
|
||||
return this.$store.getters['libraries/getCurrentLibrarySettings']
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -151,7 +154,12 @@ export default {
|
||||
async submitSearch(term) {
|
||||
this.processing = true
|
||||
this.termSearched = ''
|
||||
let results = await this.$axios.$get(`/api/search/podcast?term=${encodeURIComponent(term)}`).catch((error) => {
|
||||
|
||||
const searchParams = new URLSearchParams({
|
||||
term,
|
||||
country: this.librarySettings?.podcastSearchRegion || 'us'
|
||||
})
|
||||
let results = await this.$axios.$get(`/api/search/podcast?${searchParams.toString()}`).catch((error) => {
|
||||
console.error('Search request failed', error)
|
||||
return []
|
||||
})
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
<template>
|
||||
<div class="w-full h-screen bg-bg">
|
||||
<div class="w-full flex h-full items-center justify-center">
|
||||
<div id="page-wrapper" class="w-full h-screen overflow-y-auto">
|
||||
<div class="absolute z-0 top-0 left-0 px-6 py-3">
|
||||
<div class="flex items-center">
|
||||
<img src="~static/icon.svg" alt="Audiobookshelf Logo" class="w-10 min-w-10 h-10" />
|
||||
<h1 class="text-xl ml-4 hidden lg:block hover:underline">audiobookshelf</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative z-10 w-full flex h-full items-center justify-center">
|
||||
<div v-if="criticalError" class="w-full max-w-md rounded border border-error border-opacity-25 bg-error bg-opacity-10 p-4">
|
||||
<p class="text-center text-lg font-semibold">{{ $strings.MessageServerCouldNotBeReached }}</p>
|
||||
</div>
|
||||
@@ -10,9 +17,9 @@
|
||||
|
||||
<form @submit.prevent="submitServerSetup">
|
||||
<p class="text-lg font-semibold mb-2 pl-1 text-center">Create Root User</p>
|
||||
<ui-text-input-with-label v-model="newRoot.username" label="Username" :disabled="processing" class="w-full mb-3 text-sm" />
|
||||
<ui-text-input-with-label v-model="newRoot.password" label="Password" type="password" :disabled="processing" class="w-full mb-3 text-sm" />
|
||||
<ui-text-input-with-label v-model="confirmPassword" label="Confirm Password" type="password" :disabled="processing" class="w-full mb-3 text-sm" />
|
||||
<ui-text-input-with-label v-model.trim="newRoot.username" label="Username" :disabled="processing" class="w-full mb-3 text-sm" />
|
||||
<ui-text-input-with-label v-model.trim="newRoot.password" label="Password" type="password" :disabled="processing" class="w-full mb-3 text-sm" />
|
||||
<ui-text-input-with-label v-model.trim="confirmPassword" label="Confirm Password" type="password" :disabled="processing" class="w-full mb-3 text-sm" />
|
||||
|
||||
<p class="text-lg font-semibold mt-6 mb-2 pl-1 text-center">Directory Paths</p>
|
||||
<ui-text-input-with-label v-model="ConfigPath" label="Config Path" disabled class="w-full mb-3 text-sm" />
|
||||
@@ -23,30 +30,34 @@
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div v-else-if="isInit" class="w-full max-w-md px-8 pb-8 pt-4 -mt-40">
|
||||
<p class="text-3xl text-white text-center mb-4">{{ $strings.HeaderLogin }}</p>
|
||||
<div v-else-if="isInit" class="w-full max-w-md px-8 pb-8 pt-4 lg:-mt-40">
|
||||
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4">
|
||||
<p class="text-2xl font-semibold text-center text-white mb-4">{{ $strings.HeaderLogin }}</p>
|
||||
|
||||
<div class="w-full h-px bg-white bg-opacity-10 my-4" />
|
||||
<div class="w-full h-px bg-white bg-opacity-10 my-4" />
|
||||
|
||||
<p v-if="error" class="text-error text-center py-2">{{ error }}</p>
|
||||
<p v-if="loginCustomMessage" class="py-2 default-style mb-2" v-html="loginCustomMessage"></p>
|
||||
|
||||
<form v-show="login_local" @submit.prevent="submitForm">
|
||||
<label class="text-xs text-gray-300 uppercase">{{ $strings.LabelUsername }}</label>
|
||||
<ui-text-input v-model="username" :disabled="processing" class="mb-3 w-full" />
|
||||
<p v-if="error" class="text-error text-center py-2">{{ error }}</p>
|
||||
|
||||
<label class="text-xs text-gray-300 uppercase">{{ $strings.LabelPassword }}</label>
|
||||
<ui-text-input v-model="password" type="password" :disabled="processing" class="w-full mb-3" />
|
||||
<div class="w-full flex justify-end py-3">
|
||||
<ui-btn type="submit" :disabled="processing" color="primary" class="leading-none">{{ processing ? 'Checking...' : $strings.ButtonSubmit }}</ui-btn>
|
||||
<form v-show="login_local" @submit.prevent="submitForm">
|
||||
<label class="text-xs text-gray-300 uppercase">{{ $strings.LabelUsername }}</label>
|
||||
<ui-text-input v-model.trim="username" :disabled="processing" class="mb-3 w-full" inputName="username" />
|
||||
|
||||
<label class="text-xs text-gray-300 uppercase">{{ $strings.LabelPassword }}</label>
|
||||
<ui-text-input v-model.trim="password" type="password" :disabled="processing" class="w-full mb-3" inputName="password" />
|
||||
<div class="w-full flex justify-end py-3">
|
||||
<ui-btn type="submit" :disabled="processing" color="primary" class="leading-none">{{ processing ? 'Checking...' : $strings.ButtonSubmit }}</ui-btn>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div v-if="login_local && login_openid" class="w-full h-px bg-white bg-opacity-10 my-4" />
|
||||
|
||||
<div class="w-full flex py-3">
|
||||
<a v-if="login_openid" :href="openidAuthUri" class="w-full abs-btn outline-none rounded-md shadow-md relative border border-gray-600 text-center bg-primary text-white px-8 py-2 leading-none">
|
||||
{{ openIDButtonText }}
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div v-if="login_local && login_openid" class="w-full h-px bg-white bg-opacity-10 my-4" />
|
||||
|
||||
<div class="w-full flex py-3">
|
||||
<a v-if="login_openid" :href="openidAuthUri" class="w-full abs-btn outline-none rounded-md shadow-md relative border border-gray-600 text-center bg-primary text-white px-8 py-2 leading-none">
|
||||
{{ openIDButtonText }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -113,6 +124,9 @@ export default {
|
||||
},
|
||||
openIDButtonText() {
|
||||
return this.authFormData?.authOpenIDButtonText || 'Login with OpenId'
|
||||
},
|
||||
loginCustomMessage() {
|
||||
return this.authFormData?.authLoginCustomMessage || null
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -276,4 +290,4 @@ export default {
|
||||
this.checkStatus()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -139,11 +139,30 @@ export default class LocalAudioPlayer extends EventEmitter {
|
||||
}
|
||||
|
||||
var hlsOptions = {
|
||||
startPosition: this.startTime || -1
|
||||
// No longer needed because token is put in a query string
|
||||
// xhrSetup: (xhr) => {
|
||||
// xhr.setRequestHeader('Authorization', `Bearer ${this.token}`)
|
||||
// }
|
||||
startPosition: this.startTime || -1,
|
||||
fragLoadPolicy: {
|
||||
default: {
|
||||
maxTimeToFirstByteMs: 10000,
|
||||
maxLoadTimeMs: 120000,
|
||||
timeoutRetry: {
|
||||
maxNumRetry: 4,
|
||||
retryDelayMs: 0,
|
||||
maxRetryDelayMs: 0,
|
||||
},
|
||||
errorRetry: {
|
||||
maxNumRetry: 8,
|
||||
retryDelayMs: 1000,
|
||||
maxRetryDelayMs: 8000,
|
||||
shouldRetry: (retryConfig, retryCount, isTimeout, httpStatus, retry) => {
|
||||
if (httpStatus?.code === 404 && retryConfig?.maxNumRetry > retryCount) {
|
||||
console.log(`[HLS] Server 404 for fragment retry ${retryCount} of ${retryConfig.maxNumRetry}`)
|
||||
return true
|
||||
}
|
||||
return retry
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
this.hlsInstance = new Hls(hlsOptions)
|
||||
|
||||
@@ -156,9 +175,15 @@ export default class LocalAudioPlayer extends EventEmitter {
|
||||
})
|
||||
|
||||
this.hlsInstance.on(Hls.Events.ERROR, (e, data) => {
|
||||
console.error('[HLS] Error', data.type, data.details, data)
|
||||
if (data.details === Hls.ErrorDetails.BUFFER_STALLED_ERROR) {
|
||||
console.error('[HLS] BUFFER STALLED ERROR')
|
||||
} else if (data.details === Hls.ErrorDetails.FRAG_LOAD_ERROR) {
|
||||
// Only show error if the fragment is not being retried
|
||||
if (data.errorAction?.action !== 5) {
|
||||
console.error('[HLS] FRAG LOAD ERROR', data)
|
||||
}
|
||||
} else {
|
||||
console.error('[HLS] Error', data.type, data.details, data)
|
||||
}
|
||||
})
|
||||
this.hlsInstance.on(Hls.Events.DESTROYING, () => {
|
||||
|
||||
@@ -5,21 +5,29 @@ import { supplant } from './utils'
|
||||
const defaultCode = 'en-us'
|
||||
|
||||
const languageCodeMap = {
|
||||
'bn': { label: 'বাংলা', dateFnsLocale: 'bn' },
|
||||
'cs': { label: 'Čeština', dateFnsLocale: 'cs' },
|
||||
'da': { label: 'Dansk', dateFnsLocale: 'da' },
|
||||
'de': { label: 'Deutsch', dateFnsLocale: 'de' },
|
||||
'en-us': { label: 'English', dateFnsLocale: 'enUS' },
|
||||
'es': { label: 'Español', dateFnsLocale: 'es' },
|
||||
'et': { label: 'Eesti', dateFnsLocale: 'et' },
|
||||
'fr': { label: 'Français', dateFnsLocale: 'fr' },
|
||||
'he': { label: 'עברית', dateFnsLocale: 'he' },
|
||||
'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' },
|
||||
'zh-tw': { label: '正體中文 (Traditional Chinese)', dateFnsLocale: 'zhTW' }
|
||||
}
|
||||
Vue.prototype.$languageCodeOptions = Object.keys(languageCodeMap).map(code => {
|
||||
return {
|
||||
@@ -28,15 +36,36 @@ Vue.prototype.$languageCodeOptions = Object.keys(languageCodeMap).map(code => {
|
||||
}
|
||||
})
|
||||
|
||||
// iTunes search API uses ISO 3166 country codes: https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2
|
||||
const podcastSearchRegionMap = {
|
||||
'ua': { label: 'Україна' },
|
||||
'us': { label: 'United States' },
|
||||
'cn': { label: '中国' }
|
||||
}
|
||||
Vue.prototype.$podcastSearchRegionOptions = Object.keys(podcastSearchRegionMap).map(code => {
|
||||
return {
|
||||
text: podcastSearchRegionMap[code].label,
|
||||
value: code
|
||||
}
|
||||
})
|
||||
|
||||
Vue.prototype.$languageCodes = {
|
||||
default: defaultCode,
|
||||
current: defaultCode,
|
||||
local: null,
|
||||
server: null
|
||||
default: defaultCode, // en-us
|
||||
current: defaultCode, // Current language code in use
|
||||
local: null, // Language code set at user level
|
||||
server: null // Language code set at server level
|
||||
}
|
||||
|
||||
// Currently loaded strings (default enUS)
|
||||
Vue.prototype.$strings = { ...enUsStrings }
|
||||
|
||||
/**
|
||||
* Get string and substitute
|
||||
*
|
||||
* @param {string} key
|
||||
* @param {string[]} subs
|
||||
* @returns {string}
|
||||
*/
|
||||
Vue.prototype.$getString = (key, subs) => {
|
||||
if (!Vue.prototype.$strings[key]) return ''
|
||||
if (subs?.length && Array.isArray(subs)) {
|
||||
@@ -45,7 +74,11 @@ Vue.prototype.$getString = (key, subs) => {
|
||||
return Vue.prototype.$strings[key]
|
||||
}
|
||||
|
||||
var translations = {
|
||||
Vue.prototype.$formatNumber = (num) => {
|
||||
return Intl.NumberFormat(Vue.prototype.$languageCodes.current).format(num)
|
||||
}
|
||||
|
||||
const translations = {
|
||||
[defaultCode]: enUsStrings
|
||||
}
|
||||
|
||||
@@ -83,7 +116,7 @@ async function loadi18n(code) {
|
||||
|
||||
Vue.prototype.$setDateFnsLocale(languageCodeMap[code].dateFnsLocale)
|
||||
|
||||
this.$eventBus.$emit('change-lang', code)
|
||||
this?.$eventBus?.$emit('change-lang', code)
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -156,14 +156,14 @@ Vue.prototype.$copyToClipboard = (str, ctx) => {
|
||||
}
|
||||
|
||||
function xmlToJson(xml) {
|
||||
const json = {};
|
||||
const json = {}
|
||||
for (const res of xml.matchAll(/(?:<(\w*)(?:\s[^>]*)*>)((?:(?!<\1).)*)(?:<\/\1>)|<(\w*)(?:\s*)*\/>/gm)) {
|
||||
const key = res[1] || res[3];
|
||||
const value = res[2] && xmlToJson(res[2]);
|
||||
json[key] = ((value && Object.keys(value).length) ? value : res[2]) || null;
|
||||
const key = res[1] || res[3]
|
||||
const value = res[2] && xmlToJson(res[2])
|
||||
json[key] = ((value && Object.keys(value).length) ? value : res[2]) || null
|
||||
|
||||
}
|
||||
return json;
|
||||
return json
|
||||
}
|
||||
Vue.prototype.$xmlToJson = xmlToJson
|
||||
|
||||
|
||||
BIN
client/static/ios_icon.png
Normal file
BIN
client/static/ios_icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
2
client/static/robots.txt
Normal file
2
client/static/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
User-Agent: *
|
||||
Disallow: /
|
||||
@@ -99,7 +99,7 @@ export const getters = {
|
||||
return `http://localhost:3333${rootState.routerBasePath}/api/items/${libraryItemId}/cover?token=${userToken}&ts=${lastUpdate}${raw ? '&raw=1' : ''}`
|
||||
}
|
||||
|
||||
return `${rootState.routerBasePath}/api/items/${libraryItemId}/cover?token=${userToken}&ts=${lastUpdate}`
|
||||
return `${rootState.routerBasePath}/api/items/${libraryItemId}/cover?token=${userToken}&ts=${lastUpdate}${raw ? '&raw=1' : ''}`
|
||||
},
|
||||
getLibraryItemCoverSrcById: (state, getters, rootState, rootGetters) => (libraryItemId, timestamp = null, raw = false) => {
|
||||
const placeholder = `${rootState.routerBasePath}/book_placeholder.jpg`
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user