mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-12-31 11:38:47 -05:00
Compare commits
245 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74652e2e54 | ||
|
|
4db26f9f79 | ||
|
|
ff8a58c7bc | ||
|
|
6f67c7bfa2 | ||
|
|
e9f5bd9bfe | ||
|
|
56e213d654 | ||
|
|
98323de64c | ||
|
|
4a13712b1c | ||
|
|
0387436111 | ||
|
|
7685ead000 | ||
|
|
8665d66923 | ||
|
|
9a808602c4 | ||
|
|
813e553dbb | ||
|
|
be050a7d57 | ||
|
|
065675697d | ||
|
|
8f6832fc2e | ||
|
|
bdb154a6e5 | ||
|
|
f557289274 | ||
|
|
a5627a1b52 | ||
|
|
33f20d54cc | ||
|
|
dadd41cb5c | ||
|
|
35e27e4f61 | ||
|
|
84839bea44 | ||
|
|
1342897858 | ||
|
|
c32efb8db8 | ||
|
|
f9ed412e4e | ||
|
|
6ae3ad508e | ||
|
|
24af702b41 | ||
|
|
a57ff20f35 | ||
|
|
39e710deb1 | ||
|
|
3b6fa73ac0 | ||
|
|
e2dd66d450 | ||
|
|
b1b53a1eae | ||
|
|
6f73345f39 | ||
|
|
c7b4b3bd3e | ||
|
|
98d543e3e5 | ||
|
|
4de4e958a0 | ||
|
|
cc5e92ec8e | ||
|
|
6cb9dfaa85 | ||
|
|
8790166ac1 | ||
|
|
3b97e2146d | ||
|
|
0bb1cf002d | ||
|
|
307c7ebc9d | ||
|
|
cc1b41995d | ||
|
|
730d60575e | ||
|
|
1b96297cc7 | ||
|
|
128c554543 | ||
|
|
1b5ab6c378 | ||
|
|
e4961feffb | ||
|
|
eb5f257b8c | ||
|
|
e271e89835 | ||
|
|
f5009f76f4 | ||
|
|
a3e63e03d2 | ||
|
|
2ae3ea346f | ||
|
|
8542d433a2 | ||
|
|
03984f96d4 | ||
|
|
eab019c577 | ||
|
|
179f11f55d | ||
|
|
5a21e63d0b | ||
|
|
24ef105732 | ||
|
|
589c4f73d2 | ||
|
|
55fdc48d5d | ||
|
|
4d45a902bb | ||
|
|
69bac2ec1e | ||
|
|
122ec140e8 | ||
|
|
6a0adf7433 | ||
|
|
c1b2aaec9f | ||
|
|
a49acdb2e4 | ||
|
|
9b67fbe8d9 | ||
|
|
2d13215f1f | ||
|
|
a77c3aae93 | ||
|
|
164937b454 | ||
|
|
b0a8f3d207 | ||
|
|
77cc0934be | ||
|
|
718890cfad | ||
|
|
418adcf891 | ||
|
|
b96f878d69 | ||
|
|
22b8622c67 | ||
|
|
3dc9416da6 | ||
|
|
5e5b674c17 | ||
|
|
3656eab8bf | ||
|
|
25ca950dd0 | ||
|
|
8fca84e4bd | ||
|
|
56579f440b | ||
|
|
a59311f795 | ||
|
|
042c89039c | ||
|
|
d94482827a | ||
|
|
a8dab5653b | ||
|
|
1d1200a3f2 | ||
|
|
4d110ebe7e | ||
|
|
b300f0d10c | ||
|
|
6dc4dc8f49 | ||
|
|
dfae6cf89f | ||
|
|
d7f18bdd8b | ||
|
|
05b102722b | ||
|
|
ef954ee68f | ||
|
|
dbaea9f87d | ||
|
|
64768ec2f9 | ||
|
|
14ee17de47 | ||
|
|
034b8956a2 | ||
|
|
1a3f0e332e | ||
|
|
fc36e86db7 | ||
|
|
60b4bc1a7e | ||
|
|
9fdc8df8bc | ||
|
|
212b97fa20 | ||
|
|
704fbaced8 | ||
|
|
575a162f8b | ||
|
|
d2e0844493 | ||
|
|
f2baf3fafd | ||
|
|
916fd039ca | ||
|
|
e248b6d8d8 | ||
|
|
936de68622 | ||
|
|
a99257e758 | ||
|
|
c89d77dd06 | ||
|
|
3138865d69 | ||
|
|
4d29ebd647 | ||
|
|
fd58df4729 | ||
|
|
5078818295 | ||
|
|
7181df0479 | ||
|
|
6c618d7760 | ||
|
|
17b8cf19b7 | ||
|
|
e018f8341e | ||
|
|
59b5f8cbbe | ||
|
|
d6108a0722 | ||
|
|
1af7e59d88 | ||
|
|
7b425e9a9d | ||
|
|
596a03900b | ||
|
|
b283644d95 | ||
|
|
808690c137 | ||
|
|
136c347586 | ||
|
|
e81238038e | ||
|
|
fcf6964d7d | ||
|
|
bd75ad4576 | ||
|
|
f970d8e539 | ||
|
|
c49010b4e1 | ||
|
|
146093d81e | ||
|
|
11ccbf1913 | ||
|
|
a4a334a18a | ||
|
|
387a37e4da | ||
|
|
ebad304aa9 | ||
|
|
8b557a0cb9 | ||
|
|
40b808e73d | ||
|
|
a8b57a1ce9 | ||
|
|
35315843f2 | ||
|
|
27b9d3b94f | ||
|
|
0010ac5a40 | ||
|
|
884808f34e | ||
|
|
f75ed07497 | ||
|
|
b707d6f3c9 | ||
|
|
a2d4a4a906 | ||
|
|
434d743d99 | ||
|
|
30f16b05fe | ||
|
|
92a88f4416 | ||
|
|
5c9c122af2 | ||
|
|
620d5ce578 | ||
|
|
363e1cee4b | ||
|
|
93f576772a | ||
|
|
d4612bae92 | ||
|
|
e01af27008 | ||
|
|
657fe0a650 | ||
|
|
9a6ec5548e | ||
|
|
0807509ea7 | ||
|
|
d9d1c4e360 | ||
|
|
2135e5b066 | ||
|
|
b69eb10ae0 | ||
|
|
e1512b6f54 | ||
|
|
1b8e8215d6 | ||
|
|
9b44e36e7b | ||
|
|
db1ca08c2e | ||
|
|
557d3243c3 | ||
|
|
785942b94f | ||
|
|
3df7caa838 | ||
|
|
aef2c52630 | ||
|
|
dccad3055b | ||
|
|
c629923a80 | ||
|
|
b4f1fd5b25 | ||
|
|
267897ce74 | ||
|
|
022bf9d0ef | ||
|
|
61c759e0c4 | ||
|
|
cfb3ce0c60 | ||
|
|
72396c5a98 | ||
|
|
12f231b886 | ||
|
|
6aeed24296 | ||
|
|
d8b6e09bc0 | ||
|
|
d95975cade | ||
|
|
c4208a4690 | ||
|
|
7c7a6df6e4 | ||
|
|
791c058ef8 | ||
|
|
c847aea0a4 | ||
|
|
e56164aa5a | ||
|
|
cfb5e909a9 | ||
|
|
071444a9e7 | ||
|
|
34ac972130 | ||
|
|
97b5cf04f5 | ||
|
|
0d50d730d9 | ||
|
|
3a7fd0bcc9 | ||
|
|
f0edea5d52 | ||
|
|
9c6b07df99 | ||
|
|
caacf461ab | ||
|
|
5bdbc75522 | ||
|
|
0d3e6b1d0a | ||
|
|
a122e25cba | ||
|
|
d7b287bfed | ||
|
|
ba4f585318 | ||
|
|
3f859723a6 | ||
|
|
c820d0e62b | ||
|
|
7a47032a96 | ||
|
|
2db4dd6a40 | ||
|
|
f58e2b6dce | ||
|
|
859a53e79a | ||
|
|
ad0edc6329 | ||
|
|
002fb7a35e | ||
|
|
cc62a20a5d | ||
|
|
ec7e965dfa | ||
|
|
9c3f5406a9 | ||
|
|
f4ec6948d2 | ||
|
|
9a51c3be0f | ||
|
|
b1ee54522a | ||
|
|
c14d13440f | ||
|
|
8c84640484 | ||
|
|
0d8917ced6 | ||
|
|
a006eb489d | ||
|
|
f2941e04d3 | ||
|
|
2728546660 | ||
|
|
eeb7c80518 | ||
|
|
c8c40360ad | ||
|
|
79ab656217 | ||
|
|
5c250da388 | ||
|
|
505e0eb3a2 | ||
|
|
388444e51f | ||
|
|
08d7a9aa14 | ||
|
|
f650ae7f18 | ||
|
|
6d138ae905 | ||
|
|
956678c08c | ||
|
|
911c854365 | ||
|
|
3c5dc17e3c | ||
|
|
e709cc4cb1 | ||
|
|
da7825e3e3 | ||
|
|
4039dc7968 | ||
|
|
e345c4cc9e | ||
|
|
a08cfa436e | ||
|
|
7207efb4da | ||
|
|
481611ff33 | ||
|
|
b67cd37a38 | ||
|
|
d2512d324a |
@@ -1,4 +1,15 @@
|
||||
FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:16
|
||||
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
||||
&& apt-get install ffmpeg gnupg2 -y
|
||||
# [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
|
||||
FROM mcr.microsoft.com/devcontainers/javascript-node:0-${VARIANT} as base
|
||||
|
||||
# Setup the node environment
|
||||
ENV NODE_ENV=development
|
||||
|
||||
# Install additional OS packages.
|
||||
RUN apt-get update && \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get -y install --no-install-recommends \
|
||||
curl tzdata ffmpeg && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Move tone executable to appropriate directory
|
||||
COPY --from=sandreas/tone:v0.1.5 /usr/local/bin/tone /usr/local/bin/
|
||||
|
||||
9
.devcontainer/dev.js
Normal file
9
.devcontainer/dev.js
Normal file
@@ -0,0 +1,9 @@
|
||||
// Using port 3333 is important when running the client web app separately
|
||||
const Path = require('path')
|
||||
module.exports.config = {
|
||||
Port: 3333,
|
||||
ConfigPath: Path.resolve('config'),
|
||||
MetadataPath: Path.resolve('metadata'),
|
||||
FFmpegPath: '/usr/bin/ffmpeg',
|
||||
FFProbePath: '/usr/bin/ffprobe'
|
||||
}
|
||||
@@ -1,12 +1,40 @@
|
||||
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
|
||||
// README at: https://github.com/devcontainers/templates/tree/main/src/javascript-node
|
||||
{
|
||||
"build": { "dockerfile": "Dockerfile" },
|
||||
"mounts": [
|
||||
"source=abs-node-node_modules,target=${containerWorkspaceFolder}/node_modules,type=volume"
|
||||
],
|
||||
"features": {
|
||||
"fish": "latest"
|
||||
"name": "Audiobookshelf",
|
||||
"build": {
|
||||
"dockerfile": "Dockerfile",
|
||||
// Update 'VARIANT' to pick a Node version: 18, 16, 14.
|
||||
// Append -bullseye or -buster to pin to an OS version.
|
||||
// Use -bullseye variants on local arm64/Apple Silicon.
|
||||
"args": {
|
||||
"VARIANT": "16"
|
||||
}
|
||||
},
|
||||
"extensions": [
|
||||
"eamodio.gitlens"
|
||||
]
|
||||
"mounts": [
|
||||
"source=abs-server-node_modules,target=${containerWorkspaceFolder}/node_modules,type=volume",
|
||||
"source=abs-client-node_modules,target=${containerWorkspaceFolder}/client/node_modules,type=volume"
|
||||
],
|
||||
// Features to add to the dev container. More info: https://containers.dev/features.
|
||||
// "features": {},
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
"forwardPorts": [
|
||||
3000,
|
||||
3333
|
||||
],
|
||||
// Use 'postCreateCommand' to run commands after the container is created.
|
||||
"postCreateCommand": "sh .devcontainer/post-create.sh",
|
||||
// Configure tool-specific properties.
|
||||
"customizations": {
|
||||
// Configure properties specific to VS Code.
|
||||
"vscode": {
|
||||
// Add the IDs of extensions you want installed when the container is created.
|
||||
"extensions": [
|
||||
"dbaeumer.vscode-eslint",
|
||||
"octref.vetur"
|
||||
]
|
||||
}
|
||||
}
|
||||
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
|
||||
// "remoteUser": "root"
|
||||
}
|
||||
29
.devcontainer/post-create.sh
Normal file
29
.devcontainer/post-create.sh
Normal file
@@ -0,0 +1,29 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Mark the working directory as safe for use with git
|
||||
git config --global --add safe.directory $PWD
|
||||
|
||||
# If there is no dev.js file, create it
|
||||
if [ ! -f dev.js ]; then
|
||||
cp .devcontainer/dev.js .
|
||||
fi
|
||||
|
||||
# Update permissions for node_modules folders
|
||||
# https://code.visualstudio.com/remote/advancedcontainers/improve-performance#_use-a-targeted-named-volume
|
||||
if [ -d node_modules ]; then
|
||||
sudo chown $(id -u):$(id -g) node_modules
|
||||
fi
|
||||
|
||||
if [ -d client/node_modules ]; then
|
||||
sudo chown $(id -u):$(id -g) client/node_modules
|
||||
fi
|
||||
|
||||
# Install packages for the server
|
||||
if [ -f package.json ]; then
|
||||
npm ci
|
||||
fi
|
||||
|
||||
# Install packages and build the client
|
||||
if [ -f client/package.json ]; then
|
||||
(cd client; npm ci; npm run generate)
|
||||
fi
|
||||
5
.gitattributes
vendored
Normal file
5
.gitattributes
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
# Set the default behavior, in case people don't have core.autocrlf set.
|
||||
* text=auto
|
||||
|
||||
# Declare files that will always have CRLF line endings on checkout.
|
||||
.devcontainer/post-create.sh text eol=lf
|
||||
44
.github/workflows/integration-test.yml
vendored
Normal file
44
.github/workflows/integration-test.yml
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
name: Integration Test
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches-ignore:
|
||||
- 'dependabot/**' # Don't run dependabot branches, as they are already covered by pull requests
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: build and test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: setup nade
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16
|
||||
|
||||
- name: install pkg
|
||||
run: npm install -g pkg
|
||||
|
||||
- name: get client dependencies
|
||||
working-directory: client
|
||||
run: npm ci
|
||||
|
||||
- name: build client
|
||||
working-directory: client
|
||||
run: npm run generate
|
||||
|
||||
- name: get server dependencies
|
||||
run: npm ci --only=production
|
||||
|
||||
- name: build binary
|
||||
run: pkg -t node18-linux-x64 -o audiobookshelf .
|
||||
|
||||
- name: run audiobookshelf
|
||||
run: |
|
||||
./audiobookshelf &
|
||||
sleep 5
|
||||
|
||||
- name: test if server is available
|
||||
run: curl -sf http://127.0.0.1:3333 | grep Audiobookshelf
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,6 +1,6 @@
|
||||
.env
|
||||
dev.js
|
||||
node_modules/
|
||||
/dev.js
|
||||
**/node_modules/
|
||||
/config/
|
||||
/audiobooks/
|
||||
/audiobooks2/
|
||||
|
||||
44
.vscode/launch.json
vendored
Normal file
44
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Debug server",
|
||||
"runtimeExecutable": "npm",
|
||||
"args": [
|
||||
"run",
|
||||
"dev"
|
||||
],
|
||||
"skipFiles": [
|
||||
"<node_internals>/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Debug client (nuxt)",
|
||||
"runtimeExecutable": "npm",
|
||||
"args": [
|
||||
"run",
|
||||
"dev"
|
||||
],
|
||||
"cwd": "${workspaceFolder}/client",
|
||||
"skipFiles": [
|
||||
"${workspaceFolder}/<node_internals>/**"
|
||||
]
|
||||
}
|
||||
],
|
||||
"compounds": [
|
||||
{
|
||||
"name": "Debug server and client (nuxt)",
|
||||
"configurations": [
|
||||
"Debug server",
|
||||
"Debug client (nuxt)"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
40
.vscode/tasks.json
vendored
Normal file
40
.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"path": "client",
|
||||
"type": "npm",
|
||||
"script": "generate",
|
||||
"detail": "nuxt generate",
|
||||
"label": "Build client",
|
||||
"group": {
|
||||
"kind": "build",
|
||||
"isDefault": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"dependsOn": [
|
||||
"Build client"
|
||||
],
|
||||
"type": "npm",
|
||||
"script": "dev",
|
||||
"detail": "nodemon --watch server index.js",
|
||||
"label": "Run server",
|
||||
"group": {
|
||||
"kind": "test",
|
||||
"isDefault": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "client",
|
||||
"type": "npm",
|
||||
"script": "dev",
|
||||
"detail": "nuxt",
|
||||
"label": "Run Live-reload client",
|
||||
"group": {
|
||||
"kind": "test",
|
||||
"isDefault": false
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -6,7 +6,7 @@ RUN npm ci && npm cache clean --force
|
||||
RUN npm run generate
|
||||
|
||||
### STAGE 1: Build server ###
|
||||
FROM sandreas/tone:v0.1.2 AS tone
|
||||
FROM sandreas/tone:v0.1.5 AS tone
|
||||
FROM node:16-alpine
|
||||
|
||||
ENV NODE_ENV=production
|
||||
@@ -29,4 +29,4 @@ HEALTHCHECK \
|
||||
--timeout=3s \
|
||||
--start-period=10s \
|
||||
CMD curl -f http://127.0.0.1/healthcheck || exit 1
|
||||
CMD ["npm", "start"]
|
||||
CMD ["node", "index.js"]
|
||||
|
||||
@@ -50,7 +50,7 @@ install_ffmpeg() {
|
||||
echo "Starting FFMPEG Install"
|
||||
|
||||
WGET="wget https://johnvansickle.com/ffmpeg/builds/ffmpeg-git-amd64-static.tar.xz --output-document=ffmpeg-git-amd64-static.tar.xz"
|
||||
WGET_TONE="wget https://github.com/sandreas/tone/releases/download/v0.1.2/tone-0.1.2-linux-x64.tar.gz --output-document=tone-0.1.2-linux-x64.tar.gz"
|
||||
WGET_TONE="wget https://github.com/sandreas/tone/releases/download/v0.1.5/tone-0.1.5-linux-x64.tar.gz --output-document=tone-0.1.5-linux-x64.tar.gz"
|
||||
|
||||
if ! cd "$FFMPEG_INSTALL_DIR"; then
|
||||
echo "Creating ffmpeg install dir at $FFMPEG_INSTALL_DIR"
|
||||
@@ -66,8 +66,8 @@ install_ffmpeg() {
|
||||
# Temp downloading tone library to the ffmpeg dir
|
||||
echo "Getting tone.."
|
||||
$WGET_TONE
|
||||
tar xvf tone-0.1.2-linux-x64.tar.gz --strip-components=1
|
||||
rm tone-0.1.2-linux-x64.tar.gz
|
||||
tar xvf tone-0.1.5-linux-x64.tar.gz --strip-components=1
|
||||
rm tone-0.1.5-linux-x64.tar.gz
|
||||
|
||||
echo "Good to go on Ffmpeg (& tone)... hopefully"
|
||||
}
|
||||
|
||||
@@ -58,9 +58,6 @@
|
||||
<span class="material-icons text-2xl -ml-2 pr-1 text-white">play_arrow</span>
|
||||
{{ $strings.ButtonPlay }}
|
||||
</ui-btn>
|
||||
<ui-tooltip v-if="userIsAdminOrUp && isBookLibrary" :text="$strings.ButtonQuickMatch" direction="bottom">
|
||||
<ui-icon-btn :disabled="processingBatch" icon="auto_awesome" @click="batchAutoMatchClick" class="mx-1.5" />
|
||||
</ui-tooltip>
|
||||
<ui-tooltip v-if="isBookLibrary" :text="selectedIsFinished ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" direction="bottom">
|
||||
<ui-read-icon-btn :disabled="processingBatch" :is-read="selectedIsFinished" @click="toggleBatchRead" class="mx-1.5" />
|
||||
</ui-tooltip>
|
||||
@@ -75,8 +72,11 @@
|
||||
<ui-tooltip v-if="userCanDelete" :text="$strings.ButtonRemove" direction="bottom">
|
||||
<ui-icon-btn :disabled="processingBatch" icon="delete" bg-color="error" class="mx-1.5" @click="batchDeleteClick" />
|
||||
</ui-tooltip>
|
||||
<ui-tooltip :text="$strings.LabelDeselectAll" direction="bottom">
|
||||
<span class="material-icons text-4xl px-4 hover:text-gray-100 cursor-pointer" :class="processingBatch ? 'text-gray-400' : ''" @click="cancelSelectionMode">close</span>
|
||||
|
||||
<ui-context-menu-dropdown v-if="contextMenuItems.length && !processingBatch" :items="contextMenuItems" class="ml-1" @action="contextMenuAction" />
|
||||
|
||||
<ui-tooltip :text="$strings.LabelDeselectAll" direction="bottom" class="flex items-center">
|
||||
<span class="material-icons text-3xl px-4 hover:text-gray-100 cursor-pointer" :class="processingBatch ? 'text-gray-400' : ''" @click="cancelSelectionMode">close</span>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
@@ -160,9 +160,59 @@ export default {
|
||||
},
|
||||
isHttps() {
|
||||
return location.protocol === 'https:' || process.env.NODE_ENV === 'development'
|
||||
},
|
||||
contextMenuItems() {
|
||||
if (!this.userIsAdminOrUp) return []
|
||||
|
||||
const options = [
|
||||
{
|
||||
text: this.$strings.ButtonQuickMatch,
|
||||
action: 'quick-match'
|
||||
}
|
||||
]
|
||||
|
||||
if (!this.isPodcastLibrary && this.selectedMediaItemsArePlayable) {
|
||||
options.push({
|
||||
text: 'Quick Embed Metadata',
|
||||
action: 'quick-embed'
|
||||
})
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
requestBatchQuickEmbed() {
|
||||
const payload = {
|
||||
message: 'Warning! Quick embed will not backup your audio files. Make sure that you have a backup of your audio files. <br><br>Would you like to continue?',
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.$axios
|
||||
.$post(`/api/tools/batch/embed-metadata`, {
|
||||
libraryItemIds: this.selectedMediaItems.map((i) => i.id)
|
||||
})
|
||||
.then(() => {
|
||||
console.log('Audio metadata embed started')
|
||||
this.cancelSelectionMode()
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Audio metadata embed failed', error)
|
||||
const errorMsg = error.response.data || 'Failed to embed metadata'
|
||||
this.$toast.error(errorMsg)
|
||||
})
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
},
|
||||
contextMenuAction(action) {
|
||||
if (action === 'quick-embed') {
|
||||
this.requestBatchQuickEmbed()
|
||||
} else if (action === 'quick-match') {
|
||||
this.batchAutoMatchClick()
|
||||
}
|
||||
},
|
||||
async playSelectedItems() {
|
||||
this.$store.commit('setProcessingBatch', true)
|
||||
|
||||
@@ -237,26 +287,37 @@ export default {
|
||||
})
|
||||
},
|
||||
batchDeleteClick() {
|
||||
const audiobookText = this.numMediaItemsSelected > 1 ? `these ${this.numMediaItemsSelected} items` : 'this item'
|
||||
const confirmMsg = `Are you sure you want to remove ${audiobookText}?\n\n*Does not delete your files, only removes the items from Audiobookshelf`
|
||||
if (confirm(confirmMsg)) {
|
||||
this.$store.commit('setProcessingBatch', true)
|
||||
this.$axios
|
||||
.$post(`/api/items/batch/delete`, {
|
||||
libraryItemIds: this.selectedMediaItems.map((i) => i.id)
|
||||
})
|
||||
.then(() => {
|
||||
this.$toast.success('Batch delete success!')
|
||||
this.$store.commit('setProcessingBatch', false)
|
||||
this.$store.commit('globals/resetSelectedMediaItems', [])
|
||||
this.$eventBus.$emit('bookshelf_clear_selection')
|
||||
})
|
||||
.catch((error) => {
|
||||
this.$toast.error('Batch delete failed')
|
||||
console.error('Failed to batch delete', error)
|
||||
this.$store.commit('setProcessingBatch', false)
|
||||
})
|
||||
const payload = {
|
||||
message: `This will delete ${this.numMediaItemsSelected} library items from the database and your file system. Are you sure?`,
|
||||
checkboxLabel: 'Delete from file system. Uncheck to only remove from database.',
|
||||
yesButtonText: this.$strings.ButtonDelete,
|
||||
yesButtonColor: 'error',
|
||||
checkboxDefaultValue: true,
|
||||
callback: (confirmed, hardDelete) => {
|
||||
if (confirmed) {
|
||||
this.$store.commit('setProcessingBatch', true)
|
||||
|
||||
this.$axios
|
||||
.$post(`/api/items/batch/delete?hard=${hardDelete ? 1 : 0}`, {
|
||||
libraryItemIds: this.selectedMediaItems.map((i) => i.id)
|
||||
})
|
||||
.then(() => {
|
||||
this.$toast.success('Batch delete success')
|
||||
this.$store.commit('globals/resetSelectedMediaItems', [])
|
||||
this.$eventBus.$emit('bookshelf_clear_selection')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Batch delete failed', error)
|
||||
this.$toast.error('Batch delete failed')
|
||||
})
|
||||
.finally(() => {
|
||||
this.$store.commit('setProcessingBatch', false)
|
||||
})
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
},
|
||||
batchEditClick() {
|
||||
this.$router.push('/batch')
|
||||
|
||||
@@ -28,6 +28,9 @@
|
||||
<widgets-authors-slider v-else-if="shelf.type === 'authors'" :key="index + '.'" :items="shelf.entities" :height="192 * sizeMultiplier" class="bookshelf-row pl-8 my-6">
|
||||
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ $strings[shelf.labelStringKey] }}</p>
|
||||
</widgets-authors-slider>
|
||||
<widgets-narrators-slider v-else-if="shelf.type === 'narrators'" :key="index + '.'" :items="shelf.entities" :height="100 * sizeMultiplier" class="bookshelf-row pl-8 my-6">
|
||||
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ $strings[shelf.labelStringKey] }}</p>
|
||||
</widgets-narrators-slider>
|
||||
</template>
|
||||
</div>
|
||||
<!-- Regular bookshelf view -->
|
||||
@@ -185,8 +188,8 @@ export default {
|
||||
this.shelves = categories
|
||||
},
|
||||
async setShelvesFromSearch() {
|
||||
var shelves = []
|
||||
if (this.results.books && this.results.books.length) {
|
||||
const shelves = []
|
||||
if (this.results.books?.length) {
|
||||
shelves.push({
|
||||
id: 'books',
|
||||
label: 'Books',
|
||||
@@ -196,7 +199,7 @@ export default {
|
||||
})
|
||||
}
|
||||
|
||||
if (this.results.podcasts && this.results.podcasts.length) {
|
||||
if (this.results.podcasts?.length) {
|
||||
shelves.push({
|
||||
id: 'podcasts',
|
||||
label: 'Podcasts',
|
||||
@@ -206,7 +209,7 @@ export default {
|
||||
})
|
||||
}
|
||||
|
||||
if (this.results.series && this.results.series.length) {
|
||||
if (this.results.series?.length) {
|
||||
shelves.push({
|
||||
id: 'series',
|
||||
label: 'Series',
|
||||
@@ -221,7 +224,7 @@ export default {
|
||||
})
|
||||
})
|
||||
}
|
||||
if (this.results.tags && this.results.tags.length) {
|
||||
if (this.results.tags?.length) {
|
||||
shelves.push({
|
||||
id: 'tags',
|
||||
label: 'Tags',
|
||||
@@ -236,7 +239,7 @@ export default {
|
||||
})
|
||||
})
|
||||
}
|
||||
if (this.results.authors && this.results.authors.length) {
|
||||
if (this.results.authors?.length) {
|
||||
shelves.push({
|
||||
id: 'authors',
|
||||
label: 'Authors',
|
||||
@@ -250,6 +253,20 @@ export default {
|
||||
})
|
||||
})
|
||||
}
|
||||
if (this.results.narrators?.length) {
|
||||
shelves.push({
|
||||
id: 'narrators',
|
||||
label: 'Narrators',
|
||||
labelStringKey: 'LabelNarrators',
|
||||
type: 'narrators',
|
||||
entities: this.results.narrators.map((n) => {
|
||||
return {
|
||||
...n,
|
||||
type: 'narrator'
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
this.shelves = shelves
|
||||
},
|
||||
scan() {
|
||||
|
||||
@@ -41,6 +41,11 @@
|
||||
<cards-author-card :key="entity.id" :width="bookCoverWidth / 1.25" :height="bookCoverWidth" :author="entity" :size-multiplier="sizeMultiplier" @hook:updated="updatedBookCard" class="pb-6 mx-2" @edit="editAuthor" />
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="shelf.type === 'narrators'" class="flex items-center">
|
||||
<template v-for="entity in shelf.entities">
|
||||
<cards-narrator-card :key="entity.name" :width="150" :height="100" :narrator="entity" :size-multiplier="sizeMultiplier" @hook:updated="updatedBookCard" class="pb-6 mx-2" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -88,6 +93,7 @@ export default {
|
||||
return this.bookCoverWidth * this.bookCoverAspectRatio
|
||||
},
|
||||
shelfHeight() {
|
||||
if (this.shelf.type === 'narrators') return 148
|
||||
return this.bookCoverHeight + 48
|
||||
},
|
||||
paddingLeft() {
|
||||
|
||||
@@ -64,12 +64,22 @@
|
||||
|
||||
<div class="flex-grow hidden sm:inline-block" />
|
||||
|
||||
<!-- collapse series checkbox -->
|
||||
<ui-checkbox v-if="isLibraryPage && isBookLibrary && !isBatchSelecting" v-model="settings.collapseSeries" :label="$strings.LabelCollapseSeries" checkbox-bg="bg" check-color="white" small class="mr-2" @input="updateCollapseSeries" />
|
||||
|
||||
<!-- library filter select -->
|
||||
<controls-library-filter-select v-if="isLibraryPage && !isBatchSelecting" v-model="settings.filterBy" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateFilter" />
|
||||
|
||||
<!-- library sort select -->
|
||||
<controls-library-sort-select v-if="isLibraryPage && !isBatchSelecting" v-model="settings.orderBy" :descending.sync="settings.orderDesc" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateOrder" />
|
||||
|
||||
<!-- series filter select -->
|
||||
<controls-library-filter-select v-if="isSeriesPage && !isBatchSelecting" v-model="settings.seriesFilterBy" is-series class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateSeriesFilter" />
|
||||
|
||||
<!-- series sort select -->
|
||||
<controls-sort-select v-if="isSeriesPage && !isBatchSelecting" v-model="settings.seriesSortBy" :descending.sync="settings.seriesSortDesc" :items="seriesSortItems" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateSeriesSort" />
|
||||
|
||||
<!-- issues page remove all button -->
|
||||
<ui-btn v-if="isIssuesFilter && userCanDelete && !isBatchSelecting" :loading="processingIssues" color="error" small class="ml-4" @click="removeAllIssues">{{ $strings.ButtonRemoveAll }} {{ numShowing }} {{ entityName }}</ui-btn>
|
||||
</template>
|
||||
<!-- search page -->
|
||||
@@ -153,6 +163,14 @@ export default {
|
||||
text: this.$strings.LabelAddedAt,
|
||||
value: 'addedAt'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelLastBookAdded,
|
||||
value: 'lastBookAdded'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelLastBookUpdated,
|
||||
value: 'lastBookUpdated'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelTotalDuration,
|
||||
value: 'totalDuration'
|
||||
@@ -171,6 +189,9 @@ export default {
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
libraryProvider() {
|
||||
return this.$store.getters['libraries/getLibraryProvider'](this.currentLibraryId) || 'google'
|
||||
},
|
||||
currentLibraryMediaType() {
|
||||
return this.$store.getters['libraries/getCurrentLibraryMediaType']
|
||||
},
|
||||
@@ -305,7 +326,11 @@ export default {
|
||||
const payload = {}
|
||||
if (author.asin) payload.asin = author.asin
|
||||
else payload.q = author.name
|
||||
console.log('Payload', payload, 'author', author)
|
||||
|
||||
payload.region = 'us'
|
||||
if (this.libraryProvider.startsWith('audible.')) {
|
||||
payload.region = this.libraryProvider.split('.').pop() || 'us'
|
||||
}
|
||||
|
||||
this.$eventBus.$emit(`searching-author-${author.id}`, true)
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
<span class="material-icons text-2xl">arrow_back</span>
|
||||
</div>
|
||||
|
||||
<nuxt-link v-for="route in configRoutes" :key="route.id" :to="route.path" class="w-full px-4 h-12 border-b border-primary border-opacity-30 flex items-center cursor-pointer relative" :class="routeName === route.id ? 'bg-primary bg-opacity-70' : 'hover:bg-primary hover:bg-opacity-30'">
|
||||
<p>{{ route.title }}</p>
|
||||
<nuxt-link v-for="route in configRoutes" :key="route.id" :to="route.path" class="w-full px-3 h-12 border-b border-primary border-opacity-30 flex items-center cursor-pointer relative" :class="routeName === route.id ? 'bg-primary bg-opacity-70' : 'hover:bg-primary hover:bg-opacity-30'">
|
||||
<p class="leading-4">{{ route.title }}</p>
|
||||
<div v-show="routeName === route.iod" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link>
|
||||
|
||||
|
||||
@@ -86,6 +86,14 @@
|
||||
<div v-show="isPlaylistsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/download-queue`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPodcastDownloadQueuePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||
<span class="material-icons text-2xl">file_download</span>
|
||||
|
||||
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonDownloadQueue }}</p>
|
||||
|
||||
<div v-show="isPodcastDownloadQueuePage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link v-if="numIssues" :to="`/library/${currentLibraryId}/bookshelf?filter=issues`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-opacity-40 cursor-pointer relative" :class="showingIssues ? 'bg-error bg-opacity-40' : ' bg-error bg-opacity-20'">
|
||||
<span class="material-icons text-2xl">warning</span>
|
||||
|
||||
@@ -149,6 +157,9 @@ export default {
|
||||
isMusicLibrary() {
|
||||
return this.currentLibraryMediaType === 'music'
|
||||
},
|
||||
isPodcastDownloadQueuePage() {
|
||||
return this.$route.name === 'library-library-podcast-download-queue'
|
||||
},
|
||||
isPodcastSearchPage() {
|
||||
return this.$route.name === 'library-library-podcast-search'
|
||||
},
|
||||
@@ -212,4 +223,4 @@ export default {
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
<template>
|
||||
<div v-if="streamLibraryItem" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-48 sm:h-44 md:h-40 z-50 bg-primary px-2 md:px-4 pb-1 md:pb-4 pt-2">
|
||||
<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 id="videoDock" />
|
||||
<nuxt-link v-if="!playerHandler.isVideo" :to="`/item/${streamLibraryItem.id}`" class="absolute left-2 top-2 md:left-4 cursor-pointer">
|
||||
<covers-book-cover :library-item="streamLibraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" />
|
||||
</nuxt-link>
|
||||
<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>
|
||||
<nuxt-link :to="`/item/${streamLibraryItem.id}`" class="hover:underline cursor-pointer text-sm sm:text-lg">
|
||||
<div class="min-w-0">
|
||||
<nuxt-link :to="`/item/${streamLibraryItem.id}`" class="hover:underline cursor-pointer text-sm sm:text-lg block truncate">
|
||||
{{ title }}
|
||||
</nuxt-link>
|
||||
<div v-if="!playerHandler.isVideo" class="text-gray-400 flex items-center">
|
||||
<span class="material-icons text-sm">person</span>
|
||||
<p v-if="podcastAuthor" class="pl-1 sm:pl-1.5 text-xs sm:text-base">{{ podcastAuthor }}</p>
|
||||
<p v-else-if="musicArtists" class="pl-1 sm:pl-1.5 text-xs sm:text-base">{{ musicArtists }}</p>
|
||||
<p v-else-if="authors.length" class="pl-1 sm:pl-1.5 text-xs sm:text-base">
|
||||
<nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">, </span></nuxt-link>
|
||||
</p>
|
||||
<p v-else class="text-xs sm:text-base cursor-pointer pl-1 sm:pl-1.5">{{ $strings.LabelUnknown }}</p>
|
||||
<div class="flex items-center">
|
||||
<div v-if="podcastAuthor" class="pl-1 sm:pl-1.5 text-xs sm:text-base">{{ podcastAuthor }}</div>
|
||||
<div v-else-if="musicArtists" class="pl-1 sm:pl-1.5 text-xs sm:text-base">{{ musicArtists }}</div>
|
||||
<div v-else-if="authors.length" class="pl-1 sm:pl-1.5 text-xs sm:text-base">
|
||||
<nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">, </span></nuxt-link>
|
||||
</div>
|
||||
<div v-else class="text-xs sm:text-base cursor-pointer pl-1 sm:pl-1.5">{{ $strings.LabelUnknown }}</div>
|
||||
<widgets-explicit-indicator :explicit="isExplicit"></widgets-explicit-indicator>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-gray-400 flex items-center">
|
||||
@@ -78,7 +81,7 @@ export default {
|
||||
sleepTimerRemaining: 0,
|
||||
sleepTimer: null,
|
||||
displayTitle: null,
|
||||
initialPlaybackRate: 1,
|
||||
currentPlaybackRate: 1,
|
||||
syncFailedToast: null
|
||||
}
|
||||
},
|
||||
@@ -117,22 +120,31 @@ export default {
|
||||
streamLibraryItem() {
|
||||
return this.$store.state.streamLibraryItem
|
||||
},
|
||||
streamEpisode() {
|
||||
if (!this.$store.state.streamEpisodeId) return null
|
||||
const episodes = this.streamLibraryItem.media.episodes || []
|
||||
return episodes.find((ep) => ep.id === this.$store.state.streamEpisodeId)
|
||||
},
|
||||
libraryItemId() {
|
||||
return this.streamLibraryItem ? this.streamLibraryItem.id : null
|
||||
return this.streamLibraryItem?.id || null
|
||||
},
|
||||
media() {
|
||||
return this.streamLibraryItem ? this.streamLibraryItem.media || {} : {}
|
||||
return this.streamLibraryItem?.media || {}
|
||||
},
|
||||
isPodcast() {
|
||||
return this.streamLibraryItem ? this.streamLibraryItem.mediaType === 'podcast' : false
|
||||
return this.streamLibraryItem?.mediaType === 'podcast'
|
||||
},
|
||||
isMusic() {
|
||||
return this.streamLibraryItem ? this.streamLibraryItem.mediaType === 'music' : false
|
||||
return this.streamLibraryItem?.mediaType === 'music'
|
||||
},
|
||||
isExplicit() {
|
||||
return this.mediaMetadata.explicit || false
|
||||
},
|
||||
mediaMetadata() {
|
||||
return this.media.metadata || {}
|
||||
},
|
||||
chapters() {
|
||||
if (this.streamEpisode) return this.streamEpisode.chapters || []
|
||||
return this.media.chapters || []
|
||||
},
|
||||
title() {
|
||||
@@ -146,7 +158,8 @@ export default {
|
||||
return this.streamLibraryItem ? this.streamLibraryItem.libraryId : null
|
||||
},
|
||||
totalDurationPretty() {
|
||||
return this.$secondsToTimestamp(this.totalDuration)
|
||||
// Adjusted by playback rate
|
||||
return this.$secondsToTimestamp(this.totalDuration / this.currentPlaybackRate)
|
||||
},
|
||||
podcastAuthor() {
|
||||
if (!this.isPodcast) return null
|
||||
@@ -249,7 +262,7 @@ export default {
|
||||
this.playerHandler.setVolume(volume)
|
||||
},
|
||||
setPlaybackRate(playbackRate) {
|
||||
this.initialPlaybackRate = playbackRate
|
||||
this.currentPlaybackRate = playbackRate
|
||||
this.playerHandler.setPlaybackRate(playbackRate)
|
||||
},
|
||||
seek(time) {
|
||||
@@ -378,7 +391,7 @@ export default {
|
||||
libraryItem: session.libraryItem,
|
||||
episodeId: session.episodeId
|
||||
})
|
||||
this.playerHandler.prepareOpenSession(session, this.initialPlaybackRate)
|
||||
this.playerHandler.prepareOpenSession(session, this.currentPlaybackRate)
|
||||
},
|
||||
streamOpen(session) {
|
||||
console.log(`[StreamContainer] Stream session open`, session)
|
||||
@@ -445,7 +458,7 @@ export default {
|
||||
if (this.$refs.audioPlayer) this.$refs.audioPlayer.checkUpdateChapterTrack()
|
||||
})
|
||||
|
||||
this.playerHandler.load(libraryItem, episodeId, true, this.initialPlaybackRate, payload.startTime)
|
||||
this.playerHandler.load(libraryItem, episodeId, true, this.currentPlaybackRate, payload.startTime)
|
||||
},
|
||||
pauseItem() {
|
||||
this.playerHandler.pause()
|
||||
@@ -453,6 +466,13 @@ export default {
|
||||
showFailedProgressSyncs() {
|
||||
if (!isNaN(this.syncFailedToast)) this.$toast.dismiss(this.syncFailedToast)
|
||||
this.syncFailedToast = this.$toast('Progress is not being synced. Restart playback', { timeout: false, type: 'error' })
|
||||
},
|
||||
sessionClosedEvent(sessionId) {
|
||||
if (this.playerHandler.currentSessionId === sessionId) {
|
||||
console.log('sessionClosedEvent closing current session', sessionId)
|
||||
this.playerHandler.resetPlayer() // Closes player without reporting to server
|
||||
this.$store.commit('setMediaPlaying', null)
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
@@ -474,4 +494,4 @@ export default {
|
||||
#streamContainer {
|
||||
box-shadow: 0px -6px 8px #1111113f;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -77,6 +77,12 @@ export default {
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
},
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
libraryProvider() {
|
||||
return this.$store.getters['libraries/getLibraryProvider'](this.currentLibraryId) || 'google'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -92,6 +98,11 @@ export default {
|
||||
if (this.asin) payload.asin = this.asin
|
||||
else payload.q = this.name
|
||||
|
||||
payload.region = 'us'
|
||||
if (this.libraryProvider.startsWith('audible.')) {
|
||||
payload.region = this.libraryProvider.split('.').pop() || 'us'
|
||||
}
|
||||
|
||||
var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, payload).catch((error) => {
|
||||
console.error('Failed', error)
|
||||
return null
|
||||
|
||||
@@ -28,7 +28,11 @@
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="px-4 flex-grow">
|
||||
<h1>{{ book.title }}</h1>
|
||||
<h1>
|
||||
<div class="flex items-center">
|
||||
{{ book.title }}<widgets-explicit-indicator :explicit="book.explicit" />
|
||||
</div>
|
||||
</h1>
|
||||
<p class="text-base text-gray-300 whitespace-nowrap truncate">by {{ book.author }}</p>
|
||||
<p v-if="book.genres" class="text-xs text-gray-400 leading-5">{{ book.genres.join(', ') }}</p>
|
||||
<p class="text-xs text-gray-400 leading-5">{{ book.trackCount }} Episodes</p>
|
||||
@@ -78,4 +82,4 @@ export default {
|
||||
this.selectedCover = this.bookCovers.length ? this.bookCovers[0] : this.book.cover || null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<p v-if="matchKey !== 'authors'" class="text-xs text-gray-200 truncate">by {{ authorName }}</p>
|
||||
<p v-else class="truncate text-xs text-gray-200" v-html="matchHtml" />
|
||||
|
||||
<div v-if="matchKey === 'series' || matchKey === 'tags' || matchKey === 'isbn' || matchKey === 'asin' || matchKey === 'episode'" class="m-0 p-0 truncate text-xs" v-html="matchHtml" />
|
||||
<div v-if="matchKey === 'series' || matchKey === 'tags' || matchKey === 'isbn' || matchKey === 'asin' || matchKey === 'episode' || matchKey === 'narrators'" class="m-0 p-0 truncate text-xs" v-html="matchHtml" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -67,12 +67,13 @@ export default {
|
||||
// but with removing commas periods etc this is no longer plausible
|
||||
const html = this.matchText
|
||||
|
||||
if (this.matchKey === 'episode') return `<p class="truncate">Episode: ${html}</p>`
|
||||
if (this.matchKey === 'tags') return `<p class="truncate">Tags: ${html}</p>`
|
||||
if (this.matchKey === 'episode') return `<p class="truncate">${this.$strings.LabelEpisode}: ${html}</p>`
|
||||
if (this.matchKey === 'tags') return `<p class="truncate">${this.$strings.LabelTags}: ${html}</p>`
|
||||
if (this.matchKey === 'authors') return `by ${html}`
|
||||
if (this.matchKey === 'isbn') return `<p class="truncate">ISBN: ${html}</p>`
|
||||
if (this.matchKey === 'asin') return `<p class="truncate">ASIN: ${html}</p>`
|
||||
if (this.matchKey === 'series') return `<p class="truncate">Series: ${html}</p>`
|
||||
if (this.matchKey === 'series') return `<p class="truncate">${this.$strings.LabelSeries}: ${html}</p>`
|
||||
if (this.matchKey === 'narrators') return `<p class="truncate">${this.$strings.LabelNarrator}: ${html}</p>`
|
||||
return `${html}`
|
||||
}
|
||||
},
|
||||
|
||||
85
client/components/cards/ItemTaskRunningCard.vue
Normal file
85
client/components/cards/ItemTaskRunningCard.vue
Normal file
@@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<div class="flex items-center h-full px-1 overflow-hidden">
|
||||
<div class="h-5 w-5 min-w-5 text-lg mr-1.5 flex items-center justify-center">
|
||||
<span v-if="isFinished" :class="taskIconStatus" class="material-icons text-base">{{actionIcon}}</span>
|
||||
<widgets-loading-spinner v-else />
|
||||
</div>
|
||||
<div class="flex-grow px-2 taskRunningCardContent">
|
||||
<p class="truncate text-sm">{{ title }}</p>
|
||||
|
||||
<p class="truncate text-xs text-gray-300">{{ description }}</p>
|
||||
|
||||
<p v-if="isFailed && failedMessage" class="text-xs truncate text-red-500">{{ failedMessage }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
task: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
title() {
|
||||
return this.task.title || 'No Title'
|
||||
},
|
||||
description() {
|
||||
return this.task.description || ''
|
||||
},
|
||||
details() {
|
||||
return this.task.details || 'Unknown'
|
||||
},
|
||||
isFinished() {
|
||||
return this.task.isFinished || false
|
||||
},
|
||||
isFailed() {
|
||||
return this.task.isFailed || false
|
||||
},
|
||||
failedMessage() {
|
||||
return this.task.error || ''
|
||||
},
|
||||
action() {
|
||||
return this.task.action || ''
|
||||
},
|
||||
actionIcon() {
|
||||
switch (this.action) {
|
||||
case 'download-podcast-episode':
|
||||
return 'cloud_download'
|
||||
case 'encode-m4b':
|
||||
return 'sync'
|
||||
default:
|
||||
return 'settings'
|
||||
}
|
||||
},
|
||||
taskIconStatus() {
|
||||
if (this.isFinished && this.isFailed) {
|
||||
return 'text-red-500'
|
||||
}
|
||||
if (this.isFinished && !this.isFailed) {
|
||||
return 'text-green-500'
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.taskRunningCardContent {
|
||||
width: calc(100% - 80px);
|
||||
height: 75px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
@@ -7,9 +7,12 @@
|
||||
|
||||
<!-- Alternative bookshelf title/author/sort -->
|
||||
<div v-if="isAlternativeBookshelfView || isAuthorBookshelfView" class="absolute left-0 z-50 w-full" :style="{ bottom: `-${titleDisplayBottomOffset}rem` }">
|
||||
<p class="truncate" :style="{ fontSize: 0.9 * sizeMultiplier + 'rem' }">
|
||||
{{ displayTitle }}
|
||||
</p>
|
||||
<div :style="{ fontSize: 0.9 * sizeMultiplier + 'rem' }">
|
||||
<div class="flex items-center">
|
||||
<span class="truncate">{{ displayTitle }}</span>
|
||||
<widgets-explicit-indicator :explicit="isExplicit" />
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
@@ -102,8 +105,10 @@
|
||||
</div>
|
||||
|
||||
<!-- Podcast Episode # -->
|
||||
<div v-if="recentEpisodeNumber && !isHovering && !isSelectionMode && !processing" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">Episode #{{ recentEpisodeNumber }}</p>
|
||||
<div v-if="recentEpisodeNumber !== null && !isHovering && !isSelectionMode && !processing" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">
|
||||
Episode<span v-if="recentEpisodeNumber"> #{{ recentEpisodeNumber }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Podcast Num Episodes -->
|
||||
@@ -193,6 +198,9 @@ export default {
|
||||
isMusic() {
|
||||
return this.mediaType === 'music'
|
||||
},
|
||||
isExplicit() {
|
||||
return this.mediaMetadata.explicit || false
|
||||
},
|
||||
placeholderUrl() {
|
||||
const config = this.$config || this.$nuxt.$config
|
||||
return `${config.routerBasePath}/book_placeholder.jpg`
|
||||
@@ -236,7 +244,7 @@ export default {
|
||||
if (this.recentEpisode.episode) {
|
||||
return this.recentEpisode.episode.replace(/^#/, '')
|
||||
}
|
||||
return this.recentEpisode.index
|
||||
return ''
|
||||
},
|
||||
collapsedSeries() {
|
||||
// Only added to item object when collapseSeries is enabled
|
||||
@@ -317,8 +325,13 @@ export default {
|
||||
if (this.episodeProgress) return this.episodeProgress
|
||||
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId)
|
||||
},
|
||||
useEBookProgress() {
|
||||
if (!this.userProgress || this.userProgress.progress) return false
|
||||
return this.userProgress.ebookProgress > 0
|
||||
},
|
||||
userProgressPercent() {
|
||||
return this.userProgress ? this.userProgress.progress || 0 : 0
|
||||
if (this.useEBookProgress) return Math.max(Math.min(1, this.userProgress.ebookProgress), 0)
|
||||
return this.userProgress ? Math.max(Math.min(1, this.userProgress.progress), 0) || 0 : 0
|
||||
},
|
||||
itemIsFinished() {
|
||||
return this.userProgress ? !!this.userProgress.isFinished : false
|
||||
@@ -513,6 +526,14 @@ export default {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.userCanDelete) {
|
||||
items.push({
|
||||
func: 'deleteLibraryItem',
|
||||
text: this.$strings.ButtonDelete
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
},
|
||||
_socket() {
|
||||
@@ -734,7 +755,7 @@ export default {
|
||||
episodeId: this.recentEpisode.id,
|
||||
title: this.recentEpisode.title,
|
||||
subtitle: this.mediaMetadata.title,
|
||||
caption: this.recentEpisode.publishedAt ? `Published ${this.$formatDate(this.recentEpisode.publishedAt, 'MMM do, yyyy')}` : 'Unknown publish date',
|
||||
caption: this.recentEpisode.publishedAt ? `Published ${this.$formatDate(this.recentEpisode.publishedAt, this.dateFormat)}` : 'Unknown publish date',
|
||||
duration: this.recentEpisode.audioFile.duration || null,
|
||||
coverPath: this.media.coverPath || null
|
||||
}
|
||||
@@ -764,6 +785,35 @@ export default {
|
||||
this.store.commit('globals/setSelectedPlaylistItems', [{ libraryItem: this.libraryItem, episode: this.recentEpisode }])
|
||||
this.store.commit('globals/setShowPlaylistsModal', true)
|
||||
},
|
||||
deleteLibraryItem() {
|
||||
const payload = {
|
||||
message: 'This will delete the library item from the database and your file system. Are you sure?',
|
||||
checkboxLabel: 'Delete from file system. Uncheck to only remove from database.',
|
||||
yesButtonText: this.$strings.ButtonDelete,
|
||||
yesButtonColor: 'error',
|
||||
checkboxDefaultValue: true,
|
||||
callback: (confirmed, hardDelete) => {
|
||||
if (confirmed) {
|
||||
this.processing = true
|
||||
const axios = this.$axios || this.$nuxt.$axios
|
||||
axios
|
||||
.$delete(`/api/items/${this.libraryItemId}?hard=${hardDelete ? 1 : 0}`)
|
||||
.then(() => {
|
||||
this.$toast.success('Item deleted')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to delete item', error)
|
||||
this.$toast.error('Failed to delete item')
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
})
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.store.commit('globals/setConfirmPrompt', payload)
|
||||
},
|
||||
createMoreMenu() {
|
||||
if (!this.$refs.moreIcon) return
|
||||
|
||||
@@ -858,7 +908,7 @@ export default {
|
||||
episodeId: episode.id,
|
||||
title: episode.title,
|
||||
subtitle: this.mediaMetadata.title,
|
||||
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, 'MMM do, yyyy')}` : 'Unknown publish date',
|
||||
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, this.dateFormat)}` : 'Unknown publish date',
|
||||
duration: episode.audioFile.duration || null,
|
||||
coverPath: this.media.coverPath || null
|
||||
})
|
||||
|
||||
@@ -81,13 +81,20 @@ export default {
|
||||
return this.title
|
||||
},
|
||||
displaySortLine() {
|
||||
if (this.orderBy === 'addedAt') {
|
||||
// return this.addedAt
|
||||
return 'Added ' + this.$formatDate(this.addedAt, this.dateFormat)
|
||||
} else if (this.orderBy === 'totalDuration') {
|
||||
return 'Duration: ' + this.$elapsedPrettyExtended(this.totalDuration, false)
|
||||
switch (this.orderBy) {
|
||||
case 'addedAt':
|
||||
return `${this.$strings.LabelAdded} ${this.$formatDate(this.addedAt, this.dateFormat)}`
|
||||
case 'totalDuration':
|
||||
return `${this.$strings.LabelDuration} ${this.$elapsedPrettyExtended(this.totalDuration, false)}`
|
||||
case 'lastBookUpdated':
|
||||
const lastUpdated = Math.max(...(this.books).map(x => x.updatedAt), 0)
|
||||
return `${this.$strings.LabelLastBookUpdated} ${this.$formatDate(lastUpdated, this.dateFormat)}`
|
||||
case 'lastBookAdded':
|
||||
const lastBookAdded = Math.max(...(this.books).map(x => x.addedAt), 0)
|
||||
return `${this.$strings.LabelLastBookAdded} ${this.$formatDate(lastBookAdded, this.dateFormat)}`
|
||||
default:
|
||||
return null
|
||||
}
|
||||
return null
|
||||
},
|
||||
books() {
|
||||
return this.series ? this.series.books || [] : []
|
||||
|
||||
50
client/components/cards/NarratorCard.vue
Normal file
50
client/components/cards/NarratorCard.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=narrators.${$encode(narrator.name)}`">
|
||||
<div :style="{ width: width + 'px', height: height + 'px' }" class="bg-primary box-shadow-book rounded-md relative overflow-hidden">
|
||||
<div class="absolute inset-0 w-full h-full flex items-center justify-center pointer-events-none opacity-20">
|
||||
<span class="material-icons-outlined text-8xl">record_voice_over</span>
|
||||
</div>
|
||||
|
||||
<!-- Narrator name & num books overlay -->
|
||||
<div class="absolute bottom-0 left-0 w-full py-1 bg-black bg-opacity-60 px-2">
|
||||
<p class="text-center font-semibold truncate" :style="{ fontSize: sizeMultiplier * 0.75 + 'rem' }">{{ name }}</p>
|
||||
<p class="text-center text-gray-200" :style="{ fontSize: sizeMultiplier * 0.65 + 'rem' }">{{ numBooks }} Book{{ numBooks === 1 ? '' : 's' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
narrator: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
width: Number,
|
||||
height: Number,
|
||||
sizeMultiplier: {
|
||||
type: Number,
|
||||
default: 1
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
name() {
|
||||
return this.narrator?.name || ''
|
||||
},
|
||||
numBooks() {
|
||||
return this.narrator?.books?.length || 0
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
},
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
}
|
||||
},
|
||||
methods: {}
|
||||
}
|
||||
</script>
|
||||
34
client/components/cards/NarratorSearchCard.vue
Normal file
34
client/components/cards/NarratorSearchCard.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<div class="flex h-full px-1 overflow-hidden">
|
||||
<div class="w-10 h-10 flex items-center justify-center">
|
||||
<span class="material-icons text-2xl text-gray-200">record_voice_over</span>
|
||||
</div>
|
||||
<div class="flex-grow px-2 narratorSearchCardContent h-full">
|
||||
<p class="truncate text-sm">{{ narrator }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
narrator: String
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.narratorSearchCardContent {
|
||||
width: calc(100% - 40px);
|
||||
height: 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
@@ -63,6 +63,15 @@
|
||||
</nuxt-link>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<p v-if="narratorResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">{{ $strings.LabelNarrators }}</p>
|
||||
<template v-for="narrator in narratorResults">
|
||||
<li :key="narrator.name" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
|
||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=narrators.${$encode(narrator.name)}`">
|
||||
<cards-narrator-search-card :narrator="narrator.name" />
|
||||
</nuxt-link>
|
||||
</li>
|
||||
</template>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -84,6 +93,7 @@ export default {
|
||||
authorResults: [],
|
||||
seriesResults: [],
|
||||
tagResults: [],
|
||||
narratorResults: [],
|
||||
searchTimeout: null,
|
||||
lastSearch: null
|
||||
}
|
||||
@@ -114,6 +124,7 @@ export default {
|
||||
this.authorResults = []
|
||||
this.seriesResults = []
|
||||
this.tagResults = []
|
||||
this.narratorResults = []
|
||||
this.showMenu = false
|
||||
this.isFetching = false
|
||||
this.isTyping = false
|
||||
@@ -142,7 +153,7 @@ export default {
|
||||
}
|
||||
this.isFetching = true
|
||||
|
||||
var searchResults = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/search?q=${value}&limit=3`).catch((error) => {
|
||||
const searchResults = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/search?q=${value}&limit=3`).catch((error) => {
|
||||
console.error('Search error', error)
|
||||
return []
|
||||
})
|
||||
@@ -155,6 +166,7 @@ export default {
|
||||
this.authorResults = searchResults.authors || []
|
||||
this.seriesResults = searchResults.series || []
|
||||
this.tagResults = searchResults.tags || []
|
||||
this.narratorResults = searchResults.narrators || []
|
||||
|
||||
this.isFetching = false
|
||||
if (!this.showMenu) {
|
||||
|
||||
@@ -185,6 +185,11 @@ export default {
|
||||
value: 'tracks',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelAbridged,
|
||||
value: 'abridged',
|
||||
sublist: false
|
||||
},
|
||||
{
|
||||
text: this.$strings.ButtonIssues,
|
||||
value: 'issues',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div ref="wrapper" class="relative ml-4 sm:ml-8" v-click-outside="clickOutside">
|
||||
<div class="flex items-center justify-center text-gray-300 cursor-pointer h-full" @mousedown.prevent @mouseup.prevent @click="setShowMenu(true)">
|
||||
<span class="font-mono uppercase text-gray-200 text-sm sm:text-base">{{ playbackRate.toFixed(1) }}<span class="text-base sm:text-lg">⨯</span></span>
|
||||
<span class="font-mono uppercase text-gray-200 text-sm sm:text-base">{{ playbackRate.toFixed(1) }}<span class="text-base">x</span></span>
|
||||
</div>
|
||||
<div v-show="showMenu" class="absolute -top-20 z-20 bg-bg border-black-200 border shadow-xl rounded-lg" :style="{ left: menuLeft + 'px' }">
|
||||
<div class="absolute -bottom-1.5 right-0 w-full flex justify-center" :style="{ left: arrowLeft + 'px' }">
|
||||
@@ -11,7 +11,7 @@
|
||||
<template v-for="rate in rates">
|
||||
<div :key="rate" class="h-full border-black-300 w-11 cursor-pointer border rounded-sm" :class="value === rate ? 'bg-black-100' : 'hover:bg-black hover:bg-opacity-10'" style="min-width: 44px; max-width: 44px" @click="set(rate)">
|
||||
<div class="w-full h-full flex justify-center items-center">
|
||||
<p class="text-xs text-center font-mono">{{ rate }}<span class="text-sm">⨯</span></p>
|
||||
<p class="text-xs text-center font-mono">{{ rate }}<span class="text-sm">x</span></p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -19,7 +19,7 @@
|
||||
<div class="w-full py-1 px-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<ui-icon-btn :disabled="!canDecrement" icon="remove" @click="decrement" />
|
||||
<p class="px-2 text-2xl sm:text-3xl">{{ playbackRate }}<span class="text-2xl">⨯</span></p>
|
||||
<p class="px-2 text-2xl sm:text-3xl">{{ playbackRate }}<span class="text-2xl">x</span></p>
|
||||
<ui-icon-btn :disabled="!canIncrement" icon="add" @click="increment" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<form @submit.prevent="submitForm">
|
||||
<div class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300">
|
||||
<div class="px-4 w-full 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 py-2">
|
||||
<div class="w-1/2 px-2">
|
||||
@@ -96,7 +96,14 @@
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!newUser.permissions.accessAllTags" class="my-4">
|
||||
<ui-multi-select-dropdown v-model="newUser.itemTagsAccessible" :items="itemTags" :label="$strings.LabelTagsAccessibleToUser" />
|
||||
<div class="flex items-center">
|
||||
<ui-multi-select-dropdown v-model="newUser.itemTagsSelected" :items="itemTags" :label="tagsSelectionText" />
|
||||
<div class="flex items-center pt-4 px-2">
|
||||
<p class="px-3 font-semibold" id="selected-tags-not-accessible--permissions-toggle">{{ $strings.LabelInvert }}</p>
|
||||
<ui-toggle-switch labeledBy="selected-tags-not-accessible--permissions-toggle" v-model="newUser.permissions.selectedTagsNotAccessible" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -185,6 +192,9 @@ export default {
|
||||
value: t
|
||||
}
|
||||
})
|
||||
},
|
||||
tagsSelectionText() {
|
||||
return this.newUser.permissions.selectedTagsNotAccessible ? this.$strings.LabelTagsNotAccessibleToUser : this.$strings.LabelTagsAccessibleToUser
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -193,8 +203,11 @@ export default {
|
||||
if (this.$refs.modal) this.$refs.modal.setHide()
|
||||
},
|
||||
accessAllTagsToggled(val) {
|
||||
if (val && this.newUser.itemTagsAccessible.length) {
|
||||
this.newUser.itemTagsAccessible = []
|
||||
if (val) {
|
||||
if (this.newUser.itemTagsSelected?.length) {
|
||||
this.newUser.itemTagsSelected = []
|
||||
}
|
||||
this.newUser.permissions.selectedTagsNotAccessible = false
|
||||
}
|
||||
},
|
||||
fetchAllTags() {
|
||||
@@ -226,7 +239,7 @@ export default {
|
||||
this.$toast.error('Must select at least one library')
|
||||
return
|
||||
}
|
||||
if (!this.newUser.permissions.accessAllTags && !this.newUser.itemTagsAccessible.length) {
|
||||
if (!this.newUser.permissions.accessAllTags && !this.newUser.itemTagsSelected.length) {
|
||||
this.$toast.error('Must select at least one tag')
|
||||
return
|
||||
}
|
||||
@@ -307,12 +320,12 @@ export default {
|
||||
delete: type === 'admin',
|
||||
upload: type === 'admin',
|
||||
accessAllLibraries: true,
|
||||
accessAllTags: true
|
||||
accessAllTags: true,
|
||||
selectedTagsNotAccessible: false
|
||||
}
|
||||
},
|
||||
init() {
|
||||
this.fetchAllTags()
|
||||
|
||||
this.isNew = !this.account
|
||||
if (this.account) {
|
||||
this.newUser = {
|
||||
@@ -322,9 +335,10 @@ export default {
|
||||
isActive: this.account.isActive,
|
||||
permissions: { ...this.account.permissions },
|
||||
librariesAccessible: [...(this.account.librariesAccessible || [])],
|
||||
itemTagsAccessible: [...(this.account.itemTagsAccessible || [])]
|
||||
itemTagsSelected: [...(this.account.itemTagsSelected || [])]
|
||||
}
|
||||
} else {
|
||||
this.fetchAllTags()
|
||||
this.newUser = {
|
||||
username: null,
|
||||
password: null,
|
||||
@@ -336,7 +350,8 @@ export default {
|
||||
delete: false,
|
||||
upload: false,
|
||||
accessAllLibraries: true,
|
||||
accessAllTags: true
|
||||
accessAllTags: true,
|
||||
selectedTagsNotAccessible: false
|
||||
},
|
||||
librariesAccessible: []
|
||||
}
|
||||
|
||||
118
client/components/modals/AudioFileDataModal.vue
Normal file
118
client/components/modals/AudioFileDataModal.vue
Normal file
@@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" name="audiofile-data-modal" :width="700" :height="'unset'">
|
||||
<div v-if="audioFile" ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden p-6" style="max-height: 80vh">
|
||||
<p class="text-base text-gray-200">{{ metadata.filename }}</p>
|
||||
|
||||
<div class="w-full h-px bg-white bg-opacity-10 my-4" />
|
||||
|
||||
<ui-text-input-with-label :value="metadata.path" readonly :label="$strings.LabelPath" class="mb-4 text-sm" />
|
||||
|
||||
<div class="flex flex-col sm:flex-row text-sm">
|
||||
<div class="w-full sm:w-1/2">
|
||||
<div class="flex mb-1">
|
||||
<p class="w-32 text-black-50">
|
||||
{{ $strings.LabelSize }}
|
||||
</p>
|
||||
<p>{{ $bytesPretty(metadata.size) }}</p>
|
||||
</div>
|
||||
<div class="flex mb-1">
|
||||
<p class="w-32 text-black-50">
|
||||
{{ $strings.LabelDuration }}
|
||||
</p>
|
||||
<p>{{ $secondsToTimestamp(audioFile.duration) }}</p>
|
||||
</div>
|
||||
<div class="flex mb-1">
|
||||
<p class="w-32 text-black-50">{{ $strings.LabelFormat }}</p>
|
||||
<p>{{ audioFile.format }}</p>
|
||||
</div>
|
||||
<div class="flex mb-1">
|
||||
<p class="w-32 text-black-50">
|
||||
{{ $strings.LabelChapters }}
|
||||
</p>
|
||||
<p>{{ audioFile.chapters?.length || 0 }}</p>
|
||||
</div>
|
||||
<div v-if="audioFile.embeddedCoverArt" class="flex mb-1">
|
||||
<p class="w-32 text-black-50">
|
||||
{{ $strings.LabelEmbeddedCover }}
|
||||
</p>
|
||||
<p>{{ audioFile.embeddedCoverArt || '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full sm:w-1/2">
|
||||
<div class="flex mb-1">
|
||||
<p class="w-32 text-black-50">
|
||||
{{ $strings.LabelCodec }}
|
||||
</p>
|
||||
<p>{{ audioFile.codec }}</p>
|
||||
</div>
|
||||
<div class="flex mb-1">
|
||||
<p class="w-32 text-black-50">
|
||||
{{ $strings.LabelChannels }}
|
||||
</p>
|
||||
<p>{{ audioFile.channels }} ({{ audioFile.channelLayout }})</p>
|
||||
</div>
|
||||
<div class="flex mb-1">
|
||||
<p class="w-32 text-black-50">
|
||||
{{ $strings.LabelBitrate }}
|
||||
</p>
|
||||
<p>{{ $bytesPretty(audioFile.bitRate || 0, 0) }}</p>
|
||||
</div>
|
||||
<div class="flex mb-1">
|
||||
<p class="w-32 text-black-50">{{ $strings.LabelTimeBase }}</p>
|
||||
<p>{{ audioFile.timeBase }}</p>
|
||||
</div>
|
||||
<div v-if="audioFile.language" class="flex mb-1">
|
||||
<p class="w-32 text-black-50">
|
||||
{{ $strings.LabelLanguage }}
|
||||
</p>
|
||||
<p>{{ audioFile.language || '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full h-px bg-white bg-opacity-10 my-4" />
|
||||
|
||||
<p class="font-bold mb-2">{{ $strings.LabelMetaTags }}</p>
|
||||
|
||||
<div v-for="(value, key) in metaTags" :key="key" class="flex mb-1 text-sm">
|
||||
<p class="w-32 min-w-32 text-black-50 mb-1">
|
||||
{{ key.replace('tag', '') }}
|
||||
</p>
|
||||
<p>{{ value }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
audioFile: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
metadata() {
|
||||
return this.audioFile?.metadata || {}
|
||||
},
|
||||
metaTags() {
|
||||
return this.audioFile?.metaTags || {}
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -73,6 +73,12 @@ export default {
|
||||
},
|
||||
canCreateBookmark() {
|
||||
return !this.bookmarks.find((bm) => bm.time === this.currentTime)
|
||||
},
|
||||
dateFormat() {
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
},
|
||||
timeFormat() {
|
||||
return this.$store.state.serverSettings.timeFormat
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -111,7 +117,7 @@ export default {
|
||||
},
|
||||
submitCreateBookmark() {
|
||||
if (!this.newBookmarkTitle) {
|
||||
this.newBookmarkTitle = this.$formatDate(Date.now(), 'MMM dd, yyyy HH:mm')
|
||||
this.newBookmarkTitle = this.$formatDatetime(Date.now(), this.dateFormat, this.timeFormat)
|
||||
}
|
||||
var bookmark = {
|
||||
title: this.newBookmarkTitle,
|
||||
@@ -134,4 +140,4 @@ export default {
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
<modals-modal v-model="show" name="chapters" :width="600" :height="'unset'">
|
||||
<div id="chapter-modal-wrapper" ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
||||
<template v-for="chap in chapters">
|
||||
<div :key="chap.id" :id="`chapter-row-${chap.id}`" class="flex items-center px-6 py-3 justify-start cursor-pointer hover:bg-bg relative" :class="chap.id === currentChapterId ? 'bg-yellow-400 bg-opacity-10' : chap.end <= currentChapterStart ? 'bg-success bg-opacity-5' : 'bg-opacity-20'" @click="clickChapter(chap)">
|
||||
<div :key="chap.id" :id="`chapter-row-${chap.id}`" class="flex items-center px-6 py-3 justify-start cursor-pointer hover:bg-bg relative" :class="chap.id === currentChapterId ? 'bg-yellow-400 bg-opacity-10' : chap.end / _playbackRate <= currentChapterStart ? 'bg-success bg-opacity-5' : 'bg-opacity-20'" @click="clickChapter(chap)">
|
||||
<p class="chapter-title truncate text-sm md:text-base">
|
||||
{{ chap.title }}
|
||||
</p>
|
||||
<span class="font-mono text-xxs sm:text-xs text-gray-400 pl-2 whitespace-nowrap">{{ $elapsedPrettyExtended(chap.end - chap.start) }}</span>
|
||||
<span class="font-mono text-xxs sm:text-xs text-gray-400 pl-2 whitespace-nowrap">{{ $elapsedPrettyExtended((chap.end - chap.start) / _playbackRate) }}</span>
|
||||
<span class="flex-grow" />
|
||||
<span class="font-mono text-xs sm:text-sm text-gray-300">{{ $secondsToTimestamp(chap.start) }}</span>
|
||||
<span class="font-mono text-xs sm:text-sm text-gray-300">{{ $secondsToTimestamp(chap.start / _playbackRate) }}</span>
|
||||
|
||||
<div v-show="chap.id === currentChapterId" class="w-0.5 h-full absolute top-0 left-0 bg-yellow-400" />
|
||||
</div>
|
||||
@@ -28,7 +28,8 @@ export default {
|
||||
currentChapter: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
}
|
||||
},
|
||||
playbackRate: Number
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
@@ -47,11 +48,15 @@ export default {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
_playbackRate() {
|
||||
if (!this.playbackRate || isNaN(this.playbackRate)) return 1
|
||||
return this.playbackRate
|
||||
},
|
||||
currentChapterId() {
|
||||
return this.currentChapter ? this.currentChapter.id : null
|
||||
},
|
||||
currentChapterStart() {
|
||||
return this.currentChapter ? this.currentChapter.start : 0
|
||||
return (this.currentChapter?.start || 0) / this._playbackRate
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -61,13 +66,11 @@ export default {
|
||||
scrollToChapter() {
|
||||
if (!this.currentChapterId) return
|
||||
|
||||
var container = this.$refs.container
|
||||
if (container) {
|
||||
var currChapterEl = document.getElementById(`chapter-row-${this.currentChapterId}`)
|
||||
if (this.$refs.container) {
|
||||
const currChapterEl = document.getElementById(`chapter-row-${this.currentChapterId}`)
|
||||
if (currChapterEl) {
|
||||
var offsetTop = currChapterEl.offsetTop
|
||||
var containerHeight = container.clientHeight
|
||||
container.scrollTo({ top: offsetTop - containerHeight / 2 })
|
||||
const containerHeight = this.$refs.container.clientHeight
|
||||
this.$refs.container.scrollTo({ top: currChapterEl.offsetTop - containerHeight / 2 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,13 +19,13 @@
|
||||
<div class="flex items-center -mx-1 mb-1">
|
||||
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelStartedAt }}</div>
|
||||
<div class="px-1">
|
||||
{{ $formatDate(_session.startedAt, 'MMMM do, yyyy HH:mm') }}
|
||||
{{ $formatDatetime(_session.startedAt, dateFormat, timeFormat) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center -mx-1 mb-1">
|
||||
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelUpdatedAt }}</div>
|
||||
<div class="px-1">
|
||||
{{ $formatDate(_session.updatedAt, 'MMMM do, yyyy HH:mm') }}
|
||||
{{ $formatDatetime(_session.updatedAt, dateFormat, timeFormat) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center -mx-1 mb-1">
|
||||
@@ -98,7 +98,8 @@
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<ui-btn small color="error" @click.stop="deleteSessionClick">{{ $strings.ButtonDelete }}</ui-btn>
|
||||
<ui-btn v-if="!isOpenSession" small color="error" @click.stop="deleteSessionClick">{{ $strings.ButtonDelete }}</ui-btn>
|
||||
<ui-btn v-else small color="error" @click.stop="closeSessionClick">Close Open Session</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</modals-modal>
|
||||
@@ -151,6 +152,15 @@ export default {
|
||||
else if (playMethod === this.$constants.PlayMethod.DIRECTSTREAM) return 'Direct Stream'
|
||||
else if (playMethod === this.$constants.PlayMethod.LOCAL) return 'Local'
|
||||
return 'Unknown'
|
||||
},
|
||||
dateFormat() {
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
},
|
||||
timeFormat() {
|
||||
return this.$store.state.serverSettings.timeFormat
|
||||
},
|
||||
isOpenSession() {
|
||||
return !!this._session.open
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -182,8 +192,26 @@ export default {
|
||||
var errMsg = error.response ? error.response.data || '' : ''
|
||||
this.$toast.error(errMsg || this.$strings.ToastSessionDeleteFailed)
|
||||
})
|
||||
},
|
||||
closeSessionClick() {
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$post(`/api/session/${this._session.id}/close`)
|
||||
.then(() => {
|
||||
this.$toast.success('Session closed')
|
||||
this.show = false
|
||||
this.$emit('closedSession')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to close session', error)
|
||||
const errMsg = error.response?.data || ''
|
||||
this.$toast.error(errMsg || 'Failed to close open session')
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -9,10 +9,14 @@
|
||||
<div ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
||||
<div v-if="!timerSet" class="w-full">
|
||||
<template v-for="time in sleepTimes">
|
||||
<div :key="time.text" class="flex items-center px-6 py-3 justify-center cursor-pointer hover:bg-bg relative" @click="setTime(time)">
|
||||
<div :key="time.text" class="flex items-center px-6 py-3 justify-center cursor-pointer hover:bg-bg relative" @click="setTime(time.seconds)">
|
||||
<p class="text-xl text-center">{{ time.text }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<form class="flex items-center justify-center px-6 py-3" @submit.prevent="submitCustomTime">
|
||||
<ui-text-input v-model="customTime" type="number" step="any" min="0.1" placeholder="Time in minutes" class="w-48" />
|
||||
<ui-btn color="success" type="submit" padding-x="0" class="h-9 w-12 flex items-center justify-center ml-1">Set</ui-btn>
|
||||
</form>
|
||||
</div>
|
||||
<div v-else class="w-full p-4">
|
||||
<div class="mb-4 flex items-center justify-center">
|
||||
@@ -48,19 +52,28 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
customTime: null,
|
||||
sleepTimes: [
|
||||
{
|
||||
seconds: 10,
|
||||
text: '10 seconds'
|
||||
},
|
||||
{
|
||||
seconds: 60 * 5,
|
||||
text: '5 minutes'
|
||||
},
|
||||
{
|
||||
seconds: 60 * 15,
|
||||
text: '15 minutes'
|
||||
},
|
||||
{
|
||||
seconds: 60 * 20,
|
||||
text: '20 minutes'
|
||||
},
|
||||
{
|
||||
seconds: 60 * 30,
|
||||
text: '30 minutes'
|
||||
},
|
||||
{
|
||||
seconds: 60 * 45,
|
||||
text: '45 minutes'
|
||||
},
|
||||
{
|
||||
seconds: 60 * 60,
|
||||
text: '60 minutes'
|
||||
@@ -72,10 +85,6 @@ export default {
|
||||
{
|
||||
seconds: 60 * 120,
|
||||
text: '2 hours'
|
||||
},
|
||||
{
|
||||
seconds: 60 * 180,
|
||||
text: '3 hours'
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -97,8 +106,17 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
setTime(time) {
|
||||
this.$emit('set', time.seconds)
|
||||
submitCustomTime() {
|
||||
if (!this.customTime || isNaN(this.customTime) || Number(this.customTime) <= 0) {
|
||||
this.customTime = null
|
||||
return
|
||||
}
|
||||
|
||||
const timeInSeconds = Math.round(Number(this.customTime) * 60)
|
||||
this.setTime(timeInSeconds)
|
||||
},
|
||||
setTime(seconds) {
|
||||
this.$emit('set', seconds)
|
||||
},
|
||||
increment(amount) {
|
||||
this.$emit('increment', amount)
|
||||
|
||||
@@ -85,6 +85,12 @@ export default {
|
||||
},
|
||||
title() {
|
||||
return this.$strings.HeaderUpdateAuthor
|
||||
},
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
libraryProvider() {
|
||||
return this.$store.getters['libraries/getLibraryProvider'](this.currentLibraryId) || 'google'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -151,6 +157,11 @@ export default {
|
||||
if (this.authorCopy.asin) payload.asin = this.authorCopy.asin
|
||||
else payload.q = this.authorCopy.name
|
||||
|
||||
payload.region = 'us'
|
||||
if (this.libraryProvider.startsWith('audible.')) {
|
||||
payload.region = this.libraryProvider.split('.').pop() || 'us'
|
||||
}
|
||||
|
||||
var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, payload).catch((error) => {
|
||||
console.error('Failed', error)
|
||||
return null
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
<div class="w-full h-full overflow-hidden overflow-y-auto px-2 sm:px-4 py-6 relative">
|
||||
<div class="flex flex-wrap">
|
||||
<div class="relative">
|
||||
<covers-book-cover :library-item="libraryItem" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
<covers-preview-cover :src="$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId, null, true)" :width="120" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
|
||||
<!-- book cover overlay -->
|
||||
<div v-if="media.coverPath" class="absolute top-0 left-0 w-full h-full z-10 opacity-0 hover:opacity-100 transition-opacity duration-100">
|
||||
<div class="absolute top-0 left-0 w-full h-16 bg-gradient-to-b from-black-600 to-transparent" />
|
||||
@@ -27,14 +28,14 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div v-if="localCovers.length" class="mb-4 mt-6 border-t border-b border-primary">
|
||||
<div v-if="localCovers.length" class="mb-4 mt-6 border-t border-b border-white border-opacity-10">
|
||||
<div class="flex items-center justify-center py-2">
|
||||
<p>{{ localCovers.length }} local image{{ localCovers.length !== 1 ? 's' : '' }}</p>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn small @click="showLocalCovers = !showLocalCovers">{{ showLocalCovers ? $strings.ButtonHide : $strings.ButtonShow }}</ui-btn>
|
||||
</div>
|
||||
|
||||
<div v-if="showLocalCovers" class="flex items-center justify-center">
|
||||
<div v-if="showLocalCovers" class="flex items-center justify-center pb-2">
|
||||
<template v-for="cover in localCovers">
|
||||
<div :key="cover.path" class="m-0.5 mb-5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover.metadata.path === coverPath ? 'border-yellow-300' : ''" @click="setCover(cover)">
|
||||
<div class="h-24 bg-primary" :style="{ width: 96 / bookCoverAspectRatio + 'px' }">
|
||||
@@ -48,13 +49,13 @@
|
||||
</div>
|
||||
<form @submit.prevent="submitSearchForm">
|
||||
<div class="flex items-center justify-start -mx-1 h-20">
|
||||
<div class="w-40 px-1">
|
||||
<div class="w-48 px-1">
|
||||
<ui-dropdown v-model="provider" :items="providers" :label="$strings.LabelProvider" small />
|
||||
</div>
|
||||
<div class="w-72 px-1">
|
||||
<ui-text-input-with-label v-model="searchTitle" :label="searchTitleLabel" :placeholder="$strings.PlaceholderSearch" />
|
||||
</div>
|
||||
<div v-show="provider != 'itunes'" class="w-72 px-1">
|
||||
<div v-show="provider != 'itunes' && provider != 'audiobookcovers'" class="w-72 px-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>
|
||||
@@ -127,7 +128,7 @@ export default {
|
||||
},
|
||||
providers() {
|
||||
if (this.isPodcast) return this.$store.state.scanners.podcastProviders
|
||||
return this.$store.state.scanners.providers
|
||||
return [...this.$store.state.scanners.providers, ...this.$store.state.scanners.coverOnlyProviders]
|
||||
},
|
||||
searchTitleLabel() {
|
||||
if (this.provider.startsWith('audible')) return this.$strings.LabelSearchTitleOrASIN
|
||||
|
||||
@@ -7,11 +7,6 @@
|
||||
|
||||
<div class="absolute bottom-0 left-0 w-full py-2 md:py-4 bg-bg" :class="isScrollable ? 'box-shadow-md-up' : 'border-t border-white border-opacity-5'">
|
||||
<div class="flex items-center px-4">
|
||||
<ui-btn v-if="userCanDelete" color="error" type="button" class="h-8 hidden md:block" :padding-x="3" small @click.stop.prevent="removeItem">{{ $strings.ButtonRemove }}</ui-btn>
|
||||
<ui-icon-btn bg-color="error" icon="delete" class="md:hidden" :size="7" icon-font-size="1rem" @click.stop.prevent="removeItem" />
|
||||
|
||||
<div class="flex-grow" />
|
||||
|
||||
<ui-tooltip :disabled="!!quickMatching" :text="$getString('MessageQuickMatchDescription', [libraryProvider])" direction="bottom" class="mr-2 md:mr-4">
|
||||
<ui-btn v-if="userIsAdminOrUp" :loading="quickMatching" color="bg" type="button" class="h-full" small @click.stop.prevent="quickMatch">{{ $strings.ButtonQuickMatch }}</ui-btn>
|
||||
</ui-tooltip>
|
||||
@@ -20,6 +15,8 @@
|
||||
<ui-btn v-if="userIsAdminOrUp && !isFile" :loading="rescanning" :disabled="!!libraryScan" color="bg" type="button" class="h-full" small @click.stop.prevent="rescan">{{ $strings.ButtonReScan }}</ui-btn>
|
||||
</ui-tooltip>
|
||||
|
||||
<div class="flex-grow" />
|
||||
|
||||
<!-- desktop -->
|
||||
<ui-btn @click="save" class="mx-2 hidden md:block">{{ $strings.ButtonSave }}</ui-btn>
|
||||
<ui-btn @click="saveAndClose" class="mx-2 hidden md:block">{{ $strings.ButtonSaveAndClose }}</ui-btn>
|
||||
@@ -77,9 +74,6 @@ export default {
|
||||
mediaMetadata() {
|
||||
return this.media.metadata || {}
|
||||
},
|
||||
userCanDelete() {
|
||||
return this.$store.getters['user/getUserCanDelete']
|
||||
},
|
||||
libraryId() {
|
||||
return this.libraryItem ? this.libraryItem.libraryId : null
|
||||
},
|
||||
@@ -184,23 +178,6 @@ export default {
|
||||
}
|
||||
return false
|
||||
},
|
||||
removeItem() {
|
||||
if (confirm(`Are you sure you want to remove this item?\n\n*Does not delete your files, only removes the item from audiobookshelf`)) {
|
||||
this.isProcessing = true
|
||||
this.$axios
|
||||
.$delete(`/api/items/${this.libraryItemId}`)
|
||||
.then(() => {
|
||||
console.log('Item removed')
|
||||
this.$toast.success('Item Removed')
|
||||
this.$emit('close')
|
||||
this.isProcessing = false
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Remove item failed', error)
|
||||
this.isProcessing = false
|
||||
})
|
||||
}
|
||||
},
|
||||
checkIsScrollable() {
|
||||
this.$nextTick(() => {
|
||||
var formWrapper = document.getElementById('formWrapper')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
|
||||
<tables-library-files-table expanded :files="libraryFiles" :library-item-id="libraryItem.id" :is-missing="isMissing" />
|
||||
<tables-library-files-table expanded :library-item="libraryItem" :is-missing="isMissing" in-modal />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -30,9 +30,6 @@ export default {
|
||||
media() {
|
||||
return this.libraryItem.media || {}
|
||||
},
|
||||
libraryFiles() {
|
||||
return this.libraryItem.libraryFiles || []
|
||||
},
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
|
||||
@@ -34,13 +34,25 @@
|
||||
</div>
|
||||
<ui-checkbox v-model="selectAll" checkbox-bg="bg" @input="selectAllToggled" />
|
||||
<form @submit.prevent="submitMatchUpdate">
|
||||
<div v-if="selectedMatchOrig.cover" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.cover" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<ui-text-input-with-label v-model="selectedMatch.cover" :disabled="!selectedMatchUsage.cover" readonly :label="$strings.LabelCover" class="flex-grow mx-4" />
|
||||
<div class="min-w-12 max-w-12 md:min-w-16 md:max-w-16">
|
||||
<a :href="selectedMatch.cover" target="_blank" class="w-full bg-primary">
|
||||
<img :src="selectedMatch.cover" class="h-full w-full object-contain" />
|
||||
</a>
|
||||
<div v-if="selectedMatchOrig.cover" class="flex flex-wrap md:flex-nowrap items-center justify-center">
|
||||
<div class="flex flex-grow items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.cover" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<ui-text-input-with-label v-model="selectedMatch.cover" :disabled="!selectedMatchUsage.cover" readonly :label="$strings.LabelCover" class="flex-grow mx-4" />
|
||||
</div>
|
||||
|
||||
<div class="flex py-2">
|
||||
<div>
|
||||
<p class="text-center text-gray-200">New</p>
|
||||
<a :href="selectedMatch.cover" target="_blank" class="bg-primary">
|
||||
<covers-preview-cover :src="selectedMatch.cover" :width="100" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
</a>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatchOrig.title" class="flex items-center py-2">
|
||||
@@ -103,7 +115,7 @@
|
||||
<div v-if="selectedMatchOrig.genres && selectedMatchOrig.genres.length" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.genres" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-multi-select v-model="selectedMatch.genres" :items="selectedMatch.genres" :disabled="!selectedMatchUsage.genres" :label="$strings.LabelGenres" />
|
||||
<ui-multi-select v-model="selectedMatch.genres" :items="genres" :disabled="!selectedMatchUsage.genres" :label="$strings.LabelGenres" />
|
||||
<p v-if="mediaMetadata.genres" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.genres.join(', ') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -164,6 +176,20 @@
|
||||
<p v-if="mediaMetadata.releaseDate" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.releaseDate || '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatchOrig.explicit != null" class="flex items-center pb-2" :class="{ 'pt-2': mediaMetadata.explicit == null }">
|
||||
<ui-checkbox v-model="selectedMatchUsage.explicit" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4" :class="{ 'pt-4': mediaMetadata.explicit != null }">
|
||||
<ui-checkbox v-model="selectedMatch.explicit" :label="$strings.LabelExplicit" :disabled="!selectedMatchUsage.explicit" :checkbox-bg="!selectedMatchUsage.explicit ? 'bg' : 'primary'" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
|
||||
<p v-if="mediaMetadata.explicit != null" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.explicit ? 'Explicit (checked)' : 'Not Explicit (unchecked)' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatchOrig.abridged != null" class="flex items-center pb-2" :class="{ 'pt-2': mediaMetadata.abridged == null }">
|
||||
<ui-checkbox v-model="selectedMatchUsage.abridged" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4" :class="{ 'pt-4': mediaMetadata.abridged != null }">
|
||||
<ui-checkbox v-model="selectedMatch.abridged" :label="$strings.LabelAbridged" :disabled="!selectedMatchUsage.abridged" :checkbox-bg="!selectedMatchUsage.abridged ? 'bg' : 'primary'" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
|
||||
<p v-if="mediaMetadata.abridged != null" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.abridged ? 'Abridged (checked)' : 'Unabridged (unchecked)' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end py-2">
|
||||
<ui-btn color="success" type="submit">{{ $strings.ButtonSubmit }}</ui-btn>
|
||||
@@ -209,6 +235,7 @@ export default {
|
||||
explicit: true,
|
||||
asin: true,
|
||||
isbn: true,
|
||||
abridged: true,
|
||||
// Podcast specific
|
||||
itunesPageUrl: true,
|
||||
itunesId: true,
|
||||
@@ -273,6 +300,12 @@ export default {
|
||||
},
|
||||
isPodcast() {
|
||||
return this.mediaType == 'podcast'
|
||||
},
|
||||
genres() {
|
||||
const filterData = this.$store.state.libraries.filterData || {}
|
||||
const currentGenres = filterData.genres || []
|
||||
const selectedMatchGenres = this.selectedMatch.genres || []
|
||||
return [...new Set([...currentGenres ,...selectedMatchGenres])]
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -327,6 +360,7 @@ export default {
|
||||
res.itunesPageUrl = res.pageUrl || null
|
||||
res.itunesId = res.id || null
|
||||
res.author = res.artistName || null
|
||||
res.explicit = res.explicit || false
|
||||
return res
|
||||
})
|
||||
}
|
||||
@@ -352,6 +386,7 @@ export default {
|
||||
explicit: true,
|
||||
asin: true,
|
||||
isbn: true,
|
||||
abridged: true,
|
||||
// Podcast specific
|
||||
itunesPageUrl: true,
|
||||
itunesId: true,
|
||||
@@ -468,7 +503,6 @@ export default {
|
||||
} else if (key === 'narrator') {
|
||||
updatePayload.metadata.narrators = this.selectedMatch[key].split(',').map((v) => v.trim())
|
||||
} else if (key === 'genres') {
|
||||
// updatePayload.metadata.genres = this.selectedMatch[key].split(',').map((v) => v.trim())
|
||||
updatePayload.metadata.genres = [...this.selectedMatch[key]]
|
||||
} else if (key === 'tags') {
|
||||
updatePayload.tags = this.selectedMatch[key].split(',').map((v) => v.trim())
|
||||
|
||||
@@ -59,6 +59,14 @@ export default {
|
||||
newMaxNewEpisodesToDownload: 0
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
libraryItem: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
if (newVal) this.init()
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isProcessing: {
|
||||
get() {
|
||||
@@ -176,4 +184,4 @@ export default {
|
||||
height: calc(100% - 80px);
|
||||
max-height: calc(100% - 80px);
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -46,8 +46,20 @@
|
||||
>{{ $strings.ButtonOpenManager }}
|
||||
<span class="material-icons text-lg ml-2">launch</span>
|
||||
</ui-btn>
|
||||
|
||||
<ui-btn v-if="!isMetadataEmbedQueued && !isEmbedTaskRunning" class="w-full mt-4" small @click.stop="quickEmbed">Quick Embed</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- queued alert -->
|
||||
<widgets-alert v-if="isMetadataEmbedQueued" type="warning" class="mt-4">
|
||||
<p class="text-lg">Queued for metadata embed ({{ queuedEmbedLIds.length }} in queue)</p>
|
||||
</widgets-alert>
|
||||
|
||||
<!-- processing alert -->
|
||||
<widgets-alert v-if="isEmbedTaskRunning" type="warning" class="mt-4">
|
||||
<p class="text-lg">Currently embedding metadata</p>
|
||||
</widgets-alert>
|
||||
</div>
|
||||
|
||||
<p v-if="!mediaTracks.length" class="text-lg text-center my-8">{{ $strings.MessageNoAudioTracks }}</p>
|
||||
@@ -71,10 +83,10 @@ export default {
|
||||
return this.$store.state.showExperimentalFeatures
|
||||
},
|
||||
libraryItemId() {
|
||||
return this.libraryItem ? this.libraryItem.id : null
|
||||
return this.libraryItem?.id || null
|
||||
},
|
||||
media() {
|
||||
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||
return this.libraryItem?.media || {}
|
||||
},
|
||||
mediaTracks() {
|
||||
return this.media.tracks || []
|
||||
@@ -92,9 +104,49 @@ export default {
|
||||
showMp3Split() {
|
||||
if (!this.mediaTracks.length) return false
|
||||
return this.isSingleM4b && this.chapters.length
|
||||
},
|
||||
queuedEmbedLIds() {
|
||||
return this.$store.state.tasks.queuedEmbedLIds || []
|
||||
},
|
||||
isMetadataEmbedQueued() {
|
||||
return this.queuedEmbedLIds.some((lid) => lid === this.libraryItemId)
|
||||
},
|
||||
tasks() {
|
||||
return this.$store.getters['tasks/getTasksByLibraryItemId'](this.libraryItemId)
|
||||
},
|
||||
embedTask() {
|
||||
return this.tasks.find((t) => t.action === 'embed-metadata')
|
||||
},
|
||||
encodeTask() {
|
||||
return this.tasks.find((t) => t.action === 'encode-m4b')
|
||||
},
|
||||
isEmbedTaskRunning() {
|
||||
return this.embedTask && !this.embedTask?.isFinished
|
||||
},
|
||||
isEncodeTaskRunning() {
|
||||
return this.encodeTask && !this.encodeTask?.isFinished
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
methods: {
|
||||
quickEmbed() {
|
||||
const payload = {
|
||||
message: 'Warning! Quick embed will not backup your audio files. Make sure that you have a backup of your audio files. <br><br>Would you like to continue?',
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.$axios
|
||||
.$post(`/api/tools/item/${this.libraryItemId}/embed-metadata`)
|
||||
.then(() => {
|
||||
console.log('Audio metadata encode started')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Audio metadata encode failed', error)
|
||||
})
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -10,7 +10,7 @@
|
||||
<div class="w-full px-3 py-5 md:p-12">
|
||||
<ui-dropdown v-model="newNotification.eventName" :label="$strings.LabelNotificationEvent" :items="eventOptions" class="mb-4" @input="eventOptionUpdated" />
|
||||
|
||||
<ui-multi-select v-model="newNotification.urls" :label="$strings.LabelNotificationAppriseURL" class="mb-2" />
|
||||
<ui-multi-select ref="urlsInput" v-model="newNotification.urls" :label="$strings.LabelNotificationAppriseURL" class="mb-2" />
|
||||
|
||||
<ui-text-input-with-label v-model="newNotification.titleTemplate" :label="$strings.LabelNotificationTitleTemplate" class="mb-2" />
|
||||
|
||||
@@ -103,6 +103,8 @@ export default {
|
||||
if (this.$refs.modal) this.$refs.modal.setHide()
|
||||
},
|
||||
submitForm() {
|
||||
this.$refs.urlsInput?.forceBlur()
|
||||
|
||||
if (!this.newNotification.urls.length) {
|
||||
this.$toast.error('Must enter an Apprise URL')
|
||||
return
|
||||
|
||||
@@ -11,8 +11,15 @@
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-show="canGoPrev" class="absolute -left-24 top-0 bottom-0 h-full pointer-events-none flex items-center px-6">
|
||||
<div class="material-icons text-5xl text-white text-opacity-50 hover:text-opacity-90 cursor-pointer pointer-events-auto" @click.stop.prevent="goPrevEpisode" @mousedown.prevent>arrow_back_ios</div>
|
||||
</div>
|
||||
<div v-show="canGoNext" class="absolute -right-24 top-0 bottom-0 h-full pointer-events-none flex items-center px-6">
|
||||
<div class="material-icons text-5xl text-white text-opacity-50 hover:text-opacity-90 cursor-pointer pointer-events-auto" @click.stop.prevent="goNextEpisode" @mousedown.prevent>arrow_forward_ios</div>
|
||||
</div>
|
||||
|
||||
<div ref="wrapper" class="p-4 w-full text-sm rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300 relative overflow-y-auto" style="max-height: 80vh">
|
||||
<component v-if="libraryItem && show" :is="tabComponentName" :library-item="libraryItem" :episode="episode" :processing.sync="processing" @close="show = false" @selectTab="selectTab" />
|
||||
<component v-if="libraryItem && show" :is="tabComponentName" :library-item="libraryItem" :episode="episodeItem" :processing.sync="processing" @close="show = false" @selectTab="selectTab" />
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
@@ -21,8 +28,8 @@
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
episodeItem: null,
|
||||
processing: false,
|
||||
selectedTab: 'details',
|
||||
tabs: [
|
||||
{
|
||||
id: 'details',
|
||||
@@ -37,6 +44,29 @@ export default {
|
||||
]
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
show: {
|
||||
handler(newVal) {
|
||||
if (newVal) {
|
||||
const availableTabIds = this.tabs.map((tab) => tab.id)
|
||||
if (!availableTabIds.length) {
|
||||
this.show = false
|
||||
return
|
||||
}
|
||||
|
||||
if (!availableTabIds.includes(this.selectedTab)) {
|
||||
this.selectedTab = availableTabIds[0]
|
||||
}
|
||||
|
||||
this.episodeItem = null
|
||||
this.init()
|
||||
this.registerListeners()
|
||||
} else {
|
||||
this.unregisterListeners()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
@@ -46,27 +76,118 @@ export default {
|
||||
this.$store.commit('globals/setShowEditPodcastEpisodeModal', val)
|
||||
}
|
||||
},
|
||||
selectedTab: {
|
||||
get() {
|
||||
return this.$store.state.editPodcastModalTab
|
||||
},
|
||||
set(val) {
|
||||
this.$store.commit('setEditPodcastModalTab', val)
|
||||
}
|
||||
},
|
||||
libraryItem() {
|
||||
return this.$store.state.selectedLibraryItem
|
||||
},
|
||||
episode() {
|
||||
return this.$store.state.globals.selectedEpisode
|
||||
},
|
||||
selectedEpisodeId() {
|
||||
return this.episode.id
|
||||
},
|
||||
title() {
|
||||
if (!this.libraryItem) return ''
|
||||
return this.libraryItem.media.metadata.title || 'Unknown'
|
||||
return this.libraryItem?.media.metadata.title || 'Unknown'
|
||||
},
|
||||
tabComponentName() {
|
||||
var _tab = this.tabs.find((t) => t.id === this.selectedTab)
|
||||
const _tab = this.tabs.find((t) => t.id === this.selectedTab)
|
||||
return _tab ? _tab.component : ''
|
||||
},
|
||||
episodeTableEpisodeIds() {
|
||||
return this.$store.state.episodeTableEpisodeIds || []
|
||||
},
|
||||
currentEpisodeIndex() {
|
||||
if (!this.episodeTableEpisodeIds.length) return 0
|
||||
return this.episodeTableEpisodeIds.findIndex((bid) => bid === this.selectedEpisodeId)
|
||||
},
|
||||
canGoPrev() {
|
||||
return this.episodeTableEpisodeIds.length && this.currentEpisodeIndex > 0
|
||||
},
|
||||
canGoNext() {
|
||||
return this.episodeTableEpisodeIds.length && this.currentEpisodeIndex < this.episodeTableEpisodeIds.length - 1
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async goPrevEpisode() {
|
||||
if (this.currentEpisodeIndex - 1 < 0) return
|
||||
const prevEpisodeId = this.episodeTableEpisodeIds[this.currentEpisodeIndex - 1]
|
||||
this.processing = true
|
||||
const prevEpisode = await this.$axios.$get(`/api/podcasts/${this.libraryItem.id}/episode/${prevEpisodeId}`).catch((error) => {
|
||||
const errorMsg = error.response && error.response.data ? error.response.data : 'Failed to fetch episode'
|
||||
this.$toast.error(errorMsg)
|
||||
return null
|
||||
})
|
||||
this.processing = false
|
||||
if (prevEpisode) {
|
||||
this.episodeItem = prevEpisode
|
||||
this.$store.commit('globals/setSelectedEpisode', prevEpisode)
|
||||
} else {
|
||||
console.error('Episode not found', prevEpisodeId)
|
||||
}
|
||||
},
|
||||
async goNextEpisode() {
|
||||
if (this.currentEpisodeIndex >= this.episodeTableEpisodeIds.length - 1) return
|
||||
this.processing = true
|
||||
const nextEpisodeId = this.episodeTableEpisodeIds[this.currentEpisodeIndex + 1]
|
||||
const nextEpisode = await this.$axios.$get(`/api/podcasts/${this.libraryItem.id}/episode/${nextEpisodeId}`).catch((error) => {
|
||||
const errorMsg = error.response && error.response.data ? error.response.data : 'Failed to fetch book'
|
||||
this.$toast.error(errorMsg)
|
||||
return null
|
||||
})
|
||||
this.processing = false
|
||||
if (nextEpisode) {
|
||||
this.episodeItem = nextEpisode
|
||||
this.$store.commit('globals/setSelectedEpisode', nextEpisode)
|
||||
} else {
|
||||
console.error('Episode not found', nextEpisodeId)
|
||||
}
|
||||
},
|
||||
selectTab(tab) {
|
||||
this.selectedTab = tab
|
||||
if (this.selectedTab === tab) return
|
||||
if (this.tabs.find((t) => t.id === tab)) {
|
||||
this.selectedTab = tab
|
||||
this.processing = false
|
||||
}
|
||||
},
|
||||
init() {
|
||||
this.fetchFull()
|
||||
},
|
||||
async fetchFull() {
|
||||
try {
|
||||
this.processing = true
|
||||
this.episodeItem = await this.$axios.$get(`/api/podcasts/${this.libraryItem.id}/episode/${this.selectedEpisodeId}`)
|
||||
this.processing = false
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch episode', this.selectedEpisodeId, error)
|
||||
this.processing = false
|
||||
this.show = false
|
||||
}
|
||||
},
|
||||
hotkey(action) {
|
||||
if (action === this.$hotkeys.Modal.NEXT_PAGE) {
|
||||
this.goNextEpisode()
|
||||
} else if (action === this.$hotkeys.Modal.PREV_PAGE) {
|
||||
this.goPrevEpisode()
|
||||
}
|
||||
},
|
||||
registerListeners() {
|
||||
this.$eventBus.$on('modal-hotkey', this.hotkey)
|
||||
},
|
||||
unregisterListeners() {
|
||||
this.$eventBus.$off('modal-hotkey', this.hotkey)
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
mounted() {},
|
||||
beforeDestroy() {
|
||||
this.unregisterListeners()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -77,4 +198,4 @@ export default {
|
||||
.tab.tab-selected {
|
||||
height: 41px;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -6,21 +6,33 @@
|
||||
</div>
|
||||
</template>
|
||||
<div ref="wrapper" id="podcast-wrapper" class="p-4 w-full text-sm py-2 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden">
|
||||
<div v-if="episodesCleaned.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>
|
||||
<div ref="episodeContainer" id="episodes-scroll" class="w-full overflow-x-hidden overflow-y-auto">
|
||||
<div
|
||||
v-for="(episode, index) in episodes"
|
||||
v-for="(episode, index) in episodesList"
|
||||
:key="index"
|
||||
class="relative"
|
||||
:class="itemEpisodeMap[episode.enclosure.url] ? 'bg-primary bg-opacity-40' : selectedEpisodes[String(index)] ? 'cursor-pointer bg-success bg-opacity-10' : index % 2 == 0 ? 'cursor-pointer bg-primary bg-opacity-25 hover:bg-opacity-40' : 'cursor-pointer bg-primary bg-opacity-5 hover:bg-opacity-25'"
|
||||
@click="toggleSelectEpisode(index, episode)"
|
||||
:class="itemEpisodeMap[episode.cleanUrl] ? 'bg-primary bg-opacity-40' : selectedEpisodes[episode.cleanUrl] ? 'cursor-pointer bg-success bg-opacity-10' : index % 2 == 0 ? 'cursor-pointer bg-primary bg-opacity-25 hover:bg-opacity-40' : 'cursor-pointer bg-primary bg-opacity-5 hover:bg-opacity-25'"
|
||||
@click="toggleSelectEpisode(episode)"
|
||||
>
|
||||
<div class="absolute top-0 left-0 h-full flex items-center p-2">
|
||||
<span v-if="itemEpisodeMap[episode.enclosure.url]" class="material-icons text-success text-xl">download_done</span>
|
||||
<ui-checkbox v-else v-model="selectedEpisodes[String(index)]" small checkbox-bg="primary" border-color="gray-600" />
|
||||
<span v-if="itemEpisodeMap[episode.cleanUrl]" class="material-icons text-success text-xl">download_done</span>
|
||||
<ui-checkbox v-else v-model="selectedEpisodes[episode.cleanUrl]" small checkbox-bg="primary" border-color="gray-600" />
|
||||
</div>
|
||||
<div class="px-8 py-2">
|
||||
<p v-if="episode.episode" class="font-semibold text-gray-200">#{{ episode.episode }}</p>
|
||||
<p class="break-words mb-1">{{ episode.title }}</p>
|
||||
<div class="flex items-center font-semibold text-gray-200">
|
||||
<div v-if="episode.season || episode.episode">#</div>
|
||||
<div v-if="episode.season">{{ episode.season }}x</div>
|
||||
<div v-if="episode.episode">{{ episode.episode }}</div>
|
||||
</div>
|
||||
<div class="flex items-center mb-1">
|
||||
<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 class="text-xs text-gray-300">Published {{ episode.publishedAt ? $dateDistanceFromNow(episode.publishedAt) : 'Unknown' }}</p>
|
||||
</div>
|
||||
@@ -51,8 +63,12 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
processing: false,
|
||||
episodesCleaned: [],
|
||||
selectedEpisodes: {},
|
||||
selectAll: false
|
||||
selectAll: false,
|
||||
search: null,
|
||||
searchTimeout: null,
|
||||
searchText: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -77,7 +93,7 @@ export default {
|
||||
return this.libraryItem.media.metadata.title || 'Unknown'
|
||||
},
|
||||
allDownloaded() {
|
||||
return !this.episodes.some((episode) => !this.itemEpisodeMap[episode.enclosure.url])
|
||||
return !this.episodesCleaned.some((episode) => !this.itemEpisodeMap[episode.cleanUrl])
|
||||
},
|
||||
episodesSelected() {
|
||||
return Object.keys(this.selectedEpisodes).filter((key) => !!this.selectedEpisodes[key])
|
||||
@@ -93,38 +109,52 @@ export default {
|
||||
itemEpisodeMap() {
|
||||
var map = {}
|
||||
this.itemEpisodes.forEach((item) => {
|
||||
if (item.enclosure) map[item.enclosure.url] = true
|
||||
if (item.enclosure) map[item.enclosure.url.split('?')[0]] = true
|
||||
})
|
||||
return map
|
||||
},
|
||||
episodesList() {
|
||||
return this.episodesCleaned.filter((episode) => {
|
||||
if (!this.searchText) return true
|
||||
return (episode.title && episode.title.toLowerCase().includes(this.searchText)) || (episode.subtitle && episode.subtitle.toLowerCase().includes(this.searchText))
|
||||
})
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
inputUpdate() {
|
||||
clearTimeout(this.searchTimeout)
|
||||
this.searchTimeout = setTimeout(() => {
|
||||
if (!this.search || !this.search.trim()) {
|
||||
this.searchText = ''
|
||||
return
|
||||
}
|
||||
this.searchText = this.search.toLowerCase().trim()
|
||||
}, 500)
|
||||
},
|
||||
toggleSelectAll(val) {
|
||||
for (let i = 0; i < this.episodes.length; i++) {
|
||||
const episode = this.episodes[i]
|
||||
if (this.itemEpisodeMap[episode.enclosure.url]) this.selectedEpisodes[String(i)] = false
|
||||
else this.$set(this.selectedEpisodes, String(i), val)
|
||||
for (const episode of this.episodesCleaned) {
|
||||
if (this.itemEpisodeMap[episode.cleanUrl]) this.selectedEpisodes[episode.cleanUrl] = false
|
||||
else this.$set(this.selectedEpisodes, episode.cleanUrl, val)
|
||||
}
|
||||
},
|
||||
checkSetIsSelectedAll() {
|
||||
for (let i = 0; i < this.episodes.length; i++) {
|
||||
const episode = this.episodes[i]
|
||||
if (!this.itemEpisodeMap[episode.enclosure.url] && !this.selectedEpisodes[String(i)]) {
|
||||
for (const episode of this.episodesCleaned) {
|
||||
if (!this.itemEpisodeMap[episode.cleanUrl] && !this.selectedEpisodes[episode.cleanUrl]) {
|
||||
this.selectAll = false
|
||||
return
|
||||
}
|
||||
}
|
||||
this.selectAll = true
|
||||
},
|
||||
toggleSelectEpisode(index, episode) {
|
||||
if (this.itemEpisodeMap[episode.enclosure.url]) return
|
||||
this.$set(this.selectedEpisodes, String(index), !this.selectedEpisodes[String(index)])
|
||||
toggleSelectEpisode(episode) {
|
||||
if (this.itemEpisodeMap[episode.enclosure.url?.split('?')[0]]) return
|
||||
this.$set(this.selectedEpisodes, episode.cleanUrl, !this.selectedEpisodes[episode.cleanUrl])
|
||||
this.checkSetIsSelectedAll()
|
||||
},
|
||||
submit() {
|
||||
var episodesToDownload = []
|
||||
if (this.episodesSelected.length) {
|
||||
episodesToDownload = this.episodesSelected.map((episodeIndex) => this.episodes[Number(episodeIndex)])
|
||||
episodesToDownload = this.episodesSelected.map((cleanUrl) => this.episodesCleaned.find((ep) => ep.cleanUrl == cleanUrl))
|
||||
}
|
||||
|
||||
var payloadSize = JSON.stringify(episodesToDownload).length
|
||||
@@ -154,7 +184,15 @@ export default {
|
||||
})
|
||||
},
|
||||
init() {
|
||||
this.episodes.sort((a, b) => (a.publishedAt < b.publishedAt ? 1 : -1))
|
||||
this.episodesCleaned = this.episodes
|
||||
.filter((ep) => ep.enclosure?.url)
|
||||
.map((_ep) => {
|
||||
return {
|
||||
..._ep,
|
||||
cleanUrl: _ep.enclosure.url.split('?')[0]
|
||||
}
|
||||
})
|
||||
this.episodesCleaned.sort((a, b) => (a.publishedAt < b.publishedAt ? 1 : -1))
|
||||
this.selectAll = false
|
||||
this.selectedEpisodes = {}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,17 @@
|
||||
<ui-multi-select v-model="podcast.genres" :items="podcast.genres" :label="$strings.LabelGenres" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap">
|
||||
<div class="md:w-1/4 p-2">
|
||||
<ui-dropdown :label="$strings.LabelPodcastType" v-model="podcast.type" :items="podcastTypes" small />
|
||||
</div>
|
||||
<div class="md:w-1/4 p-2">
|
||||
<ui-text-input-with-label v-model="podcast.language" :label="$strings.LabelLanguage" />
|
||||
</div>
|
||||
<div class="md:w-1/4 px-2 pt-7">
|
||||
<ui-checkbox v-model="podcast.explicit" :label="$strings.LabelExplicit" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-2 w-full">
|
||||
<ui-textarea-with-label v-model="podcast.description" :label="$strings.LabelDescription" :rows="3" />
|
||||
</div>
|
||||
@@ -82,7 +93,10 @@ export default {
|
||||
itunesPageUrl: '',
|
||||
itunesId: '',
|
||||
itunesArtistId: '',
|
||||
autoDownloadEpisodes: false
|
||||
autoDownloadEpisodes: false,
|
||||
language: '',
|
||||
explicit: false,
|
||||
type: ''
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -140,6 +154,9 @@ export default {
|
||||
selectedFolderPath() {
|
||||
if (!this.selectedFolder) return ''
|
||||
return this.selectedFolder.fullPath
|
||||
},
|
||||
podcastTypes() {
|
||||
return this.$store.state.globals.podcastTypes || []
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -170,7 +187,9 @@ export default {
|
||||
itunesPageUrl: this.podcast.itunesPageUrl,
|
||||
itunesId: this.podcast.itunesId,
|
||||
itunesArtistId: this.podcast.itunesArtistId,
|
||||
language: this.podcast.language
|
||||
language: this.podcast.language,
|
||||
explicit: this.podcast.explicit,
|
||||
type: this.podcast.type
|
||||
},
|
||||
autoDownloadEpisodes: this.podcast.autoDownloadEpisodes
|
||||
}
|
||||
@@ -205,9 +224,11 @@ export default {
|
||||
this.podcast.itunesPageUrl = this._podcastData.pageUrl || ''
|
||||
this.podcast.itunesId = this._podcastData.id || ''
|
||||
this.podcast.itunesArtistId = this._podcastData.artistId || ''
|
||||
this.podcast.language = this._podcastData.language || ''
|
||||
this.podcast.language = this._podcastData.language || this.feedMetadata.language || ''
|
||||
this.podcast.autoDownloadEpisodes = false
|
||||
this.podcast.type = this._podcastData.type || this.feedMetadata.type || 'episodic'
|
||||
|
||||
this.podcast.explicit = this._podcastData.explicit || this.feedMetadata.explicit === 'yes' || this.feedMetadata.explicit == 'true'
|
||||
if (this.folderItems[0]) {
|
||||
this.selectedFolderId = this.folderItems[0].value
|
||||
this.folderUpdated()
|
||||
@@ -226,4 +247,4 @@ export default {
|
||||
#episodes-scroll {
|
||||
max-height: calc(80vh - 200px);
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<ui-text-input-with-label v-model="newEpisode.episode" :label="$strings.LabelEpisode" />
|
||||
</div>
|
||||
<div class="w-1/5 p-1">
|
||||
<ui-text-input-with-label v-model="newEpisode.episodeType" :label="$strings.LabelEpisodeType" />
|
||||
<ui-dropdown v-model="newEpisode.episodeType" :label="$strings.LabelEpisodeType" :items="episodeTypes" small />
|
||||
</div>
|
||||
<div class="w-2/5 p-1">
|
||||
<ui-text-input-with-label v-model="pubDateInput" @input="updatePubDate" type="datetime-local" :label="$strings.LabelPubDate" />
|
||||
@@ -24,11 +24,17 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-end pt-4">
|
||||
<ui-btn @click="submit">{{ $strings.ButtonSubmit }}</ui-btn>
|
||||
<!-- desktop -->
|
||||
<ui-btn @click="submit" class="mx-2 hidden md:block">{{ $strings.ButtonSave }}</ui-btn>
|
||||
<ui-btn @click="saveAndClose" class="mx-2 hidden md:block">{{ $strings.ButtonSaveAndClose }}</ui-btn>
|
||||
|
||||
<!-- mobile -->
|
||||
<ui-btn @click="saveAndClose" class="mx-2 md:hidden">{{ $strings.ButtonSave }}</ui-btn>
|
||||
</div>
|
||||
<div v-if="enclosureUrl" class="py-4">
|
||||
<p class="text-xs text-gray-300 font-semibold">Episode URL from RSS feed</p>
|
||||
<a :href="enclosureUrl" target="_blank" class="text-xs text-blue-400 hover:text-blue-500 hover:underline">{{ enclosureUrl }}</a>
|
||||
<div v-if="enclosureUrl" class="pb-4 pt-6">
|
||||
<ui-text-input-with-label :value="enclosureUrl" readonly class="text-xs">
|
||||
<label class="px-1 text-xs text-gray-200 font-semibold">Episode URL from RSS feed</label>
|
||||
</ui-text-input-with-label>
|
||||
</div>
|
||||
<div v-else class="py-4">
|
||||
<p class="text-xs text-gray-300 font-semibold">Episode not linked to RSS feed episode</p>
|
||||
@@ -89,6 +95,9 @@ export default {
|
||||
},
|
||||
enclosureUrl() {
|
||||
return this.enclosure.url
|
||||
},
|
||||
episodeTypes() {
|
||||
return this.$store.state.globals.episodeTypes || []
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -122,28 +131,43 @@ export default {
|
||||
}
|
||||
return updatePayload
|
||||
},
|
||||
submit() {
|
||||
const payload = this.getUpdatePayload()
|
||||
if (!Object.keys(payload).length) {
|
||||
return this.$toast.info('No updates were made')
|
||||
async saveAndClose() {
|
||||
const wasUpdated = await this.submit()
|
||||
if (wasUpdated !== null) this.$emit('close')
|
||||
},
|
||||
async submit() {
|
||||
if (this.isProcessing) {
|
||||
return null
|
||||
}
|
||||
|
||||
const updatedDetails = this.getUpdatePayload()
|
||||
if (!Object.keys(updatedDetails).length) {
|
||||
this.$toast.info('No changes were made')
|
||||
return false
|
||||
}
|
||||
return this.updateDetails(updatedDetails)
|
||||
},
|
||||
async updateDetails(updatedDetails) {
|
||||
this.isProcessing = true
|
||||
this.$axios
|
||||
.$patch(`/api/podcasts/${this.libraryItem.id}/episode/${this.episodeId}`, payload)
|
||||
.then(() => {
|
||||
this.isProcessing = false
|
||||
const updateResult = await this.$axios.$patch(`/api/podcasts/${this.libraryItem.id}/episode/${this.episodeId}`, updatedDetails).catch((error) => {
|
||||
console.error('Failed update episode', error)
|
||||
this.isProcessing = false
|
||||
this.$toast.error(error?.response?.data || 'Failed to update episode')
|
||||
return false
|
||||
})
|
||||
|
||||
this.isProcessing = false
|
||||
if (updateResult) {
|
||||
if (updateResult) {
|
||||
this.$toast.success('Podcast episode updated')
|
||||
this.$emit('close')
|
||||
})
|
||||
.catch((error) => {
|
||||
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed to update episode'
|
||||
console.error('Failed update episode', error)
|
||||
this.isProcessing = false
|
||||
this.$toast.error(errorMsg)
|
||||
})
|
||||
return true
|
||||
} else {
|
||||
this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -14,6 +14,27 @@
|
||||
|
||||
<span class="material-icons absolute right-2 bottom-2 p-0.5 text-base transition-transform duration-100 text-gray-300 hover:text-white transform hover:scale-125 cursor-pointer" @click="copyToClipboard(currentFeed.feedUrl)">content_copy</span>
|
||||
</div>
|
||||
|
||||
<div v-if="currentFeed.meta" class="mt-5">
|
||||
<div class="flex py-0.5">
|
||||
<div class="w-48">
|
||||
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelRSSFeedPreventIndexing }}</span>
|
||||
</div>
|
||||
<div>{{ currentFeed.meta.preventIndexing ? 'Yes' : 'No' }}</div>
|
||||
</div>
|
||||
<div v-if="currentFeed.meta.ownerName" class="flex py-0.5">
|
||||
<div class="w-48">
|
||||
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelRSSFeedCustomOwnerName }}</span>
|
||||
</div>
|
||||
<div>{{ currentFeed.meta.ownerName }}</div>
|
||||
</div>
|
||||
<div v-if="currentFeed.meta.ownerEmail" class="flex py-0.5">
|
||||
<div class="w-48">
|
||||
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelRSSFeedCustomOwnerEmail }}</span>
|
||||
</div>
|
||||
<div>{{ currentFeed.meta.ownerEmail }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="w-full">
|
||||
<p class="text-lg font-semibold mb-4">{{ $strings.HeaderOpenRSSFeed }}</p>
|
||||
@@ -22,6 +43,7 @@
|
||||
<ui-text-input-with-label v-model="newFeedSlug" :label="$strings.LabelRSSFeedSlug" />
|
||||
<p class="text-xs text-gray-400 py-0.5 px-1">{{ $getString('MessageFeedURLWillBe', [demoFeedUrl]) }}</p>
|
||||
</div>
|
||||
<widgets-rss-feed-metadata-builder v-model="metadataDetails" />
|
||||
|
||||
<p v-if="isHttp" class="w-full pt-2 text-warning text-xs">{{ $strings.NoteRSSFeedPodcastAppsHttps }}</p>
|
||||
<p v-if="hasEpisodesWithoutPubDate" class="w-full pt-2 text-warning text-xs">{{ $strings.NoteRSSFeedPodcastAppsPubDate }}</p>
|
||||
@@ -41,7 +63,12 @@ export default {
|
||||
return {
|
||||
processing: false,
|
||||
newFeedSlug: null,
|
||||
currentFeed: null
|
||||
currentFeed: null,
|
||||
metadataDetails: {
|
||||
preventIndexing: true,
|
||||
ownerName: '',
|
||||
ownerEmail: ''
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -107,7 +134,8 @@ export default {
|
||||
|
||||
const payload = {
|
||||
serverAddress: window.origin,
|
||||
slug: this.newFeedSlug
|
||||
slug: this.newFeedSlug,
|
||||
metadataDetails: this.metadataDetails
|
||||
}
|
||||
if (this.$isDev) payload.serverAddress = `http://localhost:3333${this.$config.routerBasePath}`
|
||||
|
||||
|
||||
@@ -38,7 +38,8 @@ export default {
|
||||
currentChapter: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
playbackRate: Number
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -63,6 +64,10 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
_playbackRate() {
|
||||
if (!this.playbackRate || isNaN(this.playbackRate)) return 1
|
||||
return this.playbackRate
|
||||
},
|
||||
currentChapterDuration() {
|
||||
if (!this.currentChapter) return 0
|
||||
return this.currentChapter.end - this.currentChapter.start
|
||||
@@ -81,8 +86,8 @@ export default {
|
||||
clickTrack(e) {
|
||||
if (this.loading) return
|
||||
|
||||
var offsetX = e.offsetX
|
||||
var perc = offsetX / this.trackWidth
|
||||
const offsetX = e.offsetX
|
||||
const perc = offsetX / this.trackWidth
|
||||
const baseTime = this.useChapterTrack ? this.currentChapterStart : 0
|
||||
const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration
|
||||
const time = baseTime + perc * duration
|
||||
@@ -111,7 +116,7 @@ export default {
|
||||
this.updateReadyTrack()
|
||||
},
|
||||
updateReadyTrack() {
|
||||
var widthReady = Math.round(this.trackWidth * this.percentReady)
|
||||
const widthReady = Math.round(this.trackWidth * this.percentReady)
|
||||
if (this.readyTrackWidth === widthReady) return
|
||||
this.readyTrackWidth = widthReady
|
||||
if (this.$refs.readyTrack) this.$refs.readyTrack.style.width = widthReady + 'px'
|
||||
@@ -124,7 +129,7 @@ export default {
|
||||
const time = this.useChapterTrack ? Math.max(0, this.currentTime - this.currentChapterStart) : this.currentTime
|
||||
const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration
|
||||
|
||||
var ptWidth = Math.round((time / duration) * this.trackWidth)
|
||||
const ptWidth = Math.round((time / duration) * this.trackWidth)
|
||||
if (this.playedTrackWidth === ptWidth) {
|
||||
return
|
||||
}
|
||||
@@ -133,7 +138,7 @@ export default {
|
||||
},
|
||||
setChapterTicks() {
|
||||
this.chapterTicks = this.chapters.map((chap) => {
|
||||
var perc = chap.start / this.duration
|
||||
const perc = chap.start / this.duration
|
||||
return {
|
||||
title: chap.title,
|
||||
left: perc * this.trackWidth
|
||||
@@ -141,7 +146,7 @@ export default {
|
||||
})
|
||||
},
|
||||
mousemoveTrack(e) {
|
||||
var offsetX = e.offsetX
|
||||
const offsetX = e.offsetX
|
||||
|
||||
const baseTime = this.useChapterTrack ? this.currentChapterStart : 0
|
||||
const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration
|
||||
@@ -167,7 +172,7 @@ export default {
|
||||
this.$refs.hoverTimestampArrow.style.left = posLeft + 'px'
|
||||
}
|
||||
if (this.$refs.hoverTimestampText) {
|
||||
var hoverText = this.$secondsToTimestamp(progressTime)
|
||||
var hoverText = this.$secondsToTimestamp(progressTime / this._playbackRate)
|
||||
|
||||
var chapter = this.chapters.find((chapter) => chapter.start <= totalTime && totalTime < chapter.end)
|
||||
if (chapter && chapter.title) {
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
<player-playback-controls :loading="loading" :seek-loading="seekLoading" :playback-rate.sync="playbackRate" :paused="paused" :has-next-chapter="hasNextChapter" @prevChapter="prevChapter" @nextChapter="nextChapter" @jumpForward="jumpForward" @jumpBackward="jumpBackward" @setPlaybackRate="setPlaybackRate" @playPause="playPause" />
|
||||
</div>
|
||||
|
||||
<player-track-bar ref="trackbar" :loading="loading" :chapters="chapters" :duration="duration" :current-chapter="currentChapter" @seek="seek" />
|
||||
<player-track-bar ref="trackbar" :loading="loading" :chapters="chapters" :duration="duration" :current-chapter="currentChapter" :playback-rate="playbackRate" @seek="seek" />
|
||||
|
||||
<div class="flex">
|
||||
<p ref="currentTimestamp" class="font-mono text-xxs sm:text-sm text-gray-100 pointer-events-auto">00:00:00</p>
|
||||
@@ -59,7 +59,7 @@
|
||||
<p class="font-mono text-xxs sm:text-sm text-gray-100 pointer-events-auto">{{ timeRemainingPretty }}</p>
|
||||
</div>
|
||||
|
||||
<modals-chapters-modal v-model="showChaptersModal" :current-chapter="currentChapter" :chapters="chapters" @select="selectChapter" />
|
||||
<modals-chapters-modal v-model="showChaptersModal" :current-chapter="currentChapter" :playback-rate="playbackRate" :chapters="chapters" @select="selectChapter" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -92,6 +92,11 @@ export default {
|
||||
useChapterTrack: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
playbackRate() {
|
||||
this.updateTimestamp()
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
sleepTimerRemainingString() {
|
||||
var rounded = Math.round(this.sleepTimerRemaining)
|
||||
@@ -213,18 +218,14 @@ export default {
|
||||
}
|
||||
},
|
||||
increasePlaybackRate() {
|
||||
var rates = [0.25, 0.5, 0.8, 1, 1.3, 1.5, 2, 2.5, 3]
|
||||
var currentRateIndex = rates.findIndex((r) => r === this.playbackRate)
|
||||
if (currentRateIndex >= rates.length - 1) return
|
||||
this.playbackRate = rates[currentRateIndex + 1] || 1
|
||||
this.playbackRateChanged(this.playbackRate)
|
||||
if (this.playbackRate >= 10) return
|
||||
this.playbackRate = Number((this.playbackRate + 0.1).toFixed(1))
|
||||
this.setPlaybackRate(this.playbackRate)
|
||||
},
|
||||
decreasePlaybackRate() {
|
||||
var rates = [0.25, 0.5, 0.8, 1, 1.3, 1.5, 2, 2.5, 3]
|
||||
var currentRateIndex = rates.findIndex((r) => r === this.playbackRate)
|
||||
if (currentRateIndex <= 0) return
|
||||
this.playbackRate = rates[currentRateIndex - 1] || 1
|
||||
this.playbackRateChanged(this.playbackRate)
|
||||
if (this.playbackRate <= 0.5) return
|
||||
this.playbackRate = Number((this.playbackRate - 0.1).toFixed(1))
|
||||
this.setPlaybackRate(this.playbackRate)
|
||||
},
|
||||
setPlaybackRate(playbackRate) {
|
||||
this.$emit('setPlaybackRate', playbackRate)
|
||||
@@ -289,14 +290,13 @@ export default {
|
||||
if (this.$refs.trackbar) this.$refs.trackbar.setPercentageReady(percentageReady)
|
||||
},
|
||||
updateTimestamp() {
|
||||
var ts = this.$refs.currentTimestamp
|
||||
const ts = this.$refs.currentTimestamp
|
||||
if (!ts) {
|
||||
console.error('No timestamp el')
|
||||
return
|
||||
}
|
||||
const time = this.useChapterTrack ? Math.max(0, this.currentTime - this.currentChapterStart) : this.currentTime
|
||||
var currTimeClean = this.$secondsToTimestamp(time)
|
||||
ts.innerText = currTimeClean
|
||||
ts.innerText = this.$secondsToTimestamp(time / this.playbackRate)
|
||||
},
|
||||
setBufferTime(bufferTime) {
|
||||
if (this.$refs.trackbar) this.$refs.trackbar.setBufferTime(bufferTime)
|
||||
@@ -312,7 +312,7 @@ export default {
|
||||
this.useChapterTrack = this.chapters.length ? _useChapterTrack : false
|
||||
|
||||
if (this.$refs.trackbar) this.$refs.trackbar.setUseChapterTrack(this.useChapterTrack)
|
||||
this.$emit('setPlaybackRate', this.playbackRate)
|
||||
this.setPlaybackRate(this.playbackRate)
|
||||
},
|
||||
settingsUpdated(settings) {
|
||||
if (settings.playbackRate && this.playbackRate !== settings.playbackRate) {
|
||||
|
||||
@@ -3,11 +3,14 @@
|
||||
<div class="absolute top-0 left-0 right-0 w-full h-36 bg-gradient-to-t from-transparent via-black-500 to-black-700 opacity-90 pointer-events-none" />
|
||||
<div ref="content" class="relative text-white" :style="{ height: modalHeight, width: modalWidth }" v-click-outside="clickedOutside">
|
||||
<div class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300">
|
||||
<p class="text-lg mb-8 mt-2 px-1" v-html="message" />
|
||||
<p class="text-lg mb-6 mt-2 px-1" v-html="message" />
|
||||
|
||||
<ui-checkbox v-if="checkboxLabel" v-model="checkboxValue" checkbox-bg="bg" :label="checkboxLabel" label-class="pl-2 text-base" class="mb-6 px-1" />
|
||||
|
||||
<div class="flex px-1 items-center">
|
||||
<ui-btn v-if="isYesNo" color="primary" @click="nevermind">{{ $strings.ButtonCancel }}</ui-btn>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn v-if="isYesNo" color="success" @click="confirm">{{ $strings.ButtonYes }}</ui-btn>
|
||||
<ui-btn v-if="isYesNo" :color="yesButtonColor" @click="confirm">{{ yesButtonText }}</ui-btn>
|
||||
<ui-btn v-else color="primary" @click="confirm">{{ $strings.ButtonOk }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
@@ -21,7 +24,8 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
el: null,
|
||||
content: null
|
||||
content: null,
|
||||
checkboxValue: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -57,6 +61,18 @@ export default {
|
||||
persistent() {
|
||||
return !!this.confirmPromptOptions.persistent
|
||||
},
|
||||
checkboxLabel() {
|
||||
return this.confirmPromptOptions.checkboxLabel
|
||||
},
|
||||
yesButtonText() {
|
||||
return this.confirmPromptOptions.yesButtonText || this.$strings.ButtonYes
|
||||
},
|
||||
yesButtonColor() {
|
||||
return this.confirmPromptOptions.yesButtonColor || 'success'
|
||||
},
|
||||
checkboxDefaultValue() {
|
||||
return !!this.confirmPromptOptions.checkboxDefaultValue
|
||||
},
|
||||
isYesNo() {
|
||||
return this.type === 'yesNo'
|
||||
},
|
||||
@@ -84,10 +100,11 @@ export default {
|
||||
this.show = false
|
||||
},
|
||||
confirm() {
|
||||
if (this.callback) this.callback(true)
|
||||
if (this.callback) this.callback(true, this.checkboxValue)
|
||||
this.show = false
|
||||
},
|
||||
setShow() {
|
||||
this.checkboxValue = this.checkboxDefaultValue
|
||||
this.$eventBus.$emit('showing-prompt', true)
|
||||
document.body.appendChild(this.el)
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
<template>
|
||||
<div class="h-full w-full">
|
||||
<div class="h-full flex items-center">
|
||||
<div style="width: 100px; max-width: 100px" class="h-full flex items-center overflow-x-hidden justify-center">
|
||||
<span v-show="hasPrev" class="material-icons text-white text-opacity-50 hover:text-opacity-80 cursor-pointer text-6xl" @mousedown.prevent @click="prev">chevron_left</span>
|
||||
<div class="h-full flex items-center justify-center">
|
||||
<div style="width: 100px; max-width: 100px" class="h-full hidden sm:flex items-center overflow-x-hidden justify-center">
|
||||
<span v-if="hasPrev" class="material-icons text-white text-opacity-50 hover:text-opacity-80 cursor-pointer text-6xl" @mousedown.prevent @click="prev">chevron_left</span>
|
||||
</div>
|
||||
<div id="frame" class="w-full" style="height: 650px">
|
||||
<div id="viewer" class="border border-gray-100 bg-white shadow-md"></div>
|
||||
|
||||
<div class="py-4 flex justify-center" style="height: 50px">
|
||||
<p>{{ progress }}%</p>
|
||||
</div>
|
||||
<div id="frame" class="w-full" style="height: 80%">
|
||||
<div id="viewer"></div>
|
||||
</div>
|
||||
<div style="width: 100px; max-width: 100px" class="h-full flex items-center justify-center overflow-x-hidden">
|
||||
<span v-show="hasNext" class="material-icons text-white text-opacity-50 hover:text-opacity-80 cursor-pointer text-6xl" @mousedown.prevent @click="next">chevron_right</span>
|
||||
<div style="width: 100px; max-width: 100px" class="h-full hidden sm:flex items-center justify-center overflow-x-hidden">
|
||||
<span v-if="hasNext" class="material-icons text-white text-opacity-50 hover:text-opacity-80 cursor-pointer text-6xl" @mousedown.prevent @click="next">chevron_right</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -21,108 +17,252 @@
|
||||
<script>
|
||||
import ePub from 'epubjs'
|
||||
|
||||
/**
|
||||
* @typedef {object} EpubReader
|
||||
* @property {ePub.Book} book
|
||||
* @property {ePub.Rendition} rendition
|
||||
*/
|
||||
export default {
|
||||
props: {
|
||||
url: String
|
||||
url: String,
|
||||
libraryItem: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
windowWidth: 0,
|
||||
/** @type {ePub.Book} */
|
||||
book: null,
|
||||
rendition: null,
|
||||
chapters: [],
|
||||
title: '',
|
||||
author: '',
|
||||
progress: 0,
|
||||
hasNext: true,
|
||||
hasPrev: false
|
||||
/** @type {ePub.Rendition} */
|
||||
rendition: null
|
||||
}
|
||||
},
|
||||
computed: {},
|
||||
methods: {
|
||||
changedChapter() {
|
||||
if (this.rendition) {
|
||||
this.rendition.display(this.selectedChapter)
|
||||
}
|
||||
computed: {
|
||||
/** @returns {string} */
|
||||
libraryItemId() {
|
||||
return this.libraryItem?.id
|
||||
},
|
||||
hasPrev() {
|
||||
return !this.rendition?.location?.atStart
|
||||
},
|
||||
hasNext() {
|
||||
return !this.rendition?.location?.atEnd
|
||||
},
|
||||
/** @returns {Array<ePub.NavItem>} */
|
||||
chapters() {
|
||||
return this.book ? this.book.navigation.toc : []
|
||||
},
|
||||
userMediaProgress() {
|
||||
if (!this.libraryItemId) return
|
||||
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId)
|
||||
},
|
||||
localStorageLocationsKey() {
|
||||
return `ebookLocations-${this.libraryItemId}`
|
||||
},
|
||||
readerWidth() {
|
||||
if (this.windowWidth < 640) return this.windowWidth
|
||||
return this.windowWidth - 200
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
prev() {
|
||||
if (this.rendition) {
|
||||
this.rendition.prev()
|
||||
}
|
||||
return this.rendition?.prev()
|
||||
},
|
||||
next() {
|
||||
if (this.rendition) {
|
||||
this.rendition.next()
|
||||
return this.rendition?.next()
|
||||
},
|
||||
goToChapter(href) {
|
||||
return this.rendition?.display(href)
|
||||
},
|
||||
keyUp(e) {
|
||||
const rtl = this.book.package.metadata.direction === 'rtl'
|
||||
if ((e.keyCode || e.which) == 37) {
|
||||
return rtl ? this.next() : this.prev()
|
||||
} else if ((e.keyCode || e.which) == 39) {
|
||||
return rtl ? this.prev() : this.next()
|
||||
}
|
||||
},
|
||||
keyUp() {
|
||||
if ((e.keyCode || e.which) == 37) {
|
||||
this.prev()
|
||||
} else if ((e.keyCode || e.which) == 39) {
|
||||
this.next()
|
||||
/**
|
||||
* @param {object} payload
|
||||
* @param {string} payload.ebookLocation - CFI of the current location
|
||||
* @param {string} payload.ebookProgress - eBook Progress Percentage
|
||||
*/
|
||||
updateProgress(payload) {
|
||||
this.$axios.$patch(`/api/me/progress/${this.libraryItemId}`, payload).catch((error) => {
|
||||
console.error('EpubReader.updateProgress failed:', error)
|
||||
})
|
||||
},
|
||||
getAllEbookLocationData() {
|
||||
const locations = []
|
||||
let totalSize = 0 // Total in bytes
|
||||
|
||||
for (const key in localStorage) {
|
||||
if (!localStorage.hasOwnProperty(key) || !key.startsWith('ebookLocations-')) {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const ebookLocations = JSON.parse(localStorage[key])
|
||||
if (!ebookLocations.locations) throw new Error('Invalid locations object')
|
||||
|
||||
ebookLocations.key = key
|
||||
ebookLocations.size = (localStorage[key].length + key.length) * 2
|
||||
locations.push(ebookLocations)
|
||||
totalSize += ebookLocations.size
|
||||
} catch (error) {
|
||||
console.error('Failed to parse ebook locations', key, error)
|
||||
localStorage.removeItem(key)
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by oldest lastAccessed first
|
||||
locations.sort((a, b) => a.lastAccessed - b.lastAccessed)
|
||||
|
||||
return {
|
||||
locations,
|
||||
totalSize
|
||||
}
|
||||
},
|
||||
/** @param {string} locationString */
|
||||
checkSaveLocations(locationString) {
|
||||
const maxSizeInBytes = 3000000 // Allow epub locations to take up to 3MB of space
|
||||
const newLocationsSize = JSON.stringify({ lastAccessed: Date.now(), locations: locationString }).length * 2
|
||||
|
||||
// Too large overall
|
||||
if (newLocationsSize > maxSizeInBytes) {
|
||||
console.error('Epub locations are too large to store. Size =', newLocationsSize)
|
||||
return
|
||||
}
|
||||
|
||||
const ebookLocationsData = this.getAllEbookLocationData()
|
||||
|
||||
let availableSpace = maxSizeInBytes - ebookLocationsData.totalSize
|
||||
|
||||
// Remove epub locations until there is room for locations
|
||||
while (availableSpace < newLocationsSize && ebookLocationsData.locations.length) {
|
||||
const oldestLocation = ebookLocationsData.locations.shift()
|
||||
console.log(`Removing cached locations for epub "${oldestLocation.key}" taking up ${oldestLocation.size} bytes`)
|
||||
availableSpace += oldestLocation.size
|
||||
localStorage.removeItem(oldestLocation.key)
|
||||
}
|
||||
|
||||
console.log(`Cacheing epub locations with key "${this.localStorageLocationsKey}" taking up ${newLocationsSize} bytes`)
|
||||
this.saveLocations(locationString)
|
||||
},
|
||||
/** @param {string} locationString */
|
||||
saveLocations(locationString) {
|
||||
localStorage.setItem(
|
||||
this.localStorageLocationsKey,
|
||||
JSON.stringify({
|
||||
lastAccessed: Date.now(),
|
||||
locations: locationString
|
||||
})
|
||||
)
|
||||
},
|
||||
loadLocations() {
|
||||
const locationsObjString = localStorage.getItem(this.localStorageLocationsKey)
|
||||
if (!locationsObjString) return null
|
||||
|
||||
const locationsObject = JSON.parse(locationsObjString)
|
||||
|
||||
// Remove invalid location objects
|
||||
if (!locationsObject.locations) {
|
||||
console.error('Invalid epub locations stored', this.localStorageLocationsKey)
|
||||
localStorage.removeItem(this.localStorageLocationsKey)
|
||||
return null
|
||||
}
|
||||
|
||||
// Update lastAccessed
|
||||
this.saveLocations(locationsObject.locations)
|
||||
|
||||
return locationsObject.locations
|
||||
},
|
||||
/** @param {string} location - CFI of the new location */
|
||||
relocated(location) {
|
||||
if (this.userMediaProgress?.ebookLocation === location.start.cfi) {
|
||||
return
|
||||
}
|
||||
|
||||
if (location.end.percentage) {
|
||||
this.updateProgress({
|
||||
ebookLocation: location.start.cfi,
|
||||
ebookProgress: location.end.percentage
|
||||
})
|
||||
} else {
|
||||
this.updateProgress({
|
||||
ebookLocation: location.start.cfi
|
||||
})
|
||||
}
|
||||
},
|
||||
initEpub() {
|
||||
// var book = ePub(this.url, {
|
||||
// requestHeaders: {
|
||||
// Authorization: `Bearer ${this.userToken}`
|
||||
// }
|
||||
// })
|
||||
var book = ePub(this.url)
|
||||
this.book = book
|
||||
/** @type {EpubReader} */
|
||||
const reader = this
|
||||
|
||||
this.rendition = book.renderTo('viewer', {
|
||||
width: window.innerWidth - 200,
|
||||
height: 600,
|
||||
ignoreClass: 'annotator-hl',
|
||||
manager: 'continuous',
|
||||
spread: 'always'
|
||||
/** @type {ePub.Book} */
|
||||
reader.book = new ePub(reader.url, {
|
||||
width: this.readerWidth,
|
||||
height: window.innerHeight - 50
|
||||
})
|
||||
var displayed = this.rendition.display()
|
||||
|
||||
book.ready
|
||||
.then(() => {
|
||||
console.log('Book ready')
|
||||
return book.locations.generate(1600)
|
||||
/** @type {ePub.Rendition} */
|
||||
reader.rendition = reader.book.renderTo('viewer', {
|
||||
width: this.readerWidth,
|
||||
height: window.innerHeight * 0.8
|
||||
})
|
||||
|
||||
// load saved progress
|
||||
reader.rendition.display(this.userMediaProgress?.ebookLocation || reader.book.locations.start)
|
||||
|
||||
// load style
|
||||
reader.rendition.themes.default({ '*': { color: '#fff!important' } })
|
||||
|
||||
reader.book.ready.then(() => {
|
||||
// set up event listeners
|
||||
reader.rendition.on('relocated', reader.relocated)
|
||||
reader.rendition.on('keydown', reader.keyUp)
|
||||
|
||||
let touchStart = 0
|
||||
let touchEnd = 0
|
||||
reader.rendition.on('touchstart', (event) => {
|
||||
touchStart = event.changedTouches[0].screenX
|
||||
})
|
||||
.then((locations) => {
|
||||
// console.log('Loaded locations', locations)
|
||||
// Wait for book to be rendered to get current page
|
||||
displayed.then(() => {
|
||||
// Get the current CFI
|
||||
var currentLocation = this.rendition.currentLocation()
|
||||
if (!currentLocation.start) {
|
||||
console.error('No Start', currentLocation)
|
||||
} else {
|
||||
var currentPage = book.locations.percentageFromCfi(currentLocation.start.cfi)
|
||||
// console.log('current page', currentPage)
|
||||
}
|
||||
|
||||
reader.rendition.on('touchend', (event) => {
|
||||
touchEnd = event.changedTouches[0].screenX
|
||||
const touchDistanceX = Math.abs(touchEnd - touchStart)
|
||||
if (touchStart < touchEnd && touchDistanceX > 120) {
|
||||
this.next()
|
||||
}
|
||||
if (touchStart > touchEnd && touchDistanceX > 120) {
|
||||
this.prev()
|
||||
}
|
||||
})
|
||||
|
||||
// load ebook cfi locations
|
||||
const savedLocations = this.loadLocations()
|
||||
if (savedLocations) {
|
||||
reader.book.locations.load(savedLocations)
|
||||
} else {
|
||||
reader.book.locations.generate().then(() => {
|
||||
this.checkSaveLocations(reader.book.locations.save())
|
||||
})
|
||||
})
|
||||
|
||||
book.loaded.navigation.then((toc) => {
|
||||
var _chapters = []
|
||||
toc.forEach((chapter) => {
|
||||
_chapters.push(chapter)
|
||||
})
|
||||
this.chapters = _chapters
|
||||
})
|
||||
book.loaded.metadata.then((metadata) => {
|
||||
this.author = metadata.creator
|
||||
this.title = metadata.title
|
||||
})
|
||||
|
||||
this.rendition.on('keyup', this.keyUp)
|
||||
|
||||
this.rendition.on('relocated', (location) => {
|
||||
var percent = book.locations.percentageFromCfi(location.start.cfi)
|
||||
this.progress = Math.floor(percent * 100)
|
||||
|
||||
this.hasNext = !location.atEnd
|
||||
this.hasPrev = !location.atStart
|
||||
}
|
||||
})
|
||||
},
|
||||
resize() {
|
||||
this.windowWidth = window.innerWidth
|
||||
this.rendition?.resize(this.readerWidth, window.innerHeight * 0.8)
|
||||
}
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('resize', this.resize)
|
||||
this.book?.destroy()
|
||||
},
|
||||
mounted() {
|
||||
this.windowWidth = window.innerWidth
|
||||
window.addEventListener('resize', this.resize)
|
||||
this.initEpub()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
<template>
|
||||
<div class="h-full w-full">
|
||||
<div id="viewer" class="border border-gray-100 bg-white text-black shadow-md h-screen overflow-y-auto p-4" v-html="pageHtml"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
url: String,
|
||||
libraryItem: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
bookInfo: {},
|
||||
page: 0,
|
||||
numPages: 0,
|
||||
pageHtml: '',
|
||||
progress: 0
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
libraryItemId() {
|
||||
return this.libraryItem ? this.libraryItem.id : null
|
||||
},
|
||||
hasPrev() {
|
||||
return this.page > 0
|
||||
},
|
||||
hasNext() {
|
||||
return this.page < this.numPages - 1
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
prev() {
|
||||
if (!this.hasPrev) return
|
||||
this.page--
|
||||
this.loadPage()
|
||||
},
|
||||
next() {
|
||||
if (!this.hasNext) return
|
||||
this.page++
|
||||
this.loadPage()
|
||||
},
|
||||
keyUp() {
|
||||
if ((e.keyCode || e.which) == 37) {
|
||||
this.prev()
|
||||
} else if ((e.keyCode || e.which) == 39) {
|
||||
this.next()
|
||||
}
|
||||
},
|
||||
loadPage() {
|
||||
this.$axios
|
||||
.$get(`/api/ebooks/${this.libraryItemId}/page/${this.page}?dev=${this.$isDev ? 1 : 0}`)
|
||||
.then((html) => {
|
||||
this.pageHtml = html
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to load page', error)
|
||||
this.$toast.error('Failed to load page')
|
||||
})
|
||||
},
|
||||
loadInfo() {
|
||||
this.$axios
|
||||
.$get(`/api/ebooks/${this.libraryItemId}/info?dev=${this.$isDev ? 1 : 0}`)
|
||||
.then((bookInfo) => {
|
||||
this.bookInfo = bookInfo
|
||||
this.numPages = bookInfo.pages
|
||||
this.page = 0
|
||||
this.loadPage()
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to load page', error)
|
||||
this.$toast.error('Failed to load info')
|
||||
})
|
||||
},
|
||||
initEpub() {
|
||||
if (!this.libraryItemId) return
|
||||
this.loadInfo()
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.initEpub()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,24 +1,48 @@
|
||||
<template>
|
||||
<div v-if="show" class="w-screen h-screen fixed top-0 left-0 z-60 bg-primary text-white">
|
||||
<div class="absolute top-4 right-4 z-20">
|
||||
<span class="material-icons cursor-pointer text-4xl" @click="close">close</span>
|
||||
<div class="absolute top-4 left-4 z-20">
|
||||
<span v-if="hasToC && !tocOpen" ref="tocButton" class="material-icons cursor-pointer text-2xl" @click="toggleToC">menu</span>
|
||||
</div>
|
||||
|
||||
<div class="absolute top-4 left-4">
|
||||
<h1 class="text-2xl mb-1">{{ abTitle }}</h1>
|
||||
<p v-if="abAuthor">by {{ abAuthor }}</p>
|
||||
<div class="absolute top-4 left-1/2 transform -translate-x-1/2">
|
||||
<h1 class="text-lg sm:text-xl md:text-2xl mb-1" style="line-height: 1.15; font-weight: 100">
|
||||
<span style="font-weight: 600">{{ abTitle }}</span>
|
||||
<span v-if="abAuthor" style="display: inline"> – </span>
|
||||
<span v-if="abAuthor">{{ abAuthor }}</span>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="absolute top-4 right-4 z-20">
|
||||
<span v-if="hasSettings" class="material-icons cursor-pointer text-2xl" @click="openSettings">settings</span>
|
||||
<span class="material-icons cursor-pointer text-2xl" @click="close">close</span>
|
||||
</div>
|
||||
|
||||
<component v-if="componentName" ref="readerComponent" :is="componentName" :url="ebookUrl" :library-item="selectedLibraryItem" />
|
||||
|
||||
<div class="absolute bottom-2 left-2">{{ ebookType }}</div>
|
||||
<!-- TOC side nav -->
|
||||
<div v-if="tocOpen" class="w-full h-full fixed inset-0 bg-black/20 z-20" @click.stop.prevent="toggleToC"></div>
|
||||
<div v-if="hasToC" class="w-72 h-full max-h-full absolute top-0 left-0 bg-bg shadow-xl transition-transform z-30" :class="tocOpen ? 'translate-x-0' : '-translate-x-72'" @click.stop.prevent="toggleToC">
|
||||
<div class="p-4 h-full overflow-hidden">
|
||||
<p class="text-lg font-semibold mb-2">Table of Contents</p>
|
||||
<div class="tocContent">
|
||||
<ul>
|
||||
<li v-for="chapter in chapters" :key="chapter.id" class="py-1">
|
||||
<a :href="chapter.href" @click.prevent="$refs.readerComponent.goToChapter(chapter.href)">{{ chapter.label }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {}
|
||||
return {
|
||||
chapters: [],
|
||||
tocOpen: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
show(newVal) {
|
||||
@@ -37,13 +61,18 @@ export default {
|
||||
}
|
||||
},
|
||||
componentName() {
|
||||
if (this.ebookType === 'epub' && this.$isDev) return 'readers-epub-reader2'
|
||||
else if (this.ebookType === 'epub') return 'readers-epub-reader'
|
||||
if (this.ebookType === 'epub') return 'readers-epub-reader'
|
||||
else if (this.ebookType === 'mobi') return 'readers-mobi-reader'
|
||||
else if (this.ebookType === 'pdf') return 'readers-pdf-reader'
|
||||
else if (this.ebookType === 'comic') return 'readers-comic-reader'
|
||||
return null
|
||||
},
|
||||
hasToC() {
|
||||
return this.isEpub
|
||||
},
|
||||
hasSettings() {
|
||||
return false
|
||||
},
|
||||
abTitle() {
|
||||
return this.mediaMetadata.title
|
||||
},
|
||||
@@ -111,18 +140,29 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
toggleToC() {
|
||||
this.tocOpen = !this.tocOpen
|
||||
this.chapters = this.$refs.readerComponent.chapters
|
||||
},
|
||||
openSettings() {},
|
||||
hotkey(action) {
|
||||
console.log('Reader hotkey', action)
|
||||
if (!this.$refs.readerComponent) return
|
||||
|
||||
if (action === this.$hotkeys.EReader.NEXT_PAGE) {
|
||||
if (this.$refs.readerComponent.next) this.$refs.readerComponent.next()
|
||||
this.next()
|
||||
} else if (action === this.$hotkeys.EReader.PREV_PAGE) {
|
||||
if (this.$refs.readerComponent.prev) this.$refs.readerComponent.prev()
|
||||
this.prev()
|
||||
} else if (action === this.$hotkeys.EReader.CLOSE) {
|
||||
this.close()
|
||||
}
|
||||
},
|
||||
next() {
|
||||
if (this.$refs.readerComponent?.next) this.$refs.readerComponent.next()
|
||||
},
|
||||
prev() {
|
||||
if (this.$refs.readerComponent?.prev) this.$refs.readerComponent.prev()
|
||||
},
|
||||
registerListeners() {
|
||||
this.$eventBus.$on('reader-hotkey', this.hotkey)
|
||||
},
|
||||
@@ -151,4 +191,8 @@ export default {
|
||||
.ebook-viewer {
|
||||
height: calc(100% - 96px);
|
||||
}
|
||||
.tocContent {
|
||||
height: calc(100% - 36px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
123
client/components/tables/AudioTracksTableRow.vue
Normal file
123
client/components/tables/AudioTracksTableRow.vue
Normal file
@@ -0,0 +1,123 @@
|
||||
<template>
|
||||
<tr>
|
||||
<td class="text-center">
|
||||
<p>{{ track.index }}</p>
|
||||
</td>
|
||||
<td class="font-sans">{{ showFullPath ? track.metadata.path : track.metadata.filename }}</td>
|
||||
<td v-if="!showFullPath" class="hidden lg:table-cell">
|
||||
{{ track.audioFile.codec || '' }}
|
||||
</td>
|
||||
<td v-if="!showFullPath" class="hidden xl:table-cell">
|
||||
{{ $bytesPretty(track.audioFile.bitRate || 0, 0) }}
|
||||
</td>
|
||||
<td class="hidden md:table-cell">
|
||||
{{ $bytesPretty(track.metadata.size) }}
|
||||
</td>
|
||||
<td class="hidden sm:table-cell">
|
||||
{{ $secondsToTimestamp(track.duration) }}
|
||||
</td>
|
||||
<td v-if="contextMenuItems.length" class="text-center">
|
||||
<ui-context-menu-dropdown :items="contextMenuItems" menu-width="110px" @action="contextMenuAction" />
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
libraryItemId: String,
|
||||
showFullPath: Boolean,
|
||||
track: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
userCanDownload() {
|
||||
return this.$store.getters['user/getUserCanDownload']
|
||||
},
|
||||
userCanDelete() {
|
||||
return this.$store.getters['user/getUserCanDelete']
|
||||
},
|
||||
userIsAdmin() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
contextMenuItems() {
|
||||
const items = []
|
||||
if (this.userCanDownload) {
|
||||
items.push({
|
||||
text: this.$strings.LabelDownload,
|
||||
action: 'download'
|
||||
})
|
||||
}
|
||||
|
||||
if (this.userCanDelete) {
|
||||
items.push({
|
||||
text: this.$strings.ButtonDelete,
|
||||
action: 'delete'
|
||||
})
|
||||
}
|
||||
|
||||
if (this.userIsAdmin) {
|
||||
items.push({
|
||||
text: this.$strings.LabelMoreInfo,
|
||||
action: 'more'
|
||||
})
|
||||
}
|
||||
return items
|
||||
},
|
||||
downloadUrl() {
|
||||
return `${process.env.serverUrl}/s/item/${this.libraryItemId}/${this.$encodeUriPath(this.track.metadata.relPath).replace(/^\//, '')}?token=${this.userToken}`
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
contextMenuAction(action) {
|
||||
if (action === 'delete') {
|
||||
this.deleteLibraryFile()
|
||||
} else if (action === 'download') {
|
||||
this.downloadLibraryFile()
|
||||
} else if (action === 'more') {
|
||||
this.$emit('showMore', this.track.audioFile)
|
||||
}
|
||||
},
|
||||
deleteLibraryFile() {
|
||||
const payload = {
|
||||
message: 'This will delete the file from your file system. Are you sure?',
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.$axios
|
||||
.$delete(`/api/items/${this.libraryItemId}/file/${this.track.audioFile.ino}`)
|
||||
.then(() => {
|
||||
this.$toast.success('File deleted')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to delete file', error)
|
||||
this.$toast.error('Failed to delete file')
|
||||
})
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
},
|
||||
downloadLibraryFile() {
|
||||
const a = document.createElement('a')
|
||||
a.style.display = 'none'
|
||||
a.href = this.downloadUrl
|
||||
a.download = this.track.metadata.filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
setTimeout(() => {
|
||||
a.remove()
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -17,7 +17,7 @@
|
||||
<td>
|
||||
<p class="truncate text-xs sm:text-sm md:text-base">/{{ backup.path.replace(/\\/g, '/') }}</p>
|
||||
</td>
|
||||
<td class="hidden sm:table-cell font-sans text-sm">{{ backup.datePretty }}</td>
|
||||
<td class="hidden sm:table-cell font-sans text-sm">{{ $formatDatetime(backup.createdAt, dateFormat, timeFormat) }}</td>
|
||||
<td class="hidden sm:table-cell font-mono md:text-sm text-xs">{{ $bytesPretty(backup.fileSize) }}</td>
|
||||
<td>
|
||||
<div class="w-full flex flex-row items-center justify-center">
|
||||
@@ -46,7 +46,7 @@
|
||||
<p class="text-error text-lg font-semibold">{{ $strings.MessageImportantNotice }}</p>
|
||||
<p class="text-base py-1" v-html="$strings.MessageRestoreBackupWarning" />
|
||||
|
||||
<p class="text-lg text-center my-8">{{ $strings.MessageRestoreBackupConfirm }} {{ selectedBackup.datePretty }}?</p>
|
||||
<p class="text-lg text-center my-8">{{ $strings.MessageRestoreBackupConfirm }} {{ $formatDatetime(selectedBackup.createdAt, dateFormat, timeFormat) }}?</p>
|
||||
<div class="flex px-1 items-center">
|
||||
<ui-btn color="primary" @click="showConfirmApply = false">{{ $strings.ButtonNevermind }}</ui-btn>
|
||||
<div class="flex-grow" />
|
||||
@@ -71,6 +71,12 @@ export default {
|
||||
computed: {
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
dateFormat() {
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
},
|
||||
timeFormat() {
|
||||
return this.$store.state.serverSettings.timeFormat
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -90,7 +96,7 @@ export default {
|
||||
})
|
||||
},
|
||||
deleteBackupClick(backup) {
|
||||
if (confirm(this.$getString('MessageConfirmDeleteBackup', [backup.datePretty]))) {
|
||||
if (confirm(this.$getString('MessageConfirmDeleteBackup', [this.$formatDatetime(backup.createdAt, this.dateFormat, this.timeFormat)]))) {
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$delete(`/api/backups/${backup.id}`)
|
||||
@@ -208,4 +214,4 @@ export default {
|
||||
padding-bottom: 5px;
|
||||
background-color: #333;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<span class="text-sm font-mono">{{ files.length }}</span>
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">{{ $strings.ButtonFullPath }}</ui-btn>
|
||||
<ui-btn v-if="userIsAdmin" small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">{{ $strings.ButtonFullPath }}</ui-btn>
|
||||
<div class="cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="showFiles ? 'transform rotate-180' : ''">
|
||||
<span class="material-icons text-4xl">expand_more</span>
|
||||
</div>
|
||||
@@ -18,60 +18,76 @@
|
||||
<th class="text-left px-4">{{ $strings.LabelPath }}</th>
|
||||
<th class="text-left w-24 min-w-24">{{ $strings.LabelSize }}</th>
|
||||
<th class="text-left px-4 w-24">{{ $strings.LabelType }}</th>
|
||||
<th v-if="userCanDownload && !isMissing" class="text-center w-20">{{ $strings.LabelDownload }}</th>
|
||||
<th v-if="userCanDelete || userCanDownload || (userIsAdmin && audioFiles.length && !inModal)" class="text-center w-16"></th>
|
||||
</tr>
|
||||
<template v-for="file in files">
|
||||
<tr :key="file.path">
|
||||
<td class="px-4">
|
||||
{{ showFullPath ? file.metadata.path : file.metadata.relPath }}
|
||||
</td>
|
||||
<td class="font-mono">
|
||||
{{ $bytesPretty(file.metadata.size) }}
|
||||
</td>
|
||||
<td class="text-xs">
|
||||
<div class="flex items-center">
|
||||
<p>{{ file.fileType }}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td v-if="userCanDownload && !isMissing" class="text-center">
|
||||
<a :href="`${$config.routerBasePath}/s/item/${libraryItemId}/${$encodeUriPath(file.metadata.relPath).replace(/^\//, '')}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a>
|
||||
</td>
|
||||
</tr>
|
||||
<template v-for="file in filesWithAudioFile">
|
||||
<tables-library-files-table-row :key="file.path" :libraryItemId="libraryItemId" :showFullPath="showFullPath" :file="file" :inModal="inModal" @showMore="showMore" />
|
||||
</template>
|
||||
</table>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<modals-audio-file-data-modal v-model="showAudioFileDataModal" :audio-file="selectedAudioFile" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
files: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
libraryItem: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
libraryItemId: String,
|
||||
isMissing: Boolean,
|
||||
expanded: Boolean // start expanded
|
||||
expanded: Boolean, // start expanded
|
||||
inModal: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showFiles: false,
|
||||
showFullPath: false
|
||||
showFullPath: false,
|
||||
showAudioFileDataModal: false,
|
||||
selectedAudioFile: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
libraryItemId() {
|
||||
return this.libraryItem.id
|
||||
},
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
userCanDownload() {
|
||||
return this.$store.getters['user/getUserCanDownload']
|
||||
},
|
||||
userCanDelete() {
|
||||
return this.$store.getters['user/getUserCanDelete']
|
||||
},
|
||||
userIsAdmin() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
files() {
|
||||
return this.libraryItem.libraryFiles || []
|
||||
},
|
||||
audioFiles() {
|
||||
return this.libraryItem.media?.audioFiles || []
|
||||
},
|
||||
filesWithAudioFile() {
|
||||
return this.files.map((file) => {
|
||||
if (file.fileType === 'audio') {
|
||||
file.audioFile = this.audioFiles.find((af) => af.ino === file.ino)
|
||||
}
|
||||
return file
|
||||
})
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickBar() {
|
||||
this.showFiles = !this.showFiles
|
||||
},
|
||||
showMore(audioFile) {
|
||||
this.selectedAudioFile = audioFile
|
||||
this.showAudioFileDataModal = true
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
|
||||
118
client/components/tables/LibraryFilesTableRow.vue
Normal file
118
client/components/tables/LibraryFilesTableRow.vue
Normal file
@@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<tr>
|
||||
<td class="px-4">
|
||||
{{ showFullPath ? file.metadata.path : file.metadata.relPath }}
|
||||
</td>
|
||||
<td>
|
||||
{{ $bytesPretty(file.metadata.size) }}
|
||||
</td>
|
||||
<td class="text-xs">
|
||||
<div class="flex items-center">
|
||||
<p>{{ file.fileType }}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td v-if="contextMenuItems.length" class="text-center">
|
||||
<ui-context-menu-dropdown :items="contextMenuItems" menu-width="110px" @action="contextMenuAction" />
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
libraryItemId: String,
|
||||
showFullPath: Boolean,
|
||||
file: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
inModal: Boolean
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
userCanDownload() {
|
||||
return this.$store.getters['user/getUserCanDownload']
|
||||
},
|
||||
userCanDelete() {
|
||||
return this.$store.getters['user/getUserCanDelete']
|
||||
},
|
||||
userIsAdmin() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
downloadUrl() {
|
||||
return `${process.env.serverUrl}/s/item/${this.libraryItemId}/${this.$encodeUriPath(this.file.metadata.relPath).replace(/^\//, '')}?token=${this.userToken}`
|
||||
},
|
||||
contextMenuItems() {
|
||||
const items = []
|
||||
if (this.userCanDownload) {
|
||||
items.push({
|
||||
text: this.$strings.LabelDownload,
|
||||
action: 'download'
|
||||
})
|
||||
}
|
||||
if (this.userCanDelete) {
|
||||
items.push({
|
||||
text: this.$strings.ButtonDelete,
|
||||
action: 'delete'
|
||||
})
|
||||
}
|
||||
// Currently not showing this option in the Files tab modal
|
||||
if (this.userIsAdmin && this.file.audioFile && !this.inModal) {
|
||||
items.push({
|
||||
text: this.$strings.LabelMoreInfo,
|
||||
action: 'more'
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
contextMenuAction(action) {
|
||||
if (action === 'delete') {
|
||||
this.deleteLibraryFile()
|
||||
} else if (action === 'download') {
|
||||
this.downloadLibraryFile()
|
||||
} else if (action === 'more') {
|
||||
this.$emit('showMore', this.file.audioFile)
|
||||
}
|
||||
},
|
||||
deleteLibraryFile() {
|
||||
const payload = {
|
||||
message: 'This will delete the file from your file system. Are you sure?',
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.$axios
|
||||
.$delete(`/api/items/${this.libraryItemId}/file/${this.file.ino}`)
|
||||
.then(() => {
|
||||
this.$toast.success('File deleted')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to delete file', error)
|
||||
this.$toast.error('Failed to delete file')
|
||||
})
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
},
|
||||
downloadLibraryFile() {
|
||||
const a = document.createElement('a')
|
||||
a.style.display = 'none'
|
||||
a.href = this.downloadUrl
|
||||
a.download = this.file.metadata.filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
setTimeout(() => {
|
||||
a.remove()
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -5,9 +5,8 @@
|
||||
<div class="h-5 md:h-7 w-5 md:w-7 rounded-full bg-white bg-opacity-10 flex items-center justify-center">
|
||||
<span class="text-sm font-mono">{{ tracks.length }}</span>
|
||||
</div>
|
||||
<!-- <span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ tracks.length }}</span> -->
|
||||
<div class="flex-grow" />
|
||||
<ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">{{ $strings.ButtonFullPath }}</ui-btn>
|
||||
<ui-btn v-if="userIsAdmin" small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">{{ $strings.ButtonFullPath }}</ui-btn>
|
||||
<nuxt-link v-if="userCanUpdate && !isFile" :to="`/audiobook/${libraryItemId}/edit`" class="mr-2 md:mr-4" @mousedown.prevent>
|
||||
<ui-btn small color="primary">{{ $strings.ButtonManageTracks }}</ui-btn>
|
||||
</nuxt-link>
|
||||
@@ -21,41 +20,20 @@
|
||||
<tr>
|
||||
<th class="w-10">#</th>
|
||||
<th class="text-left">{{ $strings.LabelFilename }}</th>
|
||||
<th class="text-left w-20">{{ $strings.LabelSize }}</th>
|
||||
<th class="text-left w-20">{{ $strings.LabelDuration }}</th>
|
||||
<th v-if="userCanDownload" class="text-center w-20">{{ $strings.LabelDownload }}</th>
|
||||
<th v-if="showExperimentalFeatures" class="text-center w-20">
|
||||
<div class="flex items-center">
|
||||
<p>Tone</p>
|
||||
<ui-tooltip text="Experimental feature for testing Tone library metadata scan results. Results logged in browser console." class="ml-2 w-2" direction="left">
|
||||
<span class="material-icons-outlined text-sm">information</span>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
</th>
|
||||
<th v-if="!showFullPath" class="text-left w-20 hidden lg:table-cell">{{ $strings.LabelCodec }}</th>
|
||||
<th v-if="!showFullPath" class="text-left w-20 hidden xl:table-cell">{{ $strings.LabelBitrate }}</th>
|
||||
<th class="text-left w-20 hidden md:table-cell">{{ $strings.LabelSize }}</th>
|
||||
<th class="text-left w-20 hidden sm:table-cell">{{ $strings.LabelDuration }}</th>
|
||||
<th class="text-center w-16"></th>
|
||||
</tr>
|
||||
<template v-for="track in tracks">
|
||||
<tr :key="track.index">
|
||||
<td class="text-center">
|
||||
<p>{{ track.index }}</p>
|
||||
</td>
|
||||
<td class="font-sans">{{ showFullPath ? track.metadata.path : track.metadata.filename }}</td>
|
||||
<td class="font-mono">
|
||||
{{ $bytesPretty(track.metadata.size) }}
|
||||
</td>
|
||||
<td class="font-mono">
|
||||
{{ $secondsToTimestamp(track.duration) }}
|
||||
</td>
|
||||
<td v-if="userCanDownload" class="text-center">
|
||||
<a :href="`${$config.routerBasePath}/s/item/${libraryItemId}/${$encodeUriPath(track.metadata.relPath).replace(/^\//, '')}?token=${userToken}`" download><span class="material-icons icon-text pt-1">download</span></a>
|
||||
</td>
|
||||
<td v-if="showExperimentalFeatures" class="text-center">
|
||||
<ui-icon-btn borderless :loading="toneProbing" icon="search" @click="toneProbe(track.index)" />
|
||||
</td>
|
||||
</tr>
|
||||
<tables-audio-tracks-table-row :key="track.index" :track="track" :library-item-id="libraryItemId" :showFullPath="showFullPath" @showMore="showMore" />
|
||||
</template>
|
||||
</table>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<modals-audio-file-data-modal v-model="showAudioFileDataModal" :audio-file="selectedAudioFile" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -77,47 +55,31 @@ export default {
|
||||
return {
|
||||
showTracks: false,
|
||||
showFullPath: false,
|
||||
toneProbing: false
|
||||
selectedAudioFile: null,
|
||||
showAudioFileDataModal: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
userCanDownload() {
|
||||
return this.$store.getters['user/getUserCanDownload']
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
},
|
||||
showExperimentalFeatures() {
|
||||
return this.$store.state.showExperimentalFeatures
|
||||
userCanDelete() {
|
||||
return this.$store.getters['user/getUserCanDelete']
|
||||
},
|
||||
userIsAdmin() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickBar() {
|
||||
this.showTracks = !this.showTracks
|
||||
},
|
||||
toneProbe(index) {
|
||||
this.toneProbing = true
|
||||
|
||||
this.$axios
|
||||
.$post(`/api/items/${this.libraryItemId}/tone-scan/${index}`)
|
||||
.then((data) => {
|
||||
console.log('Tone probe data', data)
|
||||
if (data.error) {
|
||||
this.$toast.error('Tone probe error: ' + data.error)
|
||||
} else {
|
||||
this.$toast.success('Tone probe successful! Check browser console')
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to tone probe', error)
|
||||
this.$toast.error('Tone probe failed')
|
||||
})
|
||||
.finally(() => {
|
||||
this.toneProbing = false
|
||||
})
|
||||
showMore(audioFile) {
|
||||
this.selectedAudioFile = audioFile
|
||||
this.showAudioFileDataModal = true
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
|
||||
@@ -25,13 +25,13 @@
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-xs font-mono hidden sm:table-cell">
|
||||
<ui-tooltip v-if="user.lastSeen" direction="top" :text="$formatDate(user.lastSeen, 'MMMM do, yyyy HH:mm')">
|
||||
<ui-tooltip v-if="user.lastSeen" direction="top" :text="$formatDatetime(user.lastSeen, dateFormat, timeFormat)">
|
||||
{{ $dateDistanceFromNow(user.lastSeen) }}
|
||||
</ui-tooltip>
|
||||
</td>
|
||||
<td class="text-xs font-mono hidden sm:table-cell">
|
||||
<ui-tooltip direction="top" :text="$formatDate(user.createdAt, 'MMMM do, yyyy HH:mm')">
|
||||
{{ $formatDate(user.createdAt, 'MMM d, yyyy') }}
|
||||
<ui-tooltip direction="top" :text="$formatDatetime(user.createdAt, dateFormat, timeFormat)">
|
||||
{{ $formatDate(user.createdAt, dateFormat) }}
|
||||
</ui-tooltip>
|
||||
</td>
|
||||
<td class="py-0">
|
||||
@@ -74,6 +74,12 @@ export default {
|
||||
var usermap = {}
|
||||
this.$store.state.users.usersOnline.forEach((u) => (usermap[u.id] = u))
|
||||
return usermap
|
||||
},
|
||||
dateFormat() {
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
},
|
||||
timeFormat() {
|
||||
return this.$store.state.serverSettings.timeFormat
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -201,4 +207,4 @@ export default {
|
||||
padding-bottom: 5px;
|
||||
background-color: #272727;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
65
client/components/tables/podcast/DownloadQueueTable.vue
Normal file
65
client/components/tables/podcast/DownloadQueueTable.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<div class="w-full my-2">
|
||||
<div class="w-full bg-primary px-4 md:px-6 py-2 flex items-center">
|
||||
<p class="pr-2 md:pr-4">{{ $strings.HeaderDownloadQueue }}</p>
|
||||
<div class="h-5 md:h-7 w-5 md:w-7 rounded-full bg-white bg-opacity-10 flex items-center justify-center">
|
||||
<span class="text-sm font-mono">{{ queue.length }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<transition name="slide">
|
||||
<div class="w-full">
|
||||
<table class="text-sm tracksTable">
|
||||
<tr>
|
||||
<th class="text-left px-4 min-w-48">{{ $strings.LabelPodcast }}</th>
|
||||
<th class="text-left w-32 min-w-32">{{ $strings.LabelEpisode }}</th>
|
||||
<th class="text-left px-4">{{ $strings.LabelEpisodeTitle }}</th>
|
||||
<th class="text-left px-4 w-48">{{ $strings.LabelPubDate }}</th>
|
||||
</tr>
|
||||
<template v-for="downloadQueued in queue">
|
||||
<tr :key="downloadQueued.id">
|
||||
<td class="px-4">
|
||||
<div class="flex items-center">
|
||||
<nuxt-link :to="`/item/${downloadQueued.libraryItemId}`" class="text-sm text-gray-200 hover:underline">{{ downloadQueued.podcastTitle }}</nuxt-link>
|
||||
<widgets-explicit-indicator :explicit="downloadQueued.podcastExplicit" />
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex items-center">
|
||||
<div v-if="downloadQueued.season">{{ downloadQueued.season }}x</div>
|
||||
<div v-if="downloadQueued.episode">{{ downloadQueued.episode }}</div>
|
||||
<widgets-podcast-type-indicator :type="downloadQueued.episodeType" />
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4">
|
||||
{{ downloadQueued.episodeDisplayTitle }}
|
||||
</td>
|
||||
<td class="text-xs">
|
||||
<div class="flex items-center">
|
||||
<p>{{ $dateDistanceFromNow(downloadQueued.publishedAt) }}</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</table>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
queue: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
libraryItemId: String
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -2,16 +2,18 @@
|
||||
<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">
|
||||
<p class="text-sm font-semibold">
|
||||
{{ title }}
|
||||
</p>
|
||||
<div class="flex items-center">
|
||||
<span class="text-sm font-semibold">{{ title }}</span>
|
||||
<widgets-podcast-type-indicator :type="episode.episodeType" />
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-gray-200 episode-subtitle mt-1.5 mb-0.5">{{ 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="publishedAt" class="text-sm text-gray-300">Published {{ $formatDate(publishedAt, 'MMM do, yyyy') }}</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 class="flex items-center pt-2">
|
||||
@@ -128,6 +130,9 @@ export default {
|
||||
},
|
||||
publishedAt() {
|
||||
return this.episode.publishedAt
|
||||
},
|
||||
dateFormat() {
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -205,4 +210,4 @@ export default {
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -19,7 +19,12 @@
|
||||
</template>
|
||||
</div>
|
||||
<p v-if="!episodes.length" class="py-4 text-center text-lg">{{ $strings.MessageNoEpisodes }}</p>
|
||||
<template v-for="episode in episodesSorted">
|
||||
<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>
|
||||
|
||||
@@ -46,7 +51,10 @@ export default {
|
||||
selectedEpisodes: [],
|
||||
episodesToRemove: [],
|
||||
processing: false,
|
||||
quickMatchingEpisodes: false
|
||||
quickMatchingEpisodes: false,
|
||||
search: null,
|
||||
searchTimeout: null,
|
||||
searchText: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -131,21 +139,52 @@ export default {
|
||||
return episodeProgress && !episodeProgress.isFinished
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (this.sortDesc) {
|
||||
return String(b[this.sortKey]).localeCompare(String(a[this.sortKey]), undefined, { numeric: true, sensitivity: 'base' })
|
||||
let aValue = a[this.sortKey]
|
||||
let bValue = b[this.sortKey]
|
||||
|
||||
// Sort episodes with no pub date as the oldest
|
||||
if (this.sortKey === 'publishedAt') {
|
||||
if (!aValue) aValue = Number.MAX_VALUE
|
||||
if (!bValue) bValue = Number.MAX_VALUE
|
||||
}
|
||||
return String(a[this.sortKey]).localeCompare(String(b[this.sortKey]), undefined, { numeric: true, sensitivity: 'base' })
|
||||
|
||||
if (this.sortDesc) {
|
||||
return String(bValue).localeCompare(String(aValue), undefined, { numeric: true, sensitivity: 'base' })
|
||||
}
|
||||
return String(aValue).localeCompare(String(bValue), undefined, { numeric: true, sensitivity: 'base' })
|
||||
})
|
||||
},
|
||||
episodesList() {
|
||||
return this.episodesSorted.filter((episode) => {
|
||||
if (!this.searchText) return true
|
||||
return (episode.title && episode.title.toLowerCase().includes(this.searchText)) || (episode.subtitle && episode.subtitle.toLowerCase().includes(this.searchText))
|
||||
})
|
||||
},
|
||||
selectedIsFinished() {
|
||||
// Find an item that is not finished, if none then all items finished
|
||||
return !this.selectedEpisodes.find((episode) => {
|
||||
var itemProgress = this.$store.getters['user/getUserMediaProgress'](this.libraryItem.id, episode.id)
|
||||
return !itemProgress || !itemProgress.isFinished
|
||||
})
|
||||
},
|
||||
dateFormat() {
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
},
|
||||
timeFormat() {
|
||||
return this.$store.state.serverSettings.timeFormat
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
inputUpdate() {
|
||||
clearTimeout(this.searchTimeout)
|
||||
this.searchTimeout = setTimeout(() => {
|
||||
if (!this.search || !this.search.trim()) {
|
||||
this.searchText = ''
|
||||
return
|
||||
}
|
||||
this.searchText = this.search.toLowerCase().trim()
|
||||
}, 500)
|
||||
},
|
||||
contextMenuAction(action) {
|
||||
if (action === 'quick-match-episodes') {
|
||||
if (this.quickMatchingEpisodes) return
|
||||
@@ -195,7 +234,7 @@ export default {
|
||||
episodeId: episode.id,
|
||||
title: episode.title,
|
||||
subtitle: this.mediaMetadata.title,
|
||||
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, 'MMM do, yyyy')}` : 'Unknown publish date',
|
||||
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, this.dateFormat)}` : 'Unknown publish date',
|
||||
duration: episode.audioFile.duration || null,
|
||||
coverPath: this.media.coverPath || null
|
||||
}
|
||||
@@ -263,7 +302,7 @@ export default {
|
||||
episodeId: episode.id,
|
||||
title: episode.title,
|
||||
subtitle: this.mediaMetadata.title,
|
||||
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, 'MMM do, yyyy')}` : 'Unknown publish date',
|
||||
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, this.dateFormat)}` : 'Unknown publish date',
|
||||
duration: episode.audioFile.duration || null,
|
||||
coverPath: this.media.coverPath || null
|
||||
})
|
||||
@@ -281,6 +320,8 @@ export default {
|
||||
this.showPodcastRemoveModal = true
|
||||
},
|
||||
editEpisode(episode) {
|
||||
const episodeIds = this.episodesSorted.map((e) => e.id)
|
||||
this.$store.commit('setEpisodeTableEpisodeIds', episodeIds)
|
||||
this.$store.commit('setSelectedLibraryItem', this.libraryItem)
|
||||
this.$store.commit('globals/setSelectedEpisode', episode)
|
||||
this.$store.commit('globals/setShowEditPodcastEpisodeModal', true)
|
||||
@@ -314,4 +355,4 @@ export default {
|
||||
.episode-leave-active {
|
||||
position: absolute;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<input v-model="selected" :disabled="disabled" type="checkbox" class="opacity-0 absolute" :class="!disabled ? 'cursor-pointer' : ''" />
|
||||
<svg v-if="selected" class="fill-current pointer-events-none" :class="svgClass" viewBox="0 0 20 20"><path d="M0 11l2-2 5 5L18 3l2 2L7 18z" /></svg>
|
||||
</div>
|
||||
<div v-if="label" class="select-none text-gray-100" :class="labelClassname">{{ label }}</div>
|
||||
<div v-if="label" class="select-none" :class="[labelClassname, disabled ? 'text-gray-400' : 'text-gray-100']">{{ label }}</div>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<template>
|
||||
<div class="relative h-9 w-9" v-click-outside="clickOutsideObj">
|
||||
<button type="button" :disabled="disabled" class="relative h-full w-full flex items-center justify-center shadow-sm pl-3 pr-3 text-left focus:outline-none cursor-pointer text-gray-100 hover:text-gray-200 rounded-full hover:bg-white/5" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
|
||||
<span class="material-icons" :class="iconClass">more_vert</span>
|
||||
</button>
|
||||
<slot :disabled="disabled" :showMenu="showMenu" :clickShowMenu="clickShowMenu">
|
||||
<button type="button" :disabled="disabled" class="relative h-full w-full flex items-center justify-center shadow-sm pl-3 pr-3 text-left focus:outline-none cursor-pointer text-gray-100 hover:text-gray-200 rounded-full hover:bg-white/5" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
|
||||
<span class="material-icons" :class="iconClass">more_vert</span>
|
||||
</button>
|
||||
</slot>
|
||||
|
||||
<transition name="menu">
|
||||
<div v-show="showMenu" class="absolute right-0 mt-1 z-10 bg-bg border border-black-200 shadow-lg max-h-56 w-48 rounded-md py-1 overflow-auto focus:outline-none sm:text-sm">
|
||||
<div v-show="showMenu" class="absolute right-0 mt-1 z-10 bg-bg border border-black-200 shadow-lg max-h-56 rounded-md py-1 overflow-auto focus:outline-none sm:text-sm" :style="{ width: menuWidth }">
|
||||
<template v-for="(item, index) in items">
|
||||
<div :key="index" class="flex items-center px-2 py-1.5 hover:bg-white hover:bg-opacity-5 text-white text-xs cursor-pointer" @click.stop="clickAction(item.action)">
|
||||
<p>{{ item.text }}</p>
|
||||
@@ -27,6 +29,10 @@ export default {
|
||||
iconClass: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
menuWidth: {
|
||||
type: String,
|
||||
default: '192px'
|
||||
}
|
||||
},
|
||||
data() {
|
||||
|
||||
@@ -68,8 +68,6 @@ export default {
|
||||
}
|
||||
},
|
||||
mounted() {},
|
||||
beforeDestroy() {
|
||||
console.log('Before destroy')
|
||||
}
|
||||
beforeDestroy() {}
|
||||
}
|
||||
</script>
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div ref="wrapper" class="relative">
|
||||
<input :id="inputId" ref="input" v-model="inputValue" :type="actualType" :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" 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" />
|
||||
<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>
|
||||
@@ -32,7 +32,9 @@ export default {
|
||||
noSpinner: Boolean,
|
||||
textCenter: Boolean,
|
||||
clearable: Boolean,
|
||||
inputId: String
|
||||
inputId: String,
|
||||
step: [String, Number],
|
||||
min: [String, Number]
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
||||
@@ -51,8 +51,8 @@ export default {
|
||||
tooltip.style.zIndex = 100
|
||||
tooltip.style.backgroundColor = 'rgba(0,0,0,0.85)'
|
||||
tooltip.innerHTML = this.text
|
||||
tooltip.addEventListener('mouseover', this.cancelHide);
|
||||
tooltip.addEventListener('mouseleave', this.hideTooltip);
|
||||
tooltip.addEventListener('mouseover', this.cancelHide)
|
||||
tooltip.addEventListener('mouseleave', this.hideTooltip)
|
||||
|
||||
this.setTooltipPosition(tooltip)
|
||||
|
||||
@@ -107,7 +107,7 @@ export default {
|
||||
this.isShowing = false
|
||||
},
|
||||
cancelHide() {
|
||||
if (this.hideTimeout) clearTimeout(this.hideTimeout);
|
||||
if (this.hideTimeout) clearTimeout(this.hideTimeout)
|
||||
},
|
||||
mouseover() {
|
||||
if (!this.isShowing) this.showTooltip()
|
||||
|
||||
70
client/components/widgets/AbridgedIndicator.vue
Normal file
70
client/components/widgets/AbridgedIndicator.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<ui-tooltip :text="$strings.LabelAbridged" direction="top">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12px" height="12px" viewBox="0 0 512 512" class="ml-1">
|
||||
<path
|
||||
fill="white"
|
||||
d="M 89.00,40.12
|
||||
C 89.00,40.12 127.00,40.12 127.00,40.12
|
||||
127.00,40.12 198.00,40.12 198.00,40.12
|
||||
198.00,40.12 416.00,40.12 416.00,40.12
|
||||
446.58,40.05 472.95,66.42 473.00,97.00
|
||||
473.00,97.00 473.00,303.00 473.00,303.00
|
||||
473.00,303.00 473.00,418.00 473.00,418.00
|
||||
472.65,447.55 445.06,472.95 416.00,473.00
|
||||
416.00,473.00 210.00,473.00 210.00,473.00
|
||||
210.00,473.00 95.00,473.00 95.00,473.00
|
||||
65.45,472.65 40.05,445.06 40.00,416.00
|
||||
40.00,416.00 40.00,136.00 40.00,136.00
|
||||
40.00,136.00 40.00,109.00 40.00,109.00
|
||||
40.00,109.00 40.00,96.00 40.00,96.00
|
||||
40.07,81.58 46.89,67.14 57.01,57.01
|
||||
61.17,52.86 64.86,50.13 70.00,47.31
|
||||
77.25,43.33 81.02,42.18 89.00,40.12 Z
|
||||
M 372.00,392.00
|
||||
C 372.00,392.00 364.02,364.00 364.02,364.00
|
||||
364.02,364.00 350.72,319.00 350.72,319.00
|
||||
350.72,319.00 310.42,183.00 310.42,183.00
|
||||
310.42,183.00 296.86,137.00 296.86,137.00
|
||||
296.86,137.00 291.30,121.99 291.30,121.99
|
||||
291.30,121.99 284.00,121.00 284.00,121.00
|
||||
284.00,121.00 230.00,121.00 230.00,121.00
|
||||
230.00,121.00 222.51,122.02 222.51,122.02
|
||||
222.51,122.02 216.86,137.00 216.86,137.00
|
||||
216.86,137.00 203.28,183.00 203.28,183.00
|
||||
203.28,183.00 163.28,318.00 163.28,318.00
|
||||
163.28,318.00 148.71,367.00 148.71,367.00
|
||||
148.71,367.00 142.00,392.00 142.00,392.00
|
||||
142.00,392.00 183.00,392.00 183.00,392.00
|
||||
183.00,392.00 190.86,390.43 190.86,390.43
|
||||
190.86,390.43 195.86,375.00 195.86,375.00
|
||||
195.86,375.00 206.00,338.00 206.00,338.00
|
||||
206.00,338.00 293.00,338.00 293.00,338.00
|
||||
295.64,338.01 299.26,337.65 301.30,339.60
|
||||
303.23,341.43 304.80,348.22 305.58,351.00
|
||||
305.58,351.00 313.00,378.00 313.00,378.00
|
||||
316.91,391.63 315.20,391.98 325.00,392.00
|
||||
325.00,392.00 372.00,392.00 372.00,392.00 Z
|
||||
M 254.00,170.00
|
||||
C 254.00,170.00 256.00,170.00 256.00,170.00
|
||||
256.00,170.00 263.12,197.00 263.12,197.00
|
||||
263.12,197.00 282.88,268.00 282.88,268.00
|
||||
282.88,268.00 290.00,296.00 290.00,296.00
|
||||
290.00,296.00 219.00,296.00 219.00,296.00
|
||||
219.00,296.00 230.58,253.00 230.58,253.00
|
||||
230.58,253.00 254.00,170.00 254.00,170.00 Z"
|
||||
/>
|
||||
</svg>
|
||||
</ui-tooltip>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
19
client/components/widgets/AlreadyInLibraryIndicator.vue
Normal file
19
client/components/widgets/AlreadyInLibraryIndicator.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<ui-tooltip v-if="alreadyInLibrary" :text="$strings.LabelAlreadyInYourLibrary" direction="top">
|
||||
<span class="material-icons ml-1 text-success" style="font-size: 0.8rem">check_circle</span>
|
||||
</ui-tooltip>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
alreadyInLibrary: Boolean
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -16,7 +16,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<tables-tracks-table :title="$strings.LabelStatsAudioTracks" :tracks="media.tracks" :is-file="isFile" :library-item-id="libraryItemId" class="mt-6" />
|
||||
<tables-tracks-table :title="$strings.LabelStatsAudioTracks" :tracks="tracksWithAudioFile" :is-file="isFile" :library-item-id="libraryItemId" class="mt-6" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -34,6 +34,12 @@ export default {
|
||||
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 = []
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap mt-2 -mx-1">
|
||||
<div class="w-full md:w-1/2 px-1">
|
||||
<div class="w-full md:w-1/4 px-1">
|
||||
<ui-text-input-with-label ref="publisherInput" v-model="details.publisher" :label="$strings.LabelPublisher" />
|
||||
</div>
|
||||
<div class="w-1/2 md:w-1/4 px-1 mt-2 md:mt-0">
|
||||
@@ -61,6 +61,11 @@
|
||||
<ui-checkbox v-model="details.explicit" :label="$strings.LabelExplicit" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow px-1 pt-6 mt-2 md:mt-0">
|
||||
<div class="flex justify-center">
|
||||
<ui-checkbox v-model="details.abridged" :label="$strings.LabelAbridged" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -89,7 +94,8 @@ export default {
|
||||
isbn: null,
|
||||
asin: null,
|
||||
genres: [],
|
||||
explicit: false
|
||||
explicit: false,
|
||||
abridged: false
|
||||
},
|
||||
newTags: []
|
||||
}
|
||||
@@ -271,6 +277,7 @@ export default {
|
||||
this.details.isbn = this.mediaMetadata.isbn || null
|
||||
this.details.asin = this.mediaMetadata.asin || null
|
||||
this.details.explicit = !!this.mediaMetadata.explicit
|
||||
this.details.abridged = !!this.mediaMetadata.abridged
|
||||
this.newTags = [...(this.media.tags || [])]
|
||||
},
|
||||
submitForm() {
|
||||
|
||||
@@ -36,6 +36,10 @@
|
||||
<p v-else class="text-success text-base md:text-lg text-center">{{ $strings.MessageValidCronExpression }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="cronExpression && isValid" class="flex items-center justify-center text-yellow-400 mt-2">
|
||||
<span class="material-icons-outlined mr-2 text-xl">event</span>
|
||||
<p>{{ $strings.LabelNextScheduledRun }}: {{ nextRun }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -63,6 +67,14 @@ export default {
|
||||
isValid: true
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
value: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
minuteIsValid() {
|
||||
return !(isNaN(this.selectedMinute) || this.selectedMinute === '' || this.selectedMinute < 0 || this.selectedMinute > 59)
|
||||
@@ -70,6 +82,11 @@ export default {
|
||||
hourIsValid() {
|
||||
return !(isNaN(this.selectedHour) || this.selectedHour === '' || this.selectedHour < 0 || this.selectedHour > 23)
|
||||
},
|
||||
nextRun() {
|
||||
if (!this.cronExpression) return ''
|
||||
const parsed = this.$getNextScheduledDate(this.cronExpression)
|
||||
return this.$formatJsDatetime(parsed, this.$store.state.serverSettings.dateFormat, this.$store.state.serverSettings.timeFormat) || ''
|
||||
},
|
||||
description() {
|
||||
if ((this.selectedInterval !== 'custom' || !this.selectedWeekdays.length) && this.selectedInterval !== 'daily') return ''
|
||||
|
||||
@@ -271,6 +288,11 @@ export default {
|
||||
})
|
||||
},
|
||||
init() {
|
||||
this.selectedInterval = 'custom'
|
||||
this.selectedHour = 0
|
||||
this.selectedMinute = 0
|
||||
this.selectedWeekdays = []
|
||||
|
||||
if (!this.value) return
|
||||
const pieces = this.value.split(' ')
|
||||
if (pieces.length !== 5) {
|
||||
@@ -309,4 +331,4 @@ export default {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
53
client/components/widgets/ExplicitIndicator.vue
Normal file
53
client/components/widgets/ExplicitIndicator.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<ui-tooltip v-if="explicit" :text="$strings.LabelExplicit" direction="top">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12px" height="12px" viewBox="0 0 512 512" class="ml-1">
|
||||
<path
|
||||
fill="white"
|
||||
d="M 89.00,40.12
|
||||
C 89.00,40.12 127.00,40.12 127.00,40.12
|
||||
127.00,40.12 198.00,40.12 198.00,40.12
|
||||
198.00,40.12 416.00,40.12 416.00,40.12
|
||||
446.58,40.05 472.95,66.42 473.00,97.00
|
||||
473.00,97.00 473.00,303.00 473.00,303.00
|
||||
473.00,303.00 473.00,418.00 473.00,418.00
|
||||
472.65,447.55 445.06,472.95 416.00,473.00
|
||||
416.00,473.00 210.00,473.00 210.00,473.00
|
||||
210.00,473.00 95.00,473.00 95.00,473.00
|
||||
65.45,472.65 40.05,445.06 40.00,416.00
|
||||
40.00,416.00 40.00,136.00 40.00,136.00
|
||||
40.00,136.00 40.00,109.00 40.00,109.00
|
||||
40.00,109.00 40.00,96.00 40.00,96.00
|
||||
40.07,81.58 46.89,67.14 57.01,57.01
|
||||
61.17,52.86 64.86,50.13 70.00,47.31
|
||||
77.25,43.33 81.02,42.18 89.00,40.12 Z
|
||||
M 337.00,121.00
|
||||
C 337.00,121.00 175.00,121.00 175.00,121.00
|
||||
175.00,121.00 175.00,392.00 175.00,392.00
|
||||
175.00,392.00 337.00,392.00 337.00,392.00
|
||||
337.00,392.00 337.00,349.00 337.00,349.00
|
||||
337.00,349.00 226.00,349.00 226.00,349.00
|
||||
226.00,349.00 226.00,274.00 226.00,274.00
|
||||
226.00,274.00 332.00,274.00 332.00,274.00
|
||||
332.00,274.00 332.00,232.00 332.00,232.00
|
||||
332.00,232.00 226.00,232.00 226.00,232.00
|
||||
226.00,232.00 226.00,164.00 226.00,164.00
|
||||
226.00,164.00 337.00,164.00 337.00,164.00
|
||||
337.00,164.00 337.00,121.00 337.00,121.00 Z"
|
||||
/>
|
||||
</svg>
|
||||
</ui-tooltip>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
explicit: Boolean
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
100
client/components/widgets/NarratorsSlider.vue
Normal file
100
client/components/widgets/NarratorsSlider.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<div class="flex items-center py-3">
|
||||
<slot />
|
||||
<div class="flex-grow" />
|
||||
<button v-if="isScrollable" class="w-8 h-8 mx-1 flex items-center justify-center rounded-full" :class="canScrollLeft ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollLeft">
|
||||
<span class="material-icons text-2xl">chevron_left</span>
|
||||
</button>
|
||||
<button v-if="isScrollable" class="w-8 h-8 mx-1 flex items-center justify-center rounded-full" :class="canScrollRight ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollRight">
|
||||
<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' }">
|
||||
<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" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
height: {
|
||||
type: Number,
|
||||
default: 192
|
||||
},
|
||||
bookshelfView: {
|
||||
type: Number,
|
||||
default: 1
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isScrollable: false,
|
||||
canScrollLeft: false,
|
||||
canScrollRight: false,
|
||||
clientWidth: 0
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
cardHeight() {
|
||||
return this.height
|
||||
},
|
||||
cardWidth() {
|
||||
return this.cardHeight * 1.5
|
||||
},
|
||||
booksPerPage() {
|
||||
return Math.floor(this.clientWidth / (this.cardWidth + 16))
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
scrolled() {
|
||||
this.setScrollVars()
|
||||
},
|
||||
scrollRight() {
|
||||
if (!this.canScrollRight) return
|
||||
const slider = this.$refs.slider
|
||||
if (!slider) return
|
||||
const scrollAmount = this.booksPerPage * this.cardWidth
|
||||
const maxScrollLeft = slider.scrollWidth - slider.clientWidth
|
||||
|
||||
const newScrollLeft = Math.min(maxScrollLeft, slider.scrollLeft + scrollAmount)
|
||||
slider.scrollLeft = newScrollLeft
|
||||
},
|
||||
scrollLeft() {
|
||||
if (!this.canScrollLeft) return
|
||||
const slider = this.$refs.slider
|
||||
if (!slider) return
|
||||
|
||||
const scrollAmount = this.booksPerPage * this.cardWidth
|
||||
|
||||
const newScrollLeft = Math.max(0, slider.scrollLeft - scrollAmount)
|
||||
slider.scrollLeft = newScrollLeft
|
||||
},
|
||||
setScrollVars() {
|
||||
const slider = this.$refs.slider
|
||||
if (!slider) return
|
||||
const { scrollLeft, scrollWidth, clientWidth } = slider
|
||||
const scrollPercent = (scrollLeft + clientWidth) / scrollWidth
|
||||
|
||||
this.clientWidth = clientWidth
|
||||
this.isScrollable = scrollWidth > clientWidth
|
||||
this.canScrollRight = scrollPercent < 1
|
||||
this.canScrollLeft = scrollLeft > 0
|
||||
}
|
||||
},
|
||||
updated() {
|
||||
this.setScrollVars()
|
||||
},
|
||||
mounted() {},
|
||||
beforeDestroy() {}
|
||||
}
|
||||
</script>
|
||||
@@ -1,15 +1,51 @@
|
||||
<template>
|
||||
<div v-if="tasksRunning" class="w-4 h-4 mx-3 relative">
|
||||
<div class="flex h-full items-center justify-center">
|
||||
<widgets-loading-spinner />
|
||||
</div>
|
||||
<div v-if="tasksRunning" class="w-4 h-4 mx-3 relative" v-click-outside="clickOutsideObj">
|
||||
<button type="button" :disabled="disabled" class="w-10 sm:w-full relative h-full cursor-pointer" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
|
||||
<div class="flex h-full items-center justify-center">
|
||||
<ui-tooltip text="Tasks running" direction="bottom" class="flex items-center">
|
||||
<widgets-loading-spinner />
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
</button>
|
||||
<transition name="menu">
|
||||
<div class="sm:w-80 w-full relative">
|
||||
<div v-show="showMenu" 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 globalTaskRunningMenu">
|
||||
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||
<template v-if="tasksRunningOrFailed.length">
|
||||
<p class="uppercase text-xs text-gray-400 my-1 px-1 font-semibold">{{ $strings.LabelTasks }}</p>
|
||||
<template v-for="task in tasksRunningOrFailed">
|
||||
<nuxt-link :key="task.id" v-if="actionLink(task)" :to="actionLink(task)">
|
||||
<li class="text-gray-50 select-none relative hover:bg-black-400 py-1 cursor-pointer">
|
||||
<cards-item-task-running-card :task="task" />
|
||||
</li>
|
||||
</nuxt-link>
|
||||
<li v-else :key="task.id" class="text-gray-50 select-none relative hover:bg-black-400 py-1">
|
||||
<cards-item-task-running-card :task="task" />
|
||||
</li>
|
||||
</template>
|
||||
</template>
|
||||
<li v-else class="py-2 px-2">
|
||||
<p>{{ $strings.MessageNoTasksRunning }}</p>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {}
|
||||
return {
|
||||
clickOutsideObj: {
|
||||
handler: this.clickedOutside,
|
||||
events: ['mousedown'],
|
||||
isActive: true
|
||||
},
|
||||
showMenu: false,
|
||||
disabled: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
tasks() {
|
||||
@@ -17,9 +53,39 @@ export default {
|
||||
},
|
||||
tasksRunning() {
|
||||
return this.tasks.some((t) => !t.isFinished)
|
||||
},
|
||||
tasksRunningOrFailed() {
|
||||
// return just the tasks that are running or failed in the last 1 minute
|
||||
return this.tasks.filter((t) => !t.isFinished || (t.isFailed && t.finishedAt > new Date().getTime() - 1000 * 60)) || []
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickShowMenu() {
|
||||
if (this.disabled) return
|
||||
this.showMenu = !this.showMenu
|
||||
},
|
||||
clickedOutside() {
|
||||
this.showMenu = false
|
||||
},
|
||||
actionLink(task) {
|
||||
switch (task.action) {
|
||||
case 'download-podcast-episode':
|
||||
return `/library/${task.data.libraryId}/podcast/download-queue`
|
||||
case 'encode-m4b':
|
||||
return `/audiobook/${task.data.libraryItemId}/manage?tool=m4b`
|
||||
case 'embed-metadata':
|
||||
return `/audiobook/${task.data.libraryItemId}/manage?tool=embed`
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.globalTaskRunningMenu {
|
||||
max-height: 80vh;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -39,6 +39,11 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-1/4 px-1">
|
||||
<ui-dropdown :label="$strings.LabelPodcastType" v-model="details.type" :items="podcastTypes" small class="max-w-52" />
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
@@ -65,7 +70,8 @@ export default {
|
||||
itunesId: null,
|
||||
itunesArtistId: null,
|
||||
explicit: false,
|
||||
language: null
|
||||
language: null,
|
||||
type: null
|
||||
},
|
||||
newTags: []
|
||||
}
|
||||
@@ -93,6 +99,9 @@ export default {
|
||||
},
|
||||
filterData() {
|
||||
return this.$store.state.libraries.filterData || {}
|
||||
},
|
||||
podcastTypes() {
|
||||
return this.$store.state.globals.podcastTypes || []
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -219,6 +228,7 @@ export default {
|
||||
this.details.itunesArtistId = this.mediaMetadata.itunesArtistId || ''
|
||||
this.details.language = this.mediaMetadata.language || ''
|
||||
this.details.explicit = !!this.mediaMetadata.explicit
|
||||
this.details.type = this.mediaMetadata.type || 'episodic'
|
||||
|
||||
this.newTags = [...(this.media.tags || [])]
|
||||
},
|
||||
@@ -228,4 +238,4 @@ export default {
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
31
client/components/widgets/PodcastTypeIndicator.vue
Normal file
31
client/components/widgets/PodcastTypeIndicator.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<div>
|
||||
<template v-if="type == 'bonus'">
|
||||
<ui-tooltip text="Bonus" direction="top">
|
||||
<span class="material-icons ml-1" style="font-size: 0.8rem">local_play</span>
|
||||
</ui-tooltip>
|
||||
</template>
|
||||
<template v-if="type == 'trailer'">
|
||||
<ui-tooltip text="Trailer" direction="top">
|
||||
<span class="material-icons ml-1" style="font-size: 0.8rem">local_movies</span>
|
||||
</ui-tooltip>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
type: {
|
||||
type: String,
|
||||
default: 'full'
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
90
client/components/widgets/RssFeedMetadataBuilder.vue
Normal file
90
client/components/widgets/RssFeedMetadataBuilder.vue
Normal file
@@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<div class="w-full py-2">
|
||||
<div class="flex -mb-px">
|
||||
<div class="w-1/2 h-6 rounded-tl-md relative border border-black-200 flex items-center justify-center cursor-pointer" :class="!showAdvancedView ? 'text-white bg-bg hover:bg-opacity-60 border-b-bg' : 'text-gray-400 hover:text-gray-300 bg-primary bg-opacity-70 hover:bg-opacity-60'" @click="showAdvancedView = false">
|
||||
<p class="text-sm">{{ $strings.HeaderRSSFeedGeneral }}</p>
|
||||
</div>
|
||||
<div class="w-1/2 h-6 rounded-tr-md relative border border-black-200 flex items-center justify-center -ml-px cursor-pointer" :class="showAdvancedView ? 'text-white bg-bg hover:bg-opacity-60 border-b-bg' : 'text-gray-400 hover:text-gray-300 bg-primary bg-opacity-70 hover:bg-opacity-60'" @click="showAdvancedView = true">
|
||||
<p class="text-sm">{{ $strings.HeaderAdvanced }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-2 py-4 md:p-4 border border-black-200 rounded-b-md mr-px" style="min-height: 200px">
|
||||
<template v-if="!showAdvancedView">
|
||||
<div class="flex-grow pt-2 mb-2">
|
||||
<ui-checkbox v-model="preventIndexing" :label="$strings.LabelPreventIndexing" checkbox-bg="primary" border-color="gray-600" label-class="pl-2" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="flex-grow pt-2 mb-2">
|
||||
<ui-checkbox v-model="preventIndexing" :label="$strings.LabelPreventIndexing" checkbox-bg="primary" border-color="gray-600" label-class="pl-2" />
|
||||
</div>
|
||||
<div class="w-full relative mb-1">
|
||||
<ui-text-input-with-label v-model="ownerName" :label="$strings.LabelRSSFeedCustomOwnerName" />
|
||||
</div>
|
||||
<div class="w-full relative mb-1">
|
||||
<ui-text-input-with-label v-model="ownerEmail" :label="$strings.LabelRSSFeedCustomOwnerEmail" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: {
|
||||
type: Object,
|
||||
default: () => {
|
||||
return {
|
||||
preventIndexing: true,
|
||||
ownerName: '',
|
||||
ownerEmail: ''
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showAdvancedView: false
|
||||
}
|
||||
},
|
||||
watch: {},
|
||||
computed: {
|
||||
preventIndexing: {
|
||||
get() {
|
||||
return this.value.preventIndexing
|
||||
},
|
||||
set(value) {
|
||||
this.$emit('input', {
|
||||
...this.value,
|
||||
preventIndexing: value
|
||||
})
|
||||
}
|
||||
},
|
||||
ownerName: {
|
||||
get() {
|
||||
return this.value.ownerName
|
||||
},
|
||||
set(value) {
|
||||
this.$emit('input', {
|
||||
...this.value,
|
||||
ownerName: value
|
||||
})
|
||||
}
|
||||
},
|
||||
ownerEmail: {
|
||||
get() {
|
||||
return this.value.ownerEmail
|
||||
},
|
||||
set(value) {
|
||||
this.$emit('input', {
|
||||
...this.value,
|
||||
ownerEmail: value
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -278,6 +278,13 @@ export default {
|
||||
console.log('Task finished', task)
|
||||
this.$store.commit('tasks/addUpdateTask', task)
|
||||
},
|
||||
metadataEmbedQueueUpdate(data) {
|
||||
if (data.queued) {
|
||||
this.$store.commit('tasks/addQueuedEmbedLId', data.libraryItemId)
|
||||
} else {
|
||||
this.$store.commit('tasks/removeQueuedEmbedLId', data.libraryItemId)
|
||||
}
|
||||
},
|
||||
userUpdated(user) {
|
||||
if (this.$store.state.user.user.id === user.id) {
|
||||
this.$store.commit('user/setUser', user)
|
||||
@@ -292,8 +299,17 @@ export default {
|
||||
userStreamUpdate(user) {
|
||||
this.$store.commit('users/updateUserOnline', user)
|
||||
},
|
||||
userSessionClosed(sessionId) {
|
||||
if (this.$refs.streamContainer) this.$refs.streamContainer.sessionClosedEvent(sessionId)
|
||||
},
|
||||
userMediaProgressUpdate(payload) {
|
||||
this.$store.commit('user/updateMediaProgress', payload)
|
||||
|
||||
if (payload.data) {
|
||||
if (this.$store.getters['getIsMediaStreaming'](payload.data.libraryItemId, payload.data.episodeId)) {
|
||||
// TODO: Update currently open session if being played from another device
|
||||
}
|
||||
}
|
||||
},
|
||||
collectionAdded(collection) {
|
||||
if (this.currentLibraryId !== collection.libraryId) return
|
||||
@@ -398,6 +414,7 @@ export default {
|
||||
this.socket.on('user_online', this.userOnline)
|
||||
this.socket.on('user_offline', this.userOffline)
|
||||
this.socket.on('user_stream_update', this.userStreamUpdate)
|
||||
this.socket.on('user_session_closed', this.userSessionClosed)
|
||||
this.socket.on('user_item_progress_updated', this.userMediaProgressUpdate)
|
||||
|
||||
// Collection Listeners
|
||||
@@ -418,6 +435,7 @@ export default {
|
||||
// Task Listeners
|
||||
this.socket.on('task_started', this.taskStarted)
|
||||
this.socket.on('task_finished', this.taskFinished)
|
||||
this.socket.on('metadata_embed_queue_update', this.metadataEmbedQueueUpdate)
|
||||
|
||||
this.socket.on('backup_applied', this.backupApplied)
|
||||
|
||||
@@ -531,12 +549,18 @@ export default {
|
||||
},
|
||||
loadTasks() {
|
||||
this.$axios
|
||||
.$get('/api/tasks')
|
||||
.$get('/api/tasks?include=queue')
|
||||
.then((payload) => {
|
||||
console.log('Fetched tasks', payload)
|
||||
if (payload.tasks) {
|
||||
this.$store.commit('tasks/setTasks', payload.tasks)
|
||||
}
|
||||
if (payload.queuedTaskData?.embedMetadata?.length) {
|
||||
this.$store.commit(
|
||||
'tasks/setQueuedEmbedLIds',
|
||||
payload.queuedTaskData.embedMetadata.map((td) => td.libraryItemId)
|
||||
)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to load tasks', error)
|
||||
@@ -545,6 +569,7 @@ export default {
|
||||
changeLanguage(code) {
|
||||
console.log('Changed lang', code)
|
||||
this.currentLang = code
|
||||
document.documentElement.lang = code
|
||||
}
|
||||
},
|
||||
beforeMount() {
|
||||
@@ -569,6 +594,11 @@ export default {
|
||||
this.$toast.error(this.$route.query.error)
|
||||
this.$router.replace(this.$route.path)
|
||||
}
|
||||
|
||||
// Set lang on HTML tag
|
||||
if (this.$languageCodes?.current) {
|
||||
document.documentElement.lang = this.$languageCodes.current
|
||||
}
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$eventBus.$off('change-lang', this.changeLanguage)
|
||||
|
||||
37
client/package-lock.json
generated
37
client/package-lock.json
generated
@@ -1,17 +1,18 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.2.15",
|
||||
"version": "2.2.19",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.2.15",
|
||||
"version": "2.2.19",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@nuxtjs/axios": "^5.13.6",
|
||||
"@nuxtjs/proxy": "^2.1.0",
|
||||
"core-js": "^3.16.0",
|
||||
"cron-parser": "^4.7.1",
|
||||
"date-fns": "^2.25.0",
|
||||
"epubjs": "^0.3.88",
|
||||
"hls.js": "^1.0.7",
|
||||
@@ -5464,6 +5465,17 @@
|
||||
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
|
||||
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ=="
|
||||
},
|
||||
"node_modules/cron-parser": {
|
||||
"version": "4.7.1",
|
||||
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.7.1.tgz",
|
||||
"integrity": "sha512-WguFaoQ0hQ61SgsCZLHUcNbAvlK0lypKXu62ARguefYmjzaOXIVRNrAmyXzabTwUn4sQvQLkk6bjH+ipGfw8bA==",
|
||||
"dependencies": {
|
||||
"luxon": "^3.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.3",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
|
||||
@@ -9134,6 +9146,14 @@
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/luxon": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.2.1.tgz",
|
||||
"integrity": "sha512-QrwPArQCNLAKGO/C+ZIilgIuDnEnKx5QYODdDtbFaxzsbZcc/a7WFq7MhsVYgRlwawLtvOUESTlfJ+hc/USqPg==",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/make-dir": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
|
||||
@@ -21582,6 +21602,14 @@
|
||||
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
|
||||
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ=="
|
||||
},
|
||||
"cron-parser": {
|
||||
"version": "4.7.1",
|
||||
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.7.1.tgz",
|
||||
"integrity": "sha512-WguFaoQ0hQ61SgsCZLHUcNbAvlK0lypKXu62ARguefYmjzaOXIVRNrAmyXzabTwUn4sQvQLkk6bjH+ipGfw8bA==",
|
||||
"requires": {
|
||||
"luxon": "^3.2.1"
|
||||
}
|
||||
},
|
||||
"cross-spawn": {
|
||||
"version": "7.0.3",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
|
||||
@@ -24397,6 +24425,11 @@
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"luxon": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.2.1.tgz",
|
||||
"integrity": "sha512-QrwPArQCNLAKGO/C+ZIilgIuDnEnKx5QYODdDtbFaxzsbZcc/a7WFq7MhsVYgRlwawLtvOUESTlfJ+hc/USqPg=="
|
||||
},
|
||||
"make-dir": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.2.15",
|
||||
"version": "2.2.19",
|
||||
"description": "Self-hosted audiobook and podcast client",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
@@ -16,6 +16,7 @@
|
||||
"@nuxtjs/axios": "^5.13.6",
|
||||
"@nuxtjs/proxy": "^2.1.0",
|
||||
"core-js": "^3.16.0",
|
||||
"cron-parser": "^4.7.1",
|
||||
"date-fns": "^2.25.0",
|
||||
"epubjs": "^0.3.88",
|
||||
"hls.js": "^1.0.7",
|
||||
|
||||
@@ -21,13 +21,14 @@
|
||||
<ui-checkbox v-model="showSecondInputs" checkbox-bg="primary" small label-class="text-sm text-gray-200 pl-1" label="Show seconds" class="mx-2" />
|
||||
<div class="w-32 hidden lg:block" />
|
||||
</div>
|
||||
<div class="flex items-center mb-3 py-1">
|
||||
<div class="flex items-center mb-3 py-1 -mx-1">
|
||||
<div class="w-12 hidden lg:block" />
|
||||
<ui-btn v-if="newChapters.length > 1" :color="showShiftTimes ? 'bg' : 'primary'" small @click="showShiftTimes = !showShiftTimes">{{ $strings.ButtonShiftTimes }}</ui-btn>
|
||||
<ui-btn color="primary" small class="mx-2" @click="showFindChaptersModal = true">{{ $strings.ButtonLookup }}</ui-btn>
|
||||
<ui-btn v-if="chapters.length" color="primary" small class="mx-1" @click.stop="removeAllChaptersClick">{{ $strings.ButtonRemoveAll }}</ui-btn>
|
||||
<ui-btn v-if="newChapters.length > 1" :color="showShiftTimes ? 'bg' : 'primary'" class="mx-1" small @click="showShiftTimes = !showShiftTimes">{{ $strings.ButtonShiftTimes }}</ui-btn>
|
||||
<ui-btn color="primary" small :class="{ 'mx-1': newChapters.length > 1 }" @click="showFindChaptersModal = true">{{ $strings.ButtonLookup }}</ui-btn>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn v-if="hasChanges" small class="mx-2" @click.stop="resetChapters">{{ $strings.ButtonReset }}</ui-btn>
|
||||
<ui-btn v-if="hasChanges" color="success" :disabled="!hasChanges" small @click="saveChapters">{{ $strings.ButtonSave }}</ui-btn>
|
||||
<ui-btn v-if="hasChanges" small class="mx-1" @click.stop="resetChapters">{{ $strings.ButtonReset }}</ui-btn>
|
||||
<ui-btn v-if="hasChanges" color="success" class="mx-1" :disabled="!hasChanges" small @click="saveChapters">{{ $strings.ButtonSave }}</ui-btn>
|
||||
<div class="w-32 hidden lg:block" />
|
||||
</div>
|
||||
|
||||
@@ -41,7 +42,7 @@
|
||||
<ui-text-input v-model="shiftAmount" type="number" class="max-w-20" style="height: 30px" />
|
||||
<ui-btn color="primary" class="mx-1" small @click="shiftChapterTimes">{{ $strings.ButtonAdd }}</ui-btn>
|
||||
<div class="flex-grow" />
|
||||
<span class="material-icons text-gray-200 hover:text-white cursor-pointer" @click="showShiftTimes = false">close</span>
|
||||
<span class="material-icons text-gray-200 hover:text-white cursor-pointer" @click="showShiftTimes = false">expand_less</span>
|
||||
</div>
|
||||
<p class="text-xs py-1.5 text-gray-300 max-w-md">{{ $strings.NoteChapterEditorTimes }}</p>
|
||||
</div>
|
||||
@@ -93,9 +94,16 @@
|
||||
<span class="material-icons-outlined text-lg">error_outline</span>
|
||||
</button>
|
||||
</ui-tooltip>
|
||||
|
||||
<button class="w-7 h-7 rounded-full flex items-center justify-center text-white" @click="setShowWaveform(chapter.id)">
|
||||
<span class="material-icons-outlined text-lg">graphic_eq</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="showWaveform[chapter.id]" :key="`${chapter.id}-waveform`">
|
||||
<img :src="`${baseUrl}/api/tools/item/${libraryItem.id}/waveform?start=${Math.max(0, chapter.start - 10)}&end=${Math.min(mediaDuration, chapter.start + 10)}&token=${userToken}`" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -245,7 +253,8 @@ export default {
|
||||
chapterData: null,
|
||||
showSecondInputs: false,
|
||||
audibleRegions: ['US', 'CA', 'UK', 'AU', 'FR', 'DE', 'JP', 'IT', 'IN', 'ES'],
|
||||
hasChanges: false
|
||||
hasChanges: false,
|
||||
showWaveform: {}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -255,6 +264,9 @@ export default {
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
baseUrl() {
|
||||
return process.env.serverUrl
|
||||
},
|
||||
media() {
|
||||
return this.libraryItem.media || {}
|
||||
},
|
||||
@@ -287,6 +299,9 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
setShowWaveform(chapterId) {
|
||||
this.$set(this.showWaveform, chapterId, true)
|
||||
},
|
||||
setChaptersFromTracks() {
|
||||
let currentStartTime = 0
|
||||
let index = 0
|
||||
@@ -329,6 +344,7 @@ export default {
|
||||
chap.start = Math.max(0, chap.start + amount)
|
||||
}
|
||||
}
|
||||
this.checkChapters()
|
||||
},
|
||||
editItem() {
|
||||
this.$store.commit('showEditModal', this.libraryItem)
|
||||
@@ -587,6 +603,45 @@ export default {
|
||||
]
|
||||
}
|
||||
this.checkChapters()
|
||||
},
|
||||
removeAllChaptersClick() {
|
||||
const payload = {
|
||||
message: this.$strings.MessageConfirmRemoveAllChapters,
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.removeAllChapters()
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
},
|
||||
removeAllChapters() {
|
||||
this.saving = true
|
||||
const payload = {
|
||||
chapters: []
|
||||
}
|
||||
this.$axios
|
||||
.$post(`/api/items/${this.libraryItem.id}/chapters`, payload)
|
||||
.then((data) => {
|
||||
if (data.updated) {
|
||||
this.$toast.success('Chapters removed')
|
||||
if (this.previousRoute) {
|
||||
this.$router.push(this.previousRoute)
|
||||
} else {
|
||||
this.$router.push(`/item/${this.libraryItem.id}`)
|
||||
}
|
||||
} else {
|
||||
this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to remove chapters', error)
|
||||
this.$toast.error('Failed to remove chapters')
|
||||
})
|
||||
.finally(() => {
|
||||
this.saving = false
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
|
||||
@@ -62,14 +62,20 @@
|
||||
<div class="w-full h-px bg-white bg-opacity-10 my-8" />
|
||||
|
||||
<div class="w-full max-w-4xl mx-auto">
|
||||
<div v-if="isEmbedTool" class="w-full flex justify-end items-center mb-4">
|
||||
<ui-checkbox v-if="!isFinished" v-model="shouldBackupAudioFiles" label="Backup audio files" medium checkbox-bg="bg" label-class="pl-2 text-base md:text-lg" @input="toggleBackupAudioFiles" />
|
||||
<!-- queued alert -->
|
||||
<widgets-alert v-if="isMetadataEmbedQueued" type="warning" class="mb-4">
|
||||
<p class="text-lg">Audiobook is queued for metadata embed ({{ queuedEmbedLIds.length }} in queue)</p>
|
||||
</widgets-alert>
|
||||
<!-- metadata embed action buttons -->
|
||||
<div v-else-if="isEmbedTool" class="w-full flex justify-end items-center mb-4">
|
||||
<ui-checkbox v-if="!isTaskFinished" v-model="shouldBackupAudioFiles" :disabled="processing" label="Backup audio files" medium checkbox-bg="bg" label-class="pl-2 text-base md:text-lg" @input="toggleBackupAudioFiles" />
|
||||
|
||||
<div class="flex-grow" />
|
||||
|
||||
<ui-btn v-if="!isFinished" color="primary" :loading="processing" @click.stop="embedClick">{{ $strings.ButtonStartMetadataEmbed }}</ui-btn>
|
||||
<ui-btn v-if="!isTaskFinished" color="primary" :loading="processing" @click.stop="embedClick">{{ $strings.ButtonStartMetadataEmbed }}</ui-btn>
|
||||
<p v-else class="text-success text-lg font-semibold">{{ $strings.MessageEmbedFinished }}</p>
|
||||
</div>
|
||||
<!-- m4b embed action buttons -->
|
||||
<div v-else class="w-full flex items-center mb-4">
|
||||
<button :disabled="processing" class="text-sm uppercase text-gray-200 flex items-center pt-px pl-1 pr-2 hover:bg-white/5 rounded-md" @click="showEncodeOptions = !showEncodeOptions">
|
||||
<span class="material-icons text-xl">{{ showEncodeOptions ? 'check_box' : 'check_box_outline_blank' }}</span> <span class="pl-1">Use Advanced Options</span>
|
||||
@@ -83,6 +89,7 @@
|
||||
<p v-else class="text-success text-lg font-semibold">{{ $strings.MessageM4BFinished }}</p>
|
||||
</div>
|
||||
|
||||
<!-- advanced encoding options -->
|
||||
<div v-if="isM4BTool" class="overflow-hidden">
|
||||
<transition name="slide">
|
||||
<div v-if="showEncodeOptions" class="mb-4 pb-4 border-b border-white/10">
|
||||
@@ -191,6 +198,7 @@ export default {
|
||||
cnosole.error('No audio files')
|
||||
return redirect('/?error=no audio files')
|
||||
}
|
||||
|
||||
return {
|
||||
libraryItem
|
||||
}
|
||||
@@ -200,7 +208,6 @@ export default {
|
||||
processing: false,
|
||||
audiofilesEncoding: {},
|
||||
audiofilesFinished: {},
|
||||
isFinished: false,
|
||||
toneObject: null,
|
||||
selectedTool: 'embed',
|
||||
isCancelingEncode: false,
|
||||
@@ -272,11 +279,28 @@ export default {
|
||||
isTaskFinished() {
|
||||
return this.task && this.task.isFinished
|
||||
},
|
||||
tasks() {
|
||||
return this.$store.getters['tasks/getTasksByLibraryItemId'](this.libraryItemId)
|
||||
},
|
||||
embedTask() {
|
||||
return this.tasks.find((t) => t.action === 'embed-metadata')
|
||||
},
|
||||
encodeTask() {
|
||||
return this.tasks.find((t) => t.action === 'encode-m4b')
|
||||
},
|
||||
task() {
|
||||
return this.$store.getters['tasks/getTaskByLibraryItemId'](this.libraryItemId)
|
||||
if (this.isEmbedTool) return this.embedTask
|
||||
else if (this.isM4BTool) return this.encodeTask
|
||||
return null
|
||||
},
|
||||
taskRunning() {
|
||||
return this.task && !this.task.isFinished
|
||||
},
|
||||
queuedEmbedLIds() {
|
||||
return this.$store.state.tasks.queuedEmbedLIds || []
|
||||
},
|
||||
isMetadataEmbedQueued() {
|
||||
return this.queuedEmbedLIds.some((lid) => lid === this.libraryItemId)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -322,7 +346,7 @@ export default {
|
||||
.catch((error) => {
|
||||
var errorMsg = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
|
||||
this.$toast.error(errorMsg)
|
||||
this.processing = true
|
||||
this.processing = false
|
||||
})
|
||||
},
|
||||
embedClick() {
|
||||
@@ -349,24 +373,6 @@ export default {
|
||||
this.processing = false
|
||||
})
|
||||
},
|
||||
audioMetadataStarted(data) {
|
||||
console.log('audio metadata started', data)
|
||||
if (data.libraryItemId !== this.libraryItemId) return
|
||||
this.audiofilesFinished = {}
|
||||
},
|
||||
audioMetadataFinished(data) {
|
||||
console.log('audio metadata finished', data)
|
||||
if (data.libraryItemId !== this.libraryItemId) return
|
||||
this.processing = false
|
||||
this.audiofilesEncoding = {}
|
||||
|
||||
if (data.failed) {
|
||||
this.$toast.error(data.error)
|
||||
} else {
|
||||
this.isFinished = true
|
||||
this.$toast.success('Audio file metadata updated')
|
||||
}
|
||||
},
|
||||
audiofileMetadataStarted(data) {
|
||||
if (data.libraryItemId !== this.libraryItemId) return
|
||||
this.$set(this.audiofilesEncoding, data.ino, true)
|
||||
@@ -412,14 +418,10 @@ export default {
|
||||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
this.$root.socket.on('audio_metadata_started', this.audioMetadataStarted)
|
||||
this.$root.socket.on('audio_metadata_finished', this.audioMetadataFinished)
|
||||
this.$root.socket.on('audiofile_metadata_started', this.audiofileMetadataStarted)
|
||||
this.$root.socket.on('audiofile_metadata_finished', this.audiofileMetadataFinished)
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$root.socket.off('audio_metadata_started', this.audioMetadataStarted)
|
||||
this.$root.socket.off('audio_metadata_finished', this.audioMetadataFinished)
|
||||
this.$root.socket.off('audiofile_metadata_started', this.audiofileMetadataStarted)
|
||||
this.$root.socket.off('audiofile_metadata_finished', this.audiofileMetadataFinished)
|
||||
}
|
||||
|
||||
@@ -9,10 +9,17 @@
|
||||
</div>
|
||||
|
||||
<div v-if="enableBackups" class="mb-6">
|
||||
<div class="flex items-center pl-6">
|
||||
<span class="material-icons-outlined text-2xl text-black-50">schedule</span>
|
||||
<p class="text-gray-100 px-2">{{ scheduleDescription }}</p>
|
||||
<span class="material-icons text-lg text-black-50 hover:text-yellow-500 cursor-pointer" @click="showCronBuilder = !showCronBuilder">edit</span>
|
||||
<div class="flex items-center pl-6 mb-2">
|
||||
<span class="material-icons-outlined text-2xl text-black-50 mr-2">schedule</span>
|
||||
<div class="w-48"><span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.HeaderSchedule }}:</span></div>
|
||||
<div class="text-gray-100">{{ scheduleDescription }}</div>
|
||||
<span class="material-icons text-lg text-black-50 hover:text-yellow-500 cursor-pointer ml-2" @click="showCronBuilder = !showCronBuilder">edit</span>
|
||||
</div>
|
||||
|
||||
<div v-if="nextBackupDate" class="flex items-center pl-6 py-0.5 px-2">
|
||||
<span class="material-icons-outlined text-2xl text-black-50 mr-2">event</span>
|
||||
<div class="w-48"><span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelNextBackupDate }}:</span></div>
|
||||
<div class="text-gray-100">{{ nextBackupDate }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -64,10 +71,21 @@ export default {
|
||||
serverSettings() {
|
||||
return this.$store.state.serverSettings
|
||||
},
|
||||
dateFormat() {
|
||||
return this.serverSettings.dateFormat
|
||||
},
|
||||
timeFormat() {
|
||||
return this.serverSettings.timeFormat
|
||||
},
|
||||
scheduleDescription() {
|
||||
if (!this.cronExpression) return ''
|
||||
const parsed = this.$parseCronExpression(this.cronExpression)
|
||||
return parsed ? parsed.description : 'Custom cron expression ' + this.cronExpression
|
||||
return parsed ? parsed.description : `${this.$strings.LabelCustomCronExpression} ${this.cronExpression}`
|
||||
},
|
||||
nextBackupDate() {
|
||||
if (!this.cronExpression) return ''
|
||||
const parsed = this.$getNextScheduledDate(this.cronExpression)
|
||||
return this.$formatJsDatetime(parsed, this.dateFormat, this.timeFormat) || ''
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -90,15 +108,15 @@ export default {
|
||||
updateServerSettings(payload) {
|
||||
this.updatingServerSettings = true
|
||||
this.$store
|
||||
.dispatch('updateServerSettings', payload)
|
||||
.then((success) => {
|
||||
console.log('Updated Server Settings', success)
|
||||
this.updatingServerSettings = false
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to update server settings', error)
|
||||
this.updatingServerSettings = false
|
||||
})
|
||||
.dispatch('updateServerSettings', payload)
|
||||
.then((success) => {
|
||||
console.log('Updated Server Settings', success)
|
||||
this.updatingServerSettings = false
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to update server settings', error)
|
||||
this.updatingServerSettings = false
|
||||
})
|
||||
},
|
||||
initServerSettings() {
|
||||
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
|
||||
@@ -113,4 +131,4 @@ export default {
|
||||
this.initServerSettings()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -68,8 +68,14 @@
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="py-2">
|
||||
<div class="flex-grow py-2">
|
||||
<ui-dropdown :label="$strings.LabelSettingsDateFormat" v-model="newServerSettings.dateFormat" :items="dateFormats" small class="max-w-52" @input="(val) => updateSettingsKey('dateFormat', val)" />
|
||||
<p class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelExample }}: {{ dateExample }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex-grow py-2">
|
||||
<ui-dropdown :label="$strings.LabelSettingsTimeFormat" v-model="newServerSettings.timeFormat" :items="timeFormats" small class="max-w-52" @input="(val) => updateSettingsKey('timeFormat', val)" />
|
||||
<p class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelExample }}: {{ timeExample }}</p>
|
||||
</div>
|
||||
|
||||
<div class="py-2">
|
||||
@@ -293,6 +299,17 @@ export default {
|
||||
},
|
||||
dateFormats() {
|
||||
return this.$store.state.globals.dateFormats
|
||||
},
|
||||
timeFormats() {
|
||||
return this.$store.state.globals.timeFormats
|
||||
},
|
||||
dateExample() {
|
||||
const date = new Date(2014, 2, 25)
|
||||
return this.$formatJsDate(date, this.newServerSettings.dateFormat)
|
||||
},
|
||||
timeExample() {
|
||||
const date = new Date(2014, 2, 25, 17, 30, 0)
|
||||
return this.$formatJsTime(date, this.newServerSettings.timeFormat)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -420,4 +437,4 @@ export default {
|
||||
this.initServerSettings()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -60,6 +60,25 @@
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="w-80 my-6 mx-auto">
|
||||
<h1 class="text-2xl mb-4">{{ $strings.HeaderStatsLargestItems }}</h1>
|
||||
<p v-if="!top10LargestItems.length">{{ $strings.MessageNoItems }}</p>
|
||||
<template v-for="(ab, index) in top10LargestItems">
|
||||
<div :key="index" class="w-full py-2">
|
||||
<div class="flex items-center mb-1">
|
||||
<p class="text-sm text-white text-opacity-70 w-44 pr-2 truncate">
|
||||
{{ index + 1 }}. <nuxt-link :to="`/item/${ab.id}`" class="hover:underline">{{ ab.title }}</nuxt-link>
|
||||
</p>
|
||||
<div class="flex-grow rounded-full h-2.5 bg-primary bg-opacity-0 overflow-hidden">
|
||||
<div class="bg-yellow-400 h-full rounded-full" :style="{ width: Math.round((100 * ab.size) / largestItemSize) + '%' }" />
|
||||
</div>
|
||||
<div class="w-4 ml-3">
|
||||
<p class="text-sm font-bold">{{ $bytesPretty(ab.size) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</app-settings-content>
|
||||
</div>
|
||||
@@ -105,6 +124,13 @@ export default {
|
||||
if (!this.top10LongestItems.length) return 0
|
||||
return this.top10LongestItems[0].duration
|
||||
},
|
||||
top10LargestItems() {
|
||||
return this.libraryStats ? this.libraryStats.largestItems || [] : []
|
||||
},
|
||||
largestItemSize() {
|
||||
if (!this.top10LargestItems.length) return 0
|
||||
return this.top10LargestItems[0].size
|
||||
},
|
||||
authorsWithCount() {
|
||||
return this.libraryStats ? this.libraryStats.authorsWithCount : []
|
||||
},
|
||||
@@ -135,4 +161,4 @@ export default {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
<p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p>
|
||||
</td>
|
||||
<td class="text-center hidden sm:table-cell">
|
||||
<ui-tooltip v-if="session.updatedAt" direction="top" :text="$formatDate(session.updatedAt, 'MMMM do, yyyy HH:mm')">
|
||||
<ui-tooltip v-if="session.updatedAt" direction="top" :text="$formatDatetime(session.updatedAt, dateFormat, timeFormat)">
|
||||
<p class="text-xs text-gray-200">{{ $dateDistanceFromNow(session.updatedAt) }}</p>
|
||||
</ui-tooltip>
|
||||
</td>
|
||||
@@ -52,9 +52,53 @@
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="text-white text-opacity-50">{{ $strings.MessageNoListeningSessions }}</p>
|
||||
|
||||
<!-- open listening sessions table -->
|
||||
<p v-if="openListeningSessions.length" class="text-lg mb-4 mt-8">Open Listening Sessions</p>
|
||||
<div v-if="openListeningSessions.length" class="block max-w-full">
|
||||
<table class="userSessionsTable">
|
||||
<tr class="bg-primary bg-opacity-40">
|
||||
<th class="w-48 min-w-48 text-left">{{ $strings.LabelItem }}</th>
|
||||
<th class="w-20 min-w-20 text-left hidden md:table-cell">{{ $strings.LabelUser }}</th>
|
||||
<th class="w-32 min-w-32 text-left hidden md:table-cell">{{ $strings.LabelPlayMethod }}</th>
|
||||
<th class="w-32 min-w-32 text-left hidden sm:table-cell">{{ $strings.LabelDeviceInfo }}</th>
|
||||
<th class="w-32 min-w-32">{{ $strings.LabelTimeListened }}</th>
|
||||
<th class="w-16 min-w-16">{{ $strings.LabelLastTime }}</th>
|
||||
<th class="flex-grow hidden sm:table-cell">{{ $strings.LabelLastUpdate }}</th>
|
||||
</tr>
|
||||
|
||||
<tr v-for="session in openListeningSessions" :key="`open-${session.id}`" class="cursor-pointer" @click="showSession(session)">
|
||||
<td class="py-1 max-w-48">
|
||||
<p class="text-xs text-gray-200 truncate">{{ session.displayTitle }}</p>
|
||||
<p class="text-xs text-gray-400 truncate">{{ session.displayAuthor }}</p>
|
||||
</td>
|
||||
<td class="hidden md:table-cell">
|
||||
<p v-if="filteredUserUsername" class="text-xs">{{ filteredUserUsername }}</p>
|
||||
<p v-else class="text-xs">{{ session.user ? session.user.username : 'N/A' }}</p>
|
||||
</td>
|
||||
<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>
|
||||
<td class="text-center">
|
||||
<p class="text-xs font-mono">{{ $elapsedPretty(session.timeListening) }}</p>
|
||||
</td>
|
||||
<td class="text-center hover:underline" @click.stop="clickCurrentTime(session)">
|
||||
<p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p>
|
||||
</td>
|
||||
<td class="text-center hidden sm:table-cell">
|
||||
<ui-tooltip v-if="session.updatedAt" direction="top" :text="$formatDatetime(session.updatedAt, dateFormat, timeFormat)">
|
||||
<p class="text-xs text-gray-200">{{ $dateDistanceFromNow(session.updatedAt) }}</p>
|
||||
</ui-tooltip>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</app-settings-content>
|
||||
|
||||
<modals-listening-session-modal v-model="showSessionModal" :session="selectedSession" @removedSession="removedSession" />
|
||||
<modals-listening-session-modal v-model="showSessionModal" :session="selectedSession" @removedSession="removedSession" @closedSession="closedSession" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -81,6 +125,7 @@ export default {
|
||||
showSessionModal: false,
|
||||
selectedSession: null,
|
||||
listeningSessions: [],
|
||||
openListeningSessions: [],
|
||||
numPages: 0,
|
||||
total: 0,
|
||||
currentPage: 0,
|
||||
@@ -105,9 +150,18 @@ export default {
|
||||
if (!this.userFilter) return null
|
||||
var user = this.users.find((u) => u.id === this.userFilter)
|
||||
return user ? user.username : null
|
||||
},
|
||||
dateFormat() {
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
},
|
||||
timeFormat() {
|
||||
return this.$store.state.serverSettings.timeFormat
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
closedSession() {
|
||||
this.loadOpenSessions()
|
||||
},
|
||||
removedSession() {
|
||||
// If on last page and this was the last session then load prev page
|
||||
if (this.currentPage == this.numPages - 1) {
|
||||
@@ -149,7 +203,7 @@ export default {
|
||||
episodeId: episode.id,
|
||||
title: episode.title,
|
||||
subtitle: libraryItem.media.metadata.title,
|
||||
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, 'MMM do, yyyy')}` : 'Unknown publish date',
|
||||
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, this.dateFormat)}` : 'Unknown publish date',
|
||||
duration: episode.audioFile.duration || null,
|
||||
coverPath: libraryItem.media.coverPath || null
|
||||
}
|
||||
@@ -216,7 +270,7 @@ export default {
|
||||
async loadSessions(page) {
|
||||
var userFilterQuery = this.selectedUser ? `&user=${this.selectedUser}` : ''
|
||||
const data = await this.$axios.$get(`/api/sessions?page=${page}&itemsPerPage=${this.itemsPerPage}${userFilterQuery}`).catch((err) => {
|
||||
console.error('Failed to load listening sesions', err)
|
||||
console.error('Failed to load listening sessions', err)
|
||||
return null
|
||||
})
|
||||
if (!data) {
|
||||
@@ -230,8 +284,24 @@ export default {
|
||||
this.listeningSessions = data.sessions
|
||||
this.userFilter = data.userFilter
|
||||
},
|
||||
async loadOpenSessions() {
|
||||
const data = await this.$axios.$get('/api/sessions/open').catch((err) => {
|
||||
console.error('Failed to load open sessions', err)
|
||||
return null
|
||||
})
|
||||
if (!data) {
|
||||
this.$toast.error('Failed to load open sessions')
|
||||
return
|
||||
}
|
||||
|
||||
this.openListeningSessions = (data.sessions || []).map((s) => {
|
||||
s.open = true
|
||||
return s
|
||||
})
|
||||
},
|
||||
init() {
|
||||
this.loadSessions(0)
|
||||
this.loadOpenSessions()
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
@@ -266,4 +336,4 @@ export default {
|
||||
padding: 4px 8px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -79,12 +79,12 @@
|
||||
<p class="text-sm">{{ Math.floor(item.progress * 100) }}%</p>
|
||||
</td>
|
||||
<td class="text-center hidden sm:table-cell">
|
||||
<ui-tooltip v-if="item.startedAt" direction="top" :text="$formatDate(item.startedAt, 'MMMM do, yyyy HH:mm')">
|
||||
<ui-tooltip v-if="item.startedAt" direction="top" :text="$formatDatetime(item.startedAt, dateFormat, timeFormat)">
|
||||
<p class="text-sm">{{ $dateDistanceFromNow(item.startedAt) }}</p>
|
||||
</ui-tooltip>
|
||||
</td>
|
||||
<td class="text-center hidden sm:table-cell">
|
||||
<ui-tooltip v-if="item.lastUpdate" direction="top" :text="$formatDate(item.lastUpdate, 'MMMM do, yyyy HH:mm')">
|
||||
<ui-tooltip v-if="item.lastUpdate" direction="top" :text="$formatDatetime(item.lastUpdate, dateFormat, timeFormat)">
|
||||
<p class="text-sm">{{ $dateDistanceFromNow(item.lastUpdate) }}</p>
|
||||
</ui-tooltip>
|
||||
</td>
|
||||
@@ -149,6 +149,12 @@ export default {
|
||||
latestSession() {
|
||||
if (!this.listeningSessions.sessions || !this.listeningSessions.sessions.length) return null
|
||||
return this.listeningSessions.sessions[0]
|
||||
},
|
||||
dateFormat() {
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
},
|
||||
timeFormat() {
|
||||
return this.$store.state.serverSettings.timeFormat
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
<p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p>
|
||||
</td>
|
||||
<td class="text-center hidden sm:table-cell">
|
||||
<ui-tooltip v-if="session.updatedAt" direction="top" :text="$formatDate(session.updatedAt, 'MMMM do, yyyy HH:mm')">
|
||||
<ui-tooltip v-if="session.updatedAt" direction="top" :text="$formatDatetime(session.updatedAt, dateFormat, timeFormat)">
|
||||
<p class="text-xs text-gray-200">{{ $dateDistanceFromNow(session.updatedAt) }}</p>
|
||||
</ui-tooltip>
|
||||
</td>
|
||||
@@ -96,6 +96,12 @@ export default {
|
||||
},
|
||||
userOnline() {
|
||||
return this.$store.getters['users/getIsUserOnline'](this.user.id)
|
||||
},
|
||||
dateFormat() {
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
},
|
||||
timeFormat() {
|
||||
return this.$store.state.serverSettings.timeFormat
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -140,7 +146,7 @@ export default {
|
||||
episodeId: episode.id,
|
||||
title: episode.title,
|
||||
subtitle: libraryItem.media.metadata.title,
|
||||
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, 'MMM do, yyyy')}` : 'Unknown publish date',
|
||||
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, this.dateFormat)}` : 'Unknown publish date',
|
||||
duration: episode.audioFile.duration || null,
|
||||
coverPath: libraryItem.media.coverPath || null
|
||||
}
|
||||
@@ -252,4 +258,4 @@ export default {
|
||||
padding: 4px 8px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<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 md:p-8">
|
||||
<div class="flex flex-col md:flex-row max-w-6xl mx-auto">
|
||||
<div class="w-full flex justify-center md:block md:w-52" style="min-width: 208px">
|
||||
<div 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" style="height: fit-content">
|
||||
<covers-book-cover :library-item="libraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
|
||||
@@ -21,11 +21,15 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow px-2 py-6 md:py-0 md:px-10">
|
||||
<div class="flex-grow px-2 py-6 lg:py-0 md:px-10">
|
||||
<div class="flex justify-center">
|
||||
<div class="mb-4">
|
||||
<h1 class="text-2xl md:text-3xl font-semibold">
|
||||
{{ title }}
|
||||
<div class="flex items-center">
|
||||
{{ title }}
|
||||
<widgets-explicit-indicator :explicit="isExplicit" />
|
||||
<widgets-abridged-indicator v-if="isAbridged" />
|
||||
</div>
|
||||
</h1>
|
||||
|
||||
<p v-if="bookSubtitle" class="text-gray-200 text-xl md:text-2xl">{{ bookSubtitle }}</p>
|
||||
@@ -153,7 +157,7 @@
|
||||
<div v-if="!isPodcast && progressPercent > 0" class="px-4 py-2 mt-4 bg-primary text-sm font-semibold rounded-md text-gray-100 relative max-w-max mx-auto md:mx-0" :class="resettingProgress ? 'opacity-25' : ''">
|
||||
<p v-if="progressPercent < 1" class="leading-6">{{ $strings.LabelYourProgress }}: {{ Math.round(progressPercent * 100) }}%</p>
|
||||
<p v-else class="text-xs">{{ $strings.LabelFinished }} {{ $formatDate(userProgressFinishedAt, dateFormat) }}</p>
|
||||
<p v-if="progressPercent < 1" class="text-gray-200 text-xs">{{ $getString('LabelTimeRemaining', [$elapsedPretty(userTimeRemaining)]) }}</p>
|
||||
<p v-if="progressPercent < 1 && !useEBookProgress" class="text-gray-200 text-xs">{{ $getString('LabelTimeRemaining', [$elapsedPretty(userTimeRemaining)]) }}</p>
|
||||
<p class="text-gray-400 text-xs pt-1">{{ $strings.LabelStarted }} {{ $formatDate(userProgressStartedAt, dateFormat) }}</p>
|
||||
|
||||
<div v-if="!resettingProgress" class="absolute -top-1.5 -right-1.5 p-1 w-5 h-5 rounded-full bg-bg hover:bg-error border border-primary flex items-center justify-center cursor-pointer" @click.stop="clearProgressClick">
|
||||
@@ -190,27 +194,18 @@
|
||||
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" class="mx-0.5" @click="toggleFinished" />
|
||||
</ui-tooltip>
|
||||
|
||||
<ui-tooltip v-if="showCollectionsButton" :text="$strings.LabelCollections" direction="top">
|
||||
<ui-icon-btn icon="collections_bookmark" class="mx-0.5" outlined @click="collectionsClick" />
|
||||
</ui-tooltip>
|
||||
|
||||
<ui-tooltip v-if="!isPodcast && tracks.length" :text="$strings.LabelYourPlaylists" direction="top">
|
||||
<ui-icon-btn icon="playlist_add" class="mx-0.5" outlined @click="playlistsClick" />
|
||||
</ui-tooltip>
|
||||
|
||||
<!-- Only admin or root user can download new episodes -->
|
||||
<ui-tooltip v-if="isPodcast && userIsAdminOrUp" :text="$strings.LabelFindEpisodes" direction="top">
|
||||
<ui-icon-btn icon="search" class="mx-0.5" :loading="fetchingRSSFeed" outlined @click="findEpisodesClick" />
|
||||
</ui-tooltip>
|
||||
|
||||
<ui-tooltip v-if="bookmarks.length" :text="$strings.LabelYourBookmarks" direction="top">
|
||||
<ui-icon-btn :icon="bookmarks.length ? 'bookmarks' : 'bookmark_border'" class="mx-0.5" @click="clickBookmarksBtn" />
|
||||
</ui-tooltip>
|
||||
|
||||
<!-- RSS feed -->
|
||||
<ui-tooltip v-if="showRssFeedBtn" :text="$strings.LabelOpenRSSFeed" direction="top">
|
||||
<ui-icon-btn icon="rss_feed" class="mx-0.5" :bg-color="rssFeed ? 'success' : 'primary'" outlined @click="clickRSSFeed" />
|
||||
</ui-tooltip>
|
||||
<ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" menu-width="148px" @action="contextMenuAction">
|
||||
<template #default="{ showMenu, clickShowMenu, disabled }">
|
||||
<button type="button" :disabled="disabled" class="mx-0.5 icon-btn bg-primary border border-gray-600 w-9 h-9 rounded-md flex items-center justify-center relative" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
|
||||
<span class="material-icons">more_horiz</span>
|
||||
</button>
|
||||
</template>
|
||||
</ui-context-menu-dropdown>
|
||||
</div>
|
||||
|
||||
<div class="my-4 max-w-2xl">
|
||||
@@ -229,7 +224,7 @@
|
||||
|
||||
<tables-chapters-table v-if="chapters.length" :library-item="libraryItem" class="mt-6" />
|
||||
|
||||
<tables-library-files-table v-if="libraryFiles.length" :is-missing="isMissing" :library-item-id="libraryItemId" :files="libraryFiles" class="mt-6" />
|
||||
<tables-library-files-table v-if="libraryFiles.length" :is-missing="isMissing" :library-item="libraryItem" class="mt-6" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -273,6 +268,12 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
downloadUrl() {
|
||||
return `${process.env.serverUrl}/api/items/${this.libraryItemId}/download?token=${this.userToken}`
|
||||
},
|
||||
dateFormat() {
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
},
|
||||
@@ -285,9 +286,6 @@ export default {
|
||||
userIsAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
isFile() {
|
||||
return this.libraryItem.isFile
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
@@ -297,6 +295,9 @@ export default {
|
||||
isDeveloperMode() {
|
||||
return this.$store.state.developerMode
|
||||
},
|
||||
isFile() {
|
||||
return this.libraryItem.isFile
|
||||
},
|
||||
isBook() {
|
||||
return this.libraryItem.mediaType === 'book'
|
||||
},
|
||||
@@ -315,6 +316,12 @@ export default {
|
||||
isInvalid() {
|
||||
return this.libraryItem.isInvalid
|
||||
},
|
||||
isExplicit() {
|
||||
return !!this.mediaMetadata.explicit
|
||||
},
|
||||
isAbridged() {
|
||||
return !!this.mediaMetadata.abridged
|
||||
},
|
||||
invalidAudioFiles() {
|
||||
if (!this.isBook) return []
|
||||
return this.libraryItem.media.audioFiles.filter((af) => af.invalid)
|
||||
@@ -465,7 +472,12 @@ export default {
|
||||
const duration = this.userMediaProgress.duration || this.duration
|
||||
return duration - this.userMediaProgress.currentTime
|
||||
},
|
||||
useEBookProgress() {
|
||||
if (!this.userMediaProgress || this.userMediaProgress.progress) return false
|
||||
return this.userMediaProgress.ebookProgress > 0
|
||||
},
|
||||
progressPercent() {
|
||||
if (this.useEBookProgress) return Math.max(Math.min(1, this.userMediaProgress.ebookProgress), 0)
|
||||
return this.userMediaProgress ? Math.max(Math.min(1, this.userMediaProgress.progress), 0) : 0
|
||||
},
|
||||
userProgressStartedAt() {
|
||||
@@ -504,12 +516,56 @@ export default {
|
||||
},
|
||||
showCollectionsButton() {
|
||||
return this.isBook && this.userCanUpdate
|
||||
},
|
||||
contextMenuItems() {
|
||||
const items = []
|
||||
|
||||
if (this.showCollectionsButton) {
|
||||
items.push({
|
||||
text: this.$strings.LabelCollections,
|
||||
action: 'collections'
|
||||
})
|
||||
}
|
||||
|
||||
if (!this.isPodcast && this.tracks.length) {
|
||||
items.push({
|
||||
text: this.$strings.LabelYourPlaylists,
|
||||
action: 'playlists'
|
||||
})
|
||||
}
|
||||
|
||||
if (this.bookmarks.length) {
|
||||
items.push({
|
||||
text: this.$strings.LabelYourBookmarks,
|
||||
action: 'bookmarks'
|
||||
})
|
||||
}
|
||||
|
||||
if (this.showRssFeedBtn) {
|
||||
items.push({
|
||||
text: this.$strings.LabelOpenRSSFeed,
|
||||
action: 'rss-feeds'
|
||||
})
|
||||
}
|
||||
|
||||
if (this.userCanDownload) {
|
||||
items.push({
|
||||
text: this.$strings.LabelDownload,
|
||||
action: 'download'
|
||||
})
|
||||
}
|
||||
|
||||
if (this.userCanDelete) {
|
||||
items.push({
|
||||
text: this.$strings.ButtonDelete,
|
||||
action: 'delete'
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickBookmarksBtn() {
|
||||
this.showBookmarksModal = true
|
||||
},
|
||||
selectBookmark(bookmark) {
|
||||
if (!bookmark) return
|
||||
if (this.isStreaming) {
|
||||
@@ -632,7 +688,7 @@ export default {
|
||||
episodeId: episode.id,
|
||||
title: episode.title,
|
||||
subtitle: this.title,
|
||||
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, 'MMM do, yyyy')}` : 'Unknown publish date',
|
||||
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, this.dateFormat)}` : 'Unknown publish date',
|
||||
duration: episode.audioFile.duration || null,
|
||||
coverPath: this.libraryItem.media.coverPath || null
|
||||
})
|
||||
@@ -685,14 +741,6 @@ export default {
|
||||
})
|
||||
}
|
||||
},
|
||||
collectionsClick() {
|
||||
this.$store.commit('setSelectedLibraryItem', this.libraryItem)
|
||||
this.$store.commit('globals/setShowCollectionsModal', true)
|
||||
},
|
||||
playlistsClick() {
|
||||
this.$store.commit('globals/setSelectedPlaylistItems', [{ libraryItem: this.libraryItem }])
|
||||
this.$store.commit('globals/setShowPlaylistsModal', true)
|
||||
},
|
||||
clickRSSFeed() {
|
||||
this.$store.commit('globals/setRSSFeedOpenCloseModal', {
|
||||
id: this.libraryItemId,
|
||||
@@ -750,12 +798,63 @@ export default {
|
||||
}
|
||||
this.$store.commit('addItemToQueue', queueItem)
|
||||
}
|
||||
},
|
||||
downloadLibraryItem() {
|
||||
const a = document.createElement('a')
|
||||
a.style.display = 'none'
|
||||
a.href = this.downloadUrl
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
setTimeout(() => {
|
||||
a.remove()
|
||||
})
|
||||
},
|
||||
deleteLibraryItem() {
|
||||
const payload = {
|
||||
message: 'This will delete the library item from the database and your file system. Are you sure?',
|
||||
checkboxLabel: 'Delete from file system. Uncheck to only remove from database.',
|
||||
yesButtonText: this.$strings.ButtonDelete,
|
||||
yesButtonColor: 'error',
|
||||
checkboxDefaultValue: true,
|
||||
callback: (confirmed, hardDelete) => {
|
||||
if (confirmed) {
|
||||
this.$axios
|
||||
.$delete(`/api/items/${this.libraryItemId}?hard=${hardDelete ? 1 : 0}`)
|
||||
.then(() => {
|
||||
this.$toast.success('Item deleted')
|
||||
this.$router.replace(`/library/${this.libraryId}`)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to delete item', error)
|
||||
this.$toast.error('Failed to delete item')
|
||||
})
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
},
|
||||
contextMenuAction(action) {
|
||||
if (action === 'collections') {
|
||||
this.$store.commit('setSelectedLibraryItem', this.libraryItem)
|
||||
this.$store.commit('globals/setShowCollectionsModal', true)
|
||||
} else if (action === 'playlists') {
|
||||
this.$store.commit('globals/setSelectedPlaylistItems', [{ libraryItem: this.libraryItem }])
|
||||
this.$store.commit('globals/setShowPlaylistsModal', true)
|
||||
} else if (action === 'bookmarks') {
|
||||
this.showBookmarksModal = true
|
||||
} else if (action === 'rss-feeds') {
|
||||
this.clickRSSFeed()
|
||||
} else if (action === 'download') {
|
||||
this.downloadLibraryItem()
|
||||
} else if (action === 'delete') {
|
||||
this.deleteLibraryItem()
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if (this.libraryItem.episodesDownloading) {
|
||||
this.episodeDownloadsQueued = this.libraryItem.episodesDownloading || []
|
||||
}
|
||||
this.episodeDownloadsQueued = this.libraryItem.episodeDownloadsQueued || []
|
||||
this.episodesDownloading = this.libraryItem.episodesDownloading || []
|
||||
|
||||
// use this items library id as the current
|
||||
if (this.libraryId) {
|
||||
|
||||
140
client/pages/library/_library/podcast/download-queue.vue
Normal file
140
client/pages/library/_library/podcast/download-queue.vue
Normal file
@@ -0,0 +1,140 @@
|
||||
<template>
|
||||
<div class="page" :class="streamLibraryItem ? 'streaming' : ''">
|
||||
<app-book-shelf-toolbar page="podcast-search" />
|
||||
|
||||
<div id="bookshelf" class="w-full overflow-y-auto px-2 py-6 sm:px-4 md:p-12 relative">
|
||||
<div class="w-full max-w-5xl mx-auto py-4">
|
||||
<p class="text-xl mb-2 font-semibold px-4 md:px-0">{{ $strings.HeaderCurrentDownloads }}</p>
|
||||
<p v-if="!episodesDownloading.length" class="text-lg py-4">{{ $strings.MessageNoDownloadsInProgress }}</p>
|
||||
<template v-for="episode in episodesDownloading">
|
||||
<div :key="episode.id" class="flex py-5 relative">
|
||||
<covers-preview-cover :src="$store.getters['globals/getLibraryItemCoverSrcById'](episode.libraryItemId)" :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" />
|
||||
<div class="flex-grow px-2">
|
||||
<div class="flex items-center">
|
||||
<nuxt-link :to="`/item/${episode.libraryItemId}`" class="text-sm text-gray-200 hover:underline">{{ episode.podcastTitle }}</nuxt-link>
|
||||
<widgets-explicit-indicator :explicit="episode.podcastExplicit" />
|
||||
</div>
|
||||
<p class="text-xs text-gray-300 mb-1">{{ $dateDistanceFromNow(episode.publishedAt) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- desktop -->
|
||||
<div class="hidden md:block">
|
||||
<div class="flex items-center">
|
||||
<nuxt-link :to="`/item/${episode.libraryItemId}`" class="text-sm text-gray-200 hover:underline">{{ episode.podcastTitle }}</nuxt-link>
|
||||
<widgets-explicit-indicator :explicit="episode.podcastExplicit" />
|
||||
</div>
|
||||
<p class="text-xs text-gray-300 mb-1">{{ $dateDistanceFromNow(episode.publishedAt) }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center font-semibold text-gray-200">
|
||||
<div v-if="episode.season || episode.episode">#</div>
|
||||
<div v-if="episode.season">{{ episode.season }}x</div>
|
||||
<div v-if="episode.episode">{{ episode.episode }}</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center mb-2">
|
||||
<span class="font-semibold text-sm md:text-base">{{ episode.episodeDisplayTitle }}</span>
|
||||
<widgets-podcast-type-indicator :type="episode.episodeType" />
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-gray-200 mb-4">{{ episode.subtitle }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<tables-podcast-download-queue-table v-if="episodeDownloadsQueued.length" :queue="episodeDownloadsQueued"></tables-podcast-download-queue-table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
async asyncData({ params, redirect }) {
|
||||
if (!params.library) {
|
||||
console.error('No library...', params.library)
|
||||
return redirect('/')
|
||||
}
|
||||
return {
|
||||
libraryId: params.library
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
episodesDownloading: [],
|
||||
episodeDownloadsQueued: [],
|
||||
processing: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
streamLibraryItem() {
|
||||
return this.$store.state.streamLibraryItem
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
episodeDownloadQueued(episodeDownload) {
|
||||
if (episodeDownload.libraryId === this.libraryId) {
|
||||
this.episodeDownloadsQueued.push(episodeDownload)
|
||||
}
|
||||
},
|
||||
episodeDownloadStarted(episodeDownload) {
|
||||
if (episodeDownload.libraryId === this.libraryId) {
|
||||
this.episodeDownloadsQueued = this.episodeDownloadsQueued.filter((d) => d.id !== episodeDownload.id)
|
||||
this.episodesDownloading.push(episodeDownload)
|
||||
}
|
||||
},
|
||||
episodeDownloadFinished(episodeDownload) {
|
||||
if (episodeDownload.libraryId === this.libraryId) {
|
||||
this.episodeDownloadsQueued = this.episodeDownloadsQueued.filter((d) => d.id !== episodeDownload.id)
|
||||
this.episodesDownloading = this.episodesDownloading.filter((d) => d.id !== episodeDownload.id)
|
||||
}
|
||||
},
|
||||
episodeDownloadQueueUpdated(downloadQueueDetails) {
|
||||
this.episodeDownloadsQueued = downloadQueueDetails.queue.filter((q) => q.libraryId == this.libraryId)
|
||||
},
|
||||
async loadInitialDownloadQueue() {
|
||||
this.processing = true
|
||||
const queuePayload = await this.$axios.$get(`/api/libraries/${this.libraryId}/episode-downloads`).catch((error) => {
|
||||
console.error('Failed to get download queue', error)
|
||||
this.$toast.error('Failed to get download queue')
|
||||
return null
|
||||
})
|
||||
this.processing = false
|
||||
this.episodeDownloadsQueued = queuePayload?.queue || []
|
||||
|
||||
if (queuePayload?.currentDownload) {
|
||||
this.episodesDownloading.push(queuePayload.currentDownload)
|
||||
}
|
||||
|
||||
// Initialize listeners after load to prevent event race conditions
|
||||
this.initListeners()
|
||||
},
|
||||
initListeners() {
|
||||
this.$root.socket.on('episode_download_queued', this.episodeDownloadQueued)
|
||||
this.$root.socket.on('episode_download_started', this.episodeDownloadStarted)
|
||||
this.$root.socket.on('episode_download_finished', this.episodeDownloadFinished)
|
||||
this.$root.socket.on('episode_download_queue_updated', this.episodeDownloadQueueUpdated)
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if (this.libraryId) {
|
||||
this.$store.commit('libraries/setCurrentLibrary', this.libraryId)
|
||||
}
|
||||
|
||||
this.loadInitialDownloadQueue()
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$root.socket.off('episode_download_queued', this.episodeDownloadQueued)
|
||||
this.$root.socket.off('episode_download_started', this.episodeDownloadStarted)
|
||||
this.$root.socket.off('episode_download_finished', this.episodeDownloadFinished)
|
||||
this.$root.socket.off('episode_download_queue_updated', this.episodeDownloadQueueUpdated)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -14,19 +14,36 @@
|
||||
<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" />
|
||||
<div class="flex-grow px-2">
|
||||
<nuxt-link :to="`/item/${episode.libraryItemId}`" class="text-sm text-gray-200 hover:underline">{{ episode.podcast.metadata.title }}</nuxt-link>
|
||||
|
||||
<div class="flex items-center">
|
||||
<div class="flex" @click.stop>
|
||||
<nuxt-link :to="`/item/${episode.libraryItemId}`" class="text-sm text-gray-200 hover:underline">{{ episode.podcast.metadata.title }}</nuxt-link>
|
||||
</div>
|
||||
<widgets-explicit-indicator :explicit="episode.podcast.metadata.explicit" />
|
||||
</div>
|
||||
<p class="text-xs text-gray-300 mb-1">{{ $dateDistanceFromNow(episode.publishedAt) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- desktop -->
|
||||
<div class="hidden md:block">
|
||||
<nuxt-link :to="`/item/${episode.libraryItemId}`" class="text-sm text-gray-200 hover:underline">{{ episode.podcast.metadata.title }}</nuxt-link>
|
||||
|
||||
<div class="flex items-center">
|
||||
<div class="flex" @click.stop>
|
||||
<nuxt-link :to="`/item/${episode.libraryItemId}`" class="text-sm text-gray-200 hover:underline">{{ episode.podcast.metadata.title }}</nuxt-link>
|
||||
</div>
|
||||
<widgets-explicit-indicator :explicit="episode.podcast.metadata.explicit" />
|
||||
</div>
|
||||
<p class="text-xs text-gray-300 mb-1">{{ $dateDistanceFromNow(episode.publishedAt) }}</p>
|
||||
</div>
|
||||
|
||||
<p class="font-semibold mb-2 text-sm md:text-base">{{ episode.title }}</p>
|
||||
<div class="flex items-center font-semibold text-gray-200">
|
||||
<div v-if="episode.season || episode.episode">#</div>
|
||||
<div v-if="episode.season">{{ episode.season }}x</div>
|
||||
<div v-if="episode.episode">{{ episode.episode }}</div>
|
||||
</div>
|
||||
|
||||
<div 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 }}</p>
|
||||
|
||||
@@ -113,6 +130,9 @@ export default {
|
||||
if (i.episodeId) episodeIds[i.episodeId] = true
|
||||
})
|
||||
return episodeIds
|
||||
},
|
||||
dateFormat() {
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -156,7 +176,7 @@ export default {
|
||||
episodeId: episode.id,
|
||||
title: episode.title,
|
||||
subtitle: episode.podcast.metadata.title,
|
||||
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, 'MMM do, yyyy')}` : 'Unknown publish date',
|
||||
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, this.dateFormat)}` : 'Unknown publish date',
|
||||
duration: episode.duration || null,
|
||||
coverPath: episode.podcast.coverPath || null
|
||||
})
|
||||
@@ -194,7 +214,7 @@ export default {
|
||||
episodeId: episode.id,
|
||||
title: episode.title,
|
||||
subtitle: episode.podcast.metadata.title,
|
||||
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, 'MMM do, yyyy')}` : 'Unknown publish date',
|
||||
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, this.dateFormat)}` : 'Unknown publish date',
|
||||
duration: episode.duration || null,
|
||||
coverPath: episode.podcast.coverPath || null
|
||||
}
|
||||
@@ -206,4 +226,4 @@ export default {
|
||||
this.loadRecentEpisodes()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -5,13 +5,12 @@
|
||||
<div id="bookshelf" class="w-full overflow-y-auto px-2 py-6 sm:px-4 md:p-12 relative">
|
||||
<div class="w-full max-w-4xl mx-auto flex">
|
||||
<form @submit.prevent="submit" class="flex flex-grow">
|
||||
<ui-text-input v-model="searchInput" :disabled="processing" placeholder="Enter search term or RSS feed URL" class="flex-grow mr-2 text-sm md:text-base" />
|
||||
<ui-text-input v-model="searchInput" type="search" :disabled="processing" placeholder="Enter search term or RSS feed URL" class="flex-grow mr-2 text-sm md:text-base" />
|
||||
<ui-btn type="submit" :disabled="processing" class="hidden md:block">{{ $strings.ButtonSubmit }}</ui-btn>
|
||||
<ui-btn type="submit" :disabled="processing" class="block md:hidden" small>{{ $strings.ButtonSubmit }}</ui-btn>
|
||||
</form>
|
||||
<ui-file-input ref="fileInput" :accept="'.opml, .txt'" class="ml-2" @change="opmlFileUpload">{{ $strings.ButtonUploadOPMLFile }}</ui-file-input>
|
||||
</div>
|
||||
|
||||
<div class="w-full max-w-3xl mx-auto py-4">
|
||||
<p v-if="termSearched && !results.length && !processing" class="text-center text-xl">{{ $strings.MessageNoPodcastsFound }}</p>
|
||||
<template v-for="podcast in results">
|
||||
@@ -20,7 +19,11 @@
|
||||
<img v-if="podcast.cover" :src="podcast.cover" class="h-full w-full" />
|
||||
</div>
|
||||
<div class="flex-grow pl-4 max-w-2xl">
|
||||
<a :href="podcast.pageUrl" class="text-base md:text-lg text-gray-200 hover:underline" target="_blank" @click.stop>{{ podcast.title }}</a>
|
||||
<div class="flex items-center">
|
||||
<a :href="podcast.pageUrl" class="text-base md:text-lg text-gray-200 hover:underline" target="_blank" @click.stop>{{ podcast.title }}</a>
|
||||
<widgets-explicit-indicator :explicit="podcast.explicit" />
|
||||
<widgets-already-in-library-indicator :already-in-library="podcast.alreadyInLibrary"/>
|
||||
</div>
|
||||
<p class="text-sm md:text-base text-gray-300 whitespace-nowrap truncate">by {{ podcast.artistName }}</p>
|
||||
<p class="text-xs text-gray-400 leading-5">{{ podcast.genres.join(', ') }}</p>
|
||||
<p class="text-xs text-gray-400 leading-5">{{ podcast.trackCount }} {{ $strings.HeaderEpisodes }}</p>
|
||||
@@ -68,10 +71,14 @@ export default {
|
||||
selectedPodcast: null,
|
||||
selectedPodcastFeed: null,
|
||||
showOPMLFeedsModal: false,
|
||||
opmlFeeds: []
|
||||
opmlFeeds: [],
|
||||
existentPodcasts: []
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
streamLibraryItem() {
|
||||
return this.$store.state.streamLibraryItem
|
||||
}
|
||||
@@ -144,18 +151,29 @@ export default {
|
||||
return []
|
||||
})
|
||||
console.log('Got results', results)
|
||||
for (let result of results) {
|
||||
let podcast = this.existentPodcasts.find((p) => p.itunesId === result.id || p.title === result.title.toLowerCase())
|
||||
if (podcast) {
|
||||
result.alreadyInLibrary = true
|
||||
result.existentId = podcast.id
|
||||
}
|
||||
}
|
||||
this.results = results
|
||||
this.termSearched = term
|
||||
this.processing = false
|
||||
},
|
||||
async selectPodcast(podcast) {
|
||||
console.log('Selected podcast', podcast)
|
||||
if(podcast.existentId){
|
||||
this.$router.push(`/item/${podcast.existentId}`)
|
||||
return
|
||||
}
|
||||
if (!podcast.feedUrl) {
|
||||
this.$toast.error('Invalid podcast - no feed')
|
||||
return
|
||||
}
|
||||
this.processing = true
|
||||
var payload = await this.$axios.$post(`/api/podcasts/feed`, { rssFeed: podcast.feedUrl }).catch((error) => {
|
||||
var payload = await this.$axios.$post(`/api/podcasts/feed`, {rssFeed: podcast.feedUrl}).catch((error) => {
|
||||
console.error('Failed to get feed', error)
|
||||
this.$toast.error('Failed to get podcast feed')
|
||||
return null
|
||||
@@ -167,8 +185,26 @@ export default {
|
||||
this.selectedPodcast = podcast
|
||||
this.showNewPodcastModal = true
|
||||
console.log('Got podcast feed', payload.podcast)
|
||||
},
|
||||
async fetchExistentPodcastsInYourLibrary() {
|
||||
this.processing = true
|
||||
|
||||
const podcasts = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/items?page=0&minified=1`).catch((error) => {
|
||||
console.error('Failed to fetch podcasts', error)
|
||||
return []
|
||||
})
|
||||
this.existentPodcasts = podcasts.results.map((p) => {
|
||||
return {
|
||||
title: p.media.metadata.title.toLowerCase(),
|
||||
itunesId: p.media.metadata.itunesId,
|
||||
id: p.id
|
||||
}
|
||||
})
|
||||
this.processing = false
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
mounted() {
|
||||
this.fetchExistentPodcastsInYourLibrary()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -11,27 +11,27 @@
|
||||
<script>
|
||||
export default {
|
||||
async asyncData({ store, params, redirect, query, app }) {
|
||||
var libraryId = params.library
|
||||
var library = await store.dispatch('libraries/fetch', libraryId)
|
||||
const libraryId = params.library
|
||||
const library = await store.dispatch('libraries/fetch', libraryId)
|
||||
if (!library) {
|
||||
return redirect('/oops?message=Library not found')
|
||||
}
|
||||
var query = query.q
|
||||
var results = await app.$axios.$get(`/api/libraries/${libraryId}/search?q=${query}`).catch((error) => {
|
||||
let results = await app.$axios.$get(`/api/libraries/${libraryId}/search?q=${query.q}`).catch((error) => {
|
||||
console.error('Failed to search library', error)
|
||||
return null
|
||||
})
|
||||
results = {
|
||||
podcasts: results && results.podcast ? results.podcast : null,
|
||||
books: results && results.book ? results.book : null,
|
||||
authors: results && results.authors.length ? results.authors : null,
|
||||
series: results && results.series.length ? results.series : null,
|
||||
tags: results && results.tags.length ? results.tags : null
|
||||
podcasts: results?.podcast || [],
|
||||
books: results?.book || [],
|
||||
authors: results?.authors || [],
|
||||
series: results?.series || [],
|
||||
tags: results?.tags || [],
|
||||
narrators: results?.narrators || []
|
||||
}
|
||||
return {
|
||||
libraryId,
|
||||
results,
|
||||
query
|
||||
query: query.q
|
||||
}
|
||||
},
|
||||
data() {
|
||||
@@ -55,16 +55,17 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
async search() {
|
||||
var results = await this.$axios.$get(`/api/libraries/${this.libraryId}/search?q=${this.query}`).catch((error) => {
|
||||
const results = await this.$axios.$get(`/api/libraries/${this.libraryId}/search?q=${this.query}`).catch((error) => {
|
||||
console.error('Failed to search library', error)
|
||||
return null
|
||||
})
|
||||
this.results = {
|
||||
podcasts: results && results.podcast ? results.podcast : null,
|
||||
books: results && results.book ? results.book : null,
|
||||
authors: results && results.authors.length ? results.authors : null,
|
||||
series: results && results.series.length ? results.series : null,
|
||||
tags: results && results.tags.length ? results.tags : null
|
||||
podcasts: results?.podcast || [],
|
||||
books: results?.book || [],
|
||||
authors: results?.authors || [],
|
||||
series: results?.series || [],
|
||||
tags: results?.tags || [],
|
||||
narrators: results?.narrators || []
|
||||
}
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.bookshelf) {
|
||||
|
||||
@@ -127,6 +127,7 @@ export default class LocalAudioPlayer extends EventEmitter {
|
||||
|
||||
setHlsStream() {
|
||||
this.trackStartTime = 0
|
||||
this.currentTrackIndex = 0
|
||||
|
||||
// iOS does not support Media Elements but allows for HLS in the native audio player
|
||||
if (!Hls.isSupported()) {
|
||||
|
||||
@@ -123,7 +123,7 @@ export default class PlayerHandler {
|
||||
|
||||
playerError() {
|
||||
// Switch to HLS stream on error
|
||||
if (!this.isCasting && !this.currentStreamId && (this.player instanceof LocalAudioPlayer)) {
|
||||
if (!this.isCasting && (this.player instanceof LocalAudioPlayer)) {
|
||||
console.log(`[PlayerHandler] Audio player error switching to HLS stream`)
|
||||
this.prepare(true)
|
||||
}
|
||||
@@ -173,16 +173,30 @@ export default class PlayerHandler {
|
||||
this.ctx.setBufferTime(buffertime)
|
||||
}
|
||||
|
||||
getDeviceId() {
|
||||
let deviceId = localStorage.getItem('absDeviceId')
|
||||
if (!deviceId) {
|
||||
deviceId = this.ctx.$randomId()
|
||||
localStorage.setItem('absDeviceId', deviceId)
|
||||
}
|
||||
return deviceId
|
||||
}
|
||||
|
||||
async prepare(forceTranscode = false) {
|
||||
var payload = {
|
||||
this.currentSessionId = null // Reset session
|
||||
|
||||
const payload = {
|
||||
deviceInfo: {
|
||||
deviceId: this.getDeviceId()
|
||||
},
|
||||
supportedMimeTypes: this.player.playableMimeTypes,
|
||||
mediaPlayer: this.isCasting ? 'chromecast' : 'html5',
|
||||
forceTranscode,
|
||||
forceDirectPlay: this.isCasting || this.isVideo // TODO: add transcode support for chromecast
|
||||
}
|
||||
|
||||
var path = this.episodeId ? `/api/items/${this.libraryItem.id}/play/${this.episodeId}` : `/api/items/${this.libraryItem.id}/play`
|
||||
var session = await this.ctx.$axios.$post(path, payload).catch((error) => {
|
||||
const path = this.episodeId ? `/api/items/${this.libraryItem.id}/play/${this.episodeId}` : `/api/items/${this.libraryItem.id}/play`
|
||||
const session = await this.ctx.$axios.$post(path, payload).catch((error) => {
|
||||
console.error('Failed to start stream', error)
|
||||
})
|
||||
this.prepareSession(session)
|
||||
@@ -238,12 +252,17 @@ export default class PlayerHandler {
|
||||
closePlayer() {
|
||||
console.log('[PlayerHandler] Close Player')
|
||||
this.sendCloseSession()
|
||||
this.resetPlayer()
|
||||
}
|
||||
|
||||
resetPlayer() {
|
||||
if (this.player) {
|
||||
this.player.destroy()
|
||||
}
|
||||
this.player = null
|
||||
this.playerState = 'IDLE'
|
||||
this.libraryItem = null
|
||||
this.currentSessionId = null
|
||||
this.startTime = 0
|
||||
this.stopPlayInterval()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const SupportedFileTypes = {
|
||||
image: ['png', 'jpg', 'jpeg', 'webp'],
|
||||
audio: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'oga', 'mp4', 'aac', 'wma', 'aiff', 'wav', 'webm', 'webma'],
|
||||
audio: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'oga', 'mp4', 'aac', 'wma', 'aiff', 'wav', 'webm', 'webma', 'mka', 'awb'],
|
||||
ebook: ['epub', 'pdf', 'mobi', 'azw3', 'cbr', 'cbz'],
|
||||
info: ['nfo'],
|
||||
text: ['txt'],
|
||||
|
||||
@@ -7,7 +7,7 @@ const defaultCode = 'en-us'
|
||||
const languageCodeMap = {
|
||||
'de': { label: 'Deutsch', dateFnsLocale: 'de' },
|
||||
'en-us': { label: 'English', dateFnsLocale: 'enUS' },
|
||||
// 'es': { label: 'Español', dateFnsLocale: 'es' },
|
||||
'es': { label: 'Español', dateFnsLocale: 'es' },
|
||||
'fr': { label: 'Français', dateFnsLocale: 'fr' },
|
||||
'hr': { label: 'Hrvatski', dateFnsLocale: 'hr' },
|
||||
'it': { label: 'Italiano', dateFnsLocale: 'it' },
|
||||
|
||||
@@ -23,6 +23,22 @@ Vue.prototype.$formatJsDate = (jsdate, fnsFormat = 'MM/dd/yyyy HH:mm') => {
|
||||
if (!jsdate || !isDate(jsdate)) return ''
|
||||
return format(jsdate, fnsFormat)
|
||||
}
|
||||
Vue.prototype.$formatTime = (unixms, fnsFormat = 'HH:mm') => {
|
||||
if (!unixms) return ''
|
||||
return format(unixms, fnsFormat)
|
||||
}
|
||||
Vue.prototype.$formatJsTime = (jsdate, fnsFormat = 'HH:mm') => {
|
||||
if (!jsdate || !isDate(jsdate)) return ''
|
||||
return format(jsdate, fnsFormat)
|
||||
}
|
||||
Vue.prototype.$formatDatetime = (unixms, fnsDateFormart = 'MM/dd/yyyy', fnsTimeFormat = 'HH:mm') => {
|
||||
if (!unixms) return ''
|
||||
return format(unixms, `${fnsDateFormart} ${fnsTimeFormat}`)
|
||||
}
|
||||
Vue.prototype.$formatJsDatetime = (jsdate, fnsDateFormart = 'MM/dd/yyyy', fnsTimeFormat = 'HH:mm') => {
|
||||
if (!jsdate || !isDate(jsdate)) return ''
|
||||
return format(jsdate, `${fnsDateFormart} ${fnsTimeFormat}`)
|
||||
}
|
||||
Vue.prototype.$addDaysToToday = (daysToAdd) => {
|
||||
var date = addDays(new Date(), daysToAdd)
|
||||
if (!date || !isDate(date)) return null
|
||||
@@ -167,4 +183,4 @@ export default ({ app, store }, inject) => {
|
||||
inject('isDev', process.env.NODE_ENV !== 'production')
|
||||
|
||||
store.commit('setRouterBasePath', app.$config.routerBasePath)
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user