mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-06-02 00:40:39 +02:00
Compare commits
169 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fdc792cb82 | |||
| a16fb31e6e | |||
| 4d8a1b5b6d | |||
| c382f07b05 | |||
| 9f6a7d065c | |||
| 11aa75ecbe | |||
| 05ce9c6eda | |||
| 15aaf2863c | |||
| 019063e6f4 | |||
| ea79948122 | |||
| 7a0f27e3cc | |||
| 4f75a89633 | |||
| b3f19ef628 | |||
| f16e312319 | |||
| 056da0ef70 | |||
| ca5f781531 | |||
| 53c96b2540 | |||
| 9712bdf5f0 | |||
| 0678c26627 | |||
| b52e240025 | |||
| 2fa73f7a8d | |||
| 2cc23b6d6b | |||
| 9a617226b3 | |||
| fbfc015d92 | |||
| 3e4c94e2b4 | |||
| 1da471e136 | |||
| 4dba95c000 | |||
| 36477a832c | |||
| b4aa8f0c9a | |||
| 6a974d5ef0 | |||
| 304eda9f8c | |||
| 581f2e3d15 | |||
| be2d317325 | |||
| 9f6bfeb839 | |||
| f4f5f79af7 | |||
| 92bb2fb23d | |||
| 3c406c12b4 | |||
| 81d4ac3ed2 | |||
| 32bdae31a8 | |||
| 84c16c4a39 | |||
| b8b3d05f5e | |||
| bac09de23d | |||
| b0bf9604bb | |||
| 688531f0a7 | |||
| dfc7877f69 | |||
| e00116a0e3 | |||
| 2ab287e2a9 | |||
| 1e0da09b2f | |||
| 0e7a5649cc | |||
| 30009e45da | |||
| f9a668cb41 | |||
| c848f366de | |||
| 25daab2f34 | |||
| 7170ab7239 | |||
| 063b3bb8db | |||
| 6eb6a7b115 | |||
| d0972348b9 | |||
| 0e70af77c6 | |||
| 4efca78602 | |||
| 87d10bd6f5 | |||
| 0f82aed4ce | |||
| 58f10ad7af | |||
| 68dcf87aea | |||
| c2f85deb11 | |||
| 0dd3a52cc8 | |||
| c07c73c649 | |||
| dbde5f773c | |||
| 68bf038205 | |||
| eb7f66c89e | |||
| 58ebde2982 | |||
| 604a671549 | |||
| 5286b53334 | |||
| 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 |
@@ -1,4 +1,15 @@
|
|||||||
FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:16
|
# [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
|
||||||
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
ARG VARIANT=16
|
||||||
&& apt-get install ffmpeg gnupg2 -y
|
FROM mcr.microsoft.com/devcontainers/javascript-node:0-${VARIANT} as base
|
||||||
|
|
||||||
|
# Setup the node environment
|
||||||
ENV NODE_ENV=development
|
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/
|
||||||
|
|||||||
@@ -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" },
|
"name": "Audiobookshelf",
|
||||||
"mounts": [
|
"build": {
|
||||||
"source=abs-node-node_modules,target=${containerWorkspaceFolder}/node_modules,type=volume"
|
"dockerfile": "Dockerfile",
|
||||||
],
|
// Update 'VARIANT' to pick a Node version: 18, 16, 14.
|
||||||
"features": {
|
// Append -bullseye or -buster to pin to an OS version.
|
||||||
"fish": "latest"
|
// Use -bullseye variants on local arm64/Apple Silicon.
|
||||||
|
"args": {
|
||||||
|
"VARIANT": "16"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"extensions": [
|
"mounts": [
|
||||||
"eamodio.gitlens"
|
"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"
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
.env
|
.env
|
||||||
dev.js
|
/dev.js
|
||||||
node_modules/
|
**/node_modules/
|
||||||
/config/
|
/config/
|
||||||
/audiobooks/
|
/audiobooks/
|
||||||
/audiobooks2/
|
/audiobooks2/
|
||||||
|
|||||||
Vendored
+44
@@ -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)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+40
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+1
-1
@@ -6,7 +6,7 @@ RUN npm ci && npm cache clean --force
|
|||||||
RUN npm run generate
|
RUN npm run generate
|
||||||
|
|
||||||
### STAGE 1: Build server ###
|
### STAGE 1: Build server ###
|
||||||
FROM sandreas/tone:v0.1.2 AS tone
|
FROM sandreas/tone:v0.1.5 AS tone
|
||||||
FROM node:16-alpine
|
FROM node:16-alpine
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ install_ffmpeg() {
|
|||||||
echo "Starting FFMPEG Install"
|
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="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
|
if ! cd "$FFMPEG_INSTALL_DIR"; then
|
||||||
echo "Creating ffmpeg install dir at $FFMPEG_INSTALL_DIR"
|
echo "Creating ffmpeg install dir at $FFMPEG_INSTALL_DIR"
|
||||||
@@ -66,8 +66,8 @@ install_ffmpeg() {
|
|||||||
# Temp downloading tone library to the ffmpeg dir
|
# Temp downloading tone library to the ffmpeg dir
|
||||||
echo "Getting tone.."
|
echo "Getting tone.."
|
||||||
$WGET_TONE
|
$WGET_TONE
|
||||||
tar xvf tone-0.1.2-linux-x64.tar.gz --strip-components=1
|
tar xvf tone-0.1.5-linux-x64.tar.gz --strip-components=1
|
||||||
rm tone-0.1.2-linux-x64.tar.gz
|
rm tone-0.1.5-linux-x64.tar.gz
|
||||||
|
|
||||||
echo "Good to go on Ffmpeg (& tone)... hopefully"
|
echo "Good to go on Ffmpeg (& tone)... hopefully"
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-1
@@ -112,7 +112,7 @@ input[type=number] {
|
|||||||
background-color: #373838;
|
background-color: #373838;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tracksTable tr:hover {
|
.tracksTable tr:hover:not(:has(th)) {
|
||||||
background-color: #474747;
|
background-color: #474747;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,6 +232,20 @@ Bookshelf Label
|
|||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.episode-subtitle-long {
|
||||||
|
word-break: break-word;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: -webkit-box;
|
||||||
|
line-height: 16px;
|
||||||
|
/* fallback */
|
||||||
|
max-height: 72px;
|
||||||
|
/* fallback */
|
||||||
|
-webkit-line-clamp: 6;
|
||||||
|
/* number of lines to show */
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/* Padding for toastification toasts in the top right to not cover appbar/toolbar */
|
/* Padding for toastification toasts in the top right to not cover appbar/toolbar */
|
||||||
.app-bar-and-toolbar .Vue-Toastification__container.top-right {
|
.app-bar-and-toolbar .Vue-Toastification__container.top-right {
|
||||||
|
|||||||
@@ -15,8 +15,6 @@
|
|||||||
<controls-global-search v-if="currentLibrary" class="mr-1 sm:mr-0" />
|
<controls-global-search v-if="currentLibrary" class="mr-1 sm:mr-0" />
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
|
|
||||||
<widgets-notification-widget class="hidden md:block" />
|
|
||||||
|
|
||||||
<ui-tooltip v-if="isChromecastInitialized && !isHttps" direction="bottom" text="Casting requires a secure connection" class="flex items-center">
|
<ui-tooltip v-if="isChromecastInitialized && !isHttps" direction="bottom" text="Casting requires a secure connection" class="flex items-center">
|
||||||
<span class="material-icons-outlined text-2xl text-warning text-opacity-50"> cast </span>
|
<span class="material-icons-outlined text-2xl text-warning text-opacity-50"> cast </span>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
@@ -24,6 +22,8 @@
|
|||||||
<google-cast-launcher></google-cast-launcher>
|
<google-cast-launcher></google-cast-launcher>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<widgets-notification-widget class="hidden md:block" />
|
||||||
|
|
||||||
<nuxt-link v-if="currentLibrary" to="/config/stats" class="hover:text-gray-200 cursor-pointer w-8 h-8 hidden sm:flex items-center justify-center mx-1">
|
<nuxt-link v-if="currentLibrary" to="/config/stats" class="hover:text-gray-200 cursor-pointer w-8 h-8 hidden sm:flex items-center justify-center mx-1">
|
||||||
<ui-tooltip :text="$strings.HeaderYourStats" direction="bottom" class="flex items-center">
|
<ui-tooltip :text="$strings.HeaderYourStats" direction="bottom" class="flex items-center">
|
||||||
<span class="material-icons text-2xl" aria-label="User Stats" role="button">equalizer</span>
|
<span class="material-icons text-2xl" aria-label="User Stats" role="button">equalizer</span>
|
||||||
@@ -178,6 +178,11 @@ export default {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
options.push({
|
||||||
|
text: 'Re-Scan',
|
||||||
|
action: 'rescan'
|
||||||
|
})
|
||||||
|
|
||||||
return options
|
return options
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -206,13 +211,39 @@ export default {
|
|||||||
}
|
}
|
||||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||||
},
|
},
|
||||||
contextMenuAction(action) {
|
contextMenuAction({ action }) {
|
||||||
if (action === 'quick-embed') {
|
if (action === 'quick-embed') {
|
||||||
this.requestBatchQuickEmbed()
|
this.requestBatchQuickEmbed()
|
||||||
} else if (action === 'quick-match') {
|
} else if (action === 'quick-match') {
|
||||||
this.batchAutoMatchClick()
|
this.batchAutoMatchClick()
|
||||||
|
} else if (action === 'rescan') {
|
||||||
|
this.batchRescan()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async batchRescan() {
|
||||||
|
const payload = {
|
||||||
|
message: `Are you sure you want to re-scan ${this.selectedMediaItems.length} items?`,
|
||||||
|
callback: (confirmed) => {
|
||||||
|
if (confirmed) {
|
||||||
|
this.$axios
|
||||||
|
.$post(`/api/items/batch/scan`, {
|
||||||
|
libraryItemIds: this.selectedMediaItems.map((i) => i.id)
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
console.log('Batch Re-Scan started')
|
||||||
|
this.cancelSelectionMode()
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Batch Re-Scan failed', error)
|
||||||
|
const errorMsg = error.response.data || 'Failed to batch re-scan'
|
||||||
|
this.$toast.error(errorMsg)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
type: 'yesNo'
|
||||||
|
}
|
||||||
|
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||||
|
},
|
||||||
async playSelectedItems() {
|
async playSelectedItems() {
|
||||||
this.$store.commit('setProcessingBatch', true)
|
this.$store.commit('setProcessingBatch', true)
|
||||||
|
|
||||||
@@ -287,26 +318,37 @@ export default {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
batchDeleteClick() {
|
batchDeleteClick() {
|
||||||
const audiobookText = this.numMediaItemsSelected > 1 ? `these ${this.numMediaItemsSelected} items` : 'this item'
|
const payload = {
|
||||||
const confirmMsg = `Are you sure you want to remove ${audiobookText}?\n\n*Does not delete your files, only removes the items from Audiobookshelf`
|
message: `This will delete ${this.numMediaItemsSelected} library items from the database and your file system. Are you sure?`,
|
||||||
if (confirm(confirmMsg)) {
|
checkboxLabel: 'Delete from file system. Uncheck to only remove from database.',
|
||||||
this.$store.commit('setProcessingBatch', true)
|
yesButtonText: this.$strings.ButtonDelete,
|
||||||
this.$axios
|
yesButtonColor: 'error',
|
||||||
.$post(`/api/items/batch/delete`, {
|
checkboxDefaultValue: true,
|
||||||
libraryItemIds: this.selectedMediaItems.map((i) => i.id)
|
callback: (confirmed, hardDelete) => {
|
||||||
})
|
if (confirmed) {
|
||||||
.then(() => {
|
this.$store.commit('setProcessingBatch', true)
|
||||||
this.$toast.success('Batch delete success!')
|
|
||||||
this.$store.commit('setProcessingBatch', false)
|
this.$axios
|
||||||
this.$store.commit('globals/resetSelectedMediaItems', [])
|
.$post(`/api/items/batch/delete?hard=${hardDelete ? 1 : 0}`, {
|
||||||
this.$eventBus.$emit('bookshelf_clear_selection')
|
libraryItemIds: this.selectedMediaItems.map((i) => i.id)
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.then(() => {
|
||||||
this.$toast.error('Batch delete failed')
|
this.$toast.success('Batch delete success')
|
||||||
console.error('Failed to batch delete', error)
|
this.$store.commit('globals/resetSelectedMediaItems', [])
|
||||||
this.$store.commit('setProcessingBatch', false)
|
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() {
|
batchEditClick() {
|
||||||
this.$router.push('/batch')
|
this.$router.push('/batch')
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
<!-- Alternate plain view -->
|
<!-- Alternate plain view -->
|
||||||
<div v-else-if="isAlternativeBookshelfView" class="w-full mb-24">
|
<div v-else-if="isAlternativeBookshelfView" class="w-full mb-24">
|
||||||
<template v-for="(shelf, index) in shelves">
|
<template v-for="(shelf, index) in shelves">
|
||||||
<widgets-item-slider v-if="shelf.type === 'book' || shelf.type === 'podcast'" :shelf-id="shelf.id" :key="index + '.'" :items="shelf.entities" :continue-listening-shelf="shelf.id === 'continue-listening'" :height="232 * sizeMultiplier" class="bookshelf-row pl-8 my-6" @selectEntity="(payload) => selectEntity(payload, index)">
|
<widgets-item-slider v-if="shelf.type === 'book' || shelf.type === 'podcast'" :shelf-id="shelf.id" :key="index + '.'" :items="shelf.entities" :continue-listening-shelf="shelf.id === 'continue-listening' || shelf.id === 'continue-reading'" :height="232 * sizeMultiplier" class="bookshelf-row pl-8 my-6" @selectEntity="(payload) => selectEntity(payload, index)">
|
||||||
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ $strings[shelf.labelStringKey] }}</p>
|
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ $strings[shelf.labelStringKey] }}</p>
|
||||||
</widgets-item-slider>
|
</widgets-item-slider>
|
||||||
<widgets-episode-slider v-else-if="shelf.type === 'episode'" :key="index + '.'" :items="shelf.entities" :continue-listening-shelf="shelf.id === 'continue-listening'" :height="232 * sizeMultiplier" class="bookshelf-row pl-8 my-6" @selectEntity="(payload) => selectEntity(payload, index)">
|
<widgets-episode-slider v-else-if="shelf.type === 'episode'" :key="index + '.'" :items="shelf.entities" :continue-listening-shelf="shelf.id === 'continue-listening'" :height="232 * sizeMultiplier" class="bookshelf-row pl-8 my-6" @selectEntity="(payload) => selectEntity(payload, index)">
|
||||||
@@ -28,12 +28,15 @@
|
|||||||
<widgets-authors-slider v-else-if="shelf.type === 'authors'" :key="index + '.'" :items="shelf.entities" :height="192 * sizeMultiplier" class="bookshelf-row pl-8 my-6">
|
<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>
|
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ $strings[shelf.labelStringKey] }}</p>
|
||||||
</widgets-authors-slider>
|
</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>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<!-- Regular bookshelf view -->
|
<!-- Regular bookshelf view -->
|
||||||
<div v-else class="w-full">
|
<div v-else class="w-full">
|
||||||
<template v-for="(shelf, index) in shelves">
|
<template v-for="(shelf, index) in shelves">
|
||||||
<app-book-shelf-row :key="index" :index="index" :shelf="shelf" :size-multiplier="sizeMultiplier" :book-cover-width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" :continue-listening-shelf="shelf.id === 'continue-listening'" @selectEntity="(payload) => selectEntity(payload, index)" />
|
<app-book-shelf-row :key="index" :index="index" :shelf="shelf" :size-multiplier="sizeMultiplier" :book-cover-width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" :continue-listening-shelf="shelf.id === 'continue-listening' || shelf.id === 'continue-reading'" @selectEntity="(payload) => selectEntity(payload, index)" />
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -185,8 +188,8 @@ export default {
|
|||||||
this.shelves = categories
|
this.shelves = categories
|
||||||
},
|
},
|
||||||
async setShelvesFromSearch() {
|
async setShelvesFromSearch() {
|
||||||
var shelves = []
|
const shelves = []
|
||||||
if (this.results.books && this.results.books.length) {
|
if (this.results.books?.length) {
|
||||||
shelves.push({
|
shelves.push({
|
||||||
id: 'books',
|
id: 'books',
|
||||||
label: 'Books',
|
label: 'Books',
|
||||||
@@ -196,7 +199,7 @@ export default {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.results.podcasts && this.results.podcasts.length) {
|
if (this.results.podcasts?.length) {
|
||||||
shelves.push({
|
shelves.push({
|
||||||
id: 'podcasts',
|
id: 'podcasts',
|
||||||
label: 'Podcasts',
|
label: 'Podcasts',
|
||||||
@@ -206,7 +209,7 @@ export default {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.results.series && this.results.series.length) {
|
if (this.results.series?.length) {
|
||||||
shelves.push({
|
shelves.push({
|
||||||
id: 'series',
|
id: 'series',
|
||||||
label: 'Series',
|
label: 'Series',
|
||||||
@@ -221,7 +224,7 @@ export default {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (this.results.tags && this.results.tags.length) {
|
if (this.results.tags?.length) {
|
||||||
shelves.push({
|
shelves.push({
|
||||||
id: 'tags',
|
id: 'tags',
|
||||||
label: 'Tags',
|
label: 'Tags',
|
||||||
@@ -236,7 +239,7 @@ export default {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (this.results.authors && this.results.authors.length) {
|
if (this.results.authors?.length) {
|
||||||
shelves.push({
|
shelves.push({
|
||||||
id: 'authors',
|
id: 'authors',
|
||||||
label: '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
|
this.shelves = shelves
|
||||||
},
|
},
|
||||||
scan() {
|
scan() {
|
||||||
@@ -269,7 +286,8 @@ export default {
|
|||||||
}
|
}
|
||||||
if (user.mediaProgress.length) {
|
if (user.mediaProgress.length) {
|
||||||
const mediaProgressToHide = user.mediaProgress.filter((mp) => mp.hideFromContinueListening)
|
const mediaProgressToHide = user.mediaProgress.filter((mp) => mp.hideFromContinueListening)
|
||||||
this.removeItemsFromContinueListening(mediaProgressToHide)
|
this.removeItemsFromContinueListeningReading(mediaProgressToHide, 'continue-listening')
|
||||||
|
this.removeItemsFromContinueListeningReading(mediaProgressToHide, 'continue-reading')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
libraryItemAdded(libraryItem) {
|
libraryItemAdded(libraryItem) {
|
||||||
@@ -319,8 +337,9 @@ export default {
|
|||||||
},
|
},
|
||||||
libraryItemsAdded(libraryItems) {
|
libraryItemsAdded(libraryItems) {
|
||||||
console.log('libraryItems added', libraryItems)
|
console.log('libraryItems added', libraryItems)
|
||||||
// TODO: Check if audiobook would be on this shelf
|
|
||||||
if (!this.search) {
|
const isThisLibrary = !libraryItems.some((li) => li.libraryId !== this.currentLibraryId)
|
||||||
|
if (!this.search && isThisLibrary) {
|
||||||
this.fetchCategories()
|
this.fetchCategories()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -329,6 +348,14 @@ export default {
|
|||||||
this.libraryItemUpdated(li)
|
this.libraryItemUpdated(li)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
episodeAdded(episodeWithLibraryItem) {
|
||||||
|
console.log('Podcast episode added', episodeWithLibraryItem)
|
||||||
|
|
||||||
|
const isThisLibrary = episodeWithLibraryItem.libraryItem?.libraryId === this.currentLibraryId
|
||||||
|
if (!this.search && isThisLibrary) {
|
||||||
|
this.fetchCategories()
|
||||||
|
}
|
||||||
|
},
|
||||||
removeAllSeriesFromContinueSeries(seriesIds) {
|
removeAllSeriesFromContinueSeries(seriesIds) {
|
||||||
this.shelves.forEach((shelf) => {
|
this.shelves.forEach((shelf) => {
|
||||||
if (shelf.type == 'book' && shelf.id == 'continue-series') {
|
if (shelf.type == 'book' && shelf.id == 'continue-series') {
|
||||||
@@ -340,8 +367,8 @@ export default {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
removeItemsFromContinueListening(mediaProgressItems) {
|
removeItemsFromContinueListeningReading(mediaProgressItems, categoryId) {
|
||||||
const continueListeningShelf = this.shelves.find((s) => s.id === 'continue-listening')
|
const continueListeningShelf = this.shelves.find((s) => s.id === categoryId)
|
||||||
if (continueListeningShelf) {
|
if (continueListeningShelf) {
|
||||||
if (continueListeningShelf.type === 'book') {
|
if (continueListeningShelf.type === 'book') {
|
||||||
continueListeningShelf.entities = continueListeningShelf.entities.filter((ent) => {
|
continueListeningShelf.entities = continueListeningShelf.entities.filter((ent) => {
|
||||||
@@ -356,17 +383,6 @@ export default {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// this.shelves.forEach((shelf) => {
|
|
||||||
// if (shelf.id == 'continue-listening') {
|
|
||||||
// if (shelf.type == 'book') {
|
|
||||||
// // Filter out books from continue listening shelf
|
|
||||||
// shelf.entities = shelf.entities.filter((ent) => {
|
|
||||||
// if (mediaProgressItems.some(mp => mp.libraryItemId === ent.id)) return false
|
|
||||||
// return true
|
|
||||||
// })
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// })
|
|
||||||
},
|
},
|
||||||
authorUpdated(author) {
|
authorUpdated(author) {
|
||||||
this.shelves.forEach((shelf) => {
|
this.shelves.forEach((shelf) => {
|
||||||
@@ -400,6 +416,7 @@ export default {
|
|||||||
this.$root.socket.on('item_removed', this.libraryItemRemoved)
|
this.$root.socket.on('item_removed', this.libraryItemRemoved)
|
||||||
this.$root.socket.on('items_updated', this.libraryItemsUpdated)
|
this.$root.socket.on('items_updated', this.libraryItemsUpdated)
|
||||||
this.$root.socket.on('items_added', this.libraryItemsAdded)
|
this.$root.socket.on('items_added', this.libraryItemsAdded)
|
||||||
|
this.$root.socket.on('episode_added', this.episodeAdded)
|
||||||
} else {
|
} else {
|
||||||
console.error('Error socket not initialized')
|
console.error('Error socket not initialized')
|
||||||
}
|
}
|
||||||
@@ -414,6 +431,7 @@ export default {
|
|||||||
this.$root.socket.off('item_removed', this.libraryItemRemoved)
|
this.$root.socket.off('item_removed', this.libraryItemRemoved)
|
||||||
this.$root.socket.off('items_updated', this.libraryItemsUpdated)
|
this.$root.socket.off('items_updated', this.libraryItemsUpdated)
|
||||||
this.$root.socket.off('items_added', this.libraryItemsAdded)
|
this.$root.socket.off('items_added', this.libraryItemsAdded)
|
||||||
|
this.$root.socket.off('episode_added', this.episodeAdded)
|
||||||
} else {
|
} else {
|
||||||
console.error('Error socket not initialized')
|
console.error('Error socket not initialized')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" />
|
<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>
|
</template>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -88,6 +93,7 @@ export default {
|
|||||||
return this.bookCoverWidth * this.bookCoverAspectRatio
|
return this.bookCoverWidth * this.bookCoverAspectRatio
|
||||||
},
|
},
|
||||||
shelfHeight() {
|
shelfHeight() {
|
||||||
|
if (this.shelf.type === 'narrators') return 148
|
||||||
return this.bookCoverHeight + 48
|
return this.bookCoverHeight + 48
|
||||||
},
|
},
|
||||||
paddingLeft() {
|
paddingLeft() {
|
||||||
|
|||||||
@@ -81,6 +81,8 @@
|
|||||||
|
|
||||||
<!-- issues page remove all button -->
|
<!-- 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>
|
<ui-btn v-if="isIssuesFilter && userCanDelete && !isBatchSelecting" :loading="processingIssues" color="error" small class="ml-4" @click="removeAllIssues">{{ $strings.ButtonRemoveAll }} {{ numShowing }} {{ entityName }}</ui-btn>
|
||||||
|
|
||||||
|
<ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" menu-width="110px" class="ml-2" @action="contextMenuAction" />
|
||||||
</template>
|
</template>
|
||||||
<!-- search page -->
|
<!-- search page -->
|
||||||
<template v-else-if="page === 'search'">
|
<template v-else-if="page === 'search'">
|
||||||
@@ -163,6 +165,14 @@ export default {
|
|||||||
text: this.$strings.LabelAddedAt,
|
text: this.$strings.LabelAddedAt,
|
||||||
value: 'addedAt'
|
value: 'addedAt'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelLastBookAdded,
|
||||||
|
value: 'lastBookAdded'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelLastBookUpdated,
|
||||||
|
value: 'lastBookUpdated'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
text: this.$strings.LabelTotalDuration,
|
text: this.$strings.LabelTotalDuration,
|
||||||
value: 'totalDuration'
|
value: 'totalDuration'
|
||||||
@@ -178,9 +188,15 @@ export default {
|
|||||||
userCanUpdate() {
|
userCanUpdate() {
|
||||||
return this.$store.getters['user/getUserCanUpdate']
|
return this.$store.getters['user/getUserCanUpdate']
|
||||||
},
|
},
|
||||||
|
userCanDownload() {
|
||||||
|
return this.$store.getters['user/getUserCanDownload']
|
||||||
|
},
|
||||||
currentLibraryId() {
|
currentLibraryId() {
|
||||||
return this.$store.state.libraries.currentLibraryId
|
return this.$store.state.libraries.currentLibraryId
|
||||||
},
|
},
|
||||||
|
libraryProvider() {
|
||||||
|
return this.$store.getters['libraries/getLibraryProvider'](this.currentLibraryId) || 'google'
|
||||||
|
},
|
||||||
currentLibraryMediaType() {
|
currentLibraryMediaType() {
|
||||||
return this.$store.getters['libraries/getCurrentLibraryMediaType']
|
return this.$store.getters['libraries/getCurrentLibraryMediaType']
|
||||||
},
|
},
|
||||||
@@ -265,10 +281,30 @@ export default {
|
|||||||
},
|
},
|
||||||
isIssuesFilter() {
|
isIssuesFilter() {
|
||||||
return this.filterBy === 'issues' && this.$route.query.filter === 'issues'
|
return this.filterBy === 'issues' && this.$route.query.filter === 'issues'
|
||||||
|
},
|
||||||
|
contextMenuItems() {
|
||||||
|
const items = []
|
||||||
|
|
||||||
|
if (this.isPodcastLibrary && this.isLibraryPage && this.userCanDownload) {
|
||||||
|
items.push({
|
||||||
|
text: 'Export OPML',
|
||||||
|
action: 'export-opml'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return items
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
seriesContextMenuAction(action) {
|
contextMenuAction({ action }) {
|
||||||
|
if (action === 'export-opml') {
|
||||||
|
this.exportOPML()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
exportOPML() {
|
||||||
|
this.$downloadFile(`/api/libraries/${this.currentLibraryId}/opml?token=${this.$store.getters['user/getToken']}`, null, true)
|
||||||
|
},
|
||||||
|
seriesContextMenuAction({ action }) {
|
||||||
if (action === 'open-rss-feed') {
|
if (action === 'open-rss-feed') {
|
||||||
this.showOpenSeriesRSSFeed()
|
this.showOpenSeriesRSSFeed()
|
||||||
} else if (action === 're-add-to-continue-listening') {
|
} else if (action === 're-add-to-continue-listening') {
|
||||||
@@ -315,7 +351,11 @@ export default {
|
|||||||
const payload = {}
|
const payload = {}
|
||||||
if (author.asin) payload.asin = author.asin
|
if (author.asin) payload.asin = author.asin
|
||||||
else payload.q = author.name
|
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)
|
this.$eventBus.$emit(`searching-author-${author.id}`, true)
|
||||||
|
|
||||||
|
|||||||
@@ -90,6 +90,11 @@ export default {
|
|||||||
title: this.$strings.HeaderNotifications,
|
title: this.$strings.HeaderNotifications,
|
||||||
path: '/config/notifications'
|
path: '/config/notifications'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'config-email',
|
||||||
|
title: this.$strings.HeaderEmail,
|
||||||
|
path: '/config/email'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'config-item-metadata-utils',
|
id: 'config-item-metadata-utils',
|
||||||
title: this.$strings.HeaderItemMetadataUtils,
|
title: this.$strings.HeaderItemMetadataUtils,
|
||||||
|
|||||||
@@ -49,6 +49,14 @@
|
|||||||
<div v-show="paramId === 'collections'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
<div v-show="paramId === 'collections'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
|
<nuxt-link v-if="showPlaylists" :to="`/library/${currentLibraryId}/bookshelf/playlists`" 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="isPlaylistsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||||
|
<span class="material-icons text-2.5xl">queue_music</span>
|
||||||
|
|
||||||
|
<p class="pt-0.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonPlaylists }}</p>
|
||||||
|
|
||||||
|
<div v-show="isPlaylistsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||||
|
</nuxt-link>
|
||||||
|
|
||||||
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/authors`" 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="isAuthorsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/authors`" 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="isAuthorsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||||
<svg class="w-6 h-6" viewBox="0 0 24 24">
|
<svg class="w-6 h-6" viewBox="0 0 24 24">
|
||||||
<path
|
<path
|
||||||
@@ -62,6 +70,14 @@
|
|||||||
<div v-show="isAuthorsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
<div v-show="isAuthorsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
|
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/narrators`" 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="isNarratorsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||||
|
<span class="material-icons text-2xl">record_voice_over</span>
|
||||||
|
|
||||||
|
<p class="pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.LabelNarrators }}</p>
|
||||||
|
|
||||||
|
<div v-show="isNarratorsPage" 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/search`" 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="isPodcastSearchPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/search`" 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="isPodcastSearchPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||||
<span class="abs-icons icon-podcast text-xl"></span>
|
<span class="abs-icons icon-podcast text-xl"></span>
|
||||||
|
|
||||||
@@ -78,14 +94,6 @@
|
|||||||
<div v-show="isMusicAlbumsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
<div v-show="isMusicAlbumsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
<nuxt-link v-if="showPlaylists" :to="`/library/${currentLibraryId}/bookshelf/playlists`" 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="isPlaylistsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
|
||||||
<span class="material-icons text-2.5xl">queue_music</span>
|
|
||||||
|
|
||||||
<p class="pt-0.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonPlaylists }}</p>
|
|
||||||
|
|
||||||
<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'">
|
<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>
|
<span class="material-icons text-2xl">file_download</span>
|
||||||
|
|
||||||
@@ -178,6 +186,9 @@ export default {
|
|||||||
isAuthorsPage() {
|
isAuthorsPage() {
|
||||||
return this.$route.name === 'library-library-authors'
|
return this.$route.name === 'library-library-authors'
|
||||||
},
|
},
|
||||||
|
isNarratorsPage() {
|
||||||
|
return this.$route.name === 'library-library-narrators'
|
||||||
|
},
|
||||||
isPlaylistsPage() {
|
isPlaylistsPage() {
|
||||||
return this.paramId === 'playlists'
|
return this.paramId === 'playlists'
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
<div v-if="podcastAuthor" class="pl-1 sm:pl-1.5 text-xs sm:text-base">{{ podcastAuthor }}</div>
|
<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="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">
|
<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>
|
<nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}?library=${libraryId}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">, </span></nuxt-link>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="text-xs sm:text-base cursor-pointer pl-1 sm:pl-1.5">{{ $strings.LabelUnknown }}</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>
|
<widgets-explicit-indicator :explicit="isExplicit"></widgets-explicit-indicator>
|
||||||
@@ -81,7 +81,7 @@ export default {
|
|||||||
sleepTimerRemaining: 0,
|
sleepTimerRemaining: 0,
|
||||||
sleepTimer: null,
|
sleepTimer: null,
|
||||||
displayTitle: null,
|
displayTitle: null,
|
||||||
initialPlaybackRate: 1,
|
currentPlaybackRate: 1,
|
||||||
syncFailedToast: null
|
syncFailedToast: null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -120,17 +120,22 @@ export default {
|
|||||||
streamLibraryItem() {
|
streamLibraryItem() {
|
||||||
return this.$store.state.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() {
|
libraryItemId() {
|
||||||
return this.streamLibraryItem ? this.streamLibraryItem.id : null
|
return this.streamLibraryItem?.id || null
|
||||||
},
|
},
|
||||||
media() {
|
media() {
|
||||||
return this.streamLibraryItem ? this.streamLibraryItem.media || {} : {}
|
return this.streamLibraryItem?.media || {}
|
||||||
},
|
},
|
||||||
isPodcast() {
|
isPodcast() {
|
||||||
return this.streamLibraryItem ? this.streamLibraryItem.mediaType === 'podcast' : false
|
return this.streamLibraryItem?.mediaType === 'podcast'
|
||||||
},
|
},
|
||||||
isMusic() {
|
isMusic() {
|
||||||
return this.streamLibraryItem ? this.streamLibraryItem.mediaType === 'music' : false
|
return this.streamLibraryItem?.mediaType === 'music'
|
||||||
},
|
},
|
||||||
isExplicit() {
|
isExplicit() {
|
||||||
return this.mediaMetadata.explicit || false
|
return this.mediaMetadata.explicit || false
|
||||||
@@ -139,6 +144,7 @@ export default {
|
|||||||
return this.media.metadata || {}
|
return this.media.metadata || {}
|
||||||
},
|
},
|
||||||
chapters() {
|
chapters() {
|
||||||
|
if (this.streamEpisode) return this.streamEpisode.chapters || []
|
||||||
return this.media.chapters || []
|
return this.media.chapters || []
|
||||||
},
|
},
|
||||||
title() {
|
title() {
|
||||||
@@ -152,7 +158,8 @@ export default {
|
|||||||
return this.streamLibraryItem ? this.streamLibraryItem.libraryId : null
|
return this.streamLibraryItem ? this.streamLibraryItem.libraryId : null
|
||||||
},
|
},
|
||||||
totalDurationPretty() {
|
totalDurationPretty() {
|
||||||
return this.$secondsToTimestamp(this.totalDuration)
|
// Adjusted by playback rate
|
||||||
|
return this.$secondsToTimestamp(this.totalDuration / this.currentPlaybackRate)
|
||||||
},
|
},
|
||||||
podcastAuthor() {
|
podcastAuthor() {
|
||||||
if (!this.isPodcast) return null
|
if (!this.isPodcast) return null
|
||||||
@@ -255,12 +262,16 @@ export default {
|
|||||||
this.playerHandler.setVolume(volume)
|
this.playerHandler.setVolume(volume)
|
||||||
},
|
},
|
||||||
setPlaybackRate(playbackRate) {
|
setPlaybackRate(playbackRate) {
|
||||||
this.initialPlaybackRate = playbackRate
|
this.currentPlaybackRate = playbackRate
|
||||||
this.playerHandler.setPlaybackRate(playbackRate)
|
this.playerHandler.setPlaybackRate(playbackRate)
|
||||||
},
|
},
|
||||||
seek(time) {
|
seek(time) {
|
||||||
this.playerHandler.seek(time)
|
this.playerHandler.seek(time)
|
||||||
},
|
},
|
||||||
|
playbackTimeUpdate(time) {
|
||||||
|
// When updating progress from another session
|
||||||
|
this.playerHandler.seek(time, false)
|
||||||
|
},
|
||||||
setCurrentTime(time) {
|
setCurrentTime(time) {
|
||||||
this.currentTime = time
|
this.currentTime = time
|
||||||
if (this.$refs.audioPlayer) {
|
if (this.$refs.audioPlayer) {
|
||||||
@@ -359,9 +370,8 @@ export default {
|
|||||||
navigator.mediaSession.setActionHandler('seekbackward', this.mediaSessionSeekBackward)
|
navigator.mediaSession.setActionHandler('seekbackward', this.mediaSessionSeekBackward)
|
||||||
navigator.mediaSession.setActionHandler('seekforward', this.mediaSessionSeekForward)
|
navigator.mediaSession.setActionHandler('seekforward', this.mediaSessionSeekForward)
|
||||||
navigator.mediaSession.setActionHandler('seekto', this.mediaSessionSeekTo)
|
navigator.mediaSession.setActionHandler('seekto', this.mediaSessionSeekTo)
|
||||||
navigator.mediaSession.setActionHandler('previoustrack', this.mediaSessionPreviousTrack)
|
navigator.mediaSession.setActionHandler('previoustrack', this.mediaSessionSeekBackward)
|
||||||
const hasNextChapter = this.$refs.audioPlayer && this.$refs.audioPlayer.hasNextChapter
|
navigator.mediaSession.setActionHandler('nexttrack', this.mediaSessionSeekForward)
|
||||||
navigator.mediaSession.setActionHandler('nexttrack', hasNextChapter ? this.mediaSessionNextTrack : null)
|
|
||||||
} else {
|
} else {
|
||||||
console.warn('Media session not available')
|
console.warn('Media session not available')
|
||||||
}
|
}
|
||||||
@@ -384,7 +394,7 @@ export default {
|
|||||||
libraryItem: session.libraryItem,
|
libraryItem: session.libraryItem,
|
||||||
episodeId: session.episodeId
|
episodeId: session.episodeId
|
||||||
})
|
})
|
||||||
this.playerHandler.prepareOpenSession(session, this.initialPlaybackRate)
|
this.playerHandler.prepareOpenSession(session, this.currentPlaybackRate)
|
||||||
},
|
},
|
||||||
streamOpen(session) {
|
streamOpen(session) {
|
||||||
console.log(`[StreamContainer] Stream session open`, session)
|
console.log(`[StreamContainer] Stream session open`, session)
|
||||||
@@ -451,7 +461,7 @@ export default {
|
|||||||
if (this.$refs.audioPlayer) this.$refs.audioPlayer.checkUpdateChapterTrack()
|
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() {
|
pauseItem() {
|
||||||
this.playerHandler.pause()
|
this.playerHandler.pause()
|
||||||
@@ -459,17 +469,26 @@ export default {
|
|||||||
showFailedProgressSyncs() {
|
showFailedProgressSyncs() {
|
||||||
if (!isNaN(this.syncFailedToast)) this.$toast.dismiss(this.syncFailedToast)
|
if (!isNaN(this.syncFailedToast)) this.$toast.dismiss(this.syncFailedToast)
|
||||||
this.syncFailedToast = this.$toast('Progress is not being synced. Restart playback', { timeout: false, type: 'error' })
|
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() {
|
mounted() {
|
||||||
this.$eventBus.$on('cast-session-active', this.castSessionActive)
|
this.$eventBus.$on('cast-session-active', this.castSessionActive)
|
||||||
this.$eventBus.$on('playback-seek', this.seek)
|
this.$eventBus.$on('playback-seek', this.seek)
|
||||||
|
this.$eventBus.$on('playback-time-update', this.playbackTimeUpdate)
|
||||||
this.$eventBus.$on('play-item', this.playLibraryItem)
|
this.$eventBus.$on('play-item', this.playLibraryItem)
|
||||||
this.$eventBus.$on('pause-item', this.pauseItem)
|
this.$eventBus.$on('pause-item', this.pauseItem)
|
||||||
},
|
},
|
||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
this.$eventBus.$off('cast-session-active', this.castSessionActive)
|
this.$eventBus.$off('cast-session-active', this.castSessionActive)
|
||||||
this.$eventBus.$off('playback-seek', this.seek)
|
this.$eventBus.$off('playback-seek', this.seek)
|
||||||
|
this.$eventBus.$off('playback-time-update', this.playbackTimeUpdate)
|
||||||
this.$eventBus.$off('play-item', this.playLibraryItem)
|
this.$eventBus.$off('play-item', this.playLibraryItem)
|
||||||
this.$eventBus.$off('pause-item', this.pauseItem)
|
this.$eventBus.$off('pause-item', this.pauseItem)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<nuxt-link :to="`/author/${author.id}`">
|
<nuxt-link :to="`/author/${author.id}?library=${currentLibraryId}`">
|
||||||
<div @mouseover="mouseover" @mouseleave="mouseleave">
|
<div @mouseover="mouseover" @mouseleave="mouseleave">
|
||||||
<div :style="{ width: width + 'px', height: height + 'px' }" class="bg-primary box-shadow-book rounded-md relative overflow-hidden">
|
<div :style="{ width: width + 'px', height: height + 'px' }" class="bg-primary box-shadow-book rounded-md relative overflow-hidden">
|
||||||
<!-- Image or placeholder -->
|
<!-- Image or placeholder -->
|
||||||
@@ -77,6 +77,12 @@ export default {
|
|||||||
},
|
},
|
||||||
userCanUpdate() {
|
userCanUpdate() {
|
||||||
return this.$store.getters['user/getUserCanUpdate']
|
return this.$store.getters['user/getUserCanUpdate']
|
||||||
|
},
|
||||||
|
currentLibraryId() {
|
||||||
|
return this.$store.state.libraries.currentLibraryId
|
||||||
|
},
|
||||||
|
libraryProvider() {
|
||||||
|
return this.$store.getters['libraries/getLibraryProvider'](this.currentLibraryId) || 'google'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -92,6 +98,11 @@ export default {
|
|||||||
if (this.asin) payload.asin = this.asin
|
if (this.asin) payload.asin = this.asin
|
||||||
else payload.q = this.name
|
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) => {
|
var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, payload).catch((error) => {
|
||||||
console.error('Failed', error)
|
console.error('Failed', error)
|
||||||
return null
|
return null
|
||||||
|
|||||||
@@ -5,12 +5,12 @@
|
|||||||
<p v-if="matchKey !== 'title'" class="truncate text-sm">{{ title }}</p>
|
<p v-if="matchKey !== 'title'" class="truncate text-sm">{{ title }}</p>
|
||||||
<p v-else class="truncate text-sm" v-html="matchHtml" />
|
<p v-else class="truncate text-sm" v-html="matchHtml" />
|
||||||
|
|
||||||
<p v-if="matchKey === 'subtitle'" class="truncate text-xs text-gray-300">{{ matchHtml }}</p>
|
<p v-if="matchKey === 'subtitle'" class="truncate text-xs text-gray-300" v-html="matchHtml" />
|
||||||
|
|
||||||
<p v-if="matchKey !== 'authors'" class="text-xs text-gray-200 truncate">by {{ authorName }}</p>
|
<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" />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -61,18 +61,19 @@ export default {
|
|||||||
},
|
},
|
||||||
matchHtml() {
|
matchHtml() {
|
||||||
if (!this.matchText || !this.search) return ''
|
if (!this.matchText || !this.search) return ''
|
||||||
if (this.matchKey === 'subtitle') return ''
|
|
||||||
|
|
||||||
// This used to highlight the part of the search found
|
// This used to highlight the part of the search found
|
||||||
// but with removing commas periods etc this is no longer plausible
|
// but with removing commas periods etc this is no longer plausible
|
||||||
const html = this.matchText
|
const html = this.matchText
|
||||||
|
|
||||||
if (this.matchKey === 'episode') return `<p class="truncate">Episode: ${html}</p>`
|
if (this.matchKey === 'episode') return `<p class="truncate">${this.$strings.LabelEpisode}: ${html}</p>`
|
||||||
if (this.matchKey === 'tags') return `<p class="truncate">Tags: ${html}</p>`
|
if (this.matchKey === 'tags') return `<p class="truncate">${this.$strings.LabelTags}: ${html}</p>`
|
||||||
|
if (this.matchKey === 'subtitle') return `<p class="truncate">${html}</p>`
|
||||||
if (this.matchKey === 'authors') return `by ${html}`
|
if (this.matchKey === 'authors') return `by ${html}`
|
||||||
if (this.matchKey === 'isbn') return `<p class="truncate">ISBN: ${html}</p>`
|
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 === '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}`
|
return `${html}`
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex items-center h-full px-1 overflow-hidden">
|
<div class="flex items-center px-1 overflow-hidden">
|
||||||
<div class="h-5 w-5 min-w-5 text-lg mr-1.5 flex items-center justify-center">
|
<div class="w-8 flex items-center justify-center">
|
||||||
<span v-if="isFinished" :class="taskIconStatus" class="material-icons text-base">{{actionIcon}}</span>
|
<!-- <div class="text-lg"> -->
|
||||||
|
<span v-if="isFinished" :class="taskIconStatus" class="material-icons text-base">{{ actionIcon }}</span>
|
||||||
<widgets-loading-spinner v-else />
|
<widgets-loading-spinner v-else />
|
||||||
|
<!-- </div> -->
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow px-2 taskRunningCardContent">
|
<div class="flex-grow px-2 taskRunningCardContent">
|
||||||
<p class="truncate text-sm">{{ title }}</p>
|
<p class="truncate text-sm">{{ title }}</p>
|
||||||
@@ -36,10 +38,13 @@ export default {
|
|||||||
return this.task.details || 'Unknown'
|
return this.task.details || 'Unknown'
|
||||||
},
|
},
|
||||||
isFinished() {
|
isFinished() {
|
||||||
return this.task.isFinished || false
|
return !!this.task.isFinished
|
||||||
},
|
},
|
||||||
isFailed() {
|
isFailed() {
|
||||||
return this.task.isFailed || false
|
return !!this.task.isFailed
|
||||||
|
},
|
||||||
|
isSuccess() {
|
||||||
|
return this.isFinished && !this.isFailed
|
||||||
},
|
},
|
||||||
failedMessage() {
|
failedMessage() {
|
||||||
return this.task.error || ''
|
return this.task.error || ''
|
||||||
@@ -48,6 +53,11 @@ export default {
|
|||||||
return this.task.action || ''
|
return this.task.action || ''
|
||||||
},
|
},
|
||||||
actionIcon() {
|
actionIcon() {
|
||||||
|
if (this.isFailed) {
|
||||||
|
return 'error'
|
||||||
|
} else if (this.isSuccess) {
|
||||||
|
return 'done'
|
||||||
|
}
|
||||||
switch (this.action) {
|
switch (this.action) {
|
||||||
case 'download-podcast-episode':
|
case 'download-podcast-episode':
|
||||||
return 'cloud_download'
|
return 'cloud_download'
|
||||||
@@ -68,16 +78,15 @@ export default {
|
|||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {},
|
||||||
},
|
|
||||||
mounted() {}
|
mounted() {}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.taskRunningCardContent {
|
.taskRunningCardContent {
|
||||||
width: calc(100% - 80px);
|
width: calc(100% - 84px);
|
||||||
height: 75px;
|
height: 60px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ export default {
|
|||||||
var files = this.item.itemFiles.concat(this.item.otherFiles)
|
var files = this.item.itemFiles.concat(this.item.otherFiles)
|
||||||
return {
|
return {
|
||||||
index: this.item.index,
|
index: this.item.index,
|
||||||
|
directory: this.directory,
|
||||||
...this.itemData,
|
...this.itemData,
|
||||||
files
|
files
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,6 +76,10 @@
|
|||||||
<div ref="moreIcon" v-show="!isSelectionMode && moreMenuItems.length" class="hidden md:block absolute cursor-pointer hover:text-yellow-300 300 hover:scale-125 transform duration-150" :style="{ bottom: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="clickShowMore">
|
<div ref="moreIcon" v-show="!isSelectionMode && moreMenuItems.length" class="hidden md:block absolute cursor-pointer hover:text-yellow-300 300 hover:scale-125 transform duration-150" :style="{ bottom: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="clickShowMore">
|
||||||
<span class="material-icons" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">more_vert</span>
|
<span class="material-icons" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">more_vert</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="ebookFormat" class="absolute" :style="{ bottom: 0.375 * sizeMultiplier + 'rem', left: 0.375 * sizeMultiplier + 'rem' }">
|
||||||
|
<span class="text-white/80" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ ebookFormat }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Processing/loading spinner overlay -->
|
<!-- Processing/loading spinner overlay -->
|
||||||
@@ -221,7 +225,7 @@ export default {
|
|||||||
libraryId() {
|
libraryId() {
|
||||||
return this._libraryItem.libraryId
|
return this._libraryItem.libraryId
|
||||||
},
|
},
|
||||||
hasEbook() {
|
ebookFormat() {
|
||||||
return this.media.ebookFormat
|
return this.media.ebookFormat
|
||||||
},
|
},
|
||||||
numTracks() {
|
numTracks() {
|
||||||
@@ -252,14 +256,14 @@ export default {
|
|||||||
},
|
},
|
||||||
booksInSeries() {
|
booksInSeries() {
|
||||||
// Only added to item object when collapseSeries is enabled
|
// Only added to item object when collapseSeries is enabled
|
||||||
return this.collapsedSeries ? this.collapsedSeries.numBooks : 0
|
return this.collapsedSeries?.numBooks || 0
|
||||||
},
|
},
|
||||||
seriesSequenceList() {
|
seriesSequenceList() {
|
||||||
return this.collapsedSeries ? this.collapsedSeries.seriesSequenceList : null
|
return this.collapsedSeries?.seriesSequenceList || null
|
||||||
},
|
},
|
||||||
libraryItemIdsInSeries() {
|
libraryItemIdsInSeries() {
|
||||||
// Only added to item object when collapseSeries is enabled
|
// Only added to item object when collapseSeries is enabled
|
||||||
return this.collapsedSeries ? this.collapsedSeries.libraryItemIds || [] : []
|
return this.collapsedSeries?.libraryItemIds || []
|
||||||
},
|
},
|
||||||
hasCover() {
|
hasCover() {
|
||||||
return !!this.media.coverPath
|
return !!this.media.coverPath
|
||||||
@@ -325,6 +329,9 @@ export default {
|
|||||||
if (this.episodeProgress) return this.episodeProgress
|
if (this.episodeProgress) return this.episodeProgress
|
||||||
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId)
|
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId)
|
||||||
},
|
},
|
||||||
|
isEBookOnly() {
|
||||||
|
return !this.numTracks && this.ebookFormat
|
||||||
|
},
|
||||||
useEBookProgress() {
|
useEBookProgress() {
|
||||||
if (!this.userProgress || this.userProgress.progress) return false
|
if (!this.userProgress || this.userProgress.progress) return false
|
||||||
return this.userProgress.ebookProgress > 0
|
return this.userProgress.ebookProgress > 0
|
||||||
@@ -360,13 +367,13 @@ export default {
|
|||||||
return this.store.getters['getIsStreamingFromDifferentLibrary']
|
return this.store.getters['getIsStreamingFromDifferentLibrary']
|
||||||
},
|
},
|
||||||
showReadButton() {
|
showReadButton() {
|
||||||
return !this.isSelectionMode && !this.showPlayButton && this.hasEbook && (this.showExperimentalFeatures || this.enableEReader)
|
return !this.isSelectionMode && !this.showPlayButton && this.ebookFormat && (this.showExperimentalFeatures || this.enableEReader)
|
||||||
},
|
},
|
||||||
showPlayButton() {
|
showPlayButton() {
|
||||||
return !this.isSelectionMode && !this.isMissing && !this.isInvalid && !this.isStreaming && (this.numTracks || this.recentEpisode || this.isMusic)
|
return !this.isSelectionMode && !this.isMissing && !this.isInvalid && !this.isStreaming && (this.numTracks || this.recentEpisode || this.isMusic)
|
||||||
},
|
},
|
||||||
showSmallEBookIcon() {
|
showSmallEBookIcon() {
|
||||||
return !this.isSelectionMode && this.hasEbook && (this.showExperimentalFeatures || this.enableEReader)
|
return !this.isSelectionMode && this.ebookFormat && (this.showExperimentalFeatures || this.enableEReader)
|
||||||
},
|
},
|
||||||
isMissing() {
|
isMissing() {
|
||||||
return this._libraryItem.isMissing
|
return this._libraryItem.isMissing
|
||||||
@@ -482,6 +489,18 @@ export default {
|
|||||||
text: this.$strings.LabelAddToPlaylist
|
text: this.$strings.LabelAddToPlaylist
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if (this.ebookFormat && this.store.state.libraries.ereaderDevices?.length) {
|
||||||
|
items.push({
|
||||||
|
text: this.$strings.LabelSendEbookToDevice,
|
||||||
|
subitems: this.store.state.libraries.ereaderDevices.map((d) => {
|
||||||
|
return {
|
||||||
|
text: d.name,
|
||||||
|
func: 'sendToDevice',
|
||||||
|
data: d.name
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (this.userCanUpdate) {
|
if (this.userCanUpdate) {
|
||||||
items.push({
|
items.push({
|
||||||
@@ -508,7 +527,7 @@ export default {
|
|||||||
if (this.continueListeningShelf) {
|
if (this.continueListeningShelf) {
|
||||||
items.push({
|
items.push({
|
||||||
func: 'removeFromContinueListening',
|
func: 'removeFromContinueListening',
|
||||||
text: this.$strings.ButtonRemoveFromContinueListening
|
text: this.isEBookOnly ? this.$strings.ButtonRemoveFromContinueReading : this.$strings.ButtonRemoveFromContinueListening
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (!this.isPodcast) {
|
if (!this.isPodcast) {
|
||||||
@@ -526,6 +545,14 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.userCanDelete) {
|
||||||
|
items.push({
|
||||||
|
func: 'deleteLibraryItem',
|
||||||
|
text: this.$strings.ButtonDelete
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return items
|
return items
|
||||||
},
|
},
|
||||||
_socket() {
|
_socket() {
|
||||||
@@ -704,6 +731,37 @@ export default {
|
|||||||
// More menu func
|
// More menu func
|
||||||
this.store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'match' })
|
this.store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'match' })
|
||||||
},
|
},
|
||||||
|
sendToDevice(deviceName) {
|
||||||
|
// More menu func
|
||||||
|
const payload = {
|
||||||
|
// message: `Are you sure you want to send ${this.ebookFormat} ebook "${this.title}" to device "${deviceName}"?`,
|
||||||
|
message: this.$getString('MessageConfirmSendEbookToDevice', [this.ebookFormat, this.title, deviceName]),
|
||||||
|
callback: (confirmed) => {
|
||||||
|
if (confirmed) {
|
||||||
|
const payload = {
|
||||||
|
libraryItemId: this.libraryItemId,
|
||||||
|
deviceName
|
||||||
|
}
|
||||||
|
this.processing = true
|
||||||
|
const axios = this.$axios || this.$nuxt.$axios
|
||||||
|
axios
|
||||||
|
.$post(`/api/emails/send-ebook-to-device`, payload)
|
||||||
|
.then(() => {
|
||||||
|
this.$toast.success(this.$getString('ToastSendEbookToDeviceSuccess', [deviceName]))
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to send ebook to device', error)
|
||||||
|
this.$toast.error(this.$strings.ToastSendEbookToDeviceFailed)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.processing = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
type: 'yesNo'
|
||||||
|
}
|
||||||
|
this.store.commit('globals/setConfirmPrompt', payload)
|
||||||
|
},
|
||||||
removeSeriesFromContinueListening() {
|
removeSeriesFromContinueListening() {
|
||||||
const axios = this.$axios || this.$nuxt.$axios
|
const axios = this.$axios || this.$nuxt.$axios
|
||||||
this.processing = true
|
this.processing = true
|
||||||
@@ -777,6 +835,35 @@ export default {
|
|||||||
this.store.commit('globals/setSelectedPlaylistItems', [{ libraryItem: this.libraryItem, episode: this.recentEpisode }])
|
this.store.commit('globals/setSelectedPlaylistItems', [{ libraryItem: this.libraryItem, episode: this.recentEpisode }])
|
||||||
this.store.commit('globals/setShowPlaylistsModal', true)
|
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() {
|
createMoreMenu() {
|
||||||
if (!this.$refs.moreIcon) return
|
if (!this.$refs.moreIcon) return
|
||||||
|
|
||||||
@@ -788,8 +875,8 @@ export default {
|
|||||||
items: this.moreMenuItems
|
items: this.moreMenuItems
|
||||||
},
|
},
|
||||||
created() {
|
created() {
|
||||||
this.$on('action', (func) => {
|
this.$on('action', (action) => {
|
||||||
if (_this[func]) _this[func]()
|
if (action.func && _this[action.func]) _this[action.func](action.data)
|
||||||
})
|
})
|
||||||
this.$on('close', () => {
|
this.$on('close', () => {
|
||||||
_this.isMoreMenuOpen = false
|
_this.isMoreMenuOpen = false
|
||||||
@@ -828,7 +915,8 @@ export default {
|
|||||||
this.createMoreMenu()
|
this.createMoreMenu()
|
||||||
},
|
},
|
||||||
async clickReadEBook() {
|
async clickReadEBook() {
|
||||||
var libraryItem = await this.$axios.$get(`/api/items/${this.libraryItemId}?expanded=1`).catch((error) => {
|
const axios = this.$axios || this.$nuxt.$axios
|
||||||
|
var libraryItem = await axios.$get(`/api/items/${this.libraryItemId}?expanded=1`).catch((error) => {
|
||||||
console.error('Failed to get lirbary item', this.libraryItemId)
|
console.error('Failed to get lirbary item', this.libraryItemId)
|
||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
<div class="absolute z-10 top-1.5 right-1.5 rounded-md leading-3 text-sm p-1 font-semibold text-white flex items-center justify-center" style="background-color: #cd9d49dd">{{ books.length }}</div>
|
<div class="absolute z-10 top-1.5 right-1.5 rounded-md leading-3 text-sm p-1 font-semibold text-white flex items-center justify-center" style="background-color: #cd9d49dd">{{ books.length }}</div>
|
||||||
|
|
||||||
<div v-if="isSeriesFinished" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b bg-success w-full" />
|
<div v-if="seriesPercentInProgress > 0" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b w-full" :class="isSeriesFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: seriesPercentInProgress * 100 + '%' }" />
|
||||||
|
|
||||||
<div v-if="hasValidCovers" class="bg-black bg-opacity-60 absolute top-0 left-0 w-full h-full flex items-center justify-center text-center transition-opacity" :class="isHovering ? '' : 'opacity-0'" :style="{ padding: `${sizeMultiplier}rem` }">
|
<div v-if="hasValidCovers" class="bg-black bg-opacity-60 absolute top-0 left-0 w-full h-full flex items-center justify-center text-center transition-opacity" :class="isHovering ? '' : 'opacity-0'" :style="{ padding: `${sizeMultiplier}rem` }">
|
||||||
<p :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">{{ displayTitle }}</p>
|
<p :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">{{ displayTitle }}</p>
|
||||||
@@ -81,13 +81,20 @@ export default {
|
|||||||
return this.title
|
return this.title
|
||||||
},
|
},
|
||||||
displaySortLine() {
|
displaySortLine() {
|
||||||
if (this.orderBy === 'addedAt') {
|
switch (this.orderBy) {
|
||||||
// return this.addedAt
|
case 'addedAt':
|
||||||
return 'Added ' + this.$formatDate(this.addedAt, this.dateFormat)
|
return `${this.$strings.LabelAdded} ${this.$formatDate(this.addedAt, this.dateFormat)}`
|
||||||
} else if (this.orderBy === 'totalDuration') {
|
case 'totalDuration':
|
||||||
return 'Duration: ' + this.$elapsedPrettyExtended(this.totalDuration, false)
|
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() {
|
books() {
|
||||||
return this.series ? this.series.books || [] : []
|
return this.series ? this.series.books || [] : []
|
||||||
@@ -108,6 +115,14 @@ export default {
|
|||||||
seriesBooksFinished() {
|
seriesBooksFinished() {
|
||||||
return this.seriesBookProgress.filter((p) => p.isFinished)
|
return this.seriesBookProgress.filter((p) => p.isFinished)
|
||||||
},
|
},
|
||||||
|
hasSeriesBookInProgress() {
|
||||||
|
return this.seriesBookProgress.some((p) => !p.isFinished && p.progress > 0)
|
||||||
|
},
|
||||||
|
seriesPercentInProgress() {
|
||||||
|
let totalFinishedAndInProgress = this.seriesBooksFinished.length
|
||||||
|
if (this.hasSeriesBookInProgress) totalFinishedAndInProgress += 1
|
||||||
|
return Math.min(1, Math.max(0, totalFinishedAndInProgress / this.books.length))
|
||||||
|
},
|
||||||
isSeriesFinished() {
|
isSeriesFinished() {
|
||||||
return this.books.length === this.seriesBooksFinished.length
|
return this.books.length === this.seriesBooksFinished.length
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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-40">
|
||||||
|
<span class="material-icons-outlined text-[10rem]">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>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div v-if="narrators?.length" class="flex py-0.5 mt-4">
|
||||||
|
<div class="w-32">
|
||||||
|
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelNarrators }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="max-w-[calc(100vw-10rem)] overflow-hidden overflow-ellipsis">
|
||||||
|
<template v-for="(narrator, index) in narrators">
|
||||||
|
<nuxt-link :key="narrator" :to="`/library/${libraryId}/bookshelf?filter=narrators.${$encode(narrator)}`" class="hover:underline">{{ narrator }}</nuxt-link
|
||||||
|
><span :key="index" v-if="index < narrators.length - 1">, </span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="publishedYear" class="flex py-0.5">
|
||||||
|
<div class="w-32">
|
||||||
|
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelPublishYear }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ publishedYear }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="publisher" class="flex py-0.5">
|
||||||
|
<div class="w-32">
|
||||||
|
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelPublisher }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ publisher }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="musicAlbum" class="flex py-0.5">
|
||||||
|
<div class="w-32">
|
||||||
|
<span class="text-white text-opacity-60 uppercase text-sm">Album</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ musicAlbum }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="musicAlbumArtist" class="flex py-0.5">
|
||||||
|
<div class="w-32">
|
||||||
|
<span class="text-white text-opacity-60 uppercase text-sm">Album Artist</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ musicAlbumArtist }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="musicTrackPretty" class="flex py-0.5">
|
||||||
|
<div class="w-32">
|
||||||
|
<span class="text-white text-opacity-60 uppercase text-sm">Track</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ musicTrackPretty }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="musicDiscPretty" class="flex py-0.5">
|
||||||
|
<div class="w-32">
|
||||||
|
<span class="text-white text-opacity-60 uppercase text-sm">Disc</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ musicDiscPretty }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="podcastType" class="flex py-0.5">
|
||||||
|
<div class="w-32">
|
||||||
|
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelPodcastType }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="capitalize">
|
||||||
|
{{ podcastType }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex py-0.5" v-if="genres.length">
|
||||||
|
<div class="w-32">
|
||||||
|
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelGenres }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="max-w-[calc(100vw-10rem)] overflow-hidden overflow-ellipsis">
|
||||||
|
<template v-for="(genre, index) in genres">
|
||||||
|
<nuxt-link :key="genre" :to="`/library/${libraryId}/bookshelf?filter=genres.${$encode(genre)}`" class="hover:underline">{{ genre }}</nuxt-link
|
||||||
|
><span :key="index" v-if="index < genres.length - 1">, </span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex py-0.5" v-if="tags.length">
|
||||||
|
<div class="w-32">
|
||||||
|
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelTags }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="max-w-[calc(100vw-10rem)] overflow-hidden overflow-ellipsis">
|
||||||
|
<template v-for="(tag, index) in tags">
|
||||||
|
<nuxt-link :key="tag" :to="`/library/${libraryId}/bookshelf?filter=tags.${$encode(tag)}`" class="hover:underline">{{ tag }}</nuxt-link
|
||||||
|
><span :key="index" v-if="index < tags.length - 1">, </span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="tracks.length || audioFile || (isPodcast && totalPodcastDuration)" class="flex py-0.5">
|
||||||
|
<div class="w-32">
|
||||||
|
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelDuration }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ durationPretty }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex py-0.5">
|
||||||
|
<div class="w-32">
|
||||||
|
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelSize }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ sizePretty }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
libraryItem: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
libraryId() {
|
||||||
|
return this.libraryItem.libraryId
|
||||||
|
},
|
||||||
|
isPodcast() {
|
||||||
|
return this.libraryItem.mediaType === 'podcast'
|
||||||
|
},
|
||||||
|
audioFile() {
|
||||||
|
// Music track
|
||||||
|
return this.media.audioFile
|
||||||
|
},
|
||||||
|
media() {
|
||||||
|
return this.libraryItem.media || {}
|
||||||
|
},
|
||||||
|
tracks() {
|
||||||
|
return this.media.tracks || []
|
||||||
|
},
|
||||||
|
podcastEpisodes() {
|
||||||
|
return this.media.episodes || []
|
||||||
|
},
|
||||||
|
mediaMetadata() {
|
||||||
|
return this.media.metadata || {}
|
||||||
|
},
|
||||||
|
publishedYear() {
|
||||||
|
return this.mediaMetadata.publishedYear
|
||||||
|
},
|
||||||
|
genres() {
|
||||||
|
return this.mediaMetadata.genres || []
|
||||||
|
},
|
||||||
|
tags() {
|
||||||
|
return this.media.tags || []
|
||||||
|
},
|
||||||
|
podcastAuthor() {
|
||||||
|
return this.mediaMetadata.author || ''
|
||||||
|
},
|
||||||
|
authors() {
|
||||||
|
return this.mediaMetadata.authors || []
|
||||||
|
},
|
||||||
|
publisher() {
|
||||||
|
return this.mediaMetadata.publisher || ''
|
||||||
|
},
|
||||||
|
musicArtists() {
|
||||||
|
return this.mediaMetadata.artists || []
|
||||||
|
},
|
||||||
|
musicAlbum() {
|
||||||
|
return this.mediaMetadata.album || ''
|
||||||
|
},
|
||||||
|
musicAlbumArtist() {
|
||||||
|
return this.mediaMetadata.albumArtist || ''
|
||||||
|
},
|
||||||
|
musicTrackPretty() {
|
||||||
|
if (!this.mediaMetadata.trackNumber) return null
|
||||||
|
if (!this.mediaMetadata.trackTotal) return this.mediaMetadata.trackNumber
|
||||||
|
return `${this.mediaMetadata.trackNumber} / ${this.mediaMetadata.trackTotal}`
|
||||||
|
},
|
||||||
|
musicDiscPretty() {
|
||||||
|
if (!this.mediaMetadata.discNumber) return null
|
||||||
|
if (!this.mediaMetadata.discTotal) return this.mediaMetadata.discNumber
|
||||||
|
return `${this.mediaMetadata.discNumber} / ${this.mediaMetadata.discTotal}`
|
||||||
|
},
|
||||||
|
narrators() {
|
||||||
|
return this.mediaMetadata.narrators || []
|
||||||
|
},
|
||||||
|
durationPretty() {
|
||||||
|
if (this.isPodcast) return this.$elapsedPrettyExtended(this.totalPodcastDuration)
|
||||||
|
|
||||||
|
if (!this.tracks.length && !this.audioFile) return 'N/A'
|
||||||
|
if (this.audioFile) return this.$elapsedPrettyExtended(this.duration)
|
||||||
|
return this.$elapsedPretty(this.duration)
|
||||||
|
},
|
||||||
|
duration() {
|
||||||
|
if (!this.tracks.length && !this.audioFile) return 0
|
||||||
|
return this.media.duration
|
||||||
|
},
|
||||||
|
totalPodcastDuration() {
|
||||||
|
if (!this.podcastEpisodes.length) return 0
|
||||||
|
let totalDuration = 0
|
||||||
|
this.podcastEpisodes.forEach((ep) => (totalDuration += ep.duration || 0))
|
||||||
|
return totalDuration
|
||||||
|
},
|
||||||
|
sizePretty() {
|
||||||
|
return this.$bytesPretty(this.media.size)
|
||||||
|
},
|
||||||
|
podcastType() {
|
||||||
|
return this.mediaMetadata.type
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -63,6 +63,15 @@
|
|||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
</li>
|
</li>
|
||||||
</template>
|
</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>
|
</template>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -84,6 +93,7 @@ export default {
|
|||||||
authorResults: [],
|
authorResults: [],
|
||||||
seriesResults: [],
|
seriesResults: [],
|
||||||
tagResults: [],
|
tagResults: [],
|
||||||
|
narratorResults: [],
|
||||||
searchTimeout: null,
|
searchTimeout: null,
|
||||||
lastSearch: null
|
lastSearch: null
|
||||||
}
|
}
|
||||||
@@ -114,6 +124,7 @@ export default {
|
|||||||
this.authorResults = []
|
this.authorResults = []
|
||||||
this.seriesResults = []
|
this.seriesResults = []
|
||||||
this.tagResults = []
|
this.tagResults = []
|
||||||
|
this.narratorResults = []
|
||||||
this.showMenu = false
|
this.showMenu = false
|
||||||
this.isFetching = false
|
this.isFetching = false
|
||||||
this.isTyping = false
|
this.isTyping = false
|
||||||
@@ -142,7 +153,7 @@ export default {
|
|||||||
}
|
}
|
||||||
this.isFetching = true
|
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)
|
console.error('Search error', error)
|
||||||
return []
|
return []
|
||||||
})
|
})
|
||||||
@@ -155,6 +166,7 @@ export default {
|
|||||||
this.authorResults = searchResults.authors || []
|
this.authorResults = searchResults.authors || []
|
||||||
this.seriesResults = searchResults.series || []
|
this.seriesResults = searchResults.series || []
|
||||||
this.tagResults = searchResults.tags || []
|
this.tagResults = searchResults.tags || []
|
||||||
|
this.narratorResults = searchResults.narrators || []
|
||||||
|
|
||||||
this.isFetching = false
|
this.isFetching = false
|
||||||
if (!this.showMenu) {
|
if (!this.showMenu) {
|
||||||
@@ -185,8 +197,8 @@ export default {
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style scoped>
|
||||||
.globalSearchMenu {
|
.globalSearchMenu {
|
||||||
max-height: 80vh;
|
max-height: calc(100vh - 75px);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -14,12 +14,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm">
|
<div v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-sm libraryFilterMenu">
|
||||||
<ul v-show="!sublist" class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
<ul v-show="!sublist" class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||||
<template v-for="item in selectItems">
|
<template v-for="item in selectItems">
|
||||||
<li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" :class="item.value === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedOption(item)">
|
<li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" :class="item.value === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedOption(item)">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="font-normal ml-3 block truncate text-sm md:text-base">{{ item.text }}</span>
|
<span class="font-normal ml-3 block truncate text-sm">{{ item.text }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="item.sublist" class="absolute right-1 top-0 bottom-0 h-full flex items-center">
|
<div v-if="item.sublist" class="absolute right-1 top-0 bottom-0 h-full flex items-center">
|
||||||
<span class="material-icons text-2xl">arrow_right</span>
|
<span class="material-icons text-2xl">arrow_right</span>
|
||||||
@@ -185,6 +185,11 @@ export default {
|
|||||||
value: 'tracks',
|
value: 'tracks',
|
||||||
sublist: true
|
sublist: true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelEbook,
|
||||||
|
value: 'ebook',
|
||||||
|
sublist: false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
text: this.$strings.LabelAbridged,
|
text: this.$strings.LabelAbridged,
|
||||||
value: 'abridged',
|
value: 'abridged',
|
||||||
@@ -439,4 +444,10 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.libraryFilterMenu {
|
||||||
|
max-height: calc(100vh - 125px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<form @submit.prevent="submitForm">
|
<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="w-full p-8">
|
||||||
<div class="flex py-2">
|
<div class="flex py-2">
|
||||||
<div class="w-1/2 px-2">
|
<div class="w-1/2 px-2">
|
||||||
@@ -96,7 +96,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!newUser.permissions.accessAllTags" class="my-4">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -185,6 +192,9 @@ export default {
|
|||||||
value: t
|
value: t
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
},
|
||||||
|
tagsSelectionText() {
|
||||||
|
return this.newUser.permissions.selectedTagsNotAccessible ? this.$strings.LabelTagsNotAccessibleToUser : this.$strings.LabelTagsAccessibleToUser
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -193,8 +203,11 @@ export default {
|
|||||||
if (this.$refs.modal) this.$refs.modal.setHide()
|
if (this.$refs.modal) this.$refs.modal.setHide()
|
||||||
},
|
},
|
||||||
accessAllTagsToggled(val) {
|
accessAllTagsToggled(val) {
|
||||||
if (val && this.newUser.itemTagsAccessible.length) {
|
if (val) {
|
||||||
this.newUser.itemTagsAccessible = []
|
if (this.newUser.itemTagsSelected?.length) {
|
||||||
|
this.newUser.itemTagsSelected = []
|
||||||
|
}
|
||||||
|
this.newUser.permissions.selectedTagsNotAccessible = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
fetchAllTags() {
|
fetchAllTags() {
|
||||||
@@ -226,7 +239,7 @@ export default {
|
|||||||
this.$toast.error('Must select at least one library')
|
this.$toast.error('Must select at least one library')
|
||||||
return
|
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')
|
this.$toast.error('Must select at least one tag')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -307,12 +320,12 @@ export default {
|
|||||||
delete: type === 'admin',
|
delete: type === 'admin',
|
||||||
upload: type === 'admin',
|
upload: type === 'admin',
|
||||||
accessAllLibraries: true,
|
accessAllLibraries: true,
|
||||||
accessAllTags: true
|
accessAllTags: true,
|
||||||
|
selectedTagsNotAccessible: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
init() {
|
init() {
|
||||||
this.fetchAllTags()
|
this.fetchAllTags()
|
||||||
|
|
||||||
this.isNew = !this.account
|
this.isNew = !this.account
|
||||||
if (this.account) {
|
if (this.account) {
|
||||||
this.newUser = {
|
this.newUser = {
|
||||||
@@ -322,9 +335,10 @@ export default {
|
|||||||
isActive: this.account.isActive,
|
isActive: this.account.isActive,
|
||||||
permissions: { ...this.account.permissions },
|
permissions: { ...this.account.permissions },
|
||||||
librariesAccessible: [...(this.account.librariesAccessible || [])],
|
librariesAccessible: [...(this.account.librariesAccessible || [])],
|
||||||
itemTagsAccessible: [...(this.account.itemTagsAccessible || [])]
|
itemTagsSelected: [...(this.account.itemTagsSelected || [])]
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
this.fetchAllTags()
|
||||||
this.newUser = {
|
this.newUser = {
|
||||||
username: null,
|
username: null,
|
||||||
password: null,
|
password: null,
|
||||||
@@ -336,7 +350,8 @@ export default {
|
|||||||
delete: false,
|
delete: false,
|
||||||
upload: false,
|
upload: false,
|
||||||
accessAllLibraries: true,
|
accessAllLibraries: true,
|
||||||
accessAllTags: true
|
accessAllTags: true,
|
||||||
|
selectedTagsNotAccessible: false
|
||||||
},
|
},
|
||||||
librariesAccessible: []
|
librariesAccessible: []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -2,13 +2,13 @@
|
|||||||
<modals-modal v-model="show" name="chapters" :width="600" :height="'unset'">
|
<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">
|
<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">
|
<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">
|
<p class="chapter-title truncate text-sm md:text-base">
|
||||||
{{ chap.title }}
|
{{ chap.title }}
|
||||||
</p>
|
</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="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 v-show="chap.id === currentChapterId" class="w-0.5 h-full absolute top-0 left-0 bg-yellow-400" />
|
||||||
</div>
|
</div>
|
||||||
@@ -28,7 +28,8 @@ export default {
|
|||||||
currentChapter: {
|
currentChapter: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => null
|
default: () => null
|
||||||
}
|
},
|
||||||
|
playbackRate: Number
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {}
|
return {}
|
||||||
@@ -47,11 +48,15 @@ export default {
|
|||||||
this.$emit('input', val)
|
this.$emit('input', val)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
_playbackRate() {
|
||||||
|
if (!this.playbackRate || isNaN(this.playbackRate)) return 1
|
||||||
|
return this.playbackRate
|
||||||
|
},
|
||||||
currentChapterId() {
|
currentChapterId() {
|
||||||
return this.currentChapter ? this.currentChapter.id : null
|
return this.currentChapter ? this.currentChapter.id : null
|
||||||
},
|
},
|
||||||
currentChapterStart() {
|
currentChapterStart() {
|
||||||
return this.currentChapter ? this.currentChapter.start : 0
|
return (this.currentChapter?.start || 0) / this._playbackRate
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -61,13 +66,11 @@ export default {
|
|||||||
scrollToChapter() {
|
scrollToChapter() {
|
||||||
if (!this.currentChapterId) return
|
if (!this.currentChapterId) return
|
||||||
|
|
||||||
var container = this.$refs.container
|
if (this.$refs.container) {
|
||||||
if (container) {
|
const currChapterEl = document.getElementById(`chapter-row-${this.currentChapterId}`)
|
||||||
var currChapterEl = document.getElementById(`chapter-row-${this.currentChapterId}`)
|
|
||||||
if (currChapterEl) {
|
if (currChapterEl) {
|
||||||
var offsetTop = currChapterEl.offsetTop
|
const containerHeight = this.$refs.container.clientHeight
|
||||||
var containerHeight = container.clientHeight
|
this.$refs.container.scrollTo({ top: currChapterEl.offsetTop - containerHeight / 2 })
|
||||||
container.scrollTo({ top: offsetTop - containerHeight / 2 })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,7 +98,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</modals-modal>
|
</modals-modal>
|
||||||
@@ -157,6 +158,9 @@ export default {
|
|||||||
},
|
},
|
||||||
timeFormat() {
|
timeFormat() {
|
||||||
return this.$store.state.serverSettings.timeFormat
|
return this.$store.state.serverSettings.timeFormat
|
||||||
|
},
|
||||||
|
isOpenSession() {
|
||||||
|
return !!this._session.open
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -188,6 +192,24 @@ export default {
|
|||||||
var errMsg = error.response ? error.response.data || '' : ''
|
var errMsg = error.response ? error.response.data || '' : ''
|
||||||
this.$toast.error(errMsg || this.$strings.ToastSessionDeleteFailed)
|
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() {}
|
mounted() {}
|
||||||
|
|||||||
@@ -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 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">
|
<div v-if="!timerSet" class="w-full">
|
||||||
<template v-for="time in sleepTimes">
|
<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>
|
<p class="text-xl text-center">{{ time.text }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
||||||
<div v-else class="w-full p-4">
|
<div v-else class="w-full p-4">
|
||||||
<div class="mb-4 flex items-center justify-center">
|
<div class="mb-4 flex items-center justify-center">
|
||||||
@@ -48,19 +52,28 @@ export default {
|
|||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
customTime: null,
|
||||||
sleepTimes: [
|
sleepTimes: [
|
||||||
{
|
|
||||||
seconds: 10,
|
|
||||||
text: '10 seconds'
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
seconds: 60 * 5,
|
seconds: 60 * 5,
|
||||||
text: '5 minutes'
|
text: '5 minutes'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
seconds: 60 * 15,
|
||||||
|
text: '15 minutes'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
seconds: 60 * 20,
|
||||||
|
text: '20 minutes'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
seconds: 60 * 30,
|
seconds: 60 * 30,
|
||||||
text: '30 minutes'
|
text: '30 minutes'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
seconds: 60 * 45,
|
||||||
|
text: '45 minutes'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
seconds: 60 * 60,
|
seconds: 60 * 60,
|
||||||
text: '60 minutes'
|
text: '60 minutes'
|
||||||
@@ -72,10 +85,6 @@ export default {
|
|||||||
{
|
{
|
||||||
seconds: 60 * 120,
|
seconds: 60 * 120,
|
||||||
text: '2 hours'
|
text: '2 hours'
|
||||||
},
|
|
||||||
{
|
|
||||||
seconds: 60 * 180,
|
|
||||||
text: '3 hours'
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -97,8 +106,17 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
setTime(time) {
|
submitCustomTime() {
|
||||||
this.$emit('set', time.seconds)
|
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) {
|
increment(amount) {
|
||||||
this.$emit('increment', amount)
|
this.$emit('increment', amount)
|
||||||
|
|||||||
@@ -85,6 +85,12 @@ export default {
|
|||||||
},
|
},
|
||||||
title() {
|
title() {
|
||||||
return this.$strings.HeaderUpdateAuthor
|
return this.$strings.HeaderUpdateAuthor
|
||||||
|
},
|
||||||
|
currentLibraryId() {
|
||||||
|
return this.$store.state.libraries.currentLibraryId
|
||||||
|
},
|
||||||
|
libraryProvider() {
|
||||||
|
return this.$store.getters['libraries/getLibraryProvider'](this.currentLibraryId) || 'google'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -151,6 +157,11 @@ export default {
|
|||||||
if (this.authorCopy.asin) payload.asin = this.authorCopy.asin
|
if (this.authorCopy.asin) payload.asin = this.authorCopy.asin
|
||||||
else payload.q = this.authorCopy.name
|
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) => {
|
var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, payload).catch((error) => {
|
||||||
console.error('Failed', error)
|
console.error('Failed', error)
|
||||||
return null
|
return null
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
<template>
|
||||||
|
<modals-modal ref="modal" v-model="show" name="ereader-device-edit" :width="800" :height="'unset'" :processing="processing">
|
||||||
|
<template #outer>
|
||||||
|
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||||
|
<p class="text-3xl text-white truncate">{{ title }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<form @submit.prevent="submitForm">
|
||||||
|
<div class="w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300">
|
||||||
|
<div class="w-full px-3 py-5 md:p-12">
|
||||||
|
<div class="flex items-center -mx-1 mb-2">
|
||||||
|
<div class="w-full md:w-1/2 px-1">
|
||||||
|
<ui-text-input-with-label ref="ereaderNameInput" v-model="newDevice.name" :disabled="processing" :label="$strings.LabelName" />
|
||||||
|
</div>
|
||||||
|
<div class="w-full md:w-1/2 px-1">
|
||||||
|
<ui-text-input-with-label ref="ereaderEmailInput" v-model="newDevice.email" :disabled="processing" :label="$strings.LabelEmail" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center pt-4">
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<ui-btn color="success" type="submit">{{ $strings.ButtonSubmit }}</ui-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</modals-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: Boolean,
|
||||||
|
existingDevices: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
ereaderDevice: {
|
||||||
|
type: Object,
|
||||||
|
default: () => null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
processing: false,
|
||||||
|
newDevice: {
|
||||||
|
name: '',
|
||||||
|
email: ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
show: {
|
||||||
|
handler(newVal) {
|
||||||
|
if (newVal) {
|
||||||
|
this.init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
show: {
|
||||||
|
get() {
|
||||||
|
return this.value
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('input', val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title() {
|
||||||
|
return this.ereaderDevice ? 'Create Device' : 'Update Device'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
submitForm() {
|
||||||
|
this.$refs.ereaderNameInput.blur()
|
||||||
|
this.$refs.ereaderEmailInput.blur()
|
||||||
|
|
||||||
|
if (!this.newDevice.name?.trim() || !this.newDevice.email?.trim()) {
|
||||||
|
this.$toast.error('Name and email required')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.newDevice.name = this.newDevice.name.trim()
|
||||||
|
this.newDevice.email = this.newDevice.email.trim()
|
||||||
|
|
||||||
|
if (!this.ereaderDevice) {
|
||||||
|
if (this.existingDevices.some((d) => d.name === this.newDevice.name)) {
|
||||||
|
this.$toast.error('EReader device with that name already exists')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.submitCreate()
|
||||||
|
} else {
|
||||||
|
if (this.ereaderDevice.name !== this.newDevice.name && this.existingDevices.some((d) => d.name === this.newDevice.name)) {
|
||||||
|
this.$toast.error('EReader device with that name already exists')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.submitUpdate()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
submitUpdate() {
|
||||||
|
this.processing = true
|
||||||
|
|
||||||
|
const existingDevicesWithoutThisOne = this.existingDevices.filter((d) => d.name !== this.ereaderDevice.name)
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
ereaderDevices: [
|
||||||
|
...existingDevicesWithoutThisOne,
|
||||||
|
{
|
||||||
|
...this.newDevice
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$axios
|
||||||
|
.$post(`/api/emails/ereader-devices`, payload)
|
||||||
|
.then((data) => {
|
||||||
|
this.$emit('update', data.ereaderDevices)
|
||||||
|
this.$toast.success('Device updated')
|
||||||
|
this.show = false
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to update device', error)
|
||||||
|
this.$toast.error('Failed to update device')
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.processing = false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
submitCreate() {
|
||||||
|
this.processing = true
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
ereaderDevices: [
|
||||||
|
...this.existingDevices,
|
||||||
|
{
|
||||||
|
...this.newDevice
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$axios
|
||||||
|
.$post('/api/emails/ereader-devices', payload)
|
||||||
|
.then((data) => {
|
||||||
|
this.$emit('update', data.ereaderDevices || [])
|
||||||
|
this.$toast.success('Device added')
|
||||||
|
this.show = false
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to add device', error)
|
||||||
|
this.$toast.error('Failed to add device')
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.processing = false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
init() {
|
||||||
|
if (this.ereaderDevice) {
|
||||||
|
this.newDevice.name = this.ereaderDevice.name
|
||||||
|
this.newDevice.email = this.ereaderDevice.email
|
||||||
|
} else {
|
||||||
|
this.newDevice.name = ''
|
||||||
|
this.newDevice.email = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -74,6 +74,9 @@ export default {
|
|||||||
this.$store.commit('setEditModalTab', val)
|
this.$store.commit('setEditModalTab', val)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
height() {
|
||||||
|
return Math.min(this.availableHeight, 650)
|
||||||
|
},
|
||||||
tabs() {
|
tabs() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -136,6 +139,18 @@ export default {
|
|||||||
userIsAdminOrUp() {
|
userIsAdminOrUp() {
|
||||||
return this.$store.getters['user/getIsAdminOrUp']
|
return this.$store.getters['user/getIsAdminOrUp']
|
||||||
},
|
},
|
||||||
|
selectedLibraryItem() {
|
||||||
|
return this.$store.state.selectedLibraryItem || {}
|
||||||
|
},
|
||||||
|
selectedLibraryItemId() {
|
||||||
|
return this.selectedLibraryItem.id
|
||||||
|
},
|
||||||
|
media() {
|
||||||
|
return this.libraryItem?.media || {}
|
||||||
|
},
|
||||||
|
mediaMetadata() {
|
||||||
|
return this.media.metadata || {}
|
||||||
|
},
|
||||||
availableTabs() {
|
availableTabs() {
|
||||||
if (!this.userCanUpdate && !this.userCanDownload) return []
|
if (!this.userCanUpdate && !this.userCanDownload) return []
|
||||||
return this.tabs.filter((tab) => {
|
return this.tabs.filter((tab) => {
|
||||||
@@ -144,6 +159,7 @@ export default {
|
|||||||
if (tab.admin && !this.userIsAdminOrUp) return false
|
if (tab.admin && !this.userIsAdminOrUp) return false
|
||||||
|
|
||||||
if (tab.id === 'tools' && this.isMissing) return false
|
if (tab.id === 'tools' && this.isMissing) return false
|
||||||
|
if (tab.id === 'chapters' && this.isEBookOnly) return false
|
||||||
|
|
||||||
if ((tab.id === 'tools' || tab.id === 'files') && this.userCanDownload) return true
|
if ((tab.id === 'tools' || tab.id === 'files') && this.userCanDownload) return true
|
||||||
if (tab.id !== 'tools' && tab.id !== 'files' && this.userCanUpdate) return true
|
if (tab.id !== 'tools' && tab.id !== 'files' && this.userCanUpdate) return true
|
||||||
@@ -151,9 +167,6 @@ export default {
|
|||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
height() {
|
|
||||||
return Math.min(this.availableHeight, 650)
|
|
||||||
},
|
|
||||||
tabName() {
|
tabName() {
|
||||||
var _tab = this.tabs.find((t) => t.id === this.selectedTab)
|
var _tab = this.tabs.find((t) => t.id === this.selectedTab)
|
||||||
return _tab ? _tab.component : ''
|
return _tab ? _tab.component : ''
|
||||||
@@ -161,20 +174,11 @@ export default {
|
|||||||
isMissing() {
|
isMissing() {
|
||||||
return this.selectedLibraryItem.isMissing
|
return this.selectedLibraryItem.isMissing
|
||||||
},
|
},
|
||||||
selectedLibraryItem() {
|
isEBookOnly() {
|
||||||
return this.$store.state.selectedLibraryItem || {}
|
return this.media.ebookFile && !this.media.tracks?.length
|
||||||
},
|
|
||||||
selectedLibraryItemId() {
|
|
||||||
return this.selectedLibraryItem.id
|
|
||||||
},
|
|
||||||
media() {
|
|
||||||
return this.libraryItem ? this.libraryItem.media || {} : {}
|
|
||||||
},
|
|
||||||
mediaMetadata() {
|
|
||||||
return this.media.metadata || {}
|
|
||||||
},
|
},
|
||||||
mediaType() {
|
mediaType() {
|
||||||
return this.libraryItem ? this.libraryItem.mediaType : null
|
return this.libraryItem?.mediaType || null
|
||||||
},
|
},
|
||||||
title() {
|
title() {
|
||||||
return this.mediaMetadata.title || 'No Title'
|
return this.mediaMetadata.title || 'No Title'
|
||||||
|
|||||||
@@ -17,10 +17,10 @@
|
|||||||
<div class="flex-grow sm:pl-2 md:pl-6 sm:pr-2 mt-2 md:mt-0">
|
<div class="flex-grow sm:pl-2 md:pl-6 sm:pr-2 mt-2 md:mt-0">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div v-if="userCanUpload" class="w-10 md:w-40 pr-2 pt-4 md:min-w-32">
|
<div v-if="userCanUpload" class="w-10 md:w-40 pr-2 pt-4 md:min-w-32">
|
||||||
<ui-file-input ref="fileInput" @change="fileUploadSelected"
|
<ui-file-input ref="fileInput" @change="fileUploadSelected">
|
||||||
><span class="hidden md:inline-block">{{ $strings.ButtonUploadCover }}</span
|
<span class="hidden md:inline-block">{{ $strings.ButtonUploadCover }}</span>
|
||||||
><span class="material-icons text-2xl inline-block md:!hidden">upload</span></ui-file-input
|
<span class="material-icons text-2xl inline-block md:!hidden">upload</span>
|
||||||
>
|
</ui-file-input>
|
||||||
</div>
|
</div>
|
||||||
<form @submit.prevent="submitForm" class="flex flex-grow">
|
<form @submit.prevent="submitForm" class="flex flex-grow">
|
||||||
<ui-text-input-with-label v-model="imageUrl" :label="$strings.LabelCoverImageURL" />
|
<ui-text-input-with-label v-model="imageUrl" :label="$strings.LabelCoverImageURL" />
|
||||||
@@ -36,10 +36,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="showLocalCovers" class="flex items-center justify-center pb-2">
|
<div v-if="showLocalCovers" class="flex items-center justify-center pb-2">
|
||||||
<template v-for="cover in localCovers">
|
<template v-for="localCoverFile 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 :key="localCoverFile.ino" class="m-0.5 mb-5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="localCoverFile.metadata.path === coverPath ? 'border-yellow-300' : ''" @click="setCover(localCoverFile)">
|
||||||
<div class="h-24 bg-primary" :style="{ width: 96 / bookCoverAspectRatio + 'px' }">
|
<div class="h-24 bg-primary" :style="{ width: 96 / bookCoverAspectRatio + 'px' }">
|
||||||
<covers-preview-cover :src="`${cover.localPath}?token=${userToken}`" :width="96 / bookCoverAspectRatio" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<covers-preview-cover :src="localCoverFile.localPath" :width="96 / bookCoverAspectRatio" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -49,13 +49,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<form @submit.prevent="submitSearchForm">
|
<form @submit.prevent="submitSearchForm">
|
||||||
<div class="flex items-center justify-start -mx-1 h-20">
|
<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 />
|
<ui-dropdown v-model="provider" :items="providers" :label="$strings.LabelProvider" small />
|
||||||
</div>
|
</div>
|
||||||
<div class="w-72 px-1">
|
<div class="w-72 px-1">
|
||||||
<ui-text-input-with-label v-model="searchTitle" :label="searchTitleLabel" :placeholder="$strings.PlaceholderSearch" />
|
<ui-text-input-with-label v-model="searchTitle" :label="searchTitleLabel" :placeholder="$strings.PlaceholderSearch" />
|
||||||
</div>
|
</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" />
|
<ui-text-input-with-label v-model="searchAuthor" :label="$strings.LabelAuthor" />
|
||||||
</div>
|
</div>
|
||||||
<ui-btn class="mt-5 ml-1" type="submit">{{ $strings.ButtonSearch }}</ui-btn>
|
<ui-btn class="mt-5 ml-1" type="submit">{{ $strings.ButtonSearch }}</ui-btn>
|
||||||
@@ -128,7 +128,7 @@ export default {
|
|||||||
},
|
},
|
||||||
providers() {
|
providers() {
|
||||||
if (this.isPodcast) return this.$store.state.scanners.podcastProviders
|
if (this.isPodcast) return this.$store.state.scanners.podcastProviders
|
||||||
return this.$store.state.scanners.providers
|
return [{ text: 'All', value: 'all' }, ...this.$store.state.scanners.providers, ...this.$store.state.scanners.coverOnlyProviders]
|
||||||
},
|
},
|
||||||
searchTitleLabel() {
|
searchTitleLabel() {
|
||||||
if (this.provider.startsWith('audible')) return this.$strings.LabelSearchTitleOrASIN
|
if (this.provider.startsWith('audible')) return this.$strings.LabelSearchTitleOrASIN
|
||||||
@@ -169,8 +169,8 @@ export default {
|
|||||||
return this.libraryFiles
|
return this.libraryFiles
|
||||||
.filter((f) => f.fileType === 'image')
|
.filter((f) => f.fileType === 'image')
|
||||||
.map((file) => {
|
.map((file) => {
|
||||||
var _file = { ...file }
|
const _file = { ...file }
|
||||||
_file.localPath = `${process.env.serverUrl}/s/item/${this.libraryItemId}/${this.$encodeUriPath(file.metadata.relPath).replace(/^\//, '')}`
|
_file.localPath = `${process.env.serverUrl}/api/items/${this.libraryItemId}/file/${file.ino}?token=${this.userToken}`
|
||||||
return _file
|
return _file
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -223,7 +223,7 @@ export default {
|
|||||||
this.searchTitle = this.mediaMetadata.title || ''
|
this.searchTitle = this.mediaMetadata.title || ''
|
||||||
this.searchAuthor = this.mediaMetadata.authorName || ''
|
this.searchAuthor = this.mediaMetadata.authorName || ''
|
||||||
if (this.isPodcast) this.provider = 'itunes'
|
if (this.isPodcast) this.provider = 'itunes'
|
||||||
else this.provider = localStorage.getItem('book-provider') || 'google'
|
else this.provider = localStorage.getItem('book-cover-provider') || localStorage.getItem('book-provider') || 'google'
|
||||||
},
|
},
|
||||||
removeCover() {
|
removeCover() {
|
||||||
if (!this.media.coverPath) {
|
if (!this.media.coverPath) {
|
||||||
@@ -288,13 +288,13 @@ export default {
|
|||||||
},
|
},
|
||||||
getSearchQuery() {
|
getSearchQuery() {
|
||||||
var searchQuery = `provider=${this.provider}&title=${this.searchTitle}`
|
var searchQuery = `provider=${this.provider}&title=${this.searchTitle}`
|
||||||
if (this.searchAuthor) searchQuery += `&author=${this.searchAuthor}`
|
if (this.searchAuthor) searchQuery += `&author=${this.searchAuthor || ''}`
|
||||||
if (this.isPodcast) searchQuery += '&podcast=1'
|
if (this.isPodcast) searchQuery += '&podcast=1'
|
||||||
return searchQuery
|
return searchQuery
|
||||||
},
|
},
|
||||||
persistProvider() {
|
persistProvider() {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem('book-provider', this.provider)
|
localStorage.setItem('book-cover-provider', this.provider)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('PersistProvider', error)
|
console.error('PersistProvider', error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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="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">
|
<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-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-btn v-if="userIsAdminOrUp" :loading="quickMatching" color="bg" type="button" class="h-full" small @click.stop.prevent="quickMatch">{{ $strings.ButtonQuickMatch }}</ui-btn>
|
||||||
</ui-tooltip>
|
</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-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>
|
</ui-tooltip>
|
||||||
|
|
||||||
|
<div class="flex-grow" />
|
||||||
|
|
||||||
<!-- desktop -->
|
<!-- desktop -->
|
||||||
<ui-btn @click="save" class="mx-2 hidden md:block">{{ $strings.ButtonSave }}</ui-btn>
|
<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>
|
<ui-btn @click="saveAndClose" class="mx-2 hidden md:block">{{ $strings.ButtonSaveAndClose }}</ui-btn>
|
||||||
@@ -77,9 +74,6 @@ export default {
|
|||||||
mediaMetadata() {
|
mediaMetadata() {
|
||||||
return this.media.metadata || {}
|
return this.media.metadata || {}
|
||||||
},
|
},
|
||||||
userCanDelete() {
|
|
||||||
return this.$store.getters['user/getUserCanDelete']
|
|
||||||
},
|
|
||||||
libraryId() {
|
libraryId() {
|
||||||
return this.libraryItem ? this.libraryItem.libraryId : null
|
return this.libraryItem ? this.libraryItem.libraryId : null
|
||||||
},
|
},
|
||||||
@@ -184,23 +178,6 @@ export default {
|
|||||||
}
|
}
|
||||||
return false
|
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() {
|
checkIsScrollable() {
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
var formWrapper = document.getElementById('formWrapper')
|
var formWrapper = document.getElementById('formWrapper')
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -30,9 +30,6 @@ export default {
|
|||||||
media() {
|
media() {
|
||||||
return this.libraryItem.media || {}
|
return this.libraryItem.media || {}
|
||||||
},
|
},
|
||||||
libraryFiles() {
|
|
||||||
return this.libraryItem.libraryFiles || []
|
|
||||||
},
|
|
||||||
userToken() {
|
userToken() {
|
||||||
return this.$store.getters['user/getToken']
|
return this.$store.getters['user/getToken']
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -115,7 +115,7 @@
|
|||||||
<div v-if="selectedMatchOrig.genres && selectedMatchOrig.genres.length" class="flex items-center py-2">
|
<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" />
|
<ui-checkbox v-model="selectedMatchUsage.genres" checkbox-bg="bg" @input="checkboxToggled" />
|
||||||
<div class="flex-grow ml-4">
|
<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>
|
<p v-if="mediaMetadata.genres" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.genres.join(', ') }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -300,6 +300,12 @@ export default {
|
|||||||
},
|
},
|
||||||
isPodcast() {
|
isPodcast() {
|
||||||
return this.mediaType == 'podcast'
|
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: {
|
methods: {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
<div class="w-full px-3 py-5 md:p-12">
|
<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-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" />
|
<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()
|
if (this.$refs.modal) this.$refs.modal.setHide()
|
||||||
},
|
},
|
||||||
submitForm() {
|
submitForm() {
|
||||||
|
this.$refs.urlsInput?.forceBlur()
|
||||||
|
|
||||||
if (!this.newNotification.urls.length) {
|
if (!this.newNotification.urls.length) {
|
||||||
this.$toast.error('Must enter an Apprise URL')
|
this.$toast.error('Must enter an Apprise URL')
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</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 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="episodes.length" class="w-full py-3 mx-auto flex">
|
<div v-if="episodesCleaned.length" class="w-full py-3 mx-auto flex">
|
||||||
<form @submit.prevent="submit" class="flex flex-grow">
|
<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" />
|
<ui-text-input v-model="search" @input="inputUpdate" type="search" :placeholder="$strings.PlaceholderSearchEpisode" class="flex-grow mr-2 text-sm md:text-base" />
|
||||||
</form>
|
</form>
|
||||||
@@ -16,12 +16,12 @@
|
|||||||
v-for="(episode, index) in episodesList"
|
v-for="(episode, index) in episodesList"
|
||||||
:key="index"
|
:key="index"
|
||||||
class="relative"
|
class="relative"
|
||||||
:class="itemEpisodeMap[episode.enclosure.url?.split('?')[0]] ? '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'"
|
: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(index, episode)"
|
@click="toggleSelectEpisode(episode)"
|
||||||
>
|
>
|
||||||
<div class="absolute top-0 left-0 h-full flex items-center p-2">
|
<div class="absolute top-0 left-0 h-full flex items-center p-2">
|
||||||
<span v-if="itemEpisodeMap[episode.enclosure.url?.split('?')[0]]" class="material-icons text-success text-xl">download_done</span>
|
<span v-if="itemEpisodeMap[episode.cleanUrl]" 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" />
|
<ui-checkbox v-else v-model="selectedEpisodes[episode.cleanUrl]" small checkbox-bg="primary" border-color="gray-600" />
|
||||||
</div>
|
</div>
|
||||||
<div class="px-8 py-2">
|
<div class="px-8 py-2">
|
||||||
<div class="flex items-center font-semibold text-gray-200">
|
<div class="flex items-center font-semibold text-gray-200">
|
||||||
@@ -63,6 +63,7 @@ export default {
|
|||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
processing: false,
|
processing: false,
|
||||||
|
episodesCleaned: [],
|
||||||
selectedEpisodes: {},
|
selectedEpisodes: {},
|
||||||
selectAll: false,
|
selectAll: false,
|
||||||
search: null,
|
search: null,
|
||||||
@@ -92,7 +93,7 @@ export default {
|
|||||||
return this.libraryItem.media.metadata.title || 'Unknown'
|
return this.libraryItem.media.metadata.title || 'Unknown'
|
||||||
},
|
},
|
||||||
allDownloaded() {
|
allDownloaded() {
|
||||||
return !this.episodes.some((episode) => !this.itemEpisodeMap[episode.enclosure.url?.split('?')[0]])
|
return !this.episodesCleaned.some((episode) => !this.itemEpisodeMap[episode.cleanUrl])
|
||||||
},
|
},
|
||||||
episodesSelected() {
|
episodesSelected() {
|
||||||
return Object.keys(this.selectedEpisodes).filter((key) => !!this.selectedEpisodes[key])
|
return Object.keys(this.selectedEpisodes).filter((key) => !!this.selectedEpisodes[key])
|
||||||
@@ -113,7 +114,7 @@ export default {
|
|||||||
return map
|
return map
|
||||||
},
|
},
|
||||||
episodesList() {
|
episodesList() {
|
||||||
return this.episodes.filter((episode) => {
|
return this.episodesCleaned.filter((episode) => {
|
||||||
if (!this.searchText) return true
|
if (!this.searchText) return true
|
||||||
return (episode.title && episode.title.toLowerCase().includes(this.searchText)) || (episode.subtitle && episode.subtitle.toLowerCase().includes(this.searchText))
|
return (episode.title && episode.title.toLowerCase().includes(this.searchText)) || (episode.subtitle && episode.subtitle.toLowerCase().includes(this.searchText))
|
||||||
})
|
})
|
||||||
@@ -131,31 +132,29 @@ export default {
|
|||||||
}, 500)
|
}, 500)
|
||||||
},
|
},
|
||||||
toggleSelectAll(val) {
|
toggleSelectAll(val) {
|
||||||
for (let i = 0; i < this.episodes.length; i++) {
|
for (const episode of this.episodesCleaned) {
|
||||||
const episode = this.episodes[i]
|
if (this.itemEpisodeMap[episode.cleanUrl]) this.selectedEpisodes[episode.cleanUrl] = false
|
||||||
if (this.itemEpisodeMap[episode.enclosure.url?.split('?')[0]]) this.selectedEpisodes[String(i)] = false
|
else this.$set(this.selectedEpisodes, episode.cleanUrl, val)
|
||||||
else this.$set(this.selectedEpisodes, String(i), val)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
checkSetIsSelectedAll() {
|
checkSetIsSelectedAll() {
|
||||||
for (let i = 0; i < this.episodes.length; i++) {
|
for (const episode of this.episodesCleaned) {
|
||||||
const episode = this.episodes[i]
|
if (!this.itemEpisodeMap[episode.cleanUrl] && !this.selectedEpisodes[episode.cleanUrl]) {
|
||||||
if (!this.itemEpisodeMap[episode.enclosure.url?.split('?')[0]] && !this.selectedEpisodes[String(i)]) {
|
|
||||||
this.selectAll = false
|
this.selectAll = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.selectAll = true
|
this.selectAll = true
|
||||||
},
|
},
|
||||||
toggleSelectEpisode(index, episode) {
|
toggleSelectEpisode(episode) {
|
||||||
if (this.itemEpisodeMap[episode.enclosure.url?.split('?')[0]]) return
|
if (this.itemEpisodeMap[episode.enclosure.url?.split('?')[0]]) return
|
||||||
this.$set(this.selectedEpisodes, String(index), !this.selectedEpisodes[String(index)])
|
this.$set(this.selectedEpisodes, episode.cleanUrl, !this.selectedEpisodes[episode.cleanUrl])
|
||||||
this.checkSetIsSelectedAll()
|
this.checkSetIsSelectedAll()
|
||||||
},
|
},
|
||||||
submit() {
|
submit() {
|
||||||
var episodesToDownload = []
|
var episodesToDownload = []
|
||||||
if (this.episodesSelected.length) {
|
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
|
var payloadSize = JSON.stringify(episodesToDownload).length
|
||||||
@@ -185,7 +184,15 @@ export default {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
init() {
|
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.selectAll = false
|
||||||
this.selectedEpisodes = {}
|
this.selectedEpisodes = {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,9 +31,10 @@
|
|||||||
<!-- mobile -->
|
<!-- mobile -->
|
||||||
<ui-btn @click="saveAndClose" class="mx-2 md:hidden">{{ $strings.ButtonSave }}</ui-btn>
|
<ui-btn @click="saveAndClose" class="mx-2 md:hidden">{{ $strings.ButtonSave }}</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="enclosureUrl" class="py-4">
|
<div v-if="enclosureUrl" class="pb-4 pt-6">
|
||||||
<p class="text-xs text-gray-300 font-semibold">Episode URL from RSS feed</p>
|
<ui-text-input-with-label :value="enclosureUrl" readonly class="text-xs">
|
||||||
<a :href="enclosureUrl" target="_blank" class="text-xs text-blue-400 hover:text-blue-500 hover:underline">{{ enclosureUrl }}</a>
|
<label class="px-1 text-xs text-gray-200 font-semibold">Episode URL from RSS feed</label>
|
||||||
|
</ui-text-input-with-label>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="py-4">
|
<div v-else class="py-4">
|
||||||
<p class="text-xs text-gray-300 font-semibold">Episode not linked to RSS feed episode</p>
|
<p class="text-xs text-gray-300 font-semibold">Episode not linked to RSS feed episode</p>
|
||||||
|
|||||||
@@ -38,7 +38,8 @@ export default {
|
|||||||
currentChapter: {
|
currentChapter: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => {}
|
default: () => {}
|
||||||
}
|
},
|
||||||
|
playbackRate: Number
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -63,6 +64,10 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
_playbackRate() {
|
||||||
|
if (!this.playbackRate || isNaN(this.playbackRate)) return 1
|
||||||
|
return this.playbackRate
|
||||||
|
},
|
||||||
currentChapterDuration() {
|
currentChapterDuration() {
|
||||||
if (!this.currentChapter) return 0
|
if (!this.currentChapter) return 0
|
||||||
return this.currentChapter.end - this.currentChapter.start
|
return this.currentChapter.end - this.currentChapter.start
|
||||||
@@ -81,8 +86,8 @@ export default {
|
|||||||
clickTrack(e) {
|
clickTrack(e) {
|
||||||
if (this.loading) return
|
if (this.loading) return
|
||||||
|
|
||||||
var offsetX = e.offsetX
|
const offsetX = e.offsetX
|
||||||
var perc = offsetX / this.trackWidth
|
const perc = offsetX / this.trackWidth
|
||||||
const baseTime = this.useChapterTrack ? this.currentChapterStart : 0
|
const baseTime = this.useChapterTrack ? this.currentChapterStart : 0
|
||||||
const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration
|
const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration
|
||||||
const time = baseTime + perc * duration
|
const time = baseTime + perc * duration
|
||||||
@@ -111,7 +116,7 @@ export default {
|
|||||||
this.updateReadyTrack()
|
this.updateReadyTrack()
|
||||||
},
|
},
|
||||||
updateReadyTrack() {
|
updateReadyTrack() {
|
||||||
var widthReady = Math.round(this.trackWidth * this.percentReady)
|
const widthReady = Math.round(this.trackWidth * this.percentReady)
|
||||||
if (this.readyTrackWidth === widthReady) return
|
if (this.readyTrackWidth === widthReady) return
|
||||||
this.readyTrackWidth = widthReady
|
this.readyTrackWidth = widthReady
|
||||||
if (this.$refs.readyTrack) this.$refs.readyTrack.style.width = widthReady + 'px'
|
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 time = this.useChapterTrack ? Math.max(0, this.currentTime - this.currentChapterStart) : this.currentTime
|
||||||
const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration
|
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) {
|
if (this.playedTrackWidth === ptWidth) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -133,7 +138,7 @@ export default {
|
|||||||
},
|
},
|
||||||
setChapterTicks() {
|
setChapterTicks() {
|
||||||
this.chapterTicks = this.chapters.map((chap) => {
|
this.chapterTicks = this.chapters.map((chap) => {
|
||||||
var perc = chap.start / this.duration
|
const perc = chap.start / this.duration
|
||||||
return {
|
return {
|
||||||
title: chap.title,
|
title: chap.title,
|
||||||
left: perc * this.trackWidth
|
left: perc * this.trackWidth
|
||||||
@@ -141,7 +146,7 @@ export default {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
mousemoveTrack(e) {
|
mousemoveTrack(e) {
|
||||||
var offsetX = e.offsetX
|
const offsetX = e.offsetX
|
||||||
|
|
||||||
const baseTime = this.useChapterTrack ? this.currentChapterStart : 0
|
const baseTime = this.useChapterTrack ? this.currentChapterStart : 0
|
||||||
const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration
|
const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration
|
||||||
@@ -167,7 +172,7 @@ export default {
|
|||||||
this.$refs.hoverTimestampArrow.style.left = posLeft + 'px'
|
this.$refs.hoverTimestampArrow.style.left = posLeft + 'px'
|
||||||
}
|
}
|
||||||
if (this.$refs.hoverTimestampText) {
|
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)
|
var chapter = this.chapters.find((chapter) => chapter.start <= totalTime && totalTime < chapter.end)
|
||||||
if (chapter && chapter.title) {
|
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" />
|
<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>
|
</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">
|
<div class="flex">
|
||||||
<p ref="currentTimestamp" class="font-mono text-xxs sm:text-sm text-gray-100 pointer-events-auto">00:00:00</p>
|
<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>
|
<p class="font-mono text-xxs sm:text-sm text-gray-100 pointer-events-auto">{{ timeRemainingPretty }}</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -92,6 +92,11 @@ export default {
|
|||||||
useChapterTrack: false
|
useChapterTrack: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
watch: {
|
||||||
|
playbackRate() {
|
||||||
|
this.updateTimestamp()
|
||||||
|
}
|
||||||
|
},
|
||||||
computed: {
|
computed: {
|
||||||
sleepTimerRemainingString() {
|
sleepTimerRemainingString() {
|
||||||
var rounded = Math.round(this.sleepTimerRemaining)
|
var rounded = Math.round(this.sleepTimerRemaining)
|
||||||
@@ -213,18 +218,14 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
increasePlaybackRate() {
|
increasePlaybackRate() {
|
||||||
var rates = [0.25, 0.5, 0.8, 1, 1.3, 1.5, 2, 2.5, 3]
|
if (this.playbackRate >= 10) return
|
||||||
var currentRateIndex = rates.findIndex((r) => r === this.playbackRate)
|
this.playbackRate = Number((this.playbackRate + 0.1).toFixed(1))
|
||||||
if (currentRateIndex >= rates.length - 1) return
|
this.setPlaybackRate(this.playbackRate)
|
||||||
this.playbackRate = rates[currentRateIndex + 1] || 1
|
|
||||||
this.playbackRateChanged(this.playbackRate)
|
|
||||||
},
|
},
|
||||||
decreasePlaybackRate() {
|
decreasePlaybackRate() {
|
||||||
var rates = [0.25, 0.5, 0.8, 1, 1.3, 1.5, 2, 2.5, 3]
|
if (this.playbackRate <= 0.5) return
|
||||||
var currentRateIndex = rates.findIndex((r) => r === this.playbackRate)
|
this.playbackRate = Number((this.playbackRate - 0.1).toFixed(1))
|
||||||
if (currentRateIndex <= 0) return
|
this.setPlaybackRate(this.playbackRate)
|
||||||
this.playbackRate = rates[currentRateIndex - 1] || 1
|
|
||||||
this.playbackRateChanged(this.playbackRate)
|
|
||||||
},
|
},
|
||||||
setPlaybackRate(playbackRate) {
|
setPlaybackRate(playbackRate) {
|
||||||
this.$emit('setPlaybackRate', playbackRate)
|
this.$emit('setPlaybackRate', playbackRate)
|
||||||
@@ -289,14 +290,13 @@ export default {
|
|||||||
if (this.$refs.trackbar) this.$refs.trackbar.setPercentageReady(percentageReady)
|
if (this.$refs.trackbar) this.$refs.trackbar.setPercentageReady(percentageReady)
|
||||||
},
|
},
|
||||||
updateTimestamp() {
|
updateTimestamp() {
|
||||||
var ts = this.$refs.currentTimestamp
|
const ts = this.$refs.currentTimestamp
|
||||||
if (!ts) {
|
if (!ts) {
|
||||||
console.error('No timestamp el')
|
console.error('No timestamp el')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const time = this.useChapterTrack ? Math.max(0, this.currentTime - this.currentChapterStart) : this.currentTime
|
const time = this.useChapterTrack ? Math.max(0, this.currentTime - this.currentChapterStart) : this.currentTime
|
||||||
var currTimeClean = this.$secondsToTimestamp(time)
|
ts.innerText = this.$secondsToTimestamp(time / this.playbackRate)
|
||||||
ts.innerText = currTimeClean
|
|
||||||
},
|
},
|
||||||
setBufferTime(bufferTime) {
|
setBufferTime(bufferTime) {
|
||||||
if (this.$refs.trackbar) this.$refs.trackbar.setBufferTime(bufferTime)
|
if (this.$refs.trackbar) this.$refs.trackbar.setBufferTime(bufferTime)
|
||||||
@@ -312,7 +312,7 @@ export default {
|
|||||||
this.useChapterTrack = this.chapters.length ? _useChapterTrack : false
|
this.useChapterTrack = this.chapters.length ? _useChapterTrack : false
|
||||||
|
|
||||||
if (this.$refs.trackbar) this.$refs.trackbar.setUseChapterTrack(this.useChapterTrack)
|
if (this.$refs.trackbar) this.$refs.trackbar.setUseChapterTrack(this.useChapterTrack)
|
||||||
this.$emit('setPlaybackRate', this.playbackRate)
|
this.setPlaybackRate(this.playbackRate)
|
||||||
},
|
},
|
||||||
settingsUpdated(settings) {
|
settingsUpdated(settings) {
|
||||||
if (settings.playbackRate && this.playbackRate !== settings.playbackRate) {
|
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 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 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">
|
<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">
|
<div class="flex px-1 items-center">
|
||||||
<ui-btn v-if="isYesNo" color="primary" @click="nevermind">{{ $strings.ButtonCancel }}</ui-btn>
|
<ui-btn v-if="isYesNo" color="primary" @click="nevermind">{{ $strings.ButtonCancel }}</ui-btn>
|
||||||
<div class="flex-grow" />
|
<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>
|
<ui-btn v-else color="primary" @click="confirm">{{ $strings.ButtonOk }}</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -21,7 +24,8 @@ export default {
|
|||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
el: null,
|
el: null,
|
||||||
content: null
|
content: null,
|
||||||
|
checkboxValue: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
@@ -57,6 +61,18 @@ export default {
|
|||||||
persistent() {
|
persistent() {
|
||||||
return !!this.confirmPromptOptions.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() {
|
isYesNo() {
|
||||||
return this.type === 'yesNo'
|
return this.type === 'yesNo'
|
||||||
},
|
},
|
||||||
@@ -84,10 +100,11 @@ export default {
|
|||||||
this.show = false
|
this.show = false
|
||||||
},
|
},
|
||||||
confirm() {
|
confirm() {
|
||||||
if (this.callback) this.callback(true)
|
if (this.callback) this.callback(true, this.checkboxValue)
|
||||||
this.show = false
|
this.show = false
|
||||||
},
|
},
|
||||||
setShow() {
|
setShow() {
|
||||||
|
this.checkboxValue = this.checkboxDefaultValue
|
||||||
this.$eventBus.$emit('showing-prompt', true)
|
this.$eventBus.$emit('showing-prompt', true)
|
||||||
document.body.appendChild(this.el)
|
document.body.appendChild(this.el)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full h-full">
|
<div class="w-full h-full">
|
||||||
<div v-show="showPageMenu" v-click-outside="clickOutside" class="pagemenu absolute top-9 right-20 rounded-md overflow-y-auto bg-bg shadow-lg z-20 border border-gray-400 w-52">
|
<div v-show="showPageMenu" v-click-outside="clickOutside" class="pagemenu absolute top-9 left-8 rounded-md overflow-y-auto bg-bg shadow-lg z-20 border border-gray-400 w-52">
|
||||||
<div v-for="(file, index) in pages" :key="file" class="w-full cursor-pointer hover:bg-black-200 px-2 py-1" :class="page === index ? 'bg-black-200' : ''" @click="setPage(index)">
|
<div v-for="(file, index) in pages" :key="file" class="w-full cursor-pointer hover:bg-black-200 px-2 py-1" :class="page === index ? 'bg-black-200' : ''" @click="setPage(index)">
|
||||||
<p class="text-sm truncate">{{ file }}</p>
|
<p class="text-sm truncate">{{ file }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-show="showInfoMenu" v-click-outside="clickOutside" class="pagemenu absolute top-9 right-10 rounded-md overflow-y-auto bg-bg shadow-lg z-20 border border-gray-400 w-96">
|
<div v-show="showInfoMenu" v-click-outside="clickOutside" class="pagemenu absolute top-9 left-20 rounded-md overflow-y-auto bg-bg shadow-lg z-20 border border-gray-400 w-96">
|
||||||
<div v-for="key in comicMetadataKeys" :key="key" class="w-full px-2 py-1">
|
<div v-for="key in comicMetadataKeys" :key="key" class="w-full px-2 py-1">
|
||||||
<p class="text-xs">
|
<p class="text-xs">
|
||||||
<strong>{{ key }}</strong
|
<strong>{{ key }}</strong
|
||||||
@@ -14,17 +14,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="comicMetadata" class="absolute top-0 right-52 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" @mousedown.prevent @click.stop.prevent="showInfoMenu = !showInfoMenu">
|
<div v-if="comicMetadata" class="absolute top-0 left-20 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" @mousedown.prevent @click.stop.prevent="showInfoMenu = !showInfoMenu">
|
||||||
<span class="material-icons text-xl">more</span>
|
<span class="material-icons text-xl">more</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="absolute top-0 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" style="right: 156px" @mousedown.prevent @click.stop.prevent="showPageMenu = !showPageMenu">
|
<div class="absolute top-0 left-8 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" @mousedown.prevent @click.stop.prevent="showPageMenu = !showPageMenu">
|
||||||
<span class="material-icons text-xl">menu</span>
|
<span class="material-icons text-xl">menu</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="absolute top-0 right-20 bg-bg text-gray-100 border-b border-l border-r border-gray-400 rounded-b-md px-2 h-9 flex items-center text-center z-20">
|
<div class="absolute top-0 right-16 bg-bg text-gray-100 border-b border-l border-r border-gray-400 rounded-b-md px-2 h-9 flex items-center text-center z-20">
|
||||||
<p class="font-mono">{{ page + 1 }} / {{ numPages }}</p>
|
<p class="font-mono">{{ page + 1 }} / {{ numPages }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="overflow-hidden m-auto comicwrapper relative">
|
<div class="overflow-hidden w-full h-full relative">
|
||||||
<div v-show="canGoPrev" class="absolute top-0 left-0 h-full w-1/2 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="prev" @mousedown.prevent>
|
<div v-show="canGoPrev" class="absolute top-0 left-0 h-full w-1/2 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="prev" @mousedown.prevent>
|
||||||
<div class="flex items-center justify-center h-full w-1/2">
|
<div class="flex items-center justify-center h-full w-1/2">
|
||||||
<span v-show="loadedFirstPage" class="material-icons text-5xl text-white cursor-pointer text-opacity-30 hover:text-opacity-90">arrow_back_ios</span>
|
<span v-show="loadedFirstPage" class="material-icons text-5xl text-white cursor-pointer text-opacity-30 hover:text-opacity-90">arrow_back_ios</span>
|
||||||
@@ -36,17 +36,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="h-full flex justify-center">
|
<div class="h-full flex justify-center">
|
||||||
<img v-if="mainImg" :src="mainImg" class="object-contain comicimg" />
|
<img v-if="mainImg" :src="mainImg" class="object-contain h-full m-auto" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-show="loading" class="w-full h-full absolute top-0 left-0 flex items-center justify-center z-10">
|
<div v-show="loading" class="w-full h-full absolute top-0 left-0 flex items-center justify-center z-10">
|
||||||
<ui-loading-indicator />
|
<ui-loading-indicator />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- <div v-show="loading" class="w-screen h-screen absolute top-0 left-0 bg-black bg-opacity-20 flex items-center justify-center">
|
|
||||||
<ui-loading-indicator />
|
|
||||||
</div> -->
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -61,7 +57,11 @@ Archive.init({
|
|||||||
|
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
url: String
|
libraryItem: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
},
|
||||||
|
playerOpen: Boolean
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -87,6 +87,15 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
userToken() {
|
||||||
|
return this.$store.getters['user/getToken']
|
||||||
|
},
|
||||||
|
libraryItemId() {
|
||||||
|
return this.libraryItem?.id
|
||||||
|
},
|
||||||
|
ebookUrl() {
|
||||||
|
return `/api/items/${this.libraryItemId}/ebook`
|
||||||
|
},
|
||||||
comicMetadataKeys() {
|
comicMetadataKeys() {
|
||||||
return this.comicMetadata ? Object.keys(this.comicMetadata) : []
|
return this.comicMetadata ? Object.keys(this.comicMetadata) : []
|
||||||
},
|
},
|
||||||
@@ -145,10 +154,11 @@ export default {
|
|||||||
},
|
},
|
||||||
async extract() {
|
async extract() {
|
||||||
this.loading = true
|
this.loading = true
|
||||||
console.log('Extracting', this.url)
|
var buff = await this.$axios.$get(this.ebookUrl, {
|
||||||
|
responseType: 'blob',
|
||||||
var buff = await this.$axios.$get(this.url, {
|
headers: {
|
||||||
responseType: 'blob'
|
Authorization: `Bearer ${this.userToken}`
|
||||||
|
}
|
||||||
})
|
})
|
||||||
const archive = await Archive.open(buff)
|
const archive = await Archive.open(buff)
|
||||||
const originalFilesObject = await archive.getFilesObject()
|
const originalFilesObject = await archive.getFilesObject()
|
||||||
@@ -249,15 +259,6 @@ export default {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.pagemenu {
|
.pagemenu {
|
||||||
max-height: calc(100vh - 60px);
|
max-height: calc(100% - 48px);
|
||||||
}
|
|
||||||
.comicimg {
|
|
||||||
height: calc(100vh - 40px);
|
|
||||||
margin: auto;
|
|
||||||
}
|
|
||||||
.comicwrapper {
|
|
||||||
width: 100vw;
|
|
||||||
height: calc(100vh - 40px);
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="h-full w-full">
|
<div id="epub-reader" class="h-full w-full">
|
||||||
<div class="h-full flex items-center justify-center">
|
<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">
|
<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>
|
<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>
|
||||||
@@ -24,22 +24,31 @@ import ePub from 'epubjs'
|
|||||||
*/
|
*/
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
url: String,
|
|
||||||
libraryItem: {
|
libraryItem: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => {}
|
default: () => {}
|
||||||
}
|
},
|
||||||
|
playerOpen: Boolean
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
windowWidth: 0,
|
windowWidth: 0,
|
||||||
|
windowHeight: 0,
|
||||||
/** @type {ePub.Book} */
|
/** @type {ePub.Book} */
|
||||||
book: null,
|
book: null,
|
||||||
/** @type {ePub.Rendition} */
|
/** @type {ePub.Rendition} */
|
||||||
rendition: null
|
rendition: null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
watch: {
|
||||||
|
playerOpen() {
|
||||||
|
this.resize()
|
||||||
|
}
|
||||||
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
userToken() {
|
||||||
|
return this.$store.getters['user/getToken']
|
||||||
|
},
|
||||||
/** @returns {string} */
|
/** @returns {string} */
|
||||||
libraryItemId() {
|
libraryItemId() {
|
||||||
return this.libraryItem?.id
|
return this.libraryItem?.id
|
||||||
@@ -64,6 +73,13 @@ export default {
|
|||||||
readerWidth() {
|
readerWidth() {
|
||||||
if (this.windowWidth < 640) return this.windowWidth
|
if (this.windowWidth < 640) return this.windowWidth
|
||||||
return this.windowWidth - 200
|
return this.windowWidth - 200
|
||||||
|
},
|
||||||
|
readerHeight() {
|
||||||
|
if (this.windowHeight < 400 || !this.playerOpen) return this.windowHeight
|
||||||
|
return this.windowHeight - 164
|
||||||
|
},
|
||||||
|
epubUrl() {
|
||||||
|
return `/api/items/${this.libraryItemId}/ebook`
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -201,22 +217,26 @@ export default {
|
|||||||
const reader = this
|
const reader = this
|
||||||
|
|
||||||
/** @type {ePub.Book} */
|
/** @type {ePub.Book} */
|
||||||
reader.book = new ePub(reader.url, {
|
reader.book = new ePub(reader.epubUrl, {
|
||||||
width: this.readerWidth,
|
width: this.readerWidth,
|
||||||
height: window.innerHeight - 50
|
height: this.readerHeight - 50,
|
||||||
|
openAs: 'epub',
|
||||||
|
requestHeaders: {
|
||||||
|
Authorization: `Bearer ${this.userToken}`
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
/** @type {ePub.Rendition} */
|
/** @type {ePub.Rendition} */
|
||||||
reader.rendition = reader.book.renderTo('viewer', {
|
reader.rendition = reader.book.renderTo('viewer', {
|
||||||
width: this.readerWidth,
|
width: this.readerWidth,
|
||||||
height: window.innerHeight * 0.8
|
height: this.readerHeight * 0.8
|
||||||
})
|
})
|
||||||
|
|
||||||
// load saved progress
|
// load saved progress
|
||||||
reader.rendition.display(this.userMediaProgress?.ebookLocation || reader.book.locations.start)
|
reader.rendition.display(this.userMediaProgress?.ebookLocation || reader.book.locations.start)
|
||||||
|
|
||||||
// load style
|
// load style
|
||||||
reader.rendition.themes.default({ '*': { color: '#fff!important' } })
|
reader.rendition.themes.default({ '*': { color: '#fff!important', 'background-color': 'rgb(35 35 35)!important' } })
|
||||||
|
|
||||||
reader.book.ready.then(() => {
|
reader.book.ready.then(() => {
|
||||||
// set up event listeners
|
// set up event listeners
|
||||||
@@ -253,17 +273,19 @@ export default {
|
|||||||
},
|
},
|
||||||
resize() {
|
resize() {
|
||||||
this.windowWidth = window.innerWidth
|
this.windowWidth = window.innerWidth
|
||||||
this.rendition?.resize(this.readerWidth, window.innerHeight * 0.8)
|
this.windowHeight = window.innerHeight
|
||||||
|
this.rendition?.resize(this.readerWidth, this.readerHeight * 0.8)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
mounted() {
|
||||||
|
this.windowWidth = window.innerWidth
|
||||||
|
this.windowHeight = window.innerHeight
|
||||||
|
window.addEventListener('resize', this.resize)
|
||||||
|
this.initEpub()
|
||||||
|
},
|
||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
window.removeEventListener('resize', this.resize)
|
window.removeEventListener('resize', this.resize)
|
||||||
this.book?.destroy()
|
this.book?.destroy()
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
this.windowWidth = window.innerWidth
|
|
||||||
window.addEventListener('resize', this.resize)
|
|
||||||
this.initEpub()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full h-full">
|
<div class="w-full h-full">
|
||||||
<div class="h-full max-h-full w-full">
|
<div class="h-full max-h-full w-full">
|
||||||
<div class="ebook-viewer absolute overflow-y-scroll left-0 right-0 top-12 w-full max-w-4xl m-auto z-10 border border-black border-opacity-20 shadow-md bg-white">
|
<div class="ebook-viewer absolute overflow-y-scroll left-0 right-0 top-16 w-full max-w-4xl m-auto z-10 border border-black border-opacity-20 shadow-md bg-white">
|
||||||
<iframe title="html-viewer" width="100%"> Loading </iframe>
|
<iframe title="html-viewer" width="100%"> Loading </iframe>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -15,12 +15,26 @@ import defaultCss from '@/assets/ebooks/basic.js'
|
|||||||
|
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
url: String
|
libraryItem: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
},
|
||||||
|
playerOpen: Boolean
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {}
|
return {}
|
||||||
},
|
},
|
||||||
computed: {},
|
computed: {
|
||||||
|
userToken() {
|
||||||
|
return this.$store.getters['user/getToken']
|
||||||
|
},
|
||||||
|
libraryItemId() {
|
||||||
|
return this.libraryItem?.id
|
||||||
|
},
|
||||||
|
ebookUrl() {
|
||||||
|
return `/api/items/${this.libraryItemId}/ebook`
|
||||||
|
}
|
||||||
|
},
|
||||||
methods: {
|
methods: {
|
||||||
addHtmlCss() {
|
addHtmlCss() {
|
||||||
let iframe = document.getElementsByTagName('iframe')[0]
|
let iframe = document.getElementsByTagName('iframe')[0]
|
||||||
@@ -78,8 +92,11 @@ export default {
|
|||||||
},
|
},
|
||||||
async initMobi() {
|
async initMobi() {
|
||||||
// Fetch mobi file as blob
|
// Fetch mobi file as blob
|
||||||
var buff = await this.$axios.$get(this.url, {
|
var buff = await this.$axios.$get(this.ebookUrl, {
|
||||||
responseType: 'blob'
|
responseType: 'blob',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${this.userToken}`
|
||||||
|
}
|
||||||
})
|
})
|
||||||
var reader = new FileReader()
|
var reader = new FileReader()
|
||||||
reader.onload = async (event) => {
|
reader.onload = async (event) => {
|
||||||
|
|||||||
@@ -11,15 +11,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="absolute top-0 right-20 bg-bg text-gray-100 border-b border-l border-r border-gray-400 rounded-b-md px-2 h-9 flex items-center text-center">
|
<div class="absolute top-0 right-20 bg-bg text-gray-100 border-b border-l border-r border-gray-400 z-20 rounded-b-md px-2 h-9 flex items-center text-center">
|
||||||
<p class="font-mono">{{ page }} / {{ numPages }}</p>
|
<p class="font-mono">{{ page }} / {{ numPages }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="absolute top-0 right-40 bg-bg text-gray-100 border-b border-l border-r border-gray-400 z-20 rounded-b-md px-2 h-9 flex items-center text-center">
|
||||||
|
<ui-icon-btn icon="zoom_out" :size="8" :disabled="!canScaleDown" borderless class="mr-px" @click="zoomOut" />
|
||||||
|
<ui-icon-btn icon="zoom_in" :size="8" :disabled="!canScaleUp" borderless class="ml-px" @click="zoomIn" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div :style="{ height: pdfHeight + 'px' }" class="overflow-hidden m-auto">
|
<div :style="{ height: pdfHeight + 'px' }" class="overflow-hidden m-auto">
|
||||||
<div class="flex items-center justify-center">
|
<div class="flex items-center justify-center">
|
||||||
<div :style="{ width: pdfWidth + 'px', height: pdfHeight + 'px' }" class="w-full h-full overflow-auto">
|
<div :style="{ width: pdfWidth + 'px', height: pdfHeight + 'px' }" class="overflow-auto">
|
||||||
<div v-if="loadedRatio > 0 && loadedRatio < 1" style="background-color: green; color: white; text-align: center" :style="{ width: loadedRatio * 100 + '%' }">{{ Math.floor(loadedRatio * 100) }}%</div>
|
<div v-if="loadedRatio > 0 && loadedRatio < 1" style="background-color: green; color: white; text-align: center" :style="{ width: loadedRatio * 100 + '%' }">{{ Math.floor(loadedRatio * 100) }}%</div>
|
||||||
<pdf ref="pdf" class="m-auto z-10 border border-black border-opacity-20 shadow-md" :src="url" :page="page" :rotate="rotate" @progress="loadedRatio = $event" @error="error" @num-pages="numPagesLoaded" @link-clicked="page = $event"></pdf>
|
<pdf ref="pdf" class="m-auto z-10 border border-black border-opacity-20 shadow-md" :src="pdfDocInitParams" :page="page" :rotate="rotate" @progress="progressEvt" @error="error" @num-pages="numPagesLoaded" @link-clicked="page = $event" @loaded="loadedEvt"></pdf>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -30,17 +34,24 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import pdf from 'vue-pdf'
|
import pdf from '@teckel/vue-pdf'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
pdf
|
pdf
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
url: String
|
libraryItem: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
},
|
||||||
|
playerOpen: Boolean
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
windowWidth: 0,
|
||||||
|
windowHeight: 0,
|
||||||
|
scale: 1,
|
||||||
rotate: 0,
|
rotate: 0,
|
||||||
loadedRatio: 0,
|
loadedRatio: 0,
|
||||||
page: 1,
|
page: 1,
|
||||||
@@ -48,35 +59,110 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
userToken() {
|
||||||
|
return this.$store.getters['user/getToken']
|
||||||
|
},
|
||||||
|
libraryItemId() {
|
||||||
|
return this.libraryItem?.id
|
||||||
|
},
|
||||||
|
fitToPageWidth() {
|
||||||
|
return this.pdfHeight * 0.6
|
||||||
|
},
|
||||||
pdfWidth() {
|
pdfWidth() {
|
||||||
return this.pdfHeight * 0.6667
|
return this.fitToPageWidth * this.scale
|
||||||
},
|
},
|
||||||
pdfHeight() {
|
pdfHeight() {
|
||||||
return window.innerHeight - 120
|
if (this.windowHeight < 400 || !this.playerOpen) return this.windowHeight - 120
|
||||||
|
return this.windowHeight - 284
|
||||||
|
},
|
||||||
|
maxScale() {
|
||||||
|
return Math.floor((this.windowWidth * 10) / this.fitToPageWidth) / 10
|
||||||
},
|
},
|
||||||
canGoNext() {
|
canGoNext() {
|
||||||
return this.page < this.numPages
|
return this.page < this.numPages
|
||||||
},
|
},
|
||||||
canGoPrev() {
|
canGoPrev() {
|
||||||
return this.page > 1
|
return this.page > 1
|
||||||
|
},
|
||||||
|
canScaleUp() {
|
||||||
|
return this.scale < this.maxScale
|
||||||
|
},
|
||||||
|
canScaleDown() {
|
||||||
|
return this.scale > 1
|
||||||
|
},
|
||||||
|
userMediaProgress() {
|
||||||
|
if (!this.libraryItemId) return
|
||||||
|
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId)
|
||||||
|
},
|
||||||
|
savedPage() {
|
||||||
|
return Number(this.userMediaProgress?.ebookLocation || 0)
|
||||||
|
},
|
||||||
|
pdfDocInitParams() {
|
||||||
|
return {
|
||||||
|
url: `/api/items/${this.libraryItemId}/ebook`,
|
||||||
|
httpHeaders: {
|
||||||
|
Authorization: `Bearer ${this.userToken}`
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
zoomIn() {
|
||||||
|
this.scale += 0.1
|
||||||
|
},
|
||||||
|
zoomOut() {
|
||||||
|
this.scale -= 0.1
|
||||||
|
},
|
||||||
|
updateProgress() {
|
||||||
|
if (!this.numPages) {
|
||||||
|
console.error('Num pages not loaded')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
ebookLocation: this.page,
|
||||||
|
ebookProgress: Math.max(0, Math.min(1, (Number(this.page) - 1) / Number(this.numPages)))
|
||||||
|
}
|
||||||
|
this.$axios.$patch(`/api/me/progress/${this.libraryItemId}`, payload).catch((error) => {
|
||||||
|
console.error('EpubReader.updateProgress failed:', error)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
loadedEvt() {
|
||||||
|
if (this.savedPage && this.savedPage > 0 && this.savedPage <= this.numPages) {
|
||||||
|
this.page = this.savedPage
|
||||||
|
}
|
||||||
|
},
|
||||||
|
progressEvt(progress) {
|
||||||
|
this.loadedRatio = progress
|
||||||
|
},
|
||||||
numPagesLoaded(e) {
|
numPagesLoaded(e) {
|
||||||
this.numPages = e
|
this.numPages = e
|
||||||
},
|
},
|
||||||
prev() {
|
prev() {
|
||||||
if (this.page <= 1) return
|
if (this.page <= 1) return
|
||||||
this.page--
|
this.page--
|
||||||
|
this.updateProgress()
|
||||||
},
|
},
|
||||||
next() {
|
next() {
|
||||||
if (this.page >= this.numPages) return
|
if (this.page >= this.numPages) return
|
||||||
this.page++
|
this.page++
|
||||||
|
this.updateProgress()
|
||||||
},
|
},
|
||||||
error(err) {
|
error(err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
|
},
|
||||||
|
resize() {
|
||||||
|
this.windowWidth = window.innerWidth
|
||||||
|
this.windowHeight = window.innerHeight
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {}
|
mounted() {
|
||||||
|
this.windowWidth = window.innerWidth
|
||||||
|
this.windowHeight = window.innerHeight
|
||||||
|
window.addEventListener('resize', this.resize)
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
window.removeEventListener('resize', this.resize)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="show" class="w-screen h-screen fixed top-0 left-0 z-60 bg-primary text-white">
|
<div v-if="show" id="reader" class="absolute top-0 left-0 w-full z-60 bg-primary text-white" :class="{ 'reader-player-open': !!streamLibraryItem }">
|
||||||
<div class="absolute top-4 left-4 z-20">
|
<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>
|
<span v-if="hasToC && !tocOpen" ref="tocButton" class="material-icons cursor-pointer text-2xl" @click="toggleToC">menu</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -17,17 +17,22 @@
|
|||||||
<span class="material-icons cursor-pointer text-2xl" @click="close">close</span>
|
<span class="material-icons cursor-pointer text-2xl" @click="close">close</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<component v-if="componentName" ref="readerComponent" :is="componentName" :url="ebookUrl" :library-item="selectedLibraryItem" />
|
<component v-if="componentName" ref="readerComponent" :is="componentName" :library-item="selectedLibraryItem" :player-open="!!streamLibraryItem" />
|
||||||
|
|
||||||
<!-- TOC side nav -->
|
<!-- 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="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 v-if="hasToC" class="w-96 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-96'" @click.stop.prevent="toggleToC">
|
||||||
<div class="p-4 h-full overflow-hidden">
|
<div class="p-4 h-full">
|
||||||
<p class="text-lg font-semibold mb-2">Table of Contents</p>
|
<p class="text-lg font-semibold mb-2">Table of Contents</p>
|
||||||
<div class="tocContent">
|
<div class="tocContent">
|
||||||
<ul>
|
<ul>
|
||||||
<li v-for="chapter in chapters" :key="chapter.id" class="py-1">
|
<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>
|
<a :href="chapter.href" class="text-white/70 hover:text-white" @click.prevent="$refs.readerComponent.goToChapter(chapter.href)">{{ chapter.label }}</a>
|
||||||
|
<ul v-if="chapter.subitems.length">
|
||||||
|
<li v-for="subchapter in chapter.subitems" :key="subchapter.id" class="py-1 pl-4">
|
||||||
|
<a :href="subchapter.href" class="text-white/70 hover:text-white" @click.prevent="$refs.readerComponent.goToChapter(subchapter.href)">{{ subchapter.label }}</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -67,6 +72,9 @@ export default {
|
|||||||
else if (this.ebookType === 'comic') return 'readers-comic-reader'
|
else if (this.ebookType === 'comic') return 'readers-comic-reader'
|
||||||
return null
|
return null
|
||||||
},
|
},
|
||||||
|
streamLibraryItem() {
|
||||||
|
return this.$store.state.streamLibraryItem
|
||||||
|
},
|
||||||
hasToC() {
|
hasToC() {
|
||||||
return this.isEpub
|
return this.isEpub
|
||||||
},
|
},
|
||||||
@@ -120,21 +128,6 @@ export default {
|
|||||||
isComic() {
|
isComic() {
|
||||||
return this.ebookFormat == 'cbz' || this.ebookFormat == 'cbr'
|
return this.ebookFormat == 'cbz' || this.ebookFormat == 'cbr'
|
||||||
},
|
},
|
||||||
ebookUrl() {
|
|
||||||
if (!this.ebookFile) return null
|
|
||||||
let filepath = ''
|
|
||||||
if (this.selectedLibraryItem.isFile) {
|
|
||||||
filepath = this.$encodeUriPath(this.ebookFile.metadata.filename)
|
|
||||||
} else {
|
|
||||||
const itemRelPath = this.selectedLibraryItem.relPath
|
|
||||||
if (itemRelPath.startsWith('/')) itemRelPath = itemRelPath.slice(1)
|
|
||||||
const relPath = this.ebookFile.metadata.relPath
|
|
||||||
if (relPath.startsWith('/')) relPath = relPath.slice(1)
|
|
||||||
|
|
||||||
filepath = this.$encodeUriPath(`${itemRelPath}/${relPath}`)
|
|
||||||
}
|
|
||||||
return `/ebook/${this.libraryId}/${this.folderId}/${filepath}`
|
|
||||||
},
|
|
||||||
userToken() {
|
userToken() {
|
||||||
return this.$store.getters['user/getToken']
|
return this.$store.getters['user/getToken']
|
||||||
}
|
}
|
||||||
@@ -146,7 +139,6 @@ export default {
|
|||||||
},
|
},
|
||||||
openSettings() {},
|
openSettings() {},
|
||||||
hotkey(action) {
|
hotkey(action) {
|
||||||
console.log('Reader hotkey', action)
|
|
||||||
if (!this.$refs.readerComponent) return
|
if (!this.$refs.readerComponent) return
|
||||||
|
|
||||||
if (action === this.$hotkeys.EReader.NEXT_PAGE) {
|
if (action === this.$hotkeys.EReader.NEXT_PAGE) {
|
||||||
@@ -187,12 +179,19 @@ export default {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* @import url(@/assets/calibre/basic.css); */
|
|
||||||
.ebook-viewer {
|
|
||||||
height: calc(100% - 96px);
|
|
||||||
}
|
|
||||||
.tocContent {
|
.tocContent {
|
||||||
height: calc(100% - 36px);
|
height: calc(100% - 36px);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
#reader {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
#reader.reader-player-open {
|
||||||
|
height: calc(100% - 164px);
|
||||||
|
}
|
||||||
|
@media (max-height: 400px) {
|
||||||
|
#reader.reader-player-open {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
<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}/api/items/${this.libraryItemId}/file/${this.track.audioFile.ino}/download?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() {
|
||||||
|
this.$downloadFile(this.downloadUrl, this.track.metadata.filename)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
<span class="text-sm font-mono">{{ files.length }}</span>
|
<span class="text-sm font-mono">{{ files.length }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow" />
|
<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' : ''">
|
<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>
|
<span class="material-icons text-4xl">expand_more</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -18,60 +18,79 @@
|
|||||||
<th class="text-left px-4">{{ $strings.LabelPath }}</th>
|
<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 w-24 min-w-24">{{ $strings.LabelSize }}</th>
|
||||||
<th class="text-left px-4 w-24">{{ $strings.LabelType }}</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>
|
</tr>
|
||||||
<template v-for="file in files">
|
<template v-for="file in filesWithAudioFile">
|
||||||
<tr :key="file.path">
|
<tables-library-files-table-row :key="file.path" :libraryItemId="libraryItemId" :showFullPath="showFullPath" :file="file" :inModal="inModal" @showMore="showMore" />
|
||||||
<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>
|
</template>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
|
|
||||||
|
<modals-audio-file-data-modal v-model="showAudioFileDataModal" :audio-file="selectedAudioFile" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
files: {
|
libraryItem: {
|
||||||
type: Array,
|
type: Object,
|
||||||
default: () => []
|
default: () => {}
|
||||||
},
|
},
|
||||||
libraryItemId: String,
|
|
||||||
isMissing: Boolean,
|
isMissing: Boolean,
|
||||||
expanded: Boolean // start expanded
|
expanded: Boolean, // start expanded
|
||||||
|
inModal: Boolean
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
showFiles: false,
|
showFiles: false,
|
||||||
showFullPath: false
|
showFullPath: false,
|
||||||
|
showAudioFileDataModal: false,
|
||||||
|
selectedAudioFile: null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
libraryItemId() {
|
||||||
|
return this.libraryItem.id
|
||||||
|
},
|
||||||
userToken() {
|
userToken() {
|
||||||
return this.$store.getters['user/getToken']
|
return this.$store.getters['user/getToken']
|
||||||
},
|
},
|
||||||
userCanDownload() {
|
userCanDownload() {
|
||||||
return this.$store.getters['user/getUserCanDownload']
|
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() {
|
||||||
|
if (this.libraryItem.mediaType === 'podcast') {
|
||||||
|
return this.libraryItem.media?.episodes.map((ep) => ep.audioFile) || []
|
||||||
|
}
|
||||||
|
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: {
|
methods: {
|
||||||
clickBar() {
|
clickBar() {
|
||||||
this.showFiles = !this.showFiles
|
this.showFiles = !this.showFiles
|
||||||
|
},
|
||||||
|
showMore(audioFile) {
|
||||||
|
this.selectedAudioFile = audioFile
|
||||||
|
this.showAudioFileDataModal = true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
<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}/api/items/${this.libraryItemId}/file/${this.file.ino}/download?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() {
|
||||||
|
this.$downloadFile(this.downloadUrl, this.file.metadata.filename)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
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">
|
<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>
|
<span class="text-sm font-mono">{{ tracks.length }}</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- <span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ tracks.length }}</span> -->
|
|
||||||
<div class="flex-grow" />
|
<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>
|
<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>
|
<ui-btn small color="primary">{{ $strings.ButtonManageTracks }}</ui-btn>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
@@ -21,41 +20,20 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th class="w-10">#</th>
|
<th class="w-10">#</th>
|
||||||
<th class="text-left">{{ $strings.LabelFilename }}</th>
|
<th class="text-left">{{ $strings.LabelFilename }}</th>
|
||||||
<th class="text-left w-20">{{ $strings.LabelSize }}</th>
|
<th v-if="!showFullPath" class="text-left w-20 hidden lg:table-cell">{{ $strings.LabelCodec }}</th>
|
||||||
<th class="text-left w-20">{{ $strings.LabelDuration }}</th>
|
<th v-if="!showFullPath" class="text-left w-20 hidden xl:table-cell">{{ $strings.LabelBitrate }}</th>
|
||||||
<th v-if="userCanDownload" class="text-center w-20">{{ $strings.LabelDownload }}</th>
|
<th class="text-left w-20 hidden md:table-cell">{{ $strings.LabelSize }}</th>
|
||||||
<th v-if="showExperimentalFeatures" class="text-center w-20">
|
<th class="text-left w-20 hidden sm:table-cell">{{ $strings.LabelDuration }}</th>
|
||||||
<div class="flex items-center">
|
<th class="text-center w-16"></th>
|
||||||
<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>
|
|
||||||
</tr>
|
</tr>
|
||||||
<template v-for="track in tracks">
|
<template v-for="track in tracks">
|
||||||
<tr :key="track.index">
|
<tables-audio-tracks-table-row :key="track.index" :track="track" :library-item-id="libraryItemId" :showFullPath="showFullPath" @showMore="showMore" />
|
||||||
<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>
|
|
||||||
</template>
|
</template>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
|
|
||||||
|
<modals-audio-file-data-modal v-model="showAudioFileDataModal" :audio-file="selectedAudioFile" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -77,47 +55,31 @@ export default {
|
|||||||
return {
|
return {
|
||||||
showTracks: false,
|
showTracks: false,
|
||||||
showFullPath: false,
|
showFullPath: false,
|
||||||
toneProbing: false
|
selectedAudioFile: null,
|
||||||
|
showAudioFileDataModal: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
userToken() {
|
|
||||||
return this.$store.getters['user/getToken']
|
|
||||||
},
|
|
||||||
userCanDownload() {
|
userCanDownload() {
|
||||||
return this.$store.getters['user/getUserCanDownload']
|
return this.$store.getters['user/getUserCanDownload']
|
||||||
},
|
},
|
||||||
userCanUpdate() {
|
userCanUpdate() {
|
||||||
return this.$store.getters['user/getUserCanUpdate']
|
return this.$store.getters['user/getUserCanUpdate']
|
||||||
},
|
},
|
||||||
showExperimentalFeatures() {
|
userCanDelete() {
|
||||||
return this.$store.state.showExperimentalFeatures
|
return this.$store.getters['user/getUserCanDelete']
|
||||||
|
},
|
||||||
|
userIsAdmin() {
|
||||||
|
return this.$store.getters['user/getIsAdminOrUp']
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
clickBar() {
|
clickBar() {
|
||||||
this.showTracks = !this.showTracks
|
this.showTracks = !this.showTracks
|
||||||
},
|
},
|
||||||
toneProbe(index) {
|
showMore(audioFile) {
|
||||||
this.toneProbing = true
|
this.selectedAudioFile = audioFile
|
||||||
|
this.showAudioFileDataModal = 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
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {}
|
mounted() {}
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full px-1 md:px-2 py-2 overflow-hidden relative" @mouseover="mouseover" @mouseleave="mouseleave" :class="isHovering ? 'bg-white bg-opacity-5' : ''">
|
<div class="w-full px-1 md:px-2 py-2 overflow-hidden relative" @mouseover="mouseover" @mouseleave="mouseleave" :class="isHovering ? 'bg-white bg-opacity-5' : ''">
|
||||||
<div v-if="book" class="flex h-16 md:h-20">
|
<div v-if="book" class="flex h-18 md:h-[5.5rem]">
|
||||||
<div class="w-10 min-w-10 md:w-16 md:max-w-16 h-full">
|
<div class="w-10 min-w-10 md:w-16 md:max-w-16 h-full">
|
||||||
<div class="flex h-full items-center justify-center">
|
<div class="flex h-full items-center justify-center">
|
||||||
<span class="material-icons drag-handle text-lg md:text-xl">menu</span>
|
<span class="material-icons drag-handle text-lg md:text-xl">menu</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="h-full relative flex items-center" :style="{ width: coverWidth + 'px', minWidth: coverWidth + 'px', maxWidth: coverWidth + 'px' }">
|
<div class="h-full flex items-center" :style="{ width: coverWidth + 'px', minWidth: coverWidth + 'px', maxWidth: coverWidth + 'px' }">
|
||||||
<covers-book-cover :library-item="book" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<div class="relative" :style="{ height: coverHeight + 'px', minHeight: coverHeight + 'px', maxHeight: coverHeight + 'px' }">
|
||||||
<div class="absolute top-0 left-0 bg-black bg-opacity-50 flex items-center justify-center h-full w-full z-10" v-show="isHovering && showPlayBtn">
|
<covers-book-cover :library-item="book" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
<div class="w-8 h-8 bg-white bg-opacity-20 rounded-full flex items-center justify-center hover:bg-opacity-40 cursor-pointer" @click="playClick">
|
<div class="absolute top-0 left-0 flex items-center justify-center bg-black bg-opacity-50 h-full w-full z-10" v-show="isHovering && showPlayBtn">
|
||||||
<span class="material-icons text-2xl">play_arrow</span>
|
<div class="w-8 h-8 bg-white bg-opacity-20 rounded-full flex items-center justify-center hover:bg-opacity-40 cursor-pointer" @click="playClick">
|
||||||
|
<span class="material-icons text-2xl">play_arrow</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -19,9 +21,12 @@
|
|||||||
<div class="truncate max-w-48 md:max-w-md">
|
<div class="truncate max-w-48 md:max-w-md">
|
||||||
<nuxt-link :to="`/item/${book.id}`" class="truncate hover:underline text-sm md:text-base">{{ bookTitle }}</nuxt-link>
|
<nuxt-link :to="`/item/${book.id}`" class="truncate hover:underline text-sm md:text-base">{{ bookTitle }}</nuxt-link>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="truncate max-w-48 md:max-w-md text-xs md:text-sm text-gray-300">
|
||||||
|
<nuxt-link v-for="_series in seriesList" :key="_series.id" :to="`/library/${book.libraryId}/series/${_series.id}`" class="hover:underline font-sans text-gray-300"> {{ _series.text }}</nuxt-link>
|
||||||
|
</div>
|
||||||
<div class="truncate max-w-48 md:max-w-md text-xs md:text-sm text-gray-300">
|
<div class="truncate max-w-48 md:max-w-md text-xs md:text-sm text-gray-300">
|
||||||
<template v-for="(author, index) in bookAuthors">
|
<template v-for="(author, index) in bookAuthors">
|
||||||
<nuxt-link :key="author.id" :to="`/author/${author.id}`" class="truncate hover:underline">{{ author.name }}</nuxt-link
|
<nuxt-link :key="author.id" :to="`/author/${author.id}?library=${book.libraryId}`" class="truncate hover:underline">{{ author.name }}</nuxt-link
|
||||||
><span :key="author.id + '-comma'" v-if="index < bookAuthors.length - 1">, </span>
|
><span :key="author.id + '-comma'" v-if="index < bookAuthors.length - 1">, </span>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -96,6 +101,19 @@ export default {
|
|||||||
bookDuration() {
|
bookDuration() {
|
||||||
return this.$elapsedPretty(this.media.duration)
|
return this.$elapsedPretty(this.media.duration)
|
||||||
},
|
},
|
||||||
|
series() {
|
||||||
|
return this.mediaMetadata.series || []
|
||||||
|
},
|
||||||
|
seriesList() {
|
||||||
|
return this.series.map((se) => {
|
||||||
|
let text = se.name
|
||||||
|
if (se.sequence) text += ` #${se.sequence}`
|
||||||
|
return {
|
||||||
|
...se,
|
||||||
|
text
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
isMissing() {
|
isMissing() {
|
||||||
return this.book.isMissing
|
return this.book.isMissing
|
||||||
},
|
},
|
||||||
@@ -117,6 +135,9 @@ export default {
|
|||||||
coverSize() {
|
coverSize() {
|
||||||
return this.$store.state.globals.isMobile ? 30 : 50
|
return this.$store.state.globals.isMobile ? 30 : 50
|
||||||
},
|
},
|
||||||
|
coverHeight() {
|
||||||
|
return this.coverSize * 1.6
|
||||||
|
},
|
||||||
coverWidth() {
|
coverWidth() {
|
||||||
if (this.bookCoverAspectRatio === 1) return this.coverSize * 1.6
|
if (this.bookCoverAspectRatio === 1) return this.coverSize * 1.6
|
||||||
return this.coverSize
|
return this.coverSize
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
contextMenuAction(action) {
|
contextMenuAction({ action }) {
|
||||||
this.showMobileMenu = false
|
this.showMobileMenu = false
|
||||||
if (action === 'edit') {
|
if (action === 'edit') {
|
||||||
this.editClick()
|
this.editClick()
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="truncate max-w-48 md:max-w-md text-xs md:text-sm text-gray-300">
|
<div class="truncate max-w-48 md:max-w-md text-xs md:text-sm text-gray-300">
|
||||||
<template v-for="(author, index) in bookAuthors">
|
<template v-for="(author, index) in bookAuthors">
|
||||||
<nuxt-link :key="author.id" :to="`/author/${author.id}`" class="truncate hover:underline">{{ author.name }}</nuxt-link
|
<nuxt-link :key="author.id" :to="`/author/${author.id}?library=${libraryItem.libraryId}`" class="truncate hover:underline">{{ author.name }}</nuxt-link
|
||||||
><span :key="author.id + '-comma'" v-if="index < bookAuthors.length - 1">, </span>
|
><span :key="author.id + '-comma'" v-if="index < bookAuthors.length - 1">, </span>
|
||||||
</template>
|
</template>
|
||||||
<nuxt-link v-if="episode" :to="`/item/${libraryItem.id}`" class="truncate hover:underline">{{ mediaMetadata.title }}</nuxt-link>
|
<nuxt-link v-if="episode" :to="`/item/${libraryItem.id}`" class="truncate hover:underline">{{ mediaMetadata.title }}</nuxt-link>
|
||||||
|
|||||||
@@ -7,11 +7,11 @@
|
|||||||
<widgets-podcast-type-indicator :type="episode.episodeType" />
|
<widgets-podcast-type-indicator :type="episode.episodeType" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="text-sm text-gray-200 episode-subtitle mt-1.5 mb-0.5">{{ subtitle }}</p>
|
<p class="text-sm text-gray-200 episode-subtitle mt-1.5 mb-0.5" v-html="subtitle"></p>
|
||||||
|
|
||||||
<div class="flex justify-between pt-2 max-w-xl">
|
<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.season" class="text-sm text-gray-300">Season #{{ episode.season }}</p>
|
||||||
<p v-if="episode.episode" class="text-sm text-gray-300">Episode #{{ episode.episode }}</p>
|
<p v-if="episode.episode" class="text-sm text-gray-300">Episode #{{ episode.episode }}</p>
|
||||||
|
<p v-if="episode.chapters?.length" class="text-sm text-gray-300">{{ episode.chapters.length }} Chapters</p>
|
||||||
<p v-if="publishedAt" class="text-sm text-gray-300">Published {{ $formatDate(publishedAt, dateFormat) }}</p>
|
<p v-if="publishedAt" class="text-sm text-gray-300">Published {{ $formatDate(publishedAt, dateFormat) }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -21,10 +21,6 @@
|
|||||||
<p class="pl-2 pr-1 text-sm font-semibold">{{ timeRemaining }}</p>
|
<p class="pl-2 pr-1 text-sm font-semibold">{{ timeRemaining }}</p>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- <button v-if="libraryItemIdStreaming && !isStreamingFromDifferentLibrary" class="h-8 w-8 flex justify-center items-center mx-2" :class="isQueued ? 'text-success' : ''" @click.stop="queueBtnClick">
|
|
||||||
<span class="material-icons-outlined">{{ isQueued ? 'playlist_add_check' : 'queue' }}</span>
|
|
||||||
</button> -->
|
|
||||||
|
|
||||||
<ui-tooltip v-if="libraryItemIdStreaming && !isStreamingFromDifferentLibrary" :text="isQueued ? $strings.MessageRemoveFromPlayerQueue : $strings.MessageAddToPlayerQueue" :class="isQueued ? 'text-success' : ''" direction="top">
|
<ui-tooltip v-if="libraryItemIdStreaming && !isStreamingFromDifferentLibrary" :text="isQueued ? $strings.MessageRemoveFromPlayerQueue : $strings.MessageAddToPlayerQueue" :class="isQueued ? 'text-success' : ''" direction="top">
|
||||||
<ui-icon-btn :icon="isQueued ? 'playlist_add_check' : 'playlist_play'" borderless @click="queueBtnClick" />
|
<ui-icon-btn :icon="isQueued ? 'playlist_add_check' : 'playlist_play'" borderless @click="queueBtnClick" />
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
@@ -88,7 +84,7 @@ export default {
|
|||||||
return this.episode.title || ''
|
return this.episode.title || ''
|
||||||
},
|
},
|
||||||
subtitle() {
|
subtitle() {
|
||||||
return this.episode.subtitle || ''
|
return this.episode.subtitle || this.description
|
||||||
},
|
},
|
||||||
description() {
|
description() {
|
||||||
return this.episode.description || ''
|
return this.episode.description || ''
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export default {
|
|||||||
quickMatchingEpisodes: false,
|
quickMatchingEpisodes: false,
|
||||||
search: null,
|
search: null,
|
||||||
searchTimeout: null,
|
searchTimeout: null,
|
||||||
searchText: null,
|
searchText: null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
@@ -139,19 +139,25 @@ export default {
|
|||||||
return episodeProgress && !episodeProgress.isFinished
|
return episodeProgress && !episodeProgress.isFinished
|
||||||
})
|
})
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
if (this.sortDesc) {
|
let aValue = a[this.sortKey]
|
||||||
return String(b[this.sortKey]).localeCompare(String(a[this.sortKey]), undefined, { numeric: true, sensitivity: 'base' })
|
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() {
|
episodesList() {
|
||||||
return this.episodesSorted.filter((episode) => {
|
return this.episodesSorted.filter((episode) => {
|
||||||
if (!this.searchText) return true
|
if (!this.searchText) return true
|
||||||
return (
|
return (episode.title && episode.title.toLowerCase().includes(this.searchText)) || (episode.subtitle && episode.subtitle.toLowerCase().includes(this.searchText))
|
||||||
(episode.title && episode.title.toLowerCase().includes(this.searchText)) ||
|
|
||||||
(episode.subtitle && episode.subtitle.toLowerCase().includes(this.searchText))
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
selectedIsFinished() {
|
selectedIsFinished() {
|
||||||
@@ -179,7 +185,7 @@ export default {
|
|||||||
this.searchText = this.search.toLowerCase().trim()
|
this.searchText = this.search.toLowerCase().trim()
|
||||||
}, 500)
|
}, 500)
|
||||||
},
|
},
|
||||||
contextMenuAction(action) {
|
contextMenuAction({ action }) {
|
||||||
if (action === 'quick-match-episodes') {
|
if (action === 'quick-match-episodes') {
|
||||||
if (this.quickMatchingEpisodes) return
|
if (this.quickMatchingEpisodes) return
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,25 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="relative h-9 w-9" v-click-outside="clickOutsideObj">
|
<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">
|
<slot :disabled="disabled" :showMenu="showMenu" :clickShowMenu="clickShowMenu">
|
||||||
<span class="material-icons" :class="iconClass">more_vert</span>
|
<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">
|
||||||
</button>
|
<span class="material-icons" :class="iconClass">more_vert</span>
|
||||||
|
</button>
|
||||||
|
</slot>
|
||||||
|
|
||||||
<transition name="menu">
|
<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 rounded-md py-1 focus:outline-none sm:text-sm" :style="{ width: menuWidth }">
|
||||||
<template v-for="(item, index) in items">
|
<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)">
|
<template v-if="item.subitems">
|
||||||
|
<div :key="index" class="flex items-center px-2 py-1.5 hover:bg-white hover:bg-opacity-5 text-white text-xs cursor-default" @mouseover="mouseoverItem(index)" @mouseleave="mouseleaveItem(index)" @click.stop>
|
||||||
|
<p>{{ item.text }}</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="mouseoverItemIndex === index" :key="`subitems-${index}`" @mouseover="mouseoverSubItemMenu(index)" @mouseleave="mouseleaveSubItemMenu(index)" class="absolute w-36 bg-bg rounded-md border border-black-200 shadow-lg z-50 -ml-px" :style="{ left: menuWidth, top: index * 29 + 'px' }">
|
||||||
|
<div v-for="(subitem, subitemindex) in item.subitems" :key="`subitem-${subitemindex}`" 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(subitem.action, subitem.data)">
|
||||||
|
<p>{{ subitem.text }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-else :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>
|
<p>{{ item.text }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -27,6 +39,10 @@ export default {
|
|||||||
iconClass: {
|
iconClass: {
|
||||||
type: String,
|
type: String,
|
||||||
default: ''
|
default: ''
|
||||||
|
},
|
||||||
|
menuWidth: {
|
||||||
|
type: String,
|
||||||
|
default: '192px'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
@@ -36,11 +52,31 @@ export default {
|
|||||||
events: ['mousedown'],
|
events: ['mousedown'],
|
||||||
isActive: true
|
isActive: true
|
||||||
},
|
},
|
||||||
showMenu: false
|
showMenu: false,
|
||||||
|
mouseoverItemIndex: null,
|
||||||
|
isOverSubItemMenu: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {},
|
computed: {},
|
||||||
methods: {
|
methods: {
|
||||||
|
mouseoverSubItemMenu(index) {
|
||||||
|
this.isOverSubItemMenu = true
|
||||||
|
},
|
||||||
|
mouseleaveSubItemMenu(index) {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.isOverSubItemMenu && this.mouseoverItemIndex === index) this.mouseoverItemIndex = null
|
||||||
|
}, 1)
|
||||||
|
},
|
||||||
|
mouseoverItem(index) {
|
||||||
|
this.isOverSubItemMenu = false
|
||||||
|
this.mouseoverItemIndex = index
|
||||||
|
},
|
||||||
|
mouseleaveItem(index) {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.isOverSubItemMenu) return
|
||||||
|
if (this.mouseoverItemIndex === index) this.mouseoverItemIndex = null
|
||||||
|
}, 1)
|
||||||
|
},
|
||||||
clickShowMenu() {
|
clickShowMenu() {
|
||||||
if (this.disabled) return
|
if (this.disabled) return
|
||||||
this.showMenu = !this.showMenu
|
this.showMenu = !this.showMenu
|
||||||
@@ -48,10 +84,10 @@ export default {
|
|||||||
clickedOutside() {
|
clickedOutside() {
|
||||||
this.showMenu = false
|
this.showMenu = false
|
||||||
},
|
},
|
||||||
clickAction(action) {
|
clickAction(action, data) {
|
||||||
if (this.disabled) return
|
if (this.disabled) return
|
||||||
this.showMenu = false
|
this.showMenu = false
|
||||||
this.$emit('action', action)
|
this.$emit('action', { action, data })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {}
|
mounted() {}
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="relative" v-click-outside="clickOutside">
|
|
||||||
<button type="button" class="relative w-full bg-fg border border-gray-500 rounded shadow-sm pl-3 pr-10 py-2 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="showMenu = !showMenu">
|
|
||||||
<span class="flex items-center">
|
|
||||||
<span class="block truncate">{{ label }}</span>
|
|
||||||
</span>
|
|
||||||
<span class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
|
||||||
<span class="material-icons text-2xl text-gray-100" aria-label="User Account" role="button">person</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<transition name="menu">
|
|
||||||
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full bg-primary border border-black-200 shadow-lg max-h-56 rounded-b-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox" aria-activedescendant="listbox-option-3">
|
|
||||||
<template v-for="item in items">
|
|
||||||
<nuxt-link :key="item.value" v-if="item.to" :to="item.to">
|
|
||||||
<li :key="item.value" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="clickedOption(item.value)">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<span class="font-normal ml-3 block truncate font-sans">{{ item.text }}</span>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</nuxt-link>
|
|
||||||
<li v-else :key="item.value" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="clickedOption(item.value)">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<span class="font-normal ml-3 block truncate font-sans">{{ item.text }}</span>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</template>
|
|
||||||
</ul>
|
|
||||||
</transition>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
label: {
|
|
||||||
type: String,
|
|
||||||
default: 'Menu'
|
|
||||||
},
|
|
||||||
items: {
|
|
||||||
type: Array,
|
|
||||||
default: () => []
|
|
||||||
}
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
showMenu: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
clickOutside() {
|
|
||||||
this.showMenu = false
|
|
||||||
},
|
|
||||||
clickedOption(itemValue) {
|
|
||||||
this.$emit('action', itemValue)
|
|
||||||
this.showMenu = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mounted() {}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div ref="wrapper" class="relative">
|
<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">
|
<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>
|
<span class="material-icons text-gray-300 cursor-pointer" style="font-size: 1.1rem" @click.stop.prevent="clear">close</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -32,7 +32,9 @@ export default {
|
|||||||
noSpinner: Boolean,
|
noSpinner: Boolean,
|
||||||
textCenter: Boolean,
|
textCenter: Boolean,
|
||||||
clearable: Boolean,
|
clearable: Boolean,
|
||||||
inputId: String
|
inputId: String,
|
||||||
|
step: [String, Number],
|
||||||
|
min: [String, Number]
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -34,6 +34,12 @@ export default {
|
|||||||
return {}
|
return {}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
tracksWithAudioFile() {
|
||||||
|
return this.media.tracks.map((track) => {
|
||||||
|
track.audioFile = this.media.audioFiles.find((af) => af.metadata.path === track.metadata.path)
|
||||||
|
return track
|
||||||
|
})
|
||||||
|
},
|
||||||
missingPartChunks() {
|
missingPartChunks() {
|
||||||
if (this.missingParts === 1) return this.missingParts[0]
|
if (this.missingParts === 1) return this.missingParts[0]
|
||||||
var chunks = []
|
var chunks = []
|
||||||
|
|||||||
@@ -1,6 +1,40 @@
|
|||||||
<template>
|
<template>
|
||||||
<ui-tooltip v-if="explicit" :text="$strings.LabelExplicit" direction="top">
|
<ui-tooltip v-if="explicit" :text="$strings.LabelExplicit" direction="top">
|
||||||
<span class="material-icons ml-1" style="font-size: 0.8rem">explicit</span>
|
<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>
|
</ui-tooltip>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="absolute w-36 bg-bg rounded-md border border-black-200 shadow-lg z-50" v-click-outside="clickOutsideObj" style="top: 0; left: 0">
|
<div class="absolute w-36 bg-bg rounded-md border border-black-200 shadow-lg z-50" v-click-outside="clickOutsideObj" style="top: 0; left: 0">
|
||||||
<template v-for="(item, index) in items">
|
<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.func)">
|
<template v-if="item.subitems">
|
||||||
|
<div :key="index" class="flex items-center px-2 py-1.5 hover:bg-white hover:bg-opacity-5 text-white text-xs cursor-default" @mouseover="mouseoverItem(index)" @mouseleave="mouseleaveItem(index)" @click.stop>
|
||||||
|
<p>{{ item.text }}</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="mouseoverItemIndex === index" :key="`subitems-${index}`" @mouseover="mouseoverSubItemMenu(index)" @mouseleave="mouseleaveSubItemMenu(index)" class="absolute w-36 bg-bg rounded-md border border-black-200 shadow-lg z-50" :style="{ left: 143 + 'px', top: index * 28 + 'px' }">
|
||||||
|
<div v-for="(subitem, subitemindex) in item.subitems" :key="`subitem-${subitemindex}`" 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(subitem.func, subitem.data)">
|
||||||
|
<p>{{ subitem.text }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-else :key="index" class="flex items-center px-2 py-1.5 hover:bg-white hover:bg-opacity-5 text-white text-xs cursor-pointer" @mouseover="mouseoverItem(index)" @mouseleave="mouseleaveItem(index)" @click.stop="clickAction(item.func)">
|
||||||
<p>{{ item.text }}</p>
|
<p>{{ item.text }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -22,13 +32,36 @@ export default {
|
|||||||
handler: this.clickedOutside,
|
handler: this.clickedOutside,
|
||||||
events: ['mousedown'],
|
events: ['mousedown'],
|
||||||
isActive: true
|
isActive: true
|
||||||
}
|
},
|
||||||
|
mouseoverItemIndex: null,
|
||||||
|
isOverSubItemMenu: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {},
|
computed: {},
|
||||||
methods: {
|
methods: {
|
||||||
clickAction(func) {
|
mouseoverSubItemMenu(index) {
|
||||||
this.$emit('action', func)
|
this.isOverSubItemMenu = true
|
||||||
|
},
|
||||||
|
mouseleaveSubItemMenu(index) {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.isOverSubItemMenu && this.mouseoverItemIndex === index) this.mouseoverItemIndex = null
|
||||||
|
}, 1)
|
||||||
|
},
|
||||||
|
mouseoverItem(index) {
|
||||||
|
this.isOverSubItemMenu = false
|
||||||
|
this.mouseoverItemIndex = index
|
||||||
|
},
|
||||||
|
mouseleaveItem(index) {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.isOverSubItemMenu) return
|
||||||
|
if (this.mouseoverItemIndex === index) this.mouseoverItemIndex = null
|
||||||
|
}, 1)
|
||||||
|
},
|
||||||
|
clickAction(func, data) {
|
||||||
|
this.$emit('action', {
|
||||||
|
func,
|
||||||
|
data
|
||||||
|
})
|
||||||
this.close()
|
this.close()
|
||||||
},
|
},
|
||||||
clickedOutside(e) {
|
clickedOutside(e) {
|
||||||
|
|||||||
@@ -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,19 +1,21 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="tasksRunning" class="w-4 h-4 mx-3 relative" v-click-outside="clickOutsideObj">
|
<div v-if="tasksToShow.length" 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">
|
<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">
|
<div class="flex h-full items-center justify-center">
|
||||||
<ui-tooltip text="Tasks running" direction="bottom" class="flex items-center">
|
<ui-tooltip v-if="tasksRunning" :text="$strings.LabelTasks" direction="bottom" class="flex items-center">
|
||||||
<widgets-loading-spinner />
|
<widgets-loading-spinner />
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
|
<ui-tooltip v-else text="Activities" direction="bottom" class="flex items-center">
|
||||||
|
<span class="material-icons text-1.5xl" aria-label="Activities" role="button">notifications</span>
|
||||||
|
</ui-tooltip>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<transition name="menu">
|
<transition name="menu">
|
||||||
<div class="sm:w-80 w-full relative">
|
<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">
|
<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 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">
|
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||||
<template v-if="tasksRunningOrFailed.length">
|
<template v-if="tasksToShow.length">
|
||||||
<p class="uppercase text-xs text-gray-400 my-1 px-1 font-semibold">{{ $strings.LabelTasks }}</p>
|
<template v-for="task in tasksToShow">
|
||||||
<template v-for="task in tasksRunningOrFailed">
|
|
||||||
<nuxt-link :key="task.id" v-if="actionLink(task)" :to="actionLink(task)">
|
<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">
|
<li class="text-gray-50 select-none relative hover:bg-black-400 py-1 cursor-pointer">
|
||||||
<cards-item-task-running-card :task="task" />
|
<cards-item-task-running-card :task="task" />
|
||||||
@@ -54,9 +56,10 @@ export default {
|
|||||||
tasksRunning() {
|
tasksRunning() {
|
||||||
return this.tasks.some((t) => !t.isFinished)
|
return this.tasks.some((t) => !t.isFinished)
|
||||||
},
|
},
|
||||||
tasksRunningOrFailed() {
|
tasksToShow() {
|
||||||
// return just the tasks that are running or failed in the last 1 minute
|
// return just the tasks that are running or failed (or show success) in the last 1 minute
|
||||||
return this.tasks.filter((t) => !t.isFinished || (t.isFailed && t.finishedAt > new Date().getTime() - 1000 * 60)) || []
|
const tasks = this.tasks.filter((t) => !t.isFinished || ((t.isFailed || t.showSuccess) && t.finishedAt > new Date().getTime() - 1000 * 60)) || []
|
||||||
|
return tasks.sort((a, b) => b.startedAt - a.startedAt)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -75,6 +78,8 @@ export default {
|
|||||||
return `/audiobook/${task.data.libraryItemId}/manage?tool=m4b`
|
return `/audiobook/${task.data.libraryItemId}/manage?tool=m4b`
|
||||||
case 'embed-metadata':
|
case 'embed-metadata':
|
||||||
return `/audiobook/${task.data.libraryItemId}/manage?tool=embed`
|
return `/audiobook/${task.data.libraryItemId}/manage?tool=embed`
|
||||||
|
case 'scan-item':
|
||||||
|
return `/item/${task.data.libraryItemId}`
|
||||||
default:
|
default:
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,9 @@ export default {
|
|||||||
isSocketConnected: false,
|
isSocketConnected: false,
|
||||||
isFirstSocketConnection: true,
|
isFirstSocketConnection: true,
|
||||||
socketConnectionToastId: null,
|
socketConnectionToastId: null,
|
||||||
currentLang: null
|
currentLang: null,
|
||||||
|
multiSessionOtherSessionId: null, // Used for multiple sessions open warning toast
|
||||||
|
multiSessionCurrentSessionId: null // Used for multiple sessions open warning toast
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
@@ -299,8 +301,30 @@ export default {
|
|||||||
userStreamUpdate(user) {
|
userStreamUpdate(user) {
|
||||||
this.$store.commit('users/updateUserOnline', user)
|
this.$store.commit('users/updateUserOnline', user)
|
||||||
},
|
},
|
||||||
|
userSessionClosed(sessionId) {
|
||||||
|
// If this session or other session is closed then dismiss multiple sessions warning toast
|
||||||
|
if (sessionId === this.multiSessionOtherSessionId || this.multiSessionCurrentSessionId === sessionId) {
|
||||||
|
this.multiSessionOtherSessionId = null
|
||||||
|
this.multiSessionCurrentSessionId = null
|
||||||
|
this.$toast.dismiss('multiple-sessions')
|
||||||
|
}
|
||||||
|
if (this.$refs.streamContainer) this.$refs.streamContainer.sessionClosedEvent(sessionId)
|
||||||
|
},
|
||||||
userMediaProgressUpdate(payload) {
|
userMediaProgressUpdate(payload) {
|
||||||
this.$store.commit('user/updateMediaProgress', payload)
|
this.$store.commit('user/updateMediaProgress', payload)
|
||||||
|
|
||||||
|
if (payload.data) {
|
||||||
|
if (this.$store.getters['getIsMediaStreaming'](payload.data.libraryItemId, payload.data.episodeId) && this.$store.state.playbackSessionId !== payload.sessionId) {
|
||||||
|
this.multiSessionOtherSessionId = payload.sessionId
|
||||||
|
this.multiSessionCurrentSessionId = this.$store.state.playbackSessionId
|
||||||
|
console.log(`Media progress was updated from another session (${this.multiSessionOtherSessionId}) for currently open media. Device description=${payload.deviceDescription}. Current session id=${this.multiSessionCurrentSessionId}`)
|
||||||
|
if (this.$store.state.streamIsPlaying) {
|
||||||
|
this.$toast.update('multiple-sessions', { content: `Another session is open for this item on device ${payload.deviceDescription}`, options: { timeout: 20000, type: 'warning', pauseOnFocusLoss: false } }, true)
|
||||||
|
} else {
|
||||||
|
this.$eventBus.$emit('playback-time-update', payload.data.currentTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
collectionAdded(collection) {
|
collectionAdded(collection) {
|
||||||
if (this.currentLibraryId !== collection.libraryId) return
|
if (this.currentLibraryId !== collection.libraryId) return
|
||||||
@@ -356,6 +380,11 @@ export default {
|
|||||||
adminMessageEvt(message) {
|
adminMessageEvt(message) {
|
||||||
this.$toast.info(message)
|
this.$toast.info(message)
|
||||||
},
|
},
|
||||||
|
ereaderDevicesUpdated(data) {
|
||||||
|
if (!data?.ereaderDevices) return
|
||||||
|
|
||||||
|
this.$store.commit('libraries/setEReaderDevices', data.ereaderDevices)
|
||||||
|
},
|
||||||
initializeSocket() {
|
initializeSocket() {
|
||||||
this.socket = this.$nuxtSocket({
|
this.socket = this.$nuxtSocket({
|
||||||
name: process.env.NODE_ENV === 'development' ? 'dev' : 'prod',
|
name: process.env.NODE_ENV === 'development' ? 'dev' : 'prod',
|
||||||
@@ -405,6 +434,7 @@ export default {
|
|||||||
this.socket.on('user_online', this.userOnline)
|
this.socket.on('user_online', this.userOnline)
|
||||||
this.socket.on('user_offline', this.userOffline)
|
this.socket.on('user_offline', this.userOffline)
|
||||||
this.socket.on('user_stream_update', this.userStreamUpdate)
|
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)
|
this.socket.on('user_item_progress_updated', this.userMediaProgressUpdate)
|
||||||
|
|
||||||
// Collection Listeners
|
// Collection Listeners
|
||||||
@@ -427,6 +457,9 @@ export default {
|
|||||||
this.socket.on('task_finished', this.taskFinished)
|
this.socket.on('task_finished', this.taskFinished)
|
||||||
this.socket.on('metadata_embed_queue_update', this.metadataEmbedQueueUpdate)
|
this.socket.on('metadata_embed_queue_update', this.metadataEmbedQueueUpdate)
|
||||||
|
|
||||||
|
// EReader Device Listeners
|
||||||
|
this.socket.on('ereader-devices-updated', this.ereaderDevicesUpdated)
|
||||||
|
|
||||||
this.socket.on('backup_applied', this.backupApplied)
|
this.socket.on('backup_applied', this.backupApplied)
|
||||||
|
|
||||||
this.socket.on('batch_quickmatch_complete', this.batchQuickMatchComplete)
|
this.socket.on('batch_quickmatch_complete', this.batchQuickMatchComplete)
|
||||||
@@ -559,6 +592,7 @@ export default {
|
|||||||
changeLanguage(code) {
|
changeLanguage(code) {
|
||||||
console.log('Changed lang', code)
|
console.log('Changed lang', code)
|
||||||
this.currentLang = code
|
this.currentLang = code
|
||||||
|
document.documentElement.lang = code
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
beforeMount() {
|
beforeMount() {
|
||||||
@@ -583,6 +617,11 @@ export default {
|
|||||||
this.$toast.error(this.$route.query.error)
|
this.$toast.error(this.$route.query.error)
|
||||||
this.$router.replace(this.$route.path)
|
this.$router.replace(this.$route.path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set lang on HTML tag
|
||||||
|
if (this.$languageCodes?.current) {
|
||||||
|
document.documentElement.lang = this.$languageCodes.current
|
||||||
|
}
|
||||||
},
|
},
|
||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
this.$eventBus.$off('change-lang', this.changeLanguage)
|
this.$eventBus.$off('change-lang', this.changeLanguage)
|
||||||
|
|||||||
@@ -27,11 +27,7 @@ module.exports = {
|
|||||||
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
|
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
|
||||||
{ hid: 'description', name: 'description', content: '' }
|
{ hid: 'description', name: 'description', content: '' }
|
||||||
],
|
],
|
||||||
script: [
|
script: [],
|
||||||
{
|
|
||||||
src: (process.env.ROUTER_BASE_PATH || '') + '/libs/sortable.js'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
link: [
|
link: [
|
||||||
{ rel: 'icon', type: 'image/x-icon', href: (process.env.ROUTER_BASE_PATH || '') + '/favicon.ico' }
|
{ rel: 'icon', type: 'image/x-icon', href: (process.env.ROUTER_BASE_PATH || '') + '/favicon.ico' }
|
||||||
]
|
]
|
||||||
@@ -75,9 +71,8 @@ module.exports = {
|
|||||||
],
|
],
|
||||||
|
|
||||||
proxy: {
|
proxy: {
|
||||||
'/dev/': { target: 'http://localhost:3333', pathRewrite: { '^/dev/': '' } },
|
'/s/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/' },
|
||||||
'/ebook/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/' },
|
'/api/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/' }
|
||||||
'/s/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' + process.env : '/' },
|
|
||||||
},
|
},
|
||||||
|
|
||||||
io: {
|
io: {
|
||||||
|
|||||||
Generated
+73
-73
@@ -1,16 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf-client",
|
"name": "audiobookshelf-client",
|
||||||
"version": "2.2.18",
|
"version": "2.2.22",
|
||||||
"lockfileVersion": 2,
|
"lockfileVersion": 2,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "audiobookshelf-client",
|
"name": "audiobookshelf-client",
|
||||||
"version": "2.2.18",
|
"version": "2.2.22",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxtjs/axios": "^5.13.6",
|
"@nuxtjs/axios": "^5.13.6",
|
||||||
"@nuxtjs/proxy": "^2.1.0",
|
"@nuxtjs/proxy": "^2.1.0",
|
||||||
|
"@teckel/vue-pdf": "^4.3.5",
|
||||||
"core-js": "^3.16.0",
|
"core-js": "^3.16.0",
|
||||||
"cron-parser": "^4.7.1",
|
"cron-parser": "^4.7.1",
|
||||||
"date-fns": "^2.25.0",
|
"date-fns": "^2.25.0",
|
||||||
@@ -21,7 +22,6 @@
|
|||||||
"nuxt-socket-io": "^1.1.18",
|
"nuxt-socket-io": "^1.1.18",
|
||||||
"trix": "^1.3.1",
|
"trix": "^1.3.1",
|
||||||
"v-click-outside": "^3.1.2",
|
"v-click-outside": "^3.1.2",
|
||||||
"vue-pdf": "^4.2.0",
|
|
||||||
"vue-toastification": "^1.7.11",
|
"vue-toastification": "^1.7.11",
|
||||||
"vuedraggable": "^2.24.3"
|
"vuedraggable": "^2.24.3"
|
||||||
},
|
},
|
||||||
@@ -2983,6 +2983,43 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz",
|
||||||
"integrity": "sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg=="
|
"integrity": "sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg=="
|
||||||
},
|
},
|
||||||
|
"node_modules/@teckel/vue-pdf": {
|
||||||
|
"version": "4.3.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@teckel/vue-pdf/-/vue-pdf-4.3.5.tgz",
|
||||||
|
"integrity": "sha512-g2DAbZMPbPc7NPFImOsU/e7rt7wfdmBkmFa2kPsB4x+k+Bs8yC5Icmq/VnTSEq/Y8bNvEY7i6+JoicGnlfQL7Q==",
|
||||||
|
"dependencies": {
|
||||||
|
"babel-plugin-syntax-dynamic-import": "^6.18.0",
|
||||||
|
"loader-utils": "^1.4.0",
|
||||||
|
"pdfjs-dist": "^2.5.207 <2.8.0",
|
||||||
|
"raw-loader": "^4.0.1",
|
||||||
|
"vue-resize-sensor": "^2.0.0",
|
||||||
|
"worker-loader": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@teckel/vue-pdf/node_modules/json5": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==",
|
||||||
|
"dependencies": {
|
||||||
|
"minimist": "^1.2.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"json5": "lib/cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@teckel/vue-pdf/node_modules/loader-utils": {
|
||||||
|
"version": "1.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.2.tgz",
|
||||||
|
"integrity": "sha512-I5d00Pd/jwMD2QCduo657+YM/6L3KZu++pmX9VFncxaxvHcru9jx1lBaFft+r4Mt2jK0Yhp41XlRAihzPxHNCg==",
|
||||||
|
"dependencies": {
|
||||||
|
"big.js": "^5.2.2",
|
||||||
|
"emojis-list": "^3.0.0",
|
||||||
|
"json5": "^1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/anymatch": {
|
"node_modules/@types/anymatch": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/anymatch/-/anymatch-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/anymatch/-/anymatch-3.0.0.tgz",
|
||||||
@@ -15921,43 +15958,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vue-no-ssr/-/vue-no-ssr-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/vue-no-ssr/-/vue-no-ssr-1.1.1.tgz",
|
||||||
"integrity": "sha512-ZMjqRpWabMPqPc7gIrG0Nw6vRf1+itwf0Itft7LbMXs2g3Zs/NFmevjZGN1x7K3Q95GmIjWbQZTVerxiBxI+0g=="
|
"integrity": "sha512-ZMjqRpWabMPqPc7gIrG0Nw6vRf1+itwf0Itft7LbMXs2g3Zs/NFmevjZGN1x7K3Q95GmIjWbQZTVerxiBxI+0g=="
|
||||||
},
|
},
|
||||||
"node_modules/vue-pdf": {
|
|
||||||
"version": "4.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/vue-pdf/-/vue-pdf-4.3.0.tgz",
|
|
||||||
"integrity": "sha512-zd3lJj6CbtrawgaaDDciTDjkJMUKiLWtbEmBg5CvFn9Noe9oAO/GNy/fc5c59qGuFCJ14ibIV1baw4S07e5bSQ==",
|
|
||||||
"dependencies": {
|
|
||||||
"babel-plugin-syntax-dynamic-import": "^6.18.0",
|
|
||||||
"loader-utils": "^1.4.0",
|
|
||||||
"pdfjs-dist": "2.6.347",
|
|
||||||
"raw-loader": "^4.0.2",
|
|
||||||
"vue-resize-sensor": "^2.0.0",
|
|
||||||
"worker-loader": "^2.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vue-pdf/node_modules/json5": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==",
|
|
||||||
"dependencies": {
|
|
||||||
"minimist": "^1.2.0"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"json5": "lib/cli.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vue-pdf/node_modules/loader-utils": {
|
|
||||||
"version": "1.4.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.2.tgz",
|
|
||||||
"integrity": "sha512-I5d00Pd/jwMD2QCduo657+YM/6L3KZu++pmX9VFncxaxvHcru9jx1lBaFft+r4Mt2jK0Yhp41XlRAihzPxHNCg==",
|
|
||||||
"dependencies": {
|
|
||||||
"big.js": "^5.2.2",
|
|
||||||
"emojis-list": "^3.0.0",
|
|
||||||
"json5": "^1.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=4.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vue-resize-sensor": {
|
"node_modules/vue-resize-sensor": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/vue-resize-sensor/-/vue-resize-sensor-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/vue-resize-sensor/-/vue-resize-sensor-2.0.0.tgz",
|
||||||
@@ -19591,6 +19591,39 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz",
|
||||||
"integrity": "sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg=="
|
"integrity": "sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg=="
|
||||||
},
|
},
|
||||||
|
"@teckel/vue-pdf": {
|
||||||
|
"version": "4.3.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@teckel/vue-pdf/-/vue-pdf-4.3.5.tgz",
|
||||||
|
"integrity": "sha512-g2DAbZMPbPc7NPFImOsU/e7rt7wfdmBkmFa2kPsB4x+k+Bs8yC5Icmq/VnTSEq/Y8bNvEY7i6+JoicGnlfQL7Q==",
|
||||||
|
"requires": {
|
||||||
|
"babel-plugin-syntax-dynamic-import": "^6.18.0",
|
||||||
|
"loader-utils": "^1.4.0",
|
||||||
|
"pdfjs-dist": "^2.5.207 <2.8.0",
|
||||||
|
"raw-loader": "^4.0.1",
|
||||||
|
"vue-resize-sensor": "^2.0.0",
|
||||||
|
"worker-loader": "^2.0.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"json5": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==",
|
||||||
|
"requires": {
|
||||||
|
"minimist": "^1.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"loader-utils": {
|
||||||
|
"version": "1.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.2.tgz",
|
||||||
|
"integrity": "sha512-I5d00Pd/jwMD2QCduo657+YM/6L3KZu++pmX9VFncxaxvHcru9jx1lBaFft+r4Mt2jK0Yhp41XlRAihzPxHNCg==",
|
||||||
|
"requires": {
|
||||||
|
"big.js": "^5.2.2",
|
||||||
|
"emojis-list": "^3.0.0",
|
||||||
|
"json5": "^1.0.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"@types/anymatch": {
|
"@types/anymatch": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/anymatch/-/anymatch-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/anymatch/-/anymatch-3.0.0.tgz",
|
||||||
@@ -29618,39 +29651,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vue-no-ssr/-/vue-no-ssr-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/vue-no-ssr/-/vue-no-ssr-1.1.1.tgz",
|
||||||
"integrity": "sha512-ZMjqRpWabMPqPc7gIrG0Nw6vRf1+itwf0Itft7LbMXs2g3Zs/NFmevjZGN1x7K3Q95GmIjWbQZTVerxiBxI+0g=="
|
"integrity": "sha512-ZMjqRpWabMPqPc7gIrG0Nw6vRf1+itwf0Itft7LbMXs2g3Zs/NFmevjZGN1x7K3Q95GmIjWbQZTVerxiBxI+0g=="
|
||||||
},
|
},
|
||||||
"vue-pdf": {
|
|
||||||
"version": "4.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/vue-pdf/-/vue-pdf-4.3.0.tgz",
|
|
||||||
"integrity": "sha512-zd3lJj6CbtrawgaaDDciTDjkJMUKiLWtbEmBg5CvFn9Noe9oAO/GNy/fc5c59qGuFCJ14ibIV1baw4S07e5bSQ==",
|
|
||||||
"requires": {
|
|
||||||
"babel-plugin-syntax-dynamic-import": "^6.18.0",
|
|
||||||
"loader-utils": "^1.4.0",
|
|
||||||
"pdfjs-dist": "2.6.347",
|
|
||||||
"raw-loader": "^4.0.2",
|
|
||||||
"vue-resize-sensor": "^2.0.0",
|
|
||||||
"worker-loader": "^2.0.0"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"json5": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==",
|
|
||||||
"requires": {
|
|
||||||
"minimist": "^1.2.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"loader-utils": {
|
|
||||||
"version": "1.4.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.2.tgz",
|
|
||||||
"integrity": "sha512-I5d00Pd/jwMD2QCduo657+YM/6L3KZu++pmX9VFncxaxvHcru9jx1lBaFft+r4Mt2jK0Yhp41XlRAihzPxHNCg==",
|
|
||||||
"requires": {
|
|
||||||
"big.js": "^5.2.2",
|
|
||||||
"emojis-list": "^3.0.0",
|
|
||||||
"json5": "^1.0.1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"vue-resize-sensor": {
|
"vue-resize-sensor": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/vue-resize-sensor/-/vue-resize-sensor-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/vue-resize-sensor/-/vue-resize-sensor-2.0.0.tgz",
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf-client",
|
"name": "audiobookshelf-client",
|
||||||
"version": "2.2.18",
|
"version": "2.2.22",
|
||||||
"description": "Self-hosted audiobook and podcast client",
|
"description": "Self-hosted audiobook and podcast client",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxtjs/axios": "^5.13.6",
|
"@nuxtjs/axios": "^5.13.6",
|
||||||
"@nuxtjs/proxy": "^2.1.0",
|
"@nuxtjs/proxy": "^2.1.0",
|
||||||
|
"@teckel/vue-pdf": "^4.3.5",
|
||||||
"core-js": "^3.16.0",
|
"core-js": "^3.16.0",
|
||||||
"cron-parser": "^4.7.1",
|
"cron-parser": "^4.7.1",
|
||||||
"date-fns": "^2.25.0",
|
"date-fns": "^2.25.0",
|
||||||
@@ -25,7 +26,6 @@
|
|||||||
"nuxt-socket-io": "^1.1.18",
|
"nuxt-socket-io": "^1.1.18",
|
||||||
"trix": "^1.3.1",
|
"trix": "^1.3.1",
|
||||||
"v-click-outside": "^3.1.2",
|
"v-click-outside": "^3.1.2",
|
||||||
"vue-pdf": "^4.2.0",
|
|
||||||
"vue-toastification": "^1.7.11",
|
"vue-toastification": "^1.7.11",
|
||||||
"vuedraggable": "^2.24.3"
|
"vuedraggable": "^2.24.3"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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" />
|
<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 class="w-32 hidden lg:block" />
|
||||||
</div>
|
</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" />
|
<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 v-if="chapters.length" color="primary" small class="mx-1" @click.stop="removeAllChaptersClick">{{ $strings.ButtonRemoveAll }}</ui-btn>
|
||||||
<ui-btn color="primary" small class="mx-2" @click="showFindChaptersModal = true">{{ $strings.ButtonLookup }}</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" />
|
<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" small class="mx-1" @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" color="success" class="mx-1" :disabled="!hasChanges" small @click="saveChapters">{{ $strings.ButtonSave }}</ui-btn>
|
||||||
<div class="w-32 hidden lg:block" />
|
<div class="w-32 hidden lg:block" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -41,7 +42,7 @@
|
|||||||
<ui-text-input v-model="shiftAmount" type="number" class="max-w-20" style="height: 30px" />
|
<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>
|
<ui-btn color="primary" class="mx-1" small @click="shiftChapterTimes">{{ $strings.ButtonAdd }}</ui-btn>
|
||||||
<div class="flex-grow" />
|
<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>
|
</div>
|
||||||
<p class="text-xs py-1.5 text-gray-300 max-w-md">{{ $strings.NoteChapterEditorTimes }}</p>
|
<p class="text-xs py-1.5 text-gray-300 max-w-md">{{ $strings.NoteChapterEditorTimes }}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -329,6 +330,7 @@ export default {
|
|||||||
chap.start = Math.max(0, chap.start + amount)
|
chap.start = Math.max(0, chap.start + amount)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
this.checkChapters()
|
||||||
},
|
},
|
||||||
editItem() {
|
editItem() {
|
||||||
this.$store.commit('showEditModal', this.libraryItem)
|
this.$store.commit('showEditModal', this.libraryItem)
|
||||||
@@ -587,6 +589,45 @@ export default {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
this.checkChapters()
|
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() {
|
mounted() {
|
||||||
|
|||||||
@@ -43,8 +43,8 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
async asyncData({ store, app, params, redirect }) {
|
async asyncData({ store, app, params, redirect, query }) {
|
||||||
const author = await app.$axios.$get(`/api/authors/${params.id}?library=${store.state.libraries.currentLibraryId}&include=items,series`).catch((error) => {
|
const author = await app.$axios.$get(`/api/authors/${params.id}?library=${query.library || store.state.libraries.currentLibraryId}&include=items,series`).catch((error) => {
|
||||||
console.error('Failed to get author', error)
|
console.error('Failed to get author', error)
|
||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
@@ -53,6 +53,10 @@ export default {
|
|||||||
return redirect(`/library/${store.state.libraries.currentLibraryId}/authors`)
|
return redirect(`/library/${store.state.libraries.currentLibraryId}/authors`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (query.library) {
|
||||||
|
store.commit('libraries/setCurrentLibrary', query.library)
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
author
|
author
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ export default {
|
|||||||
feed: this.rssFeed
|
feed: this.rssFeed
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
contextMenuAction(action) {
|
contextMenuAction({ action }) {
|
||||||
if (action === 'delete') {
|
if (action === 'delete') {
|
||||||
this.removeClick()
|
this.removeClick()
|
||||||
} else if (action === 'create-playlist') {
|
} else if (action === 'create-playlist') {
|
||||||
|
|||||||
+2
-12
@@ -4,7 +4,7 @@
|
|||||||
<div class="configContent" :class="`page-${currentPage}`">
|
<div class="configContent" :class="`page-${currentPage}`">
|
||||||
<div v-show="isMobilePortrait" class="w-full pb-4 px-2 flex border-b border-white border-opacity-10 mb-2 cursor-pointer" @click.stop.prevent="toggleShowMore">
|
<div v-show="isMobilePortrait" class="w-full pb-4 px-2 flex border-b border-white border-opacity-10 mb-2 cursor-pointer" @click.stop.prevent="toggleShowMore">
|
||||||
<span class="material-icons text-2xl cursor-pointer">arrow_forward</span>
|
<span class="material-icons text-2xl cursor-pointer">arrow_forward</span>
|
||||||
<p class="pl-3 capitalize">{{ $strings.HeaderSettings }}</p>
|
<p class="pl-3 capitalize">{{ currentPage }}</p>
|
||||||
</div>
|
</div>
|
||||||
<nuxt-child />
|
<nuxt-child />
|
||||||
</div>
|
</div>
|
||||||
@@ -55,6 +55,7 @@ export default {
|
|||||||
else if (pageName === 'library-stats') return this.$strings.HeaderLibraryStats
|
else if (pageName === 'library-stats') return this.$strings.HeaderLibraryStats
|
||||||
else if (pageName === 'users') return this.$strings.HeaderUsers
|
else if (pageName === 'users') return this.$strings.HeaderUsers
|
||||||
else if (pageName === 'item-metadata-utils') return this.$strings.HeaderItemMetadataUtils
|
else if (pageName === 'item-metadata-utils') return this.$strings.HeaderItemMetadataUtils
|
||||||
|
else if (pageName === 'email') return this.$strings.HeaderEmail
|
||||||
}
|
}
|
||||||
return this.$strings.HeaderSettings
|
return this.$strings.HeaderSettings
|
||||||
}
|
}
|
||||||
@@ -79,14 +80,6 @@ export default {
|
|||||||
width: 900px;
|
width: 900px;
|
||||||
max-width: calc(100% - 176px);
|
max-width: calc(100% - 176px);
|
||||||
}
|
}
|
||||||
.configContent.page-library-stats {
|
|
||||||
width: 1200px;
|
|
||||||
}
|
|
||||||
@media (max-width: 1550px) {
|
|
||||||
.configContent.page-library-stats {
|
|
||||||
margin-left: 176px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@media (max-width: 1240px) {
|
@media (max-width: 1240px) {
|
||||||
.configContent {
|
.configContent {
|
||||||
margin-left: 176px;
|
margin-left: 176px;
|
||||||
@@ -98,8 +91,5 @@ export default {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
.configContent.page-library-stats {
|
|
||||||
margin-left: 0px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<app-settings-content :header-text="$strings.HeaderEmailSettings" :description="''">
|
||||||
|
<form @submit.prevent="submitForm">
|
||||||
|
<div class="flex items-center -mx-1 mb-2">
|
||||||
|
<div class="w-full md:w-3/4 px-1">
|
||||||
|
<ui-text-input-with-label ref="hostInput" v-model="newSettings.host" :disabled="savingSettings" :label="$strings.LabelHost" />
|
||||||
|
</div>
|
||||||
|
<div class="w-full md:w-1/4 px-1">
|
||||||
|
<ui-text-input-with-label ref="portInput" v-model="newSettings.port" type="number" :disabled="savingSettings" :label="$strings.LabelPort" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center mb-2 py-3">
|
||||||
|
<ui-toggle-switch labeledBy="email-settings-secure" v-model="newSettings.secure" :disabled="savingSettings" />
|
||||||
|
<ui-tooltip :text="$strings.LabelEmailSettingsSecureHelp">
|
||||||
|
<div class="pl-4 flex items-center">
|
||||||
|
<span id="email-settings-secure">{{ $strings.LabelEmailSettingsSecure }}</span>
|
||||||
|
<span class="material-icons text-lg pl-1">info_outlined</span>
|
||||||
|
</div>
|
||||||
|
</ui-tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center -mx-1 mb-2">
|
||||||
|
<div class="w-full md:w-1/2 px-1">
|
||||||
|
<ui-text-input-with-label ref="userInput" v-model="newSettings.user" :disabled="savingSettings" :label="$strings.LabelUsername" />
|
||||||
|
</div>
|
||||||
|
<div class="w-full md:w-1/2 px-1">
|
||||||
|
<ui-text-input-with-label ref="passInput" v-model="newSettings.pass" type="password" :disabled="savingSettings" :label="$strings.LabelPassword" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center -mx-1 mb-2">
|
||||||
|
<div class="w-full md:w-1/2 px-1">
|
||||||
|
<ui-text-input-with-label ref="fromInput" v-model="newSettings.fromAddress" :disabled="savingSettings" :label="$strings.LabelEmailSettingsFromAddress" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between pt-4">
|
||||||
|
<ui-btn :loading="sendingTest" :disabled="savingSettings || !newSettings.host" type="button" @click="sendTestClick">{{ $strings.ButtonTest }}</ui-btn>
|
||||||
|
<ui-btn :loading="savingSettings" :disabled="!hasUpdates" type="submit">{{ $strings.ButtonSave }}</ui-btn>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div v-show="loading" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-25 flex items-center justify-center">
|
||||||
|
<ui-loading-indicator />
|
||||||
|
</div>
|
||||||
|
</app-settings-content>
|
||||||
|
|
||||||
|
<app-settings-content :header-text="$strings.HeaderEReaderDevices" showAddButton :description="''" @clicked="addNewDeviceClick">
|
||||||
|
<table v-if="existingEReaderDevices.length" class="tracksTable my-4">
|
||||||
|
<tr>
|
||||||
|
<th class="text-left">{{ $strings.LabelName }}</th>
|
||||||
|
<th class="text-left">{{ $strings.LabelEmail }}</th>
|
||||||
|
<th class="w-40"></th>
|
||||||
|
</tr>
|
||||||
|
<tr v-for="device in existingEReaderDevices" :key="device.name">
|
||||||
|
<td>
|
||||||
|
<p class="text-sm md:text-base text-gray-100">{{ device.name }}</p>
|
||||||
|
</td>
|
||||||
|
<td class="text-left">
|
||||||
|
<p class="text-sm md:text-base text-gray-100">{{ device.email }}</p>
|
||||||
|
</td>
|
||||||
|
<td class="w-40">
|
||||||
|
<div class="flex justify-end items-center h-10">
|
||||||
|
<ui-icon-btn icon="edit" borderless :size="8" icon-font-size="1.1rem" :disabled="deletingDeviceName === device.name" class="mx-1" @click="editDeviceClick(device)" />
|
||||||
|
<ui-icon-btn icon="delete" borderless :size="8" icon-font-size="1.1rem" :disabled="deletingDeviceName === device.name" @click="deleteDeviceClick(device)" />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<div v-else class="text-center py-4">
|
||||||
|
<p class="text-lg text-gray-100">No Devices</p>
|
||||||
|
</div>
|
||||||
|
</app-settings-content>
|
||||||
|
|
||||||
|
<modals-emails-e-reader-device-modal v-model="showEReaderDeviceModal" :existing-devices="existingEReaderDevices" :ereader-device="selectedEReaderDevice" @update="ereaderDevicesUpdated" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
loading: false,
|
||||||
|
savingSettings: false,
|
||||||
|
sendingTest: false,
|
||||||
|
deletingDeviceName: null,
|
||||||
|
settings: null,
|
||||||
|
newSettings: {
|
||||||
|
host: null,
|
||||||
|
port: 465,
|
||||||
|
secure: true,
|
||||||
|
user: null,
|
||||||
|
pass: null,
|
||||||
|
fromAddress: null
|
||||||
|
},
|
||||||
|
newEReaderDevice: {
|
||||||
|
name: '',
|
||||||
|
email: ''
|
||||||
|
},
|
||||||
|
selectedEReaderDevice: null,
|
||||||
|
showEReaderDeviceModal: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
hasUpdates() {
|
||||||
|
if (!this.settings) return true
|
||||||
|
for (const key in this.newSettings) {
|
||||||
|
if (key === 'ereaderDevices') continue
|
||||||
|
if (this.newSettings[key] !== this.settings[key]) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
existingEReaderDevices() {
|
||||||
|
return this.settings?.ereaderDevices || []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
editDeviceClick(device) {
|
||||||
|
this.selectedEReaderDevice = device
|
||||||
|
this.showEReaderDeviceModal = true
|
||||||
|
},
|
||||||
|
deleteDeviceClick(device) {
|
||||||
|
const payload = {
|
||||||
|
message: `Are you sure you want to delete e-reader device "${device.name}"?`,
|
||||||
|
callback: (confirmed) => {
|
||||||
|
if (confirmed) {
|
||||||
|
this.deleteDevice(device)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
type: 'yesNo'
|
||||||
|
}
|
||||||
|
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||||
|
},
|
||||||
|
deleteDevice(device) {
|
||||||
|
const payload = {
|
||||||
|
ereaderDevices: this.existingEReaderDevices.filter((d) => d.name !== device.name)
|
||||||
|
}
|
||||||
|
this.deletingDeviceName = device.name
|
||||||
|
this.$axios
|
||||||
|
.$patch(`/emails/ereader-devices`, payload)
|
||||||
|
.then((data) => {
|
||||||
|
this.ereaderDevicesUpdated(data.ereaderDevices)
|
||||||
|
this.$toast.success('Device deleted')
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to delete device', error)
|
||||||
|
this.$toast.error('Failed to delete device')
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.deletingDeviceName = null
|
||||||
|
})
|
||||||
|
},
|
||||||
|
ereaderDevicesUpdated(ereaderDevices) {
|
||||||
|
this.settings.ereaderDevices = ereaderDevices
|
||||||
|
this.newSettings.ereaderDevices = ereaderDevices.map((d) => ({ ...d }))
|
||||||
|
},
|
||||||
|
addNewDeviceClick() {
|
||||||
|
this.selectedEReaderDevice = null
|
||||||
|
this.showEReaderDeviceModal = true
|
||||||
|
},
|
||||||
|
sendTestClick() {
|
||||||
|
this.sendingTest = true
|
||||||
|
this.$axios
|
||||||
|
.$post('/api/emails/test')
|
||||||
|
.then(() => {
|
||||||
|
this.$toast.success('Test Email Sent')
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to send test email', error)
|
||||||
|
const errorMsg = error.response.data || 'Failed to send test email'
|
||||||
|
this.$toast.error(errorMsg)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.sendingTest = false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
validateForm() {
|
||||||
|
for (const ref of [this.$refs.hostInput, this.$refs.portInput, this.$refs.userInput, this.$refs.passInput, this.$refs.fromInput]) {
|
||||||
|
if (ref?.blur) ref.blur()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.newSettings.port) {
|
||||||
|
this.newSettings.port = Number(this.newSettings.port)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
submitForm() {
|
||||||
|
if (!this.validateForm()) return
|
||||||
|
|
||||||
|
const updatePayload = {
|
||||||
|
host: this.newSettings.host,
|
||||||
|
port: this.newSettings.port,
|
||||||
|
secure: this.newSettings.secure,
|
||||||
|
user: this.newSettings.user,
|
||||||
|
pass: this.newSettings.pass,
|
||||||
|
fromAddress: this.newSettings.fromAddress
|
||||||
|
}
|
||||||
|
this.savingSettings = true
|
||||||
|
this.$axios
|
||||||
|
.$patch('/api/emails/settings', updatePayload)
|
||||||
|
.then((data) => {
|
||||||
|
this.settings = data.settings
|
||||||
|
this.newSettings = {
|
||||||
|
...data.settings
|
||||||
|
}
|
||||||
|
this.$toast.success('Email settings updated')
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to update email settings', error)
|
||||||
|
this.$toast.error('Failed to update email settings')
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.savingSettings = false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
init() {
|
||||||
|
this.loading = true
|
||||||
|
|
||||||
|
this.$axios
|
||||||
|
.$get(`/api/emails/settings`)
|
||||||
|
.then((data) => {
|
||||||
|
this.settings = data.settings
|
||||||
|
this.newSettings = {
|
||||||
|
...this.settings
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to get email settings', error)
|
||||||
|
this.$toast.error('Failed to load email settings')
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.loading = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.init()
|
||||||
|
},
|
||||||
|
beforeDestroy() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -39,11 +39,15 @@
|
|||||||
<ui-multi-select v-model="newServerSettings.sortingPrefixes" small :items="newServerSettings.sortingPrefixes" :label="$strings.LabelPrefixesToIgnore" @input="updateSortingPrefixes" :disabled="updatingServerSettings" />
|
<ui-multi-select v-model="newServerSettings.sortingPrefixes" small :items="newServerSettings.sortingPrefixes" :label="$strings.LabelPrefixesToIgnore" @input="updateSortingPrefixes" :disabled="updatingServerSettings" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center py-2">
|
<div class="flex items-center py-2 mb-2">
|
||||||
<ui-toggle-switch labeledBy="settings-chromecast-support" v-model="newServerSettings.chromecastEnabled" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('chromecastEnabled', val)" />
|
<ui-toggle-switch labeledBy="settings-chromecast-support" v-model="newServerSettings.chromecastEnabled" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('chromecastEnabled', val)" />
|
||||||
<p class="pl-4" id="settings-chromecast-support">{{ $strings.LabelSettingsChromecastSupport }}</p>
|
<p class="pl-4" id="settings-chromecast-support">{{ $strings.LabelSettingsChromecastSupport }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="w-44 mb-2">
|
||||||
|
<ui-dropdown v-model="newServerSettings.metadataFileFormat" small :items="metadataFileFormats" label="Metadata File Format" @input="updateMetadataFileFormat" :disabled="updatingServerSettings" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="pt-4">
|
<div class="pt-4">
|
||||||
<h2 class="font-semibold">{{ $strings.HeaderSettingsDisplay }}</h2>
|
<h2 class="font-semibold">{{ $strings.HeaderSettingsDisplay }}</h2>
|
||||||
</div>
|
</div>
|
||||||
@@ -272,7 +276,17 @@ export default {
|
|||||||
useBookshelfView: false,
|
useBookshelfView: false,
|
||||||
isPurgingCache: false,
|
isPurgingCache: false,
|
||||||
newServerSettings: {},
|
newServerSettings: {},
|
||||||
showConfirmPurgeCache: false
|
showConfirmPurgeCache: false,
|
||||||
|
metadataFileFormats: [
|
||||||
|
{
|
||||||
|
text: '.json',
|
||||||
|
value: 'json'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '.abs',
|
||||||
|
value: 'abs'
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
@@ -341,6 +355,10 @@ export default {
|
|||||||
updateServerLanguage(val) {
|
updateServerLanguage(val) {
|
||||||
this.updateSettingsKey('language', val)
|
this.updateSettingsKey('language', val)
|
||||||
},
|
},
|
||||||
|
updateMetadataFileFormat(val) {
|
||||||
|
if (this.serverSettings.metadataFileFormat === val) return
|
||||||
|
this.updateSettingsKey('metadataFileFormat', val)
|
||||||
|
},
|
||||||
updateSettingsKey(key, val) {
|
updateSettingsKey(key, val) {
|
||||||
this.updateServerSettings({
|
this.updateServerSettings({
|
||||||
[key]: val
|
[key]: val
|
||||||
@@ -350,8 +368,7 @@ export default {
|
|||||||
this.updatingServerSettings = true
|
this.updatingServerSettings = true
|
||||||
this.$store
|
this.$store
|
||||||
.dispatch('updateServerSettings', payload)
|
.dispatch('updateServerSettings', payload)
|
||||||
.then((success) => {
|
.then(() => {
|
||||||
console.log('Updated Server Settings', success)
|
|
||||||
this.updatingServerSettings = false
|
this.updatingServerSettings = false
|
||||||
this.$toast.success('Server settings updated')
|
this.$toast.success('Server settings updated')
|
||||||
|
|
||||||
|
|||||||
@@ -17,8 +17,8 @@
|
|||||||
<ui-text-input v-else v-model="newTagName" />
|
<ui-text-input v-else v-model="newTagName" />
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<template v-if="editingTag !== tag">
|
<template v-if="editingTag !== tag">
|
||||||
<ui-icon-btn v-if="editingTag !== tag" icon="edit" borderless :size="8" icon-font-size="1.1rem" class="mx-1" @click="editTagClick(tag)" />
|
<ui-icon-btn icon="edit" borderless :size="8" icon-font-size="1.1rem" class="mx-1" @click="editTagClick(tag)" />
|
||||||
<ui-icon-btn v-if="editingTag !== tag" icon="delete" borderless :size="8" icon-font-size="1.1rem" @click="removeTagClick(tag)" />
|
<ui-icon-btn icon="delete" borderless :size="8" icon-font-size="1.1rem" @click="removeTagClick(tag)" />
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<ui-btn color="success" small class="mx-2" @click.stop="saveTagClick">{{ $strings.ButtonSave }}</ui-btn>
|
<ui-btn color="success" small class="mx-2" @click.stop="saveTagClick">{{ $strings.ButtonSave }}</ui-btn>
|
||||||
|
|||||||
@@ -52,9 +52,53 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p v-else class="text-white text-opacity-50">{{ $strings.MessageNoListeningSessions }}</p>
|
<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>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -81,6 +125,7 @@ export default {
|
|||||||
showSessionModal: false,
|
showSessionModal: false,
|
||||||
selectedSession: null,
|
selectedSession: null,
|
||||||
listeningSessions: [],
|
listeningSessions: [],
|
||||||
|
openListeningSessions: [],
|
||||||
numPages: 0,
|
numPages: 0,
|
||||||
total: 0,
|
total: 0,
|
||||||
currentPage: 0,
|
currentPage: 0,
|
||||||
@@ -114,6 +159,9 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
closedSession() {
|
||||||
|
this.loadOpenSessions()
|
||||||
|
},
|
||||||
removedSession() {
|
removedSession() {
|
||||||
// If on last page and this was the last session then load prev page
|
// If on last page and this was the last session then load prev page
|
||||||
if (this.currentPage == this.numPages - 1) {
|
if (this.currentPage == this.numPages - 1) {
|
||||||
@@ -222,7 +270,7 @@ export default {
|
|||||||
async loadSessions(page) {
|
async loadSessions(page) {
|
||||||
var userFilterQuery = this.selectedUser ? `&user=${this.selectedUser}` : ''
|
var userFilterQuery = this.selectedUser ? `&user=${this.selectedUser}` : ''
|
||||||
const data = await this.$axios.$get(`/api/sessions?page=${page}&itemsPerPage=${this.itemsPerPage}${userFilterQuery}`).catch((err) => {
|
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
|
return null
|
||||||
})
|
})
|
||||||
if (!data) {
|
if (!data) {
|
||||||
@@ -236,8 +284,24 @@ export default {
|
|||||||
this.listeningSessions = data.sessions
|
this.listeningSessions = data.sessions
|
||||||
this.userFilter = data.userFilter
|
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() {
|
init() {
|
||||||
this.loadSessions(0)
|
this.loadSessions(0)
|
||||||
|
this.loadOpenSessions()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
|||||||
+161
-170
@@ -1,8 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div id="page-wrapper" class="bg-bg page overflow-hidden" :class="streamLibraryItem ? 'streaming' : ''">
|
<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="w-full h-full overflow-y-auto px-2 py-6 lg:p-8">
|
||||||
<div class="flex flex-col md:flex-row max-w-6xl mx-auto">
|
<div class="flex flex-col lg: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 flex justify-center lg:block lg:w-52" style="min-width: 208px">
|
||||||
<div class="relative" style="height: fit-content">
|
<div class="relative" style="height: fit-content">
|
||||||
<covers-book-cover :library-item="libraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<covers-book-cover :library-item="libraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
|
|
||||||
@@ -21,13 +21,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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="flex justify-center">
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<h1 class="text-2xl md:text-3xl font-semibold">
|
<h1 class="text-2xl md:text-3xl font-semibold">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
{{ title }}
|
{{ title }}
|
||||||
<widgets-explicit-indicator :explicit="isExplicit" />
|
<widgets-explicit-indicator :explicit="isExplicit" />
|
||||||
|
<widgets-abridged-indicator v-if="isAbridged" />
|
||||||
</div>
|
</div>
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
@@ -41,97 +42,12 @@
|
|||||||
<nuxt-link v-for="(artist, index) in musicArtists" :key="index" :to="`/artist/${$encode(artist)}`" class="hover:underline">{{ artist }}<span v-if="index < musicArtists.length - 1">, </span></nuxt-link>
|
<nuxt-link v-for="(artist, index) in musicArtists" :key="index" :to="`/artist/${$encode(artist)}`" class="hover:underline">{{ artist }}<span v-if="index < musicArtists.length - 1">, </span></nuxt-link>
|
||||||
</p>
|
</p>
|
||||||
<p v-else-if="authors.length" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl max-w-[calc(100vw-2rem)] overflow-hidden overflow-ellipsis">
|
<p v-else-if="authors.length" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl max-w-[calc(100vw-2rem)] overflow-hidden overflow-ellipsis">
|
||||||
by <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>
|
by <nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}?library=${libraryItem.libraryId}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">, </span></nuxt-link>
|
||||||
</p>
|
</p>
|
||||||
<p v-else class="mb-2 mt-0.5 text-gray-200 text-xl">by Unknown</p>
|
<p v-else class="mb-2 mt-0.5 text-gray-200 text-xl">by Unknown</p>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div v-if="narrator" class="flex py-0.5 mt-4">
|
<content-library-item-details :library-item="libraryItem" />
|
||||||
<div class="w-32">
|
|
||||||
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelNarrators }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="max-w-[calc(100vw-10rem)] overflow-hidden overflow-ellipsis">
|
|
||||||
<template v-for="(narrator, index) in narrators">
|
|
||||||
<nuxt-link :key="narrator" :to="`/library/${libraryId}/bookshelf?filter=narrators.${$encode(narrator)}`" class="hover:underline">{{ narrator }}</nuxt-link
|
|
||||||
><span :key="index" v-if="index < narrators.length - 1">, </span>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="publishedYear" class="flex py-0.5">
|
|
||||||
<div class="w-32">
|
|
||||||
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelPublishYear }}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
{{ publishedYear }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="musicAlbum" class="flex py-0.5">
|
|
||||||
<div class="w-32">
|
|
||||||
<span class="text-white text-opacity-60 uppercase text-sm">Album</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
{{ musicAlbum }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="musicAlbumArtist" class="flex py-0.5">
|
|
||||||
<div class="w-32">
|
|
||||||
<span class="text-white text-opacity-60 uppercase text-sm">Album Artist</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
{{ musicAlbumArtist }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="musicTrackPretty" class="flex py-0.5">
|
|
||||||
<div class="w-32">
|
|
||||||
<span class="text-white text-opacity-60 uppercase text-sm">Track</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
{{ musicTrackPretty }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="musicDiscPretty" class="flex py-0.5">
|
|
||||||
<div class="w-32">
|
|
||||||
<span class="text-white text-opacity-60 uppercase text-sm">Disc</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
{{ musicDiscPretty }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex py-0.5" v-if="genres.length">
|
|
||||||
<div class="w-32">
|
|
||||||
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelGenres }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="max-w-[calc(100vw-10rem)] overflow-hidden overflow-ellipsis">
|
|
||||||
<template v-for="(genre, index) in genres">
|
|
||||||
<nuxt-link :key="genre" :to="`/library/${libraryId}/bookshelf?filter=genres.${$encode(genre)}`" class="hover:underline">{{ genre }}</nuxt-link
|
|
||||||
><span :key="index" v-if="index < genres.length - 1">, </span>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="tracks.length || audioFile || (isPodcast && totalPodcastDuration)" class="flex py-0.5">
|
|
||||||
<div class="w-32">
|
|
||||||
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelDuration }}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
{{ durationPretty }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex py-0.5">
|
|
||||||
<div class="w-32">
|
|
||||||
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelSize }}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
{{ sizePretty }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="isBook" class="flex py-0.5">
|
|
||||||
<div class="w-32">
|
|
||||||
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelAbridged }}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
{{ isAbridged ? 'Yes' : 'No' }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="hidden md:block flex-grow" />
|
<div class="hidden md:block flex-grow" />
|
||||||
</div>
|
</div>
|
||||||
@@ -201,27 +117,18 @@
|
|||||||
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" class="mx-0.5" @click="toggleFinished" />
|
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" class="mx-0.5" @click="toggleFinished" />
|
||||||
</ui-tooltip>
|
</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 -->
|
<!-- Only admin or root user can download new episodes -->
|
||||||
<ui-tooltip v-if="isPodcast && userIsAdminOrUp" :text="$strings.LabelFindEpisodes" direction="top">
|
<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-icon-btn icon="search" class="mx-0.5" :loading="fetchingRSSFeed" outlined @click="findEpisodesClick" />
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
|
|
||||||
<ui-tooltip v-if="bookmarks.length" :text="$strings.LabelYourBookmarks" direction="top">
|
<ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" menu-width="148px" @action="contextMenuAction">
|
||||||
<ui-icon-btn :icon="bookmarks.length ? 'bookmarks' : 'bookmark_border'" class="mx-0.5" @click="clickBookmarksBtn" />
|
<template #default="{ showMenu, clickShowMenu, disabled }">
|
||||||
</ui-tooltip>
|
<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>
|
||||||
<!-- RSS feed -->
|
</button>
|
||||||
<ui-tooltip v-if="showRssFeedBtn" :text="$strings.LabelOpenRSSFeed" direction="top">
|
</template>
|
||||||
<ui-icon-btn icon="rss_feed" class="mx-0.5" :bg-color="rssFeed ? 'success' : 'primary'" outlined @click="clickRSSFeed" />
|
</ui-context-menu-dropdown>
|
||||||
</ui-tooltip>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="my-4 max-w-2xl">
|
<div class="my-4 max-w-2xl">
|
||||||
@@ -240,7 +147,7 @@
|
|||||||
|
|
||||||
<tables-chapters-table v-if="chapters.length" :library-item="libraryItem" class="mt-6" />
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -284,6 +191,12 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
userToken() {
|
||||||
|
return this.$store.getters['user/getToken']
|
||||||
|
},
|
||||||
|
downloadUrl() {
|
||||||
|
return `${process.env.serverUrl}/api/items/${this.libraryItemId}/download?token=${this.userToken}`
|
||||||
|
},
|
||||||
dateFormat() {
|
dateFormat() {
|
||||||
return this.$store.state.serverSettings.dateFormat
|
return this.$store.state.serverSettings.dateFormat
|
||||||
},
|
},
|
||||||
@@ -296,9 +209,6 @@ export default {
|
|||||||
userIsAdminOrUp() {
|
userIsAdminOrUp() {
|
||||||
return this.$store.getters['user/getIsAdminOrUp']
|
return this.$store.getters['user/getIsAdminOrUp']
|
||||||
},
|
},
|
||||||
isFile() {
|
|
||||||
return this.libraryItem.isFile
|
|
||||||
},
|
|
||||||
bookCoverAspectRatio() {
|
bookCoverAspectRatio() {
|
||||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||||
},
|
},
|
||||||
@@ -308,6 +218,9 @@ export default {
|
|||||||
isDeveloperMode() {
|
isDeveloperMode() {
|
||||||
return this.$store.state.developerMode
|
return this.$store.state.developerMode
|
||||||
},
|
},
|
||||||
|
isFile() {
|
||||||
|
return this.libraryItem.isFile
|
||||||
|
},
|
||||||
isBook() {
|
isBook() {
|
||||||
return this.libraryItem.mediaType === 'book'
|
return this.libraryItem.mediaType === 'book'
|
||||||
},
|
},
|
||||||
@@ -349,9 +262,6 @@ export default {
|
|||||||
libraryId() {
|
libraryId() {
|
||||||
return this.libraryItem.libraryId
|
return this.libraryItem.libraryId
|
||||||
},
|
},
|
||||||
folderId() {
|
|
||||||
return this.libraryItem.folderId
|
|
||||||
},
|
|
||||||
libraryItemId() {
|
libraryItemId() {
|
||||||
return this.libraryItem.id
|
return this.libraryItem.id
|
||||||
},
|
},
|
||||||
@@ -377,19 +287,10 @@ export default {
|
|||||||
title() {
|
title() {
|
||||||
return this.mediaMetadata.title || 'No Title'
|
return this.mediaMetadata.title || 'No Title'
|
||||||
},
|
},
|
||||||
publishedYear() {
|
|
||||||
return this.mediaMetadata.publishedYear
|
|
||||||
},
|
|
||||||
narrator() {
|
|
||||||
return this.mediaMetadata.narratorName
|
|
||||||
},
|
|
||||||
bookSubtitle() {
|
bookSubtitle() {
|
||||||
if (this.isPodcast) return null
|
if (this.isPodcast) return null
|
||||||
return this.mediaMetadata.subtitle
|
return this.mediaMetadata.subtitle
|
||||||
},
|
},
|
||||||
genres() {
|
|
||||||
return this.mediaMetadata.genres || []
|
|
||||||
},
|
|
||||||
podcastAuthor() {
|
podcastAuthor() {
|
||||||
return this.mediaMetadata.author || ''
|
return this.mediaMetadata.author || ''
|
||||||
},
|
},
|
||||||
@@ -399,25 +300,6 @@ export default {
|
|||||||
musicArtists() {
|
musicArtists() {
|
||||||
return this.mediaMetadata.artists || []
|
return this.mediaMetadata.artists || []
|
||||||
},
|
},
|
||||||
musicAlbum() {
|
|
||||||
return this.mediaMetadata.album || ''
|
|
||||||
},
|
|
||||||
musicAlbumArtist() {
|
|
||||||
return this.mediaMetadata.albumArtist || ''
|
|
||||||
},
|
|
||||||
musicTrackPretty() {
|
|
||||||
if (!this.mediaMetadata.trackNumber) return null
|
|
||||||
if (!this.mediaMetadata.trackTotal) return this.mediaMetadata.trackNumber
|
|
||||||
return `${this.mediaMetadata.trackNumber} / ${this.mediaMetadata.trackTotal}`
|
|
||||||
},
|
|
||||||
musicDiscPretty() {
|
|
||||||
if (!this.mediaMetadata.discNumber) return null
|
|
||||||
if (!this.mediaMetadata.discTotal) return this.mediaMetadata.discNumber
|
|
||||||
return `${this.mediaMetadata.discNumber} / ${this.mediaMetadata.discTotal}`
|
|
||||||
},
|
|
||||||
narrators() {
|
|
||||||
return this.mediaMetadata.narrators || []
|
|
||||||
},
|
|
||||||
series() {
|
series() {
|
||||||
return this.mediaMetadata.series || []
|
return this.mediaMetadata.series || []
|
||||||
},
|
},
|
||||||
@@ -431,26 +313,10 @@ export default {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
durationPretty() {
|
|
||||||
if (this.isPodcast) return this.$elapsedPrettyExtended(this.totalPodcastDuration)
|
|
||||||
|
|
||||||
if (!this.tracks.length && !this.audioFile) return 'N/A'
|
|
||||||
if (this.audioFile) return this.$elapsedPrettyExtended(this.duration)
|
|
||||||
return this.$elapsedPretty(this.duration)
|
|
||||||
},
|
|
||||||
duration() {
|
duration() {
|
||||||
if (!this.tracks.length && !this.audioFile) return 0
|
if (!this.tracks.length && !this.audioFile) return 0
|
||||||
return this.media.duration
|
return this.media.duration
|
||||||
},
|
},
|
||||||
totalPodcastDuration() {
|
|
||||||
if (!this.podcastEpisodes.length) return 0
|
|
||||||
let totalDuration = 0
|
|
||||||
this.podcastEpisodes.forEach((ep) => (totalDuration += ep.duration || 0))
|
|
||||||
return totalDuration
|
|
||||||
},
|
|
||||||
sizePretty() {
|
|
||||||
return this.$bytesPretty(this.media.size)
|
|
||||||
},
|
|
||||||
libraryFiles() {
|
libraryFiles() {
|
||||||
return this.libraryItem.libraryFiles || []
|
return this.libraryItem.libraryFiles || []
|
||||||
},
|
},
|
||||||
@@ -526,12 +392,69 @@ export default {
|
|||||||
},
|
},
|
||||||
showCollectionsButton() {
|
showCollectionsButton() {
|
||||||
return this.isBook && this.userCanUpdate
|
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.ebookFile && this.$store.state.libraries.ereaderDevices?.length) {
|
||||||
|
items.push({
|
||||||
|
text: this.$strings.LabelSendEbookToDevice,
|
||||||
|
subitems: this.$store.state.libraries.ereaderDevices.map((d) => {
|
||||||
|
return {
|
||||||
|
text: d.name,
|
||||||
|
action: 'sendToDevice',
|
||||||
|
data: d.name
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.userCanDelete) {
|
||||||
|
items.push({
|
||||||
|
text: this.$strings.ButtonDelete,
|
||||||
|
action: 'delete'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return items
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
clickBookmarksBtn() {
|
|
||||||
this.showBookmarksModal = true
|
|
||||||
},
|
|
||||||
selectBookmark(bookmark) {
|
selectBookmark(bookmark) {
|
||||||
if (!bookmark) return
|
if (!bookmark) return
|
||||||
if (this.isStreaming) {
|
if (this.isStreaming) {
|
||||||
@@ -692,10 +615,11 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
clearProgressClick() {
|
clearProgressClick() {
|
||||||
|
if (!this.userMediaProgress) return
|
||||||
if (confirm(`Are you sure you want to reset your progress?`)) {
|
if (confirm(`Are you sure you want to reset your progress?`)) {
|
||||||
this.resettingProgress = true
|
this.resettingProgress = true
|
||||||
this.$axios
|
this.$axios
|
||||||
.$delete(`/api/me/progress/${this.libraryItemId}`)
|
.$delete(`/api/me/progress/${this.userMediaProgress.id}`)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
console.log('Progress reset complete')
|
console.log('Progress reset complete')
|
||||||
this.$toast.success(`Your progress was reset`)
|
this.$toast.success(`Your progress was reset`)
|
||||||
@@ -707,14 +631,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() {
|
clickRSSFeed() {
|
||||||
this.$store.commit('globals/setRSSFeedOpenCloseModal', {
|
this.$store.commit('globals/setRSSFeedOpenCloseModal', {
|
||||||
id: this.libraryItemId,
|
id: this.libraryItemId,
|
||||||
@@ -772,6 +688,81 @@ export default {
|
|||||||
}
|
}
|
||||||
this.$store.commit('addItemToQueue', queueItem)
|
this.$store.commit('addItemToQueue', queueItem)
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
downloadLibraryItem() {
|
||||||
|
this.$downloadFile(this.downloadUrl)
|
||||||
|
},
|
||||||
|
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)
|
||||||
|
},
|
||||||
|
sendToDevice(deviceName) {
|
||||||
|
const payload = {
|
||||||
|
message: this.$getString('MessageConfirmSendEbookToDevice', [this.ebookFile.ebookFormat, this.title, deviceName]),
|
||||||
|
callback: (confirmed) => {
|
||||||
|
if (confirmed) {
|
||||||
|
const payload = {
|
||||||
|
libraryItemId: this.libraryItemId,
|
||||||
|
deviceName
|
||||||
|
}
|
||||||
|
this.processing = true
|
||||||
|
this.$axios
|
||||||
|
.$post(`/api/emails/send-ebook-to-device`, payload)
|
||||||
|
.then(() => {
|
||||||
|
this.$toast.success(this.$getString('ToastSendEbookToDeviceSuccess', [deviceName]))
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to send ebook to device', error)
|
||||||
|
this.$toast.error(this.$strings.ToastSendEbookToDeviceFailed)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.processing = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
type: 'yesNo'
|
||||||
|
}
|
||||||
|
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||||
|
},
|
||||||
|
contextMenuAction({ action, data }) {
|
||||||
|
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()
|
||||||
|
} else if (action === 'sendToDevice') {
|
||||||
|
this.sendToDevice(data)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page relative" :class="streamLibraryItem ? 'streaming' : ''">
|
||||||
|
<app-book-shelf-toolbar page="narrators" is-home />
|
||||||
|
<div id="bookshelf" class="w-full h-full px-1 py-4 md:p-8 relative overflow-y-auto">
|
||||||
|
<table class="tracksTable max-w-2xl mx-auto">
|
||||||
|
<tr>
|
||||||
|
<th class="text-left">{{ $strings.LabelName }}</th>
|
||||||
|
<th class="text-center w-24">{{ $strings.LabelBooks }}</th>
|
||||||
|
<th v-if="userCanUpdate" class="w-40"></th>
|
||||||
|
</tr>
|
||||||
|
<tr v-for="narrator in narrators" :key="narrator.id">
|
||||||
|
<td>
|
||||||
|
<p v-if="selectedNarrator?.id !== narrator.id" class="text-sm md:text-base text-gray-100">{{ narrator.name }}</p>
|
||||||
|
<form v-else @submit.prevent="saveClick">
|
||||||
|
<ui-text-input v-model="newNarratorName" />
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
<td class="text-center w-24">
|
||||||
|
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=narrators.${narrator.id}`" class="hover:underline">{{ narrator.numBooks }}</nuxt-link>
|
||||||
|
</td>
|
||||||
|
<td v-if="userCanUpdate" class="w-40">
|
||||||
|
<div class="flex justify-end items-center h-10">
|
||||||
|
<template v-if="selectedNarrator?.id !== narrator.id">
|
||||||
|
<ui-icon-btn icon="edit" borderless :size="8" icon-font-size="1.1rem" class="mx-1" @click="editClick(narrator)" />
|
||||||
|
<ui-icon-btn icon="delete" borderless :size="8" icon-font-size="1.1rem" @click="removeClick(narrator)" />
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<ui-btn color="success" small class="mr-2" @click.stop="saveClick">{{ $strings.ButtonSave }}</ui-btn>
|
||||||
|
<ui-btn small @click.stop="cancelEditClick">{{ $strings.ButtonCancel }}</ui-btn>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="absolute top-0 left-0 w-full h-[calc(100%-40px)] mt-10 flex items-center justify-center bg-black/25">
|
||||||
|
<ui-loading-indicator />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
async asyncData({ store, params, redirect, query, app }) {
|
||||||
|
const libraryId = params.library
|
||||||
|
const libraryData = await store.dispatch('libraries/fetch', libraryId)
|
||||||
|
if (!libraryData) {
|
||||||
|
return redirect('/oops?message=Library not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
const library = libraryData.library
|
||||||
|
if (library.mediaType === 'podcast') {
|
||||||
|
return redirect(`/library/${libraryId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
libraryId
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
loading: true,
|
||||||
|
narrators: [],
|
||||||
|
selectedNarrator: null,
|
||||||
|
newNarratorName: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
streamLibraryItem() {
|
||||||
|
return this.$store.state.streamLibraryItem
|
||||||
|
},
|
||||||
|
currentLibraryId() {
|
||||||
|
return this.$store.state.libraries.currentLibraryId
|
||||||
|
},
|
||||||
|
userCanUpdate() {
|
||||||
|
return this.$store.getters['user/getUserCanUpdate']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
removeClick(narrator) {
|
||||||
|
const payload = {
|
||||||
|
message: this.$getString('MessageConfirmRemoveNarrator', [narrator.name]),
|
||||||
|
callback: (confirmed) => {
|
||||||
|
if (confirmed) {
|
||||||
|
this.removeNarrator(narrator.id)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
type: 'yesNo'
|
||||||
|
}
|
||||||
|
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||||
|
},
|
||||||
|
editClick(narrator) {
|
||||||
|
this.selectedNarrator = narrator
|
||||||
|
this.newNarratorName = narrator.name
|
||||||
|
},
|
||||||
|
cancelEditClick() {
|
||||||
|
this.selectedNarrator = null
|
||||||
|
this.newNarratorName = null
|
||||||
|
},
|
||||||
|
saveClick() {
|
||||||
|
if (!this.selectedNarrator) return
|
||||||
|
this.newNarratorName = this.newNarratorName?.trim() || ''
|
||||||
|
if (!this.newNarratorName || this.newNarratorName === this.selectedNarrator.name) {
|
||||||
|
this.cancelEditClick()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loading = true
|
||||||
|
this.$axios
|
||||||
|
.$patch(`/api/libraries/${this.currentLibraryId}/narrators/${this.selectedNarrator.id}`, { name: this.newNarratorName })
|
||||||
|
.then((data) => {
|
||||||
|
if (data.updated) {
|
||||||
|
this.$toast.success(this.$getString('MessageItemsUpdated', [data.updated]))
|
||||||
|
} else {
|
||||||
|
this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary)
|
||||||
|
}
|
||||||
|
this.cancelEditClick()
|
||||||
|
this.init()
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to updated narrator', error)
|
||||||
|
this.$toast.error('Failed to update narrator')
|
||||||
|
this.loading = false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
removeNarrator(id) {
|
||||||
|
this.loading = true
|
||||||
|
this.$axios
|
||||||
|
.$delete(`/api/libraries/${this.currentLibraryId}/narrators/${id}`)
|
||||||
|
.then((data) => {
|
||||||
|
if (data.updated) {
|
||||||
|
this.$toast.success(this.$getString('MessageItemsUpdated', [data.updated]))
|
||||||
|
} else {
|
||||||
|
this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary)
|
||||||
|
}
|
||||||
|
this.init()
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to remove narrator', error)
|
||||||
|
this.$toast.error('Failed to remove narrator')
|
||||||
|
this.loading = false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async init() {
|
||||||
|
this.narrators = await this.$axios
|
||||||
|
.$get(`/api/libraries/${this.currentLibraryId}/narrators`)
|
||||||
|
.then((response) => response.narrators)
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to load narrators', error)
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.init()
|
||||||
|
},
|
||||||
|
beforeDestroy() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
<widgets-podcast-type-indicator :type="episode.episodeType" />
|
<widgets-podcast-type-indicator :type="episode.episodeType" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="text-sm text-gray-200 mb-4">{{ episode.subtitle }}</p>
|
<p class="text-sm text-gray-200 mb-4 episode-subtitle-long" v-html="episode.subtitle || episode.description" />
|
||||||
|
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<button class="h-8 px-4 border border-white border-opacity-20 hover:bg-white hover:bg-opacity-10 rounded-full flex items-center justify-center cursor-pointer focus:outline-none" :class="episode.progress && episode.progress.isFinished ? 'text-white text-opacity-40' : ''" @click.stop="playClick(episode)">
|
<button class="h-8 px-4 border border-white border-opacity-20 hover:bg-white hover:bg-opacity-10 rounded-full flex items-center justify-center cursor-pointer focus:outline-none" :class="episode.progress && episode.progress.isFinished ? 'text-white text-opacity-40' : ''" @click.stop="playClick(episode)">
|
||||||
|
|||||||
@@ -11,27 +11,27 @@
|
|||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
async asyncData({ store, params, redirect, query, app }) {
|
async asyncData({ store, params, redirect, query, app }) {
|
||||||
var libraryId = params.library
|
const libraryId = params.library
|
||||||
var library = await store.dispatch('libraries/fetch', libraryId)
|
const library = await store.dispatch('libraries/fetch', libraryId)
|
||||||
if (!library) {
|
if (!library) {
|
||||||
return redirect('/oops?message=Library not found')
|
return redirect('/oops?message=Library not found')
|
||||||
}
|
}
|
||||||
var query = query.q
|
let results = await app.$axios.$get(`/api/libraries/${libraryId}/search?q=${query.q}`).catch((error) => {
|
||||||
var results = await app.$axios.$get(`/api/libraries/${libraryId}/search?q=${query}`).catch((error) => {
|
|
||||||
console.error('Failed to search library', error)
|
console.error('Failed to search library', error)
|
||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
results = {
|
results = {
|
||||||
podcasts: results && results.podcast ? results.podcast : null,
|
podcasts: results?.podcast || [],
|
||||||
books: results && results.book ? results.book : null,
|
books: results?.book || [],
|
||||||
authors: results && results.authors.length ? results.authors : null,
|
authors: results?.authors || [],
|
||||||
series: results && results.series.length ? results.series : null,
|
series: results?.series || [],
|
||||||
tags: results && results.tags.length ? results.tags : null
|
tags: results?.tags || [],
|
||||||
|
narrators: results?.narrators || []
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
libraryId,
|
libraryId,
|
||||||
results,
|
results,
|
||||||
query
|
query: query.q
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
@@ -55,16 +55,17 @@ export default {
|
|||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
async search() {
|
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)
|
console.error('Failed to search library', error)
|
||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
this.results = {
|
this.results = {
|
||||||
podcasts: results && results.podcast ? results.podcast : null,
|
podcasts: results?.podcast || [],
|
||||||
books: results && results.book ? results.book : null,
|
books: results?.book || [],
|
||||||
authors: results && results.authors.length ? results.authors : null,
|
authors: results?.authors || [],
|
||||||
series: results && results.series.length ? results.series : null,
|
series: results?.series || [],
|
||||||
tags: results && results.tags.length ? results.tags : null
|
tags: results?.tags || [],
|
||||||
|
narrators: results?.narrators || []
|
||||||
}
|
}
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
if (this.$refs.bookshelf) {
|
if (this.$refs.bookshelf) {
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export default {
|
|||||||
const payload = {
|
const payload = {
|
||||||
newRoot: { ...this.newRoot }
|
newRoot: { ...this.newRoot }
|
||||||
}
|
}
|
||||||
var success = await this.$axios
|
const success = await this.$axios
|
||||||
.$post('/init', payload)
|
.$post('/init', payload)
|
||||||
.then(() => true)
|
.then(() => true)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
@@ -124,9 +124,10 @@ export default {
|
|||||||
|
|
||||||
location.reload()
|
location.reload()
|
||||||
},
|
},
|
||||||
setUser({ user, userDefaultLibraryId, serverSettings, Source, feeds }) {
|
setUser({ user, userDefaultLibraryId, serverSettings, Source, ereaderDevices }) {
|
||||||
this.$store.commit('setServerSettings', serverSettings)
|
this.$store.commit('setServerSettings', serverSettings)
|
||||||
this.$store.commit('setSource', Source)
|
this.$store.commit('setSource', Source)
|
||||||
|
this.$store.commit('libraries/setEReaderDevices', ereaderDevices)
|
||||||
this.$setServerLanguageCode(serverSettings.language)
|
this.$setServerLanguageCode(serverSettings.language)
|
||||||
|
|
||||||
if (serverSettings.chromecastEnabled) {
|
if (serverSettings.chromecastEnabled) {
|
||||||
|
|||||||
@@ -78,6 +78,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import Path from 'path'
|
||||||
import uploadHelpers from '@/mixins/uploadHelpers'
|
import uploadHelpers from '@/mixins/uploadHelpers'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@@ -243,7 +244,7 @@ export default {
|
|||||||
ref.setUploadStatus(status)
|
ref.setUploadStatus(status)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
uploadItem(item) {
|
async uploadItem(item) {
|
||||||
var form = new FormData()
|
var form = new FormData()
|
||||||
form.set('title', item.title)
|
form.set('title', item.title)
|
||||||
if (!this.selectedLibraryIsPodcast) {
|
if (!this.selectedLibraryIsPodcast) {
|
||||||
@@ -294,18 +295,41 @@ export default {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var items = this.validateItems()
|
const items = this.validateItems()
|
||||||
if (!items) {
|
if (!items) {
|
||||||
this.$toast.error('Some invalid items')
|
this.$toast.error('Some invalid items')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.processing = true
|
this.processing = true
|
||||||
var itemsUploaded = 0
|
|
||||||
var itemsFailed = 0
|
const itemsToUpload = []
|
||||||
for (let i = 0; i < items.length; i++) {
|
|
||||||
var item = items[i]
|
// Check if path already exists before starting upload
|
||||||
|
// uploading fails if path already exists
|
||||||
|
for (const item of items) {
|
||||||
|
const filepath = Path.join(this.selectedFolder.fullPath, item.directory)
|
||||||
|
const exists = await this.$axios
|
||||||
|
.$post(`/api/filesystem/pathexists`, { filepath })
|
||||||
|
.then((data) => {
|
||||||
|
if (data.exists) {
|
||||||
|
this.$toast.error(`Filepath "${filepath}" already exists on server`)
|
||||||
|
}
|
||||||
|
return data.exists
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to check if filepath exists', error)
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
if (!exists) {
|
||||||
|
itemsToUpload.push(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let itemsUploaded = 0
|
||||||
|
let itemsFailed = 0
|
||||||
|
for (const item of itemsToUpload) {
|
||||||
this.updateItemCardStatus(item.index, 'uploading')
|
this.updateItemCardStatus(item.index, 'uploading')
|
||||||
var result = await this.uploadItem(item)
|
const result = await this.uploadItem(item)
|
||||||
if (result) itemsUploaded++
|
if (result) itemsUploaded++
|
||||||
else itemsFailed++
|
else itemsFailed++
|
||||||
this.updateItemCardStatus(item.index, result ? 'success' : 'failed')
|
this.updateItemCardStatus(item.index, result ? 'success' : 'failed')
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ export default class PlayerHandler {
|
|||||||
|
|
||||||
this.failedProgressSyncs = 0
|
this.failedProgressSyncs = 0
|
||||||
this.lastSyncTime = 0
|
this.lastSyncTime = 0
|
||||||
this.lastSyncedAt = 0
|
|
||||||
this.listeningTimeSinceSync = 0
|
this.listeningTimeSinceSync = 0
|
||||||
|
|
||||||
this.playInterval = null
|
this.playInterval = null
|
||||||
@@ -53,6 +52,11 @@ export default class PlayerHandler {
|
|||||||
return this.libraryItem.media.episodes.find(ep => ep.id === this.episodeId)
|
return this.libraryItem.media.episodes.find(ep => ep.id === this.episodeId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setSessionId(sessionId) {
|
||||||
|
this.currentSessionId = sessionId
|
||||||
|
this.ctx.$store.commit('setPlaybackSessionId', sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
load(libraryItem, episodeId, playWhenReady, playbackRate, startTimeOverride = undefined) {
|
load(libraryItem, episodeId, playWhenReady, playbackRate, startTimeOverride = undefined) {
|
||||||
this.libraryItem = libraryItem
|
this.libraryItem = libraryItem
|
||||||
this.isVideo = libraryItem.mediaType === 'video'
|
this.isVideo = libraryItem.mediaType === 'video'
|
||||||
@@ -123,7 +127,7 @@ export default class PlayerHandler {
|
|||||||
|
|
||||||
playerError() {
|
playerError() {
|
||||||
// Switch to HLS stream on error
|
// 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`)
|
console.log(`[PlayerHandler] Audio player error switching to HLS stream`)
|
||||||
this.prepare(true)
|
this.prepare(true)
|
||||||
}
|
}
|
||||||
@@ -173,16 +177,30 @@ export default class PlayerHandler {
|
|||||||
this.ctx.setBufferTime(buffertime)
|
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) {
|
async prepare(forceTranscode = false) {
|
||||||
var payload = {
|
this.setSessionId(null) // Reset session
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
deviceInfo: {
|
||||||
|
deviceId: this.getDeviceId()
|
||||||
|
},
|
||||||
supportedMimeTypes: this.player.playableMimeTypes,
|
supportedMimeTypes: this.player.playableMimeTypes,
|
||||||
mediaPlayer: this.isCasting ? 'chromecast' : 'html5',
|
mediaPlayer: this.isCasting ? 'chromecast' : 'html5',
|
||||||
forceTranscode,
|
forceTranscode,
|
||||||
forceDirectPlay: this.isCasting || this.isVideo // TODO: add transcode support for chromecast
|
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`
|
const 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 session = await this.ctx.$axios.$post(path, payload).catch((error) => {
|
||||||
console.error('Failed to start stream', error)
|
console.error('Failed to start stream', error)
|
||||||
})
|
})
|
||||||
this.prepareSession(session)
|
this.prepareSession(session)
|
||||||
@@ -196,6 +214,8 @@ export default class PlayerHandler {
|
|||||||
this.playWhenReady = false
|
this.playWhenReady = false
|
||||||
this.initialPlaybackRate = playbackRate
|
this.initialPlaybackRate = playbackRate
|
||||||
this.startTimeOverride = undefined
|
this.startTimeOverride = undefined
|
||||||
|
this.lastSyncTime = 0
|
||||||
|
this.listeningTimeSinceSync = 0
|
||||||
|
|
||||||
this.prepareSession(session)
|
this.prepareSession(session)
|
||||||
}
|
}
|
||||||
@@ -203,7 +223,7 @@ export default class PlayerHandler {
|
|||||||
prepareSession(session) {
|
prepareSession(session) {
|
||||||
this.failedProgressSyncs = 0
|
this.failedProgressSyncs = 0
|
||||||
this.startTime = this.startTimeOverride !== undefined ? this.startTimeOverride : session.currentTime
|
this.startTime = this.startTimeOverride !== undefined ? this.startTimeOverride : session.currentTime
|
||||||
this.currentSessionId = session.id
|
this.setSessionId(session.id)
|
||||||
this.displayTitle = session.displayTitle
|
this.displayTitle = session.displayTitle
|
||||||
this.displayAuthor = session.displayAuthor
|
this.displayAuthor = session.displayAuthor
|
||||||
|
|
||||||
@@ -238,12 +258,17 @@ export default class PlayerHandler {
|
|||||||
closePlayer() {
|
closePlayer() {
|
||||||
console.log('[PlayerHandler] Close Player')
|
console.log('[PlayerHandler] Close Player')
|
||||||
this.sendCloseSession()
|
this.sendCloseSession()
|
||||||
|
this.resetPlayer()
|
||||||
|
}
|
||||||
|
|
||||||
|
resetPlayer() {
|
||||||
if (this.player) {
|
if (this.player) {
|
||||||
this.player.destroy()
|
this.player.destroy()
|
||||||
}
|
}
|
||||||
this.player = null
|
this.player = null
|
||||||
this.playerState = 'IDLE'
|
this.playerState = 'IDLE'
|
||||||
this.libraryItem = null
|
this.libraryItem = null
|
||||||
|
this.setSessionId(null)
|
||||||
this.startTime = 0
|
this.startTime = 0
|
||||||
this.stopPlayInterval()
|
this.stopPlayInterval()
|
||||||
}
|
}
|
||||||
@@ -268,7 +293,8 @@ export default class PlayerHandler {
|
|||||||
const exactTimeElapsed = ((Date.now() - lastTick) / 1000)
|
const exactTimeElapsed = ((Date.now() - lastTick) / 1000)
|
||||||
lastTick = Date.now()
|
lastTick = Date.now()
|
||||||
this.listeningTimeSinceSync += exactTimeElapsed
|
this.listeningTimeSinceSync += exactTimeElapsed
|
||||||
if (this.listeningTimeSinceSync >= 5) {
|
const TimeToWaitBeforeSync = this.lastSyncTime > 0 ? 5 : 20
|
||||||
|
if (this.listeningTimeSinceSync >= TimeToWaitBeforeSync) {
|
||||||
this.sendProgressSync(currentTime)
|
this.sendProgressSync(currentTime)
|
||||||
}
|
}
|
||||||
}, 1000)
|
}, 1000)
|
||||||
@@ -278,13 +304,17 @@ export default class PlayerHandler {
|
|||||||
let syncData = null
|
let syncData = null
|
||||||
if (this.player) {
|
if (this.player) {
|
||||||
const listeningTimeToAdd = Math.max(0, Math.floor(this.listeningTimeSinceSync))
|
const listeningTimeToAdd = Math.max(0, Math.floor(this.listeningTimeSinceSync))
|
||||||
syncData = {
|
// When opening player and quickly closing dont save progress
|
||||||
timeListened: listeningTimeToAdd,
|
if (listeningTimeToAdd > 20) {
|
||||||
duration: this.getDuration(),
|
syncData = {
|
||||||
currentTime: this.getCurrentTime()
|
timeListened: listeningTimeToAdd,
|
||||||
|
duration: this.getDuration(),
|
||||||
|
currentTime: this.getCurrentTime()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.listeningTimeSinceSync = 0
|
this.listeningTimeSinceSync = 0
|
||||||
|
this.lastSyncTime = 0
|
||||||
return this.ctx.$axios.$post(`/api/session/${this.currentSessionId}/close`, syncData, { timeout: 1000 }).catch((error) => {
|
return this.ctx.$axios.$post(`/api/session/${this.currentSessionId}/close`, syncData, { timeout: 1000 }).catch((error) => {
|
||||||
console.error('Failed to close session', error)
|
console.error('Failed to close session', error)
|
||||||
})
|
})
|
||||||
@@ -303,6 +333,7 @@ export default class PlayerHandler {
|
|||||||
duration: this.getDuration(),
|
duration: this.getDuration(),
|
||||||
currentTime
|
currentTime
|
||||||
}
|
}
|
||||||
|
|
||||||
this.listeningTimeSinceSync = 0
|
this.listeningTimeSinceSync = 0
|
||||||
this.ctx.$axios.$post(`/api/session/${this.currentSessionId}/sync`, syncData, { timeout: 3000 }).then(() => {
|
this.ctx.$axios.$post(`/api/session/${this.currentSessionId}/sync`, syncData, { timeout: 3000 }).then(() => {
|
||||||
this.failedProgressSyncs = 0
|
this.failedProgressSyncs = 0
|
||||||
@@ -364,13 +395,13 @@ export default class PlayerHandler {
|
|||||||
this.player.setPlaybackRate(playbackRate)
|
this.player.setPlaybackRate(playbackRate)
|
||||||
}
|
}
|
||||||
|
|
||||||
seek(time) {
|
seek(time, shouldSync = true) {
|
||||||
if (!this.player) return
|
if (!this.player) return
|
||||||
this.player.seek(time, this.playerPlaying)
|
this.player.seek(time, this.playerPlaying)
|
||||||
this.ctx.setCurrentTime(time)
|
this.ctx.setCurrentTime(time)
|
||||||
|
|
||||||
// Update progress if paused
|
// Update progress if paused
|
||||||
if (!this.playerPlaying) {
|
if (!this.playerPlaying && shouldSync) {
|
||||||
this.sendProgressSync(time)
|
this.sendProgressSync(time)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,15 +7,10 @@ export default function ({ $axios, store, $config }) {
|
|||||||
if (config.url.startsWith('http:') || config.url.startsWith('https:')) {
|
if (config.url.startsWith('http:') || config.url.startsWith('https:')) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var bearerToken = store.state.user.user ? store.state.user.user.token : null
|
const bearerToken = store.state.user.user?.token || null
|
||||||
if (bearerToken) {
|
if (bearerToken) {
|
||||||
config.headers.common['Authorization'] = `Bearer ${bearerToken}`
|
config.headers.common['Authorization'] = `Bearer ${bearerToken}`
|
||||||
}
|
}
|
||||||
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
config.url = `/dev${config.url}`
|
|
||||||
console.log('Making request to ' + config.url)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
$axios.onError(error => {
|
$axios.onError(error => {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ const SupportedFileTypes = {
|
|||||||
ebook: ['epub', 'pdf', 'mobi', 'azw3', 'cbr', 'cbz'],
|
ebook: ['epub', 'pdf', 'mobi', 'azw3', 'cbr', 'cbz'],
|
||||||
info: ['nfo'],
|
info: ['nfo'],
|
||||||
text: ['txt'],
|
text: ['txt'],
|
||||||
metadata: ['opf', 'abs']
|
metadata: ['opf', 'abs', 'xml', 'json']
|
||||||
}
|
}
|
||||||
|
|
||||||
const DownloadStatus = {
|
const DownloadStatus = {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const languageCodeMap = {
|
|||||||
'fr': { label: 'Français', dateFnsLocale: 'fr' },
|
'fr': { label: 'Français', dateFnsLocale: 'fr' },
|
||||||
'hr': { label: 'Hrvatski', dateFnsLocale: 'hr' },
|
'hr': { label: 'Hrvatski', dateFnsLocale: 'hr' },
|
||||||
'it': { label: 'Italiano', dateFnsLocale: 'it' },
|
'it': { label: 'Italiano', dateFnsLocale: 'it' },
|
||||||
|
'nl': { label: 'Nederlands', dateFnsLocale: 'nl' },
|
||||||
'pl': { label: 'Polski', dateFnsLocale: 'pl' },
|
'pl': { label: 'Polski', dateFnsLocale: 'pl' },
|
||||||
'ru': { label: 'Русский', dateFnsLocale: 'ru' },
|
'ru': { label: 'Русский', dateFnsLocale: 'ru' },
|
||||||
'zh-cn': { label: '简体中文 (Simplified Chinese)', dateFnsLocale: 'zhCN' },
|
'zh-cn': { label: '简体中文 (Simplified Chinese)', dateFnsLocale: 'zhCN' },
|
||||||
@@ -74,10 +75,9 @@ async function loadi18n(code) {
|
|||||||
for (const key in Vue.prototype.$strings) {
|
for (const key in Vue.prototype.$strings) {
|
||||||
Vue.prototype.$strings[key] = strings[key] || translations[defaultCode][key]
|
Vue.prototype.$strings[key] = strings[key] || translations[defaultCode][key]
|
||||||
}
|
}
|
||||||
console.log(`dateFnsLocale = ${languageCodeMap[code].dateFnsLocale}`)
|
|
||||||
Vue.prototype.$setDateFnsLocale(languageCodeMap[code].dateFnsLocale)
|
Vue.prototype.$setDateFnsLocale(languageCodeMap[code].dateFnsLocale)
|
||||||
|
|
||||||
console.log('i18n strings=', Vue.prototype.$strings)
|
|
||||||
this.$eventBus.$emit('change-lang', code)
|
this.$eventBus.$emit('change-lang', code)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import Vue from 'vue'
|
import Vue from 'vue'
|
||||||
import cronParser from 'cron-parser'
|
import cronParser from 'cron-parser'
|
||||||
|
import { nanoid } from 'nanoid'
|
||||||
|
|
||||||
|
Vue.prototype.$randomId = () => nanoid()
|
||||||
|
|
||||||
Vue.prototype.$bytesPretty = (bytes, decimals = 2) => {
|
Vue.prototype.$bytesPretty = (bytes, decimals = 2) => {
|
||||||
if (isNaN(bytes) || bytes == 0) {
|
if (isNaN(bytes) || bytes == 0) {
|
||||||
@@ -142,6 +145,25 @@ Vue.prototype.$getNextScheduledDate = (expression) => {
|
|||||||
return interval.next().toDate()
|
return interval.next().toDate()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Vue.prototype.$downloadFile = (url, filename = null, openInNewTab = false) => {
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.style.display = 'none'
|
||||||
|
a.href = url
|
||||||
|
|
||||||
|
if (filename) {
|
||||||
|
a.download = filename
|
||||||
|
}
|
||||||
|
if (openInNewTab) {
|
||||||
|
a.target = '_blank'
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
setTimeout(() => {
|
||||||
|
a.remove()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function supplant(str, subs) {
|
export function supplant(str, subs) {
|
||||||
// source: http://crockford.com/javascript/remedial.html
|
// source: http://crockford.com/javascript/remedial.html
|
||||||
return str.replace(/{([^{}]*)}/g,
|
return str.replace(/{([^{}]*)}/g,
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -6,6 +6,7 @@ export const state = () => ({
|
|||||||
Source: null,
|
Source: null,
|
||||||
versionData: null,
|
versionData: null,
|
||||||
serverSettings: null,
|
serverSettings: null,
|
||||||
|
playbackSessionId: null,
|
||||||
streamLibraryItem: null,
|
streamLibraryItem: null,
|
||||||
streamEpisodeId: null,
|
streamEpisodeId: null,
|
||||||
streamIsPlaying: false,
|
streamIsPlaying: false,
|
||||||
@@ -35,7 +36,7 @@ export const getters = {
|
|||||||
return state.serverSettings[key]
|
return state.serverSettings[key]
|
||||||
},
|
},
|
||||||
getLibraryItemIdStreaming: state => {
|
getLibraryItemIdStreaming: state => {
|
||||||
return state.streamLibraryItem ? state.streamLibraryItem.id : null
|
return state.streamLibraryItem?.id || null
|
||||||
},
|
},
|
||||||
getIsStreamingFromDifferentLibrary: (state, getters, rootState) => {
|
getIsStreamingFromDifferentLibrary: (state, getters, rootState) => {
|
||||||
if (!state.streamLibraryItem) return false
|
if (!state.streamLibraryItem) return false
|
||||||
@@ -150,6 +151,9 @@ export const mutations = {
|
|||||||
if (!settings) return
|
if (!settings) return
|
||||||
state.serverSettings = settings
|
state.serverSettings = settings
|
||||||
},
|
},
|
||||||
|
setPlaybackSessionId(state, playbackSessionId) {
|
||||||
|
state.playbackSessionId = playbackSessionId
|
||||||
|
},
|
||||||
setMediaPlaying(state, payload) {
|
setMediaPlaying(state, payload) {
|
||||||
if (!payload) {
|
if (!payload) {
|
||||||
state.streamLibraryItem = null
|
state.streamLibraryItem = null
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ export const state = () => ({
|
|||||||
filterData: null,
|
filterData: null,
|
||||||
numUserPlaylists: 0,
|
numUserPlaylists: 0,
|
||||||
collections: [],
|
collections: [],
|
||||||
userPlaylists: []
|
userPlaylists: [],
|
||||||
|
ereaderDevices: []
|
||||||
})
|
})
|
||||||
|
|
||||||
export const getters = {
|
export const getters = {
|
||||||
@@ -339,5 +340,8 @@ export const mutations = {
|
|||||||
removeUserPlaylist(state, playlist) {
|
removeUserPlaylist(state, playlist) {
|
||||||
state.userPlaylists = state.userPlaylists.filter(p => p.id !== playlist.id)
|
state.userPlaylists = state.userPlaylists.filter(p => p.id !== playlist.id)
|
||||||
state.numUserPlaylists = state.userPlaylists.length
|
state.numUserPlaylists = state.userPlaylists.length
|
||||||
|
},
|
||||||
|
setEReaderDevices(state, ereaderDevices) {
|
||||||
|
state.ereaderDevices = ereaderDevices
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -63,6 +63,12 @@ export const state = () => ({
|
|||||||
text: 'iTunes',
|
text: 'iTunes',
|
||||||
value: 'itunes'
|
value: 'itunes'
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
coverOnlyProviders: [
|
||||||
|
{
|
||||||
|
text: 'AudiobookCovers.com',
|
||||||
|
value: 'audiobookcovers'
|
||||||
|
}
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export const getters = {
|
|||||||
getIsRoot: (state) => state.user && state.user.type === 'root',
|
getIsRoot: (state) => state.user && state.user.type === 'root',
|
||||||
getIsAdminOrUp: (state) => state.user && (state.user.type === 'admin' || state.user.type === 'root'),
|
getIsAdminOrUp: (state) => state.user && (state.user.type === 'admin' || state.user.type === 'root'),
|
||||||
getToken: (state) => {
|
getToken: (state) => {
|
||||||
return state.user ? state.user.token : null
|
return state.user?.token || null
|
||||||
},
|
},
|
||||||
getUserMediaProgress: (state) => (libraryItemId, episodeId = null) => {
|
getUserMediaProgress: (state) => (libraryItemId, episodeId = null) => {
|
||||||
if (!state.user.mediaProgress) return null
|
if (!state.user.mediaProgress) return null
|
||||||
|
|||||||
+65
-31
@@ -20,7 +20,7 @@
|
|||||||
"ButtonCreate": "Erstellen",
|
"ButtonCreate": "Erstellen",
|
||||||
"ButtonCreateBackup": "Sicherung erstellen",
|
"ButtonCreateBackup": "Sicherung erstellen",
|
||||||
"ButtonDelete": "Löschen",
|
"ButtonDelete": "Löschen",
|
||||||
"ButtonDownloadQueue": "Queue",
|
"ButtonDownloadQueue": "Warteschlange",
|
||||||
"ButtonEdit": "Bearbeiten",
|
"ButtonEdit": "Bearbeiten",
|
||||||
"ButtonEditChapters": "Kapitel bearbeiten",
|
"ButtonEditChapters": "Kapitel bearbeiten",
|
||||||
"ButtonEditPodcast": "Podcast bearbeiten",
|
"ButtonEditPodcast": "Podcast bearbeiten",
|
||||||
@@ -55,6 +55,7 @@
|
|||||||
"ButtonRemoveAll": "Alles löschen",
|
"ButtonRemoveAll": "Alles löschen",
|
||||||
"ButtonRemoveAllLibraryItems": "Lösche alle Bibliothekseinträge",
|
"ButtonRemoveAllLibraryItems": "Lösche alle Bibliothekseinträge",
|
||||||
"ButtonRemoveFromContinueListening": "Lösche den Eintrag aus der Fortsetzungsliste",
|
"ButtonRemoveFromContinueListening": "Lösche den Eintrag aus der Fortsetzungsliste",
|
||||||
|
"ButtonRemoveFromContinueReading": "Remove from Continue Reading",
|
||||||
"ButtonRemoveSeriesFromContinueSeries": "Lösche die Serie aus der Serienfortsetzungsliste",
|
"ButtonRemoveSeriesFromContinueSeries": "Lösche die Serie aus der Serienfortsetzungsliste",
|
||||||
"ButtonReScan": "Neu scannen",
|
"ButtonReScan": "Neu scannen",
|
||||||
"ButtonReset": "Zurücksetzen",
|
"ButtonReset": "Zurücksetzen",
|
||||||
@@ -73,6 +74,7 @@
|
|||||||
"ButtonStartM4BEncode": "M4B-Kodierung starten",
|
"ButtonStartM4BEncode": "M4B-Kodierung starten",
|
||||||
"ButtonStartMetadataEmbed": "Metadateneinbettung starten",
|
"ButtonStartMetadataEmbed": "Metadateneinbettung starten",
|
||||||
"ButtonSubmit": "Ok",
|
"ButtonSubmit": "Ok",
|
||||||
|
"ButtonTest": "Test",
|
||||||
"ButtonUpload": "Hochladen",
|
"ButtonUpload": "Hochladen",
|
||||||
"ButtonUploadBackup": "Sicherung hochladen",
|
"ButtonUploadBackup": "Sicherung hochladen",
|
||||||
"ButtonUploadCover": "Titelbild hochladen",
|
"ButtonUploadCover": "Titelbild hochladen",
|
||||||
@@ -93,10 +95,13 @@
|
|||||||
"HeaderCollection": "Sammlungen",
|
"HeaderCollection": "Sammlungen",
|
||||||
"HeaderCollectionItems": "Sammlungseinträge",
|
"HeaderCollectionItems": "Sammlungseinträge",
|
||||||
"HeaderCover": "Titelbild",
|
"HeaderCover": "Titelbild",
|
||||||
"HeaderCurrentDownloads": "Current Downloads",
|
"HeaderCurrentDownloads": "Aktuelle Downloads",
|
||||||
"HeaderDetails": "Details",
|
"HeaderDetails": "Details",
|
||||||
"HeaderDownloadQueue": "Download Queue",
|
"HeaderDownloadQueue": "Download Warteschlange",
|
||||||
|
"HeaderEmail": "Email",
|
||||||
|
"HeaderEmailSettings": "Email Settings",
|
||||||
"HeaderEpisodes": "Episoden",
|
"HeaderEpisodes": "Episoden",
|
||||||
|
"HeaderEReaderDevices": "E-Reader Devices",
|
||||||
"HeaderFiles": "Dateien",
|
"HeaderFiles": "Dateien",
|
||||||
"HeaderFindChapters": "Kapitel suchen",
|
"HeaderFindChapters": "Kapitel suchen",
|
||||||
"HeaderIgnoredFiles": "Ignorierte Dateien",
|
"HeaderIgnoredFiles": "Ignorierte Dateien",
|
||||||
@@ -142,8 +147,8 @@
|
|||||||
"HeaderSettingsGeneral": "Allgemein",
|
"HeaderSettingsGeneral": "Allgemein",
|
||||||
"HeaderSettingsScanner": "Scanner",
|
"HeaderSettingsScanner": "Scanner",
|
||||||
"HeaderSleepTimer": "Einschlaf-Timer",
|
"HeaderSleepTimer": "Einschlaf-Timer",
|
||||||
"HeaderStatsLargestItems": "Largest Items",
|
"HeaderStatsLargestItems": "Größte Medien",
|
||||||
"HeaderStatsLongestItems": "Längste Einträge (h)",
|
"HeaderStatsLongestItems": "Längste Medien (h)",
|
||||||
"HeaderStatsMinutesListeningChart": "Hörminuten (letzte 7 Tage)",
|
"HeaderStatsMinutesListeningChart": "Hörminuten (letzte 7 Tage)",
|
||||||
"HeaderStatsRecentSessions": "Neueste Ereignisse",
|
"HeaderStatsRecentSessions": "Neueste Ereignisse",
|
||||||
"HeaderStatsTop10Authors": "Top 10 Autoren",
|
"HeaderStatsTop10Authors": "Top 10 Autoren",
|
||||||
@@ -155,12 +160,13 @@
|
|||||||
"HeaderUpdateLibrary": "Bibliothek aktualisieren",
|
"HeaderUpdateLibrary": "Bibliothek aktualisieren",
|
||||||
"HeaderUsers": "Benutzer",
|
"HeaderUsers": "Benutzer",
|
||||||
"HeaderYourStats": "Eigene Statistiken",
|
"HeaderYourStats": "Eigene Statistiken",
|
||||||
"LabelAbridged": "Abridged",
|
"LabelAbridged": "Gekürzt",
|
||||||
"LabelAccountType": "Kontoart",
|
"LabelAccountType": "Kontoart",
|
||||||
"LabelAccountTypeAdmin": "Admin",
|
"LabelAccountTypeAdmin": "Admin",
|
||||||
"LabelAccountTypeGuest": "Gast",
|
"LabelAccountTypeGuest": "Gast",
|
||||||
"LabelAccountTypeUser": "Benutzer",
|
"LabelAccountTypeUser": "Benutzer",
|
||||||
"LabelActivity": "Aktivitäten",
|
"LabelActivity": "Aktivitäten",
|
||||||
|
"LabelAdded": "Hinzugefügt",
|
||||||
"LabelAddedAt": "Hinzugefügt am",
|
"LabelAddedAt": "Hinzugefügt am",
|
||||||
"LabelAddToCollection": "Zur Sammlung hinzufügen",
|
"LabelAddToCollection": "Zur Sammlung hinzufügen",
|
||||||
"LabelAddToCollectionBatch": "Füge {0} Hörbüch(er)/Podcast(s) der Sammlung hinzu",
|
"LabelAddToCollectionBatch": "Füge {0} Hörbüch(er)/Podcast(s) der Sammlung hinzu",
|
||||||
@@ -168,7 +174,7 @@
|
|||||||
"LabelAddToPlaylistBatch": "Füge {0} Hörbüch(er)/Podcast(s) der Wiedergabeliste hinzu",
|
"LabelAddToPlaylistBatch": "Füge {0} Hörbüch(er)/Podcast(s) der Wiedergabeliste hinzu",
|
||||||
"LabelAll": "Alle",
|
"LabelAll": "Alle",
|
||||||
"LabelAllUsers": "Alle Benutzer",
|
"LabelAllUsers": "Alle Benutzer",
|
||||||
"LabelAlreadyInYourLibrary": "Already in your library",
|
"LabelAlreadyInYourLibrary": "In der Bibliothek vorhanden",
|
||||||
"LabelAppend": "Anhängen",
|
"LabelAppend": "Anhängen",
|
||||||
"LabelAuthor": "Autor",
|
"LabelAuthor": "Autor",
|
||||||
"LabelAuthorFirstLast": "Autor (Vorname Nachname)",
|
"LabelAuthorFirstLast": "Autor (Vorname Nachname)",
|
||||||
@@ -182,24 +188,29 @@
|
|||||||
"LabelBackupsMaxBackupSizeHelp": "Zum Schutz vor Fehlkonfigurationen schlagen Sicherungen fehl, wenn sie die konfigurierte Größe überschreiten.",
|
"LabelBackupsMaxBackupSizeHelp": "Zum Schutz vor Fehlkonfigurationen schlagen Sicherungen fehl, wenn sie die konfigurierte Größe überschreiten.",
|
||||||
"LabelBackupsNumberToKeep": "Anzahl der aufzubewahrenden Sicherungen",
|
"LabelBackupsNumberToKeep": "Anzahl der aufzubewahrenden Sicherungen",
|
||||||
"LabelBackupsNumberToKeepHelp": "Es wird immer nur 1 Sicherung auf einmal entfernt. Wenn Sie bereits mehrere Sicherungen als die definierte max. Anzahl haben, sollten Sie diese manuell entfernen.",
|
"LabelBackupsNumberToKeepHelp": "Es wird immer nur 1 Sicherung auf einmal entfernt. Wenn Sie bereits mehrere Sicherungen als die definierte max. Anzahl haben, sollten Sie diese manuell entfernen.",
|
||||||
|
"LabelBitrate": "Bitrate",
|
||||||
"LabelBooks": "Bücher",
|
"LabelBooks": "Bücher",
|
||||||
"LabelChangePassword": "Passwort ändern",
|
"LabelChangePassword": "Passwort ändern",
|
||||||
|
"LabelChannels": "Kanäle",
|
||||||
|
"LabelChapters": "Chapters",
|
||||||
"LabelChaptersFound": "gefundene Kapitel",
|
"LabelChaptersFound": "gefundene Kapitel",
|
||||||
"LabelChapterTitle": "Kapitelüberschrift",
|
"LabelChapterTitle": "Kapitelüberschrift",
|
||||||
"LabelClosePlayer": "Player schließen",
|
"LabelClosePlayer": "Player schließen",
|
||||||
|
"LabelCodec": "Codec",
|
||||||
"LabelCollapseSeries": "Serien zusammenfassen",
|
"LabelCollapseSeries": "Serien zusammenfassen",
|
||||||
"LabelCollections": "Sammlungen",
|
"LabelCollections": "Sammlungen",
|
||||||
"LabelComplete": "Vollständig",
|
"LabelComplete": "Vollständig",
|
||||||
"LabelConfirmPassword": "Passwort bestätigen",
|
"LabelConfirmPassword": "Passwort bestätigen",
|
||||||
"LabelContinueListening": "Weiterhören",
|
"LabelContinueListening": "Weiterhören",
|
||||||
|
"LabelContinueReading": "Continue Reading",
|
||||||
"LabelContinueSeries": "Serien fortsetzen",
|
"LabelContinueSeries": "Serien fortsetzen",
|
||||||
"LabelCover": "Titelbild",
|
"LabelCover": "Titelbild",
|
||||||
"LabelCoverImageURL": "URL des Titelbildes",
|
"LabelCoverImageURL": "URL des Titelbildes",
|
||||||
"LabelCreatedAt": "Erstellt am",
|
"LabelCreatedAt": "Erstellt am",
|
||||||
"LabelCronExpression": "Cron Ausdruck",
|
"LabelCronExpression": "Cron-Ausdruck",
|
||||||
"LabelCurrent": "Aktuell",
|
"LabelCurrent": "Aktuell",
|
||||||
"LabelCurrently": "Aktuell:",
|
"LabelCurrently": "Aktuell:",
|
||||||
"LabelCustomCronExpression": "Custom Cron Expression:",
|
"LabelCustomCronExpression": "Benutzerdefinierter Cron-Ausdruck",
|
||||||
"LabelDatetime": "Datum & Uhrzeit",
|
"LabelDatetime": "Datum & Uhrzeit",
|
||||||
"LabelDescription": "Beschreibung",
|
"LabelDescription": "Beschreibung",
|
||||||
"LabelDeselectAll": "Alles abwählen",
|
"LabelDeselectAll": "Alles abwählen",
|
||||||
@@ -211,13 +222,19 @@
|
|||||||
"LabelDownload": "Herunterladen",
|
"LabelDownload": "Herunterladen",
|
||||||
"LabelDuration": "Laufzeit",
|
"LabelDuration": "Laufzeit",
|
||||||
"LabelDurationFound": "Gefundene Laufzeit:",
|
"LabelDurationFound": "Gefundene Laufzeit:",
|
||||||
|
"LabelEbook": "Ebook",
|
||||||
"LabelEdit": "Bearbeiten",
|
"LabelEdit": "Bearbeiten",
|
||||||
|
"LabelEmail": "Email",
|
||||||
|
"LabelEmailSettingsFromAddress": "From Address",
|
||||||
|
"LabelEmailSettingsSecure": "Secure",
|
||||||
|
"LabelEmailSettingsSecureHelp": "If true the connection will use TLS when connecting to server. If false then TLS is used if server supports the STARTTLS extension. In most cases set this value to true if you are connecting to port 465. For port 587 or 25 keep it false. (from nodemailer.com/smtp/#authentication)",
|
||||||
|
"LabelEmbeddedCover": "Eingebettetes Cover",
|
||||||
"LabelEnable": "Aktivieren",
|
"LabelEnable": "Aktivieren",
|
||||||
"LabelEnd": "Ende",
|
"LabelEnd": "Ende",
|
||||||
"LabelEpisode": "Episode",
|
"LabelEpisode": "Episode",
|
||||||
"LabelEpisodeTitle": "Episodentitel",
|
"LabelEpisodeTitle": "Episodentitel",
|
||||||
"LabelEpisodeType": "Episodentyp",
|
"LabelEpisodeType": "Episodentyp",
|
||||||
"LabelExample": "Example",
|
"LabelExample": "Beispiel",
|
||||||
"LabelExplicit": "Explizit (Altersbeschränkung)",
|
"LabelExplicit": "Explizit (Altersbeschränkung)",
|
||||||
"LabelFeedURL": "Feed URL",
|
"LabelFeedURL": "Feed URL",
|
||||||
"LabelFile": "Datei",
|
"LabelFile": "Datei",
|
||||||
@@ -229,9 +246,11 @@
|
|||||||
"LabelFinished": "beendet",
|
"LabelFinished": "beendet",
|
||||||
"LabelFolder": "Ordner",
|
"LabelFolder": "Ordner",
|
||||||
"LabelFolders": "Verzeichnisse",
|
"LabelFolders": "Verzeichnisse",
|
||||||
|
"LabelFormat": "Format",
|
||||||
"LabelGenre": "Kategorie",
|
"LabelGenre": "Kategorie",
|
||||||
"LabelGenres": "Kategorien",
|
"LabelGenres": "Kategorien",
|
||||||
"LabelHardDeleteFile": "Datei dauerhaft löschen",
|
"LabelHardDeleteFile": "Datei dauerhaft löschen",
|
||||||
|
"LabelHost": "Host",
|
||||||
"LabelHour": "Stunde",
|
"LabelHour": "Stunde",
|
||||||
"LabelIcon": "Symbol",
|
"LabelIcon": "Symbol",
|
||||||
"LabelIncludeInTracklist": "In die Titelliste aufnehmen",
|
"LabelIncludeInTracklist": "In die Titelliste aufnehmen",
|
||||||
@@ -247,9 +266,12 @@
|
|||||||
"LabelIntervalEveryDay": "Jeden Tag",
|
"LabelIntervalEveryDay": "Jeden Tag",
|
||||||
"LabelIntervalEveryHour": "Jede Stunde",
|
"LabelIntervalEveryHour": "Jede Stunde",
|
||||||
"LabelInvalidParts": "Ungültige Teile",
|
"LabelInvalidParts": "Ungültige Teile",
|
||||||
|
"LabelInvert": "Invert",
|
||||||
"LabelItem": "Medium",
|
"LabelItem": "Medium",
|
||||||
"LabelLanguage": "Sprache",
|
"LabelLanguage": "Sprache",
|
||||||
"LabelLanguageDefaultServer": "Standard-Server-Sprache",
|
"LabelLanguageDefaultServer": "Standard-Server-Sprache",
|
||||||
|
"LabelLastBookAdded": "Zuletzt hinzugefügtes Medium",
|
||||||
|
"LabelLastBookUpdated": "Zuletzt aktualisiertes Medium",
|
||||||
"LabelLastSeen": "Zuletzt angesehen",
|
"LabelLastSeen": "Zuletzt angesehen",
|
||||||
"LabelLastTime": "Letztes Mal",
|
"LabelLastTime": "Letztes Mal",
|
||||||
"LabelLastUpdate": "Letzte Aktualisierung",
|
"LabelLastUpdate": "Letzte Aktualisierung",
|
||||||
@@ -268,10 +290,12 @@
|
|||||||
"LabelMediaType": "Medientyp",
|
"LabelMediaType": "Medientyp",
|
||||||
"LabelMetadataProvider": "Metadatenanbieter",
|
"LabelMetadataProvider": "Metadatenanbieter",
|
||||||
"LabelMetaTag": "Meta Schlagwort",
|
"LabelMetaTag": "Meta Schlagwort",
|
||||||
|
"LabelMetaTags": "Meta Tags",
|
||||||
"LabelMinute": "Minute",
|
"LabelMinute": "Minute",
|
||||||
"LabelMissing": "Fehlend",
|
"LabelMissing": "Fehlend",
|
||||||
"LabelMissingParts": "Fehlende Teile",
|
"LabelMissingParts": "Fehlende Teile",
|
||||||
"LabelMore": "Mehr",
|
"LabelMore": "Mehr",
|
||||||
|
"LabelMoreInfo": "More Info",
|
||||||
"LabelName": "Name",
|
"LabelName": "Name",
|
||||||
"LabelNarrator": "Erzähler",
|
"LabelNarrator": "Erzähler",
|
||||||
"LabelNarrators": "Erzähler",
|
"LabelNarrators": "Erzähler",
|
||||||
@@ -279,8 +303,8 @@
|
|||||||
"LabelNewestAuthors": "Neuste Autoren",
|
"LabelNewestAuthors": "Neuste Autoren",
|
||||||
"LabelNewestEpisodes": "Neueste Episoden",
|
"LabelNewestEpisodes": "Neueste Episoden",
|
||||||
"LabelNewPassword": "Neues Passwort",
|
"LabelNewPassword": "Neues Passwort",
|
||||||
"LabelNextBackupDate": "Next backup date",
|
"LabelNextBackupDate": "Nächstes Sicherungsdatum",
|
||||||
"LabelNextScheduledRun": "Next scheduled run",
|
"LabelNextScheduledRun": "Nächster planmäßiger Durchlauf",
|
||||||
"LabelNotes": "Hinweise",
|
"LabelNotes": "Hinweise",
|
||||||
"LabelNotFinished": "nicht beendet",
|
"LabelNotFinished": "nicht beendet",
|
||||||
"LabelNotificationAppriseURL": "Apprise URL(s)",
|
"LabelNotificationAppriseURL": "Apprise URL(s)",
|
||||||
@@ -311,30 +335,33 @@
|
|||||||
"LabelPlayMethod": "Abspielmethode",
|
"LabelPlayMethod": "Abspielmethode",
|
||||||
"LabelPodcast": "Podcast",
|
"LabelPodcast": "Podcast",
|
||||||
"LabelPodcasts": "Podcasts",
|
"LabelPodcasts": "Podcasts",
|
||||||
"LabelPodcastType": "Podcast Type",
|
"LabelPodcastType": "Podcast Typ",
|
||||||
|
"LabelPort": "Port",
|
||||||
"LabelPrefixesToIgnore": "Zu ignorierende(s) Vorwort(e) (Groß- und Kleinschreibung wird nicht berücksichtigt)",
|
"LabelPrefixesToIgnore": "Zu ignorierende(s) Vorwort(e) (Groß- und Kleinschreibung wird nicht berücksichtigt)",
|
||||||
"LabelPreventIndexing": "Prevent your feed from being indexed by iTunes and Google podcast directories",
|
"LabelPreventIndexing": "Verhindere, dass dein Feed von iTunes- und Google-Podcast-Verzeichnissen indiziert wird",
|
||||||
"LabelProgress": "Fortschritt",
|
"LabelProgress": "Fortschritt",
|
||||||
"LabelProvider": "Anbieter",
|
"LabelProvider": "Anbieter",
|
||||||
"LabelPubDate": "Veröffentlichungsdatum",
|
"LabelPubDate": "Veröffentlichungsdatum",
|
||||||
"LabelPublisher": "Herausgeber",
|
"LabelPublisher": "Herausgeber",
|
||||||
"LabelPublishYear": "Jahr",
|
"LabelPublishYear": "Jahr",
|
||||||
|
"LabelReadAgain": "Read Again",
|
||||||
"LabelRecentlyAdded": "Kürzlich hinzugefügt",
|
"LabelRecentlyAdded": "Kürzlich hinzugefügt",
|
||||||
"LabelRecentSeries": "Aktuelle Serien",
|
"LabelRecentSeries": "Aktuelle Serien",
|
||||||
"LabelRecommended": "Recommended",
|
"LabelRecommended": "Empfohlen",
|
||||||
"LabelRegion": "Region",
|
"LabelRegion": "Region",
|
||||||
"LabelReleaseDate": "Veröffentlichungsdatum",
|
"LabelReleaseDate": "Veröffentlichungsdatum",
|
||||||
"LabelRemoveCover": "Lösche Titelbild",
|
"LabelRemoveCover": "Lösche Titelbild",
|
||||||
"LabelRSSFeedCustomOwnerEmail": "Custom owner Email",
|
"LabelRSSFeedCustomOwnerEmail": "Benutzerdefinierte Eigentümer-E-Mail",
|
||||||
"LabelRSSFeedCustomOwnerName": "Custom owner Name",
|
"LabelRSSFeedCustomOwnerName": "Benutzerdefinierter Name des Eigentümers",
|
||||||
"LabelRSSFeedOpen": "RSS Feed Offen",
|
"LabelRSSFeedOpen": "RSS Feed Offen",
|
||||||
"LabelRSSFeedPreventIndexing": "Prevent Indexing",
|
"LabelRSSFeedPreventIndexing": "Indizierung verhindern",
|
||||||
"LabelRSSFeedSlug": "RSS Feed Schlagwort",
|
"LabelRSSFeedSlug": "RSS Feed Schlagwort",
|
||||||
"LabelRSSFeedURL": "RSS Feed URL",
|
"LabelRSSFeedURL": "RSS Feed URL",
|
||||||
"LabelSearchTerm": "Begriff suchen",
|
"LabelSearchTerm": "Begriff suchen",
|
||||||
"LabelSearchTitle": "Titel",
|
"LabelSearchTitle": "Titel",
|
||||||
"LabelSearchTitleOrASIN": "Titel oder ASIN",
|
"LabelSearchTitleOrASIN": "Titel oder ASIN",
|
||||||
"LabelSeason": "Staffel",
|
"LabelSeason": "Staffel",
|
||||||
|
"LabelSendEbookToDevice": "Send Ebook to...",
|
||||||
"LabelSequence": "Reihenfolge",
|
"LabelSequence": "Reihenfolge",
|
||||||
"LabelSeries": "Serien",
|
"LabelSeries": "Serien",
|
||||||
"LabelSeriesName": "Serienname",
|
"LabelSeriesName": "Serienname",
|
||||||
@@ -351,7 +378,7 @@
|
|||||||
"LabelSettingsExperimentalFeaturesHelp": "Funktionen welche sich in der Entwicklung befinden, benötigen Ihr Feedback und Ihre Hilfe beim Testen. Klicken Sie hier, um die Github-Diskussion zu öffnen.",
|
"LabelSettingsExperimentalFeaturesHelp": "Funktionen welche sich in der Entwicklung befinden, benötigen Ihr Feedback und Ihre Hilfe beim Testen. Klicken Sie hier, um die Github-Diskussion zu öffnen.",
|
||||||
"LabelSettingsFindCovers": "Suche Titelbilder",
|
"LabelSettingsFindCovers": "Suche Titelbilder",
|
||||||
"LabelSettingsFindCoversHelp": "Wenn Ihr Medium kein eingebettetes Titelbild oder kein Titelbild im Ordner hat, versucht der Scanner, ein Titelbild online zu finden.<br>Hinweis: Dies verlängert die Scandauer",
|
"LabelSettingsFindCoversHelp": "Wenn Ihr Medium kein eingebettetes Titelbild oder kein Titelbild im Ordner hat, versucht der Scanner, ein Titelbild online zu finden.<br>Hinweis: Dies verlängert die Scandauer",
|
||||||
"LabelSettingsHomePageBookshelfView": "Starseite verwendet die Bücherregalansicht",
|
"LabelSettingsHomePageBookshelfView": "Startseite verwendet die Bücherregalansicht",
|
||||||
"LabelSettingsLibraryBookshelfView": "Bibliothek verwendet die Bücherregalansicht",
|
"LabelSettingsLibraryBookshelfView": "Bibliothek verwendet die Bücherregalansicht",
|
||||||
"LabelSettingsOverdriveMediaMarkers": "Verwende Overdrive Media Marker für Kapitel",
|
"LabelSettingsOverdriveMediaMarkers": "Verwende Overdrive Media Marker für Kapitel",
|
||||||
"LabelSettingsOverdriveMediaMarkersHelp": "MP3-Dateien von Overdrive werden mit eingebetteten Kapitel-Timings als benutzerdefinierte Metadaten geliefert. Wenn Sie dies aktivieren, werden diese Markierungen automatisch für die Kapiteltaktung verwendet",
|
"LabelSettingsOverdriveMediaMarkersHelp": "MP3-Dateien von Overdrive werden mit eingebetteten Kapitel-Timings als benutzerdefinierte Metadaten geliefert. Wenn Sie dies aktivieren, werden diese Markierungen automatisch für die Kapiteltaktung verwendet",
|
||||||
@@ -373,7 +400,7 @@
|
|||||||
"LabelSettingsStoreCoversWithItemHelp": "Standardmäßig werden die Titelbilder in /metadata/items gespeichert. Wenn diese Option aktiviert ist, werden die Titelbilder als jpg Datei in dem gleichen Ordner gespeichert in welchem sich auch das Medium befindet. Es wird immer nur eine Datei mit dem Namen \"cover.jpg\" gespeichert.",
|
"LabelSettingsStoreCoversWithItemHelp": "Standardmäßig werden die Titelbilder in /metadata/items gespeichert. Wenn diese Option aktiviert ist, werden die Titelbilder als jpg Datei in dem gleichen Ordner gespeichert in welchem sich auch das Medium befindet. Es wird immer nur eine Datei mit dem Namen \"cover.jpg\" gespeichert.",
|
||||||
"LabelSettingsStoreMetadataWithItem": "Metadaten als OPF-Datei im Medienordner speichern",
|
"LabelSettingsStoreMetadataWithItem": "Metadaten als OPF-Datei im Medienordner speichern",
|
||||||
"LabelSettingsStoreMetadataWithItemHelp": "Standardmäßig werden die Metadaten in /metadata/items gespeichert. Wenn diese Option aktiviert ist, werden die Metadaten als OPF-Datei (Textdatei) in dem gleichen Ordner gespeichert in welchem sich auch das Medium befindet. Es wird immer nur eine Datei mit dem Namen \"matadata.abs\" gespeichert.",
|
"LabelSettingsStoreMetadataWithItemHelp": "Standardmäßig werden die Metadaten in /metadata/items gespeichert. Wenn diese Option aktiviert ist, werden die Metadaten als OPF-Datei (Textdatei) in dem gleichen Ordner gespeichert in welchem sich auch das Medium befindet. Es wird immer nur eine Datei mit dem Namen \"matadata.abs\" gespeichert.",
|
||||||
"LabelSettingsTimeFormat": "Time Format",
|
"LabelSettingsTimeFormat": "Zeitformat",
|
||||||
"LabelShowAll": "Alles anzeigen",
|
"LabelShowAll": "Alles anzeigen",
|
||||||
"LabelSize": "Größe",
|
"LabelSize": "Größe",
|
||||||
"LabelSleepTimer": "Einschlaf-Timer",
|
"LabelSleepTimer": "Einschlaf-Timer",
|
||||||
@@ -401,7 +428,9 @@
|
|||||||
"LabelTag": "Schlagwort",
|
"LabelTag": "Schlagwort",
|
||||||
"LabelTags": "Schlagwörter",
|
"LabelTags": "Schlagwörter",
|
||||||
"LabelTagsAccessibleToUser": "Für Benutzer zugängliche Schlagwörter",
|
"LabelTagsAccessibleToUser": "Für Benutzer zugängliche Schlagwörter",
|
||||||
"LabelTasks": "Tasks Running",
|
"LabelTagsNotAccessibleToUser": "Für Benutzer nicht zugängliche Schlagwörter",
|
||||||
|
"LabelTasks": "Laufende Aufgaben",
|
||||||
|
"LabelTimeBase": "Basiszeit",
|
||||||
"LabelTimeListened": "Gehörte Zeit",
|
"LabelTimeListened": "Gehörte Zeit",
|
||||||
"LabelTimeListenedToday": "Heute gehörte Zeit",
|
"LabelTimeListenedToday": "Heute gehörte Zeit",
|
||||||
"LabelTimeRemaining": "{0} verbleibend",
|
"LabelTimeRemaining": "{0} verbleibend",
|
||||||
@@ -421,7 +450,7 @@
|
|||||||
"LabelTracksMultiTrack": "Mehrfachdatei",
|
"LabelTracksMultiTrack": "Mehrfachdatei",
|
||||||
"LabelTracksSingleTrack": "Einzeldatei",
|
"LabelTracksSingleTrack": "Einzeldatei",
|
||||||
"LabelType": "Typ",
|
"LabelType": "Typ",
|
||||||
"LabelUnabridged": "Unabridged",
|
"LabelUnabridged": "Ungekürzt",
|
||||||
"LabelUnknown": "Unbekannt",
|
"LabelUnknown": "Unbekannt",
|
||||||
"LabelUpdateCover": "Titelbild aktualisieren",
|
"LabelUpdateCover": "Titelbild aktualisieren",
|
||||||
"LabelUpdateCoverHelp": "Erlaube das Überschreiben bestehender Titelbilder für die ausgewählten Hörbücher wenn eine Übereinstimmung gefunden wird",
|
"LabelUpdateCoverHelp": "Erlaube das Überschreiben bestehender Titelbilder für die ausgewählten Hörbücher wenn eine Übereinstimmung gefunden wird",
|
||||||
@@ -450,24 +479,26 @@
|
|||||||
"MessageBackupsDescription": "In einer Sicherung werden Benutzer, Benutzerfortschritte, Details zu den Bibliotheksobjekten, Servereinstellungen und Bilder welche in <code>/metadata/items</code> & <code>/metadata/authors</code> gespeichert sind gespeichert. Sicherungen enthalten keine Dateien welche in den einzelnen Bibliotheksordnern (Medien-Ordnern) gespeichert sind.",
|
"MessageBackupsDescription": "In einer Sicherung werden Benutzer, Benutzerfortschritte, Details zu den Bibliotheksobjekten, Servereinstellungen und Bilder welche in <code>/metadata/items</code> & <code>/metadata/authors</code> gespeichert sind gespeichert. Sicherungen enthalten keine Dateien welche in den einzelnen Bibliotheksordnern (Medien-Ordnern) gespeichert sind.",
|
||||||
"MessageBatchQuickMatchDescription": "Der Schnellabgleich versucht, fehlende Titelbilder und Metadaten für die ausgewählten Artikel hinzuzufügen. Aktivieren Sie die nachstehenden Optionen, damit der Schnellabgleich vorhandene Titelbilder und/oder Metadaten überschreiben kann.",
|
"MessageBatchQuickMatchDescription": "Der Schnellabgleich versucht, fehlende Titelbilder und Metadaten für die ausgewählten Artikel hinzuzufügen. Aktivieren Sie die nachstehenden Optionen, damit der Schnellabgleich vorhandene Titelbilder und/oder Metadaten überschreiben kann.",
|
||||||
"MessageBookshelfNoCollections": "Es wurden noch keine Sammlungen erstellt",
|
"MessageBookshelfNoCollections": "Es wurden noch keine Sammlungen erstellt",
|
||||||
"MessageBookshelfNoResultsForFilter": "Keine Ergebnisse für filter \"{0}: {1}\"",
|
"MessageBookshelfNoResultsForFilter": "Keine Ergebnisse für Filter \"{0}: {1}\"",
|
||||||
"MessageBookshelfNoRSSFeeds": "Keine RSS-Feeds geöffnet",
|
"MessageBookshelfNoRSSFeeds": "Keine RSS-Feeds geöffnet",
|
||||||
"MessageBookshelfNoSeries": "Keine Serien vorhanden",
|
"MessageBookshelfNoSeries": "Keine Serien vorhanden",
|
||||||
"MessageChapterEndIsAfter": "Ungültige Kapitelendzeit: Kapitelende > Mediumende (Kapitelende liegt nach dem Ende des Mediums)",
|
"MessageChapterEndIsAfter": "Ungültige Kapitelendzeit: Kapitelende > Mediumende (Kapitelende liegt nach dem Ende des Mediums)",
|
||||||
"MessageChapterErrorFirstNotZero": "Ungültige Kapitelstartzeit: Das erste Kapitel muss bei 0 beginnen",
|
"MessageChapterErrorFirstNotZero": "Ungültige Kapitelstartzeit: Das erste Kapitel muss bei 0 beginnen",
|
||||||
"MessageChapterErrorStartGteDuration": "Ungültige Kapitelstartzeit: Kapitelanfang > Mediumlänge (Kapitelanfang liegt zeitlich nach dem Ende des Mediums -> Lösung: Kapitelanfang < Mediumlänge)",
|
"MessageChapterErrorStartGteDuration": "Ungültige Kapitelstartzeit: Kapitelanfang > Mediumlänge (Kapitelanfang liegt zeitlich nach dem Ende des Mediums -> Lösung: Kapitelanfang < Mediumlänge)",
|
||||||
"MessageChapterErrorStartLtPrev": "Ungültige Kapitelstartzeit: Kapitelanfang < Kapitelanfang vorheriges Kapitel (Kapitelanfang liegt zeitlich vor dem Beginn des vorherigen Kapitels -> Lösung: Kapitelanfang >= Startzeit des vorherigen Kapitels)",
|
"MessageChapterErrorStartLtPrev": "Ungültige Kapitelstartzeit: Kapitelanfang < Kapitelanfang vorheriges Kapitel (Kapitelanfang liegt zeitlich vor dem Beginn des vorherigen Kapitels -> Lösung: Kapitelanfang >= Startzeit des vorherigen Kapitels)",
|
||||||
"MessageChapterStartIsAfter": "Ungültige Kapitelstartzeit: Kapitelanfang > Mediumende (Kapitelanfang liegt nach dem Ende des Mediums)",
|
"MessageChapterStartIsAfter": "Ungültige Kapitelstartzeit: Kapitelanfang > Mediumende (Kapitelanfang liegt nach dem Ende des Mediums)",
|
||||||
"MessageCheckingCron": "Überprüfe cron...",
|
"MessageCheckingCron": "Überprüfe Cron...",
|
||||||
"MessageConfirmDeleteBackup": "Sind Sie sicher, dass Sie die Sicherung für {0} löschen wollen?",
|
"MessageConfirmDeleteBackup": "Sind Sie sicher, dass Sie die Sicherung für {0} löschen wollen?",
|
||||||
"MessageConfirmDeleteLibrary": "Sind Sie sicher, dass Sie die Bibliothek \"{0}\" dauerhaft löschen wollen?",
|
"MessageConfirmDeleteLibrary": "Sind Sie sicher, dass Sie die Bibliothek \"{0}\" dauerhaft löschen wollen?",
|
||||||
"MessageConfirmDeleteSession": "Sind Sie sicher, dass Sie diese Sitzung löschen möchten?",
|
"MessageConfirmDeleteSession": "Sind Sie sicher, dass Sie diese Sitzung löschen möchten?",
|
||||||
"MessageConfirmForceReScan": "Sind Sie sicher, dass Sie einen erneuten Scanvorgang erzwingen wollen?",
|
"MessageConfirmForceReScan": "Sind Sie sicher, dass Sie einen erneuten Scanvorgang erzwingen wollen?",
|
||||||
"MessageConfirmMarkSeriesFinished": "Sind Sie sicher, dass Sie alle Medien dieser Reihe als abgeschlossen markieren wollen?",
|
"MessageConfirmMarkSeriesFinished": "Sind Sie sicher, dass Sie alle Medien dieser Reihe als abgeschlossen markieren wollen?",
|
||||||
"MessageConfirmMarkSeriesNotFinished": "Sind Sie sicher, dass Sie alle Medien dieser Reihe als nicht abgeschlossen markieren wollen?",
|
"MessageConfirmMarkSeriesNotFinished": "Sind Sie sicher, dass Sie alle Medien dieser Reihe als nicht abgeschlossen markieren wollen?",
|
||||||
|
"MessageConfirmRemoveAllChapters": "Sind Sie sicher, dass Sie alle Kapitel entfernen möchten?",
|
||||||
"MessageConfirmRemoveCollection": "Sind Sie sicher, dass Sie die Sammlung \"{0}\" löschen wollen?",
|
"MessageConfirmRemoveCollection": "Sind Sie sicher, dass Sie die Sammlung \"{0}\" löschen wollen?",
|
||||||
"MessageConfirmRemoveEpisode": "Sind Sie sicher, dass Sie die Episode \"{0}\" löschen möchten?",
|
"MessageConfirmRemoveEpisode": "Sind Sie sicher, dass Sie die Episode \"{0}\" löschen möchten?",
|
||||||
"MessageConfirmRemoveEpisodes": "Sind Sie sicher, dass Sie {0} Episoden löschen wollen?",
|
"MessageConfirmRemoveEpisodes": "Sind Sie sicher, dass Sie {0} Episoden löschen wollen?",
|
||||||
|
"MessageConfirmRemoveNarrator": "Sind Sie sicher, dass Sie den Erzähler \"{0}\" löschen möchten?",
|
||||||
"MessageConfirmRemovePlaylist": "Sind Sie sicher, dass Sie die Wiedergabeliste \"{0}\" entfernen möchten?",
|
"MessageConfirmRemovePlaylist": "Sind Sie sicher, dass Sie die Wiedergabeliste \"{0}\" entfernen möchten?",
|
||||||
"MessageConfirmRenameGenre": "Sind Sie sicher, dass Sie die Kategorie \"{0}\" in \"{1}\" für alle Hörbücher/Podcasts umbenennen wollen?",
|
"MessageConfirmRenameGenre": "Sind Sie sicher, dass Sie die Kategorie \"{0}\" in \"{1}\" für alle Hörbücher/Podcasts umbenennen wollen?",
|
||||||
"MessageConfirmRenameGenreMergeNote": "Hinweis: Kategorie existiert bereits -> Kategorien werden zusammengelegt.",
|
"MessageConfirmRenameGenreMergeNote": "Hinweis: Kategorie existiert bereits -> Kategorien werden zusammengelegt.",
|
||||||
@@ -475,6 +506,7 @@
|
|||||||
"MessageConfirmRenameTag": "Sind Sie sicher, dass Sie den Tag \"{0}\" in \"{1}\" für alle Hörbücher/Podcasts umbenennen wollen?",
|
"MessageConfirmRenameTag": "Sind Sie sicher, dass Sie den Tag \"{0}\" in \"{1}\" für alle Hörbücher/Podcasts umbenennen wollen?",
|
||||||
"MessageConfirmRenameTagMergeNote": "Hinweis: Tag existiert bereits -> Tags werden zusammengelegt.",
|
"MessageConfirmRenameTagMergeNote": "Hinweis: Tag existiert bereits -> Tags werden zusammengelegt.",
|
||||||
"MessageConfirmRenameTagWarning": "Warnung! Ein ähnlicher Tag mit einem anderen Wortlaut existiert bereits: \"{0}\".",
|
"MessageConfirmRenameTagWarning": "Warnung! Ein ähnlicher Tag mit einem anderen Wortlaut existiert bereits: \"{0}\".",
|
||||||
|
"MessageConfirmSendEbookToDevice": "Are you sure you want to send {0} ebook \"{1}\" to device \"{2}\"?",
|
||||||
"MessageDownloadingEpisode": "Episode herunterladen",
|
"MessageDownloadingEpisode": "Episode herunterladen",
|
||||||
"MessageDragFilesIntoTrackOrder": "Verschieben Sie die Dateien in die richtige Reihenfolge",
|
"MessageDragFilesIntoTrackOrder": "Verschieben Sie die Dateien in die richtige Reihenfolge",
|
||||||
"MessageEmbedFinished": "Einbettung abgeschlossen!",
|
"MessageEmbedFinished": "Einbettung abgeschlossen!",
|
||||||
@@ -504,8 +536,8 @@
|
|||||||
"MessageNoCollections": "Keine Sammlungen",
|
"MessageNoCollections": "Keine Sammlungen",
|
||||||
"MessageNoCoversFound": "Keine Titelbilder gefunden",
|
"MessageNoCoversFound": "Keine Titelbilder gefunden",
|
||||||
"MessageNoDescription": "Keine Beschreibung",
|
"MessageNoDescription": "Keine Beschreibung",
|
||||||
"MessageNoDownloadsInProgress": "No downloads currently in progress",
|
"MessageNoDownloadsInProgress": "Derzeit keine Downloads in Arbeit",
|
||||||
"MessageNoDownloadsQueued": "No downloads queued",
|
"MessageNoDownloadsQueued": "Keine Downloads in der Warteschlange",
|
||||||
"MessageNoEpisodeMatchesFound": "Keine Episodenübereinstimmungen gefunden",
|
"MessageNoEpisodeMatchesFound": "Keine Episodenübereinstimmungen gefunden",
|
||||||
"MessageNoEpisodes": "Keine Episoden",
|
"MessageNoEpisodes": "Keine Episoden",
|
||||||
"MessageNoFoldersAvailable": "Keine Ordner verfügbar",
|
"MessageNoFoldersAvailable": "Keine Ordner verfügbar",
|
||||||
@@ -522,7 +554,7 @@
|
|||||||
"MessageNoSearchResultsFor": "Keine Suchergebnisse für \"{0}\"",
|
"MessageNoSearchResultsFor": "Keine Suchergebnisse für \"{0}\"",
|
||||||
"MessageNoSeries": "Keine Serien",
|
"MessageNoSeries": "Keine Serien",
|
||||||
"MessageNoTags": "Keine Tags",
|
"MessageNoTags": "Keine Tags",
|
||||||
"MessageNoTasksRunning": "No Tasks Running",
|
"MessageNoTasksRunning": "Keine laufenden Aufgaben",
|
||||||
"MessageNotYetImplemented": "Noch nicht implementiert",
|
"MessageNotYetImplemented": "Noch nicht implementiert",
|
||||||
"MessageNoUpdateNecessary": "Keine Aktualisierung erforderlich",
|
"MessageNoUpdateNecessary": "Keine Aktualisierung erforderlich",
|
||||||
"MessageNoUpdatesWereNecessary": "Keine Aktualisierungen waren notwendig",
|
"MessageNoUpdatesWereNecessary": "Keine Aktualisierungen waren notwendig",
|
||||||
@@ -536,7 +568,7 @@
|
|||||||
"MessageRemoveAllItemsWarning": "WARNUNG! Bei dieser Aktion werden alle Bibliotheksobjekte aus der Datenbank entfernt, einschließlich aller Aktualisierungen oder Online-Abgleichs, die Sie vorgenommen haben. Ihre eigentlichen Dateien bleiben davon unberührt. Sind Sie sicher?",
|
"MessageRemoveAllItemsWarning": "WARNUNG! Bei dieser Aktion werden alle Bibliotheksobjekte aus der Datenbank entfernt, einschließlich aller Aktualisierungen oder Online-Abgleichs, die Sie vorgenommen haben. Ihre eigentlichen Dateien bleiben davon unberührt. Sind Sie sicher?",
|
||||||
"MessageRemoveChapter": "Kapitel löschen",
|
"MessageRemoveChapter": "Kapitel löschen",
|
||||||
"MessageRemoveEpisodes": "Entferne {0} Episode(n)",
|
"MessageRemoveEpisodes": "Entferne {0} Episode(n)",
|
||||||
"MessageRemoveFromPlayerQueue": "Aus der Abspielwarteliste löschen Remove from player queue",
|
"MessageRemoveFromPlayerQueue": "Aus der Abspielwarteliste löschen",
|
||||||
"MessageRemoveUserWarning": "Sind Sie sicher, dass Sie den Benutzer \"{0}\" dauerhaft löschen wollen?",
|
"MessageRemoveUserWarning": "Sind Sie sicher, dass Sie den Benutzer \"{0}\" dauerhaft löschen wollen?",
|
||||||
"MessageReportBugsAndContribute": "Fehler melden, Funktionen anfordern und Beiträge leisten auf",
|
"MessageReportBugsAndContribute": "Fehler melden, Funktionen anfordern und Beiträge leisten auf",
|
||||||
"MessageResetChaptersConfirm": "Sind Sie sicher, dass Sie die Kapitel zurücksetzen und die vorgenommenen Änderungen rückgängig machen wollen?",
|
"MessageResetChaptersConfirm": "Sind Sie sicher, dass Sie die Kapitel zurücksetzen und die vorgenommenen Änderungen rückgängig machen wollen?",
|
||||||
@@ -550,7 +582,7 @@
|
|||||||
"MessageUploaderItemFailed": "Hochladen fehlgeschlagen",
|
"MessageUploaderItemFailed": "Hochladen fehlgeschlagen",
|
||||||
"MessageUploaderItemSuccess": "Erfolgreich hochgeladen!",
|
"MessageUploaderItemSuccess": "Erfolgreich hochgeladen!",
|
||||||
"MessageUploading": "Hochladen...",
|
"MessageUploading": "Hochladen...",
|
||||||
"MessageValidCronExpression": "Gültiger cron-ausdruck",
|
"MessageValidCronExpression": "Gültiger Cron-Ausdruck",
|
||||||
"MessageWatcherIsDisabledGlobally": "Überwachung ist in den Servereinstellungen global deaktiviert",
|
"MessageWatcherIsDisabledGlobally": "Überwachung ist in den Servereinstellungen global deaktiviert",
|
||||||
"MessageXLibraryIsEmpty": "{0} Bibliothek ist leer!",
|
"MessageXLibraryIsEmpty": "{0} Bibliothek ist leer!",
|
||||||
"MessageYourAudiobookDurationIsLonger": "Die Dauer Ihres Mediums ist länger als die gefundene Dauer",
|
"MessageYourAudiobookDurationIsLonger": "Die Dauer Ihres Mediums ist länger als die gefundene Dauer",
|
||||||
@@ -568,7 +600,7 @@
|
|||||||
"PlaceholderNewFolderPath": "Neuer Ordnerpfad",
|
"PlaceholderNewFolderPath": "Neuer Ordnerpfad",
|
||||||
"PlaceholderNewPlaylist": "Neuer Wiedergabelistenname",
|
"PlaceholderNewPlaylist": "Neuer Wiedergabelistenname",
|
||||||
"PlaceholderSearch": "Suche...",
|
"PlaceholderSearch": "Suche...",
|
||||||
"PlaceholderSearchEpisode": "Search episode...",
|
"PlaceholderSearchEpisode": "Suche Episode...",
|
||||||
"ToastAccountUpdateFailed": "Aktualisierung des Kontos fehlgeschlagen",
|
"ToastAccountUpdateFailed": "Aktualisierung des Kontos fehlgeschlagen",
|
||||||
"ToastAccountUpdateSuccess": "Konto aktualisiert",
|
"ToastAccountUpdateSuccess": "Konto aktualisiert",
|
||||||
"ToastAuthorImageRemoveFailed": "Bild konnte nicht entfernt werden",
|
"ToastAuthorImageRemoveFailed": "Bild konnte nicht entfernt werden",
|
||||||
@@ -629,6 +661,8 @@
|
|||||||
"ToastRemoveItemFromCollectionSuccess": "Medium aus der Sammlung gelöscht",
|
"ToastRemoveItemFromCollectionSuccess": "Medium aus der Sammlung gelöscht",
|
||||||
"ToastRSSFeedCloseFailed": "RSS-Feed konnte nicht geschlossen werden",
|
"ToastRSSFeedCloseFailed": "RSS-Feed konnte nicht geschlossen werden",
|
||||||
"ToastRSSFeedCloseSuccess": "RSS-Feed geschlossen",
|
"ToastRSSFeedCloseSuccess": "RSS-Feed geschlossen",
|
||||||
|
"ToastSendEbookToDeviceFailed": "Failed to send ebook to device",
|
||||||
|
"ToastSendEbookToDeviceSuccess": "Ebook sent to device \"{0}\"",
|
||||||
"ToastSeriesUpdateFailed": "Aktualisierung der Serien fehlgeschlagen",
|
"ToastSeriesUpdateFailed": "Aktualisierung der Serien fehlgeschlagen",
|
||||||
"ToastSeriesUpdateSuccess": "Serien aktualisiert",
|
"ToastSeriesUpdateSuccess": "Serien aktualisiert",
|
||||||
"ToastSessionDeleteFailed": "Sitzung konnte nicht gelöscht werden",
|
"ToastSessionDeleteFailed": "Sitzung konnte nicht gelöscht werden",
|
||||||
|
|||||||
@@ -55,6 +55,7 @@
|
|||||||
"ButtonRemoveAll": "Remove All",
|
"ButtonRemoveAll": "Remove All",
|
||||||
"ButtonRemoveAllLibraryItems": "Remove All Library Items",
|
"ButtonRemoveAllLibraryItems": "Remove All Library Items",
|
||||||
"ButtonRemoveFromContinueListening": "Remove from Continue Listening",
|
"ButtonRemoveFromContinueListening": "Remove from Continue Listening",
|
||||||
|
"ButtonRemoveFromContinueReading": "Remove from Continue Reading",
|
||||||
"ButtonRemoveSeriesFromContinueSeries": "Remove Series from Continue Series",
|
"ButtonRemoveSeriesFromContinueSeries": "Remove Series from Continue Series",
|
||||||
"ButtonReScan": "Re-Scan",
|
"ButtonReScan": "Re-Scan",
|
||||||
"ButtonReset": "Reset",
|
"ButtonReset": "Reset",
|
||||||
@@ -73,6 +74,7 @@
|
|||||||
"ButtonStartM4BEncode": "Start M4B Encode",
|
"ButtonStartM4BEncode": "Start M4B Encode",
|
||||||
"ButtonStartMetadataEmbed": "Start Metadata Embed",
|
"ButtonStartMetadataEmbed": "Start Metadata Embed",
|
||||||
"ButtonSubmit": "Submit",
|
"ButtonSubmit": "Submit",
|
||||||
|
"ButtonTest": "Test",
|
||||||
"ButtonUpload": "Upload",
|
"ButtonUpload": "Upload",
|
||||||
"ButtonUploadBackup": "Upload Backup",
|
"ButtonUploadBackup": "Upload Backup",
|
||||||
"ButtonUploadCover": "Upload Cover",
|
"ButtonUploadCover": "Upload Cover",
|
||||||
@@ -96,7 +98,10 @@
|
|||||||
"HeaderCurrentDownloads": "Current Downloads",
|
"HeaderCurrentDownloads": "Current Downloads",
|
||||||
"HeaderDetails": "Details",
|
"HeaderDetails": "Details",
|
||||||
"HeaderDownloadQueue": "Download Queue",
|
"HeaderDownloadQueue": "Download Queue",
|
||||||
|
"HeaderEmail": "Email",
|
||||||
|
"HeaderEmailSettings": "Email Settings",
|
||||||
"HeaderEpisodes": "Episodes",
|
"HeaderEpisodes": "Episodes",
|
||||||
|
"HeaderEReaderDevices": "E-Reader Devices",
|
||||||
"HeaderFiles": "Files",
|
"HeaderFiles": "Files",
|
||||||
"HeaderFindChapters": "Find Chapters",
|
"HeaderFindChapters": "Find Chapters",
|
||||||
"HeaderIgnoredFiles": "Ignored Files",
|
"HeaderIgnoredFiles": "Ignored Files",
|
||||||
@@ -161,6 +166,7 @@
|
|||||||
"LabelAccountTypeGuest": "Guest",
|
"LabelAccountTypeGuest": "Guest",
|
||||||
"LabelAccountTypeUser": "User",
|
"LabelAccountTypeUser": "User",
|
||||||
"LabelActivity": "Activity",
|
"LabelActivity": "Activity",
|
||||||
|
"LabelAdded": "Added",
|
||||||
"LabelAddedAt": "Added At",
|
"LabelAddedAt": "Added At",
|
||||||
"LabelAddToCollection": "Add to Collection",
|
"LabelAddToCollection": "Add to Collection",
|
||||||
"LabelAddToCollectionBatch": "Add {0} Books to Collection",
|
"LabelAddToCollectionBatch": "Add {0} Books to Collection",
|
||||||
@@ -182,16 +188,21 @@
|
|||||||
"LabelBackupsMaxBackupSizeHelp": "As a safeguard against misconfiguration, backups will fail if they exceed the configured size.",
|
"LabelBackupsMaxBackupSizeHelp": "As a safeguard against misconfiguration, backups will fail if they exceed the configured size.",
|
||||||
"LabelBackupsNumberToKeep": "Number of backups to keep",
|
"LabelBackupsNumberToKeep": "Number of backups to keep",
|
||||||
"LabelBackupsNumberToKeepHelp": "Only 1 backup will be removed at a time so if you already have more backups than this you should manually remove them.",
|
"LabelBackupsNumberToKeepHelp": "Only 1 backup will be removed at a time so if you already have more backups than this you should manually remove them.",
|
||||||
|
"LabelBitrate": "Bitrate",
|
||||||
"LabelBooks": "Books",
|
"LabelBooks": "Books",
|
||||||
"LabelChangePassword": "Change Password",
|
"LabelChangePassword": "Change Password",
|
||||||
|
"LabelChannels": "Channels",
|
||||||
|
"LabelChapters": "Chapters",
|
||||||
"LabelChaptersFound": "chapters found",
|
"LabelChaptersFound": "chapters found",
|
||||||
"LabelChapterTitle": "Chapter Title",
|
"LabelChapterTitle": "Chapter Title",
|
||||||
"LabelClosePlayer": "Close player",
|
"LabelClosePlayer": "Close player",
|
||||||
|
"LabelCodec": "Codec",
|
||||||
"LabelCollapseSeries": "Collapse Series",
|
"LabelCollapseSeries": "Collapse Series",
|
||||||
"LabelCollections": "Collections",
|
"LabelCollections": "Collections",
|
||||||
"LabelComplete": "Complete",
|
"LabelComplete": "Complete",
|
||||||
"LabelConfirmPassword": "Confirm Password",
|
"LabelConfirmPassword": "Confirm Password",
|
||||||
"LabelContinueListening": "Continue Listening",
|
"LabelContinueListening": "Continue Listening",
|
||||||
|
"LabelContinueReading": "Continue Reading",
|
||||||
"LabelContinueSeries": "Continue Series",
|
"LabelContinueSeries": "Continue Series",
|
||||||
"LabelCover": "Cover",
|
"LabelCover": "Cover",
|
||||||
"LabelCoverImageURL": "Cover Image URL",
|
"LabelCoverImageURL": "Cover Image URL",
|
||||||
@@ -211,7 +222,13 @@
|
|||||||
"LabelDownload": "Download",
|
"LabelDownload": "Download",
|
||||||
"LabelDuration": "Duration",
|
"LabelDuration": "Duration",
|
||||||
"LabelDurationFound": "Duration found:",
|
"LabelDurationFound": "Duration found:",
|
||||||
|
"LabelEbook": "Ebook",
|
||||||
"LabelEdit": "Edit",
|
"LabelEdit": "Edit",
|
||||||
|
"LabelEmail": "Email",
|
||||||
|
"LabelEmailSettingsFromAddress": "From Address",
|
||||||
|
"LabelEmailSettingsSecure": "Secure",
|
||||||
|
"LabelEmailSettingsSecureHelp": "If true the connection will use TLS when connecting to server. If false then TLS is used if server supports the STARTTLS extension. In most cases set this value to true if you are connecting to port 465. For port 587 or 25 keep it false. (from nodemailer.com/smtp/#authentication)",
|
||||||
|
"LabelEmbeddedCover": "Embedded Cover",
|
||||||
"LabelEnable": "Enable",
|
"LabelEnable": "Enable",
|
||||||
"LabelEnd": "End",
|
"LabelEnd": "End",
|
||||||
"LabelEpisode": "Episode",
|
"LabelEpisode": "Episode",
|
||||||
@@ -229,9 +246,11 @@
|
|||||||
"LabelFinished": "Finished",
|
"LabelFinished": "Finished",
|
||||||
"LabelFolder": "Folder",
|
"LabelFolder": "Folder",
|
||||||
"LabelFolders": "Folders",
|
"LabelFolders": "Folders",
|
||||||
|
"LabelFormat": "Format",
|
||||||
"LabelGenre": "Genre",
|
"LabelGenre": "Genre",
|
||||||
"LabelGenres": "Genres",
|
"LabelGenres": "Genres",
|
||||||
"LabelHardDeleteFile": "Hard delete file",
|
"LabelHardDeleteFile": "Hard delete file",
|
||||||
|
"LabelHost": "Host",
|
||||||
"LabelHour": "Hour",
|
"LabelHour": "Hour",
|
||||||
"LabelIcon": "Icon",
|
"LabelIcon": "Icon",
|
||||||
"LabelIncludeInTracklist": "Include in Tracklist",
|
"LabelIncludeInTracklist": "Include in Tracklist",
|
||||||
@@ -247,9 +266,12 @@
|
|||||||
"LabelIntervalEveryDay": "Every day",
|
"LabelIntervalEveryDay": "Every day",
|
||||||
"LabelIntervalEveryHour": "Every hour",
|
"LabelIntervalEveryHour": "Every hour",
|
||||||
"LabelInvalidParts": "Invalid Parts",
|
"LabelInvalidParts": "Invalid Parts",
|
||||||
|
"LabelInvert": "Invert",
|
||||||
"LabelItem": "Item",
|
"LabelItem": "Item",
|
||||||
"LabelLanguage": "Language",
|
"LabelLanguage": "Language",
|
||||||
"LabelLanguageDefaultServer": "Default Server Language",
|
"LabelLanguageDefaultServer": "Default Server Language",
|
||||||
|
"LabelLastBookAdded": "Last Book Added",
|
||||||
|
"LabelLastBookUpdated": "Last Book Updated",
|
||||||
"LabelLastSeen": "Last Seen",
|
"LabelLastSeen": "Last Seen",
|
||||||
"LabelLastTime": "Last Time",
|
"LabelLastTime": "Last Time",
|
||||||
"LabelLastUpdate": "Last Update",
|
"LabelLastUpdate": "Last Update",
|
||||||
@@ -268,10 +290,12 @@
|
|||||||
"LabelMediaType": "Media Type",
|
"LabelMediaType": "Media Type",
|
||||||
"LabelMetadataProvider": "Metadata Provider",
|
"LabelMetadataProvider": "Metadata Provider",
|
||||||
"LabelMetaTag": "Meta Tag",
|
"LabelMetaTag": "Meta Tag",
|
||||||
|
"LabelMetaTags": "Meta Tags",
|
||||||
"LabelMinute": "Minute",
|
"LabelMinute": "Minute",
|
||||||
"LabelMissing": "Missing",
|
"LabelMissing": "Missing",
|
||||||
"LabelMissingParts": "Missing Parts",
|
"LabelMissingParts": "Missing Parts",
|
||||||
"LabelMore": "More",
|
"LabelMore": "More",
|
||||||
|
"LabelMoreInfo": "More Info",
|
||||||
"LabelName": "Name",
|
"LabelName": "Name",
|
||||||
"LabelNarrator": "Narrator",
|
"LabelNarrator": "Narrator",
|
||||||
"LabelNarrators": "Narrators",
|
"LabelNarrators": "Narrators",
|
||||||
@@ -312,6 +336,7 @@
|
|||||||
"LabelPodcast": "Podcast",
|
"LabelPodcast": "Podcast",
|
||||||
"LabelPodcasts": "Podcasts",
|
"LabelPodcasts": "Podcasts",
|
||||||
"LabelPodcastType": "Podcast Type",
|
"LabelPodcastType": "Podcast Type",
|
||||||
|
"LabelPort": "Port",
|
||||||
"LabelPrefixesToIgnore": "Prefixes to Ignore (case insensitive)",
|
"LabelPrefixesToIgnore": "Prefixes to Ignore (case insensitive)",
|
||||||
"LabelPreventIndexing": "Prevent your feed from being indexed by iTunes and Google podcast directories",
|
"LabelPreventIndexing": "Prevent your feed from being indexed by iTunes and Google podcast directories",
|
||||||
"LabelProgress": "Progress",
|
"LabelProgress": "Progress",
|
||||||
@@ -319,6 +344,7 @@
|
|||||||
"LabelPubDate": "Pub Date",
|
"LabelPubDate": "Pub Date",
|
||||||
"LabelPublisher": "Publisher",
|
"LabelPublisher": "Publisher",
|
||||||
"LabelPublishYear": "Publish Year",
|
"LabelPublishYear": "Publish Year",
|
||||||
|
"LabelReadAgain": "Read Again",
|
||||||
"LabelRecentlyAdded": "Recently Added",
|
"LabelRecentlyAdded": "Recently Added",
|
||||||
"LabelRecentSeries": "Recent Series",
|
"LabelRecentSeries": "Recent Series",
|
||||||
"LabelRecommended": "Recommended",
|
"LabelRecommended": "Recommended",
|
||||||
@@ -335,6 +361,7 @@
|
|||||||
"LabelSearchTitle": "Search Title",
|
"LabelSearchTitle": "Search Title",
|
||||||
"LabelSearchTitleOrASIN": "Search Title or ASIN",
|
"LabelSearchTitleOrASIN": "Search Title or ASIN",
|
||||||
"LabelSeason": "Season",
|
"LabelSeason": "Season",
|
||||||
|
"LabelSendEbookToDevice": "Send Ebook to...",
|
||||||
"LabelSequence": "Sequence",
|
"LabelSequence": "Sequence",
|
||||||
"LabelSeries": "Series",
|
"LabelSeries": "Series",
|
||||||
"LabelSeriesName": "Series Name",
|
"LabelSeriesName": "Series Name",
|
||||||
@@ -401,7 +428,9 @@
|
|||||||
"LabelTag": "Tag",
|
"LabelTag": "Tag",
|
||||||
"LabelTags": "Tags",
|
"LabelTags": "Tags",
|
||||||
"LabelTagsAccessibleToUser": "Tags Accessible to User",
|
"LabelTagsAccessibleToUser": "Tags Accessible to User",
|
||||||
|
"LabelTagsNotAccessibleToUser": "Tags not Accessible to User",
|
||||||
"LabelTasks": "Tasks Running",
|
"LabelTasks": "Tasks Running",
|
||||||
|
"LabelTimeBase": "Time Base",
|
||||||
"LabelTimeListened": "Time Listened",
|
"LabelTimeListened": "Time Listened",
|
||||||
"LabelTimeListenedToday": "Time Listened Today",
|
"LabelTimeListenedToday": "Time Listened Today",
|
||||||
"LabelTimeRemaining": "{0} remaining",
|
"LabelTimeRemaining": "{0} remaining",
|
||||||
@@ -465,9 +494,11 @@
|
|||||||
"MessageConfirmForceReScan": "Are you sure you want to force re-scan?",
|
"MessageConfirmForceReScan": "Are you sure you want to force re-scan?",
|
||||||
"MessageConfirmMarkSeriesFinished": "Are you sure you want to mark all books in this series as finished?",
|
"MessageConfirmMarkSeriesFinished": "Are you sure you want to mark all books in this series as finished?",
|
||||||
"MessageConfirmMarkSeriesNotFinished": "Are you sure you want to mark all books in this series as not finished?",
|
"MessageConfirmMarkSeriesNotFinished": "Are you sure you want to mark all books in this series as not finished?",
|
||||||
|
"MessageConfirmRemoveAllChapters": "Are you sure you want to remove all chapters?",
|
||||||
"MessageConfirmRemoveCollection": "Are you sure you want to remove collection \"{0}\"?",
|
"MessageConfirmRemoveCollection": "Are you sure you want to remove collection \"{0}\"?",
|
||||||
"MessageConfirmRemoveEpisode": "Are you sure you want to remove episode \"{0}\"?",
|
"MessageConfirmRemoveEpisode": "Are you sure you want to remove episode \"{0}\"?",
|
||||||
"MessageConfirmRemoveEpisodes": "Are you sure you want to remove {0} episodes?",
|
"MessageConfirmRemoveEpisodes": "Are you sure you want to remove {0} episodes?",
|
||||||
|
"MessageConfirmRemoveNarrator": "Are you sure you want to remove narrator \"{0}\"?",
|
||||||
"MessageConfirmRemovePlaylist": "Are you sure you want to remove your playlist \"{0}\"?",
|
"MessageConfirmRemovePlaylist": "Are you sure you want to remove your playlist \"{0}\"?",
|
||||||
"MessageConfirmRenameGenre": "Are you sure you want to rename genre \"{0}\" to \"{1}\" for all items?",
|
"MessageConfirmRenameGenre": "Are you sure you want to rename genre \"{0}\" to \"{1}\" for all items?",
|
||||||
"MessageConfirmRenameGenreMergeNote": "Note: This genre already exists so they will be merged.",
|
"MessageConfirmRenameGenreMergeNote": "Note: This genre already exists so they will be merged.",
|
||||||
@@ -475,6 +506,7 @@
|
|||||||
"MessageConfirmRenameTag": "Are you sure you want to rename tag \"{0}\" to \"{1}\" for all items?",
|
"MessageConfirmRenameTag": "Are you sure you want to rename tag \"{0}\" to \"{1}\" for all items?",
|
||||||
"MessageConfirmRenameTagMergeNote": "Note: This tag already exists so they will be merged.",
|
"MessageConfirmRenameTagMergeNote": "Note: This tag already exists so they will be merged.",
|
||||||
"MessageConfirmRenameTagWarning": "Warning! A similar tag with a different casing already exists \"{0}\".",
|
"MessageConfirmRenameTagWarning": "Warning! A similar tag with a different casing already exists \"{0}\".",
|
||||||
|
"MessageConfirmSendEbookToDevice": "Are you sure you want to send {0} ebook \"{1}\" to device \"{2}\"?",
|
||||||
"MessageDownloadingEpisode": "Downloading episode",
|
"MessageDownloadingEpisode": "Downloading episode",
|
||||||
"MessageDragFilesIntoTrackOrder": "Drag files into correct track order",
|
"MessageDragFilesIntoTrackOrder": "Drag files into correct track order",
|
||||||
"MessageEmbedFinished": "Embed Finished!",
|
"MessageEmbedFinished": "Embed Finished!",
|
||||||
@@ -629,6 +661,8 @@
|
|||||||
"ToastRemoveItemFromCollectionSuccess": "Item removed from collection",
|
"ToastRemoveItemFromCollectionSuccess": "Item removed from collection",
|
||||||
"ToastRSSFeedCloseFailed": "Failed to close RSS feed",
|
"ToastRSSFeedCloseFailed": "Failed to close RSS feed",
|
||||||
"ToastRSSFeedCloseSuccess": "RSS feed closed",
|
"ToastRSSFeedCloseSuccess": "RSS feed closed",
|
||||||
|
"ToastSendEbookToDeviceFailed": "Failed to send ebook to device",
|
||||||
|
"ToastSendEbookToDeviceSuccess": "Ebook sent to device \"{0}\"",
|
||||||
"ToastSeriesUpdateFailed": "Series update failed",
|
"ToastSeriesUpdateFailed": "Series update failed",
|
||||||
"ToastSeriesUpdateSuccess": "Series update success",
|
"ToastSeriesUpdateSuccess": "Series update success",
|
||||||
"ToastSessionDeleteFailed": "Failed to delete session",
|
"ToastSessionDeleteFailed": "Failed to delete session",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user