mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-01-05 22:19:53 -05:00
Compare commits
237 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
372101592c | ||
|
|
18123664ee | ||
|
|
2e6e4f970c | ||
|
|
1c9e56ce2e | ||
|
|
9e7b84f289 | ||
|
|
86ee4dcff2 | ||
|
|
277a5fa37c | ||
|
|
51b87912f8 | ||
|
|
653019921e | ||
|
|
ccc291067d | ||
|
|
af7e3a03f0 | ||
|
|
7c40d26857 | ||
|
|
6c507de501 | ||
|
|
482a4340f5 | ||
|
|
21e704e12c | ||
|
|
2b91bff1af | ||
|
|
d11f9608b4 | ||
|
|
2b0b691b69 | ||
|
|
5dfd5c4971 | ||
|
|
201f1bff3e | ||
|
|
a22ebb257f | ||
|
|
bf6e87d4bc | ||
|
|
b823a93ae2 | ||
|
|
05afd12682 | ||
|
|
997e23150e | ||
|
|
3c5bf376b5 | ||
|
|
bca2cfda13 | ||
|
|
916b41d587 | ||
|
|
ab08d83c04 | ||
|
|
415e0a7b5a | ||
|
|
d301c12acd | ||
|
|
7aa7e662b2 | ||
|
|
1dbfb5637a | ||
|
|
4e1aacb44f | ||
|
|
954cf3e14e | ||
|
|
b61ecefce4 | ||
|
|
8562b8d1b3 | ||
|
|
06ec2159f5 | ||
|
|
68b565505e | ||
|
|
83ff2752dd | ||
|
|
d0af1c3c9a | ||
|
|
1ad46d4fb8 | ||
|
|
d3dd13eae5 | ||
|
|
f27982d887 | ||
|
|
624a44f572 | ||
|
|
e623bf7fde | ||
|
|
6fc70b8656 | ||
|
|
354cefb9f4 | ||
|
|
a78aa88dbc | ||
|
|
9ac2453676 | ||
|
|
bb70800b4e | ||
|
|
855272a558 | ||
|
|
ebb2c5f791 | ||
|
|
2e466bb164 | ||
|
|
95ebe0f087 | ||
|
|
0a6aa43b07 | ||
|
|
806a8cf659 | ||
|
|
1a32fbfeec | ||
|
|
67396c16dd | ||
|
|
b0684b6f1b | ||
|
|
661778c02c | ||
|
|
5c4241aefe | ||
|
|
3f6bc90824 | ||
|
|
4ade6e04a8 | ||
|
|
49d0835236 | ||
|
|
d90bd92bcc | ||
|
|
41c016b8c7 | ||
|
|
5b4d3f71f9 | ||
|
|
256a9322ef | ||
|
|
793f82e445 | ||
|
|
ab6da3914b | ||
|
|
0b53f0ebf3 | ||
|
|
76d668514e | ||
|
|
3c347bef7d | ||
|
|
e837e5f780 | ||
|
|
26348ccc74 | ||
|
|
729a756e21 | ||
|
|
4dbddcf179 | ||
|
|
f2fff34d4d | ||
|
|
59c5e2c1d9 | ||
|
|
067006f406 | ||
|
|
93d82b973e | ||
|
|
a9a3423b58 | ||
|
|
f4ee215ad8 | ||
|
|
48431b1c35 | ||
|
|
ce961f90ba | ||
|
|
916d2f6bb3 | ||
|
|
01e7098f00 | ||
|
|
e02fbac4cd | ||
|
|
a8fce32e70 | ||
|
|
d0637c1e3d | ||
|
|
f6702d299d | ||
|
|
033b7ece28 | ||
|
|
5f5dce6d53 | ||
|
|
82c5c7518b | ||
|
|
7a60ffb3c4 | ||
|
|
2795f657b5 | ||
|
|
9ef5b5830e | ||
|
|
879adfa633 | ||
|
|
b12a344776 | ||
|
|
50b1098797 | ||
|
|
fdfaa7eba4 | ||
|
|
5525587513 | ||
|
|
1f20ed7640 | ||
|
|
f741064843 | ||
|
|
d5138e4c0a | ||
|
|
42a30c33db | ||
|
|
e5d978f8e8 | ||
|
|
ccc82520a9 | ||
|
|
22acf52a26 | ||
|
|
2ccd2786f4 | ||
|
|
0028136935 | ||
|
|
0edc46b771 | ||
|
|
2261f3d1c3 | ||
|
|
5c0e792782 | ||
|
|
644882e04f | ||
|
|
67f51c6de9 | ||
|
|
0c8fd6ab0e | ||
|
|
5452a57a14 | ||
|
|
19f020e7a6 | ||
|
|
825641f2a9 | ||
|
|
35ab4cb2fe | ||
|
|
fd13607d89 | ||
|
|
f79b4d44b9 | ||
|
|
91e30a6e84 | ||
|
|
8ab0f164a6 | ||
|
|
578bb03404 | ||
|
|
06582b5371 | ||
|
|
7f6baf35b7 | ||
|
|
6227d0baa1 | ||
|
|
e334b585be | ||
|
|
c83b3f19f7 | ||
|
|
d31ec055f9 | ||
|
|
38c259a45e | ||
|
|
b2ee24de98 | ||
|
|
9ba0e52bb7 | ||
|
|
edc712e6f6 | ||
|
|
485888b2d9 | ||
|
|
c2e90d4d83 | ||
|
|
f5d89b8f52 | ||
|
|
378b40790a | ||
|
|
be3d38392d | ||
|
|
27fef50983 | ||
|
|
167df85c1e | ||
|
|
009e16c9a4 | ||
|
|
b40cc767b2 | ||
|
|
4f5f2d32be | ||
|
|
66be3e0281 | ||
|
|
987f188f00 | ||
|
|
daca2bdf2a | ||
|
|
8894f52439 | ||
|
|
863f81e55a | ||
|
|
d03d3735e5 | ||
|
|
3bb2df6e12 | ||
|
|
80c9efc618 | ||
|
|
3279901ab0 | ||
|
|
d43d351721 | ||
|
|
8210eba439 | ||
|
|
cbd7294b0b | ||
|
|
6064e8af87 | ||
|
|
8754f0c25f | ||
|
|
f31700f668 | ||
|
|
9877b139f6 | ||
|
|
5643c846ee | ||
|
|
5a071babe9 | ||
|
|
3d85d0bce6 | ||
|
|
68afc2c718 | ||
|
|
b3d9323f66 | ||
|
|
effc63755b | ||
|
|
b90934a72a | ||
|
|
e01748eb2f | ||
|
|
430fbf5e46 | ||
|
|
27e6b9ce0d | ||
|
|
fc614b9833 | ||
|
|
a97c102369 | ||
|
|
745a491f90 | ||
|
|
b2880ab0a9 | ||
|
|
f916454c55 | ||
|
|
701b8ea12e | ||
|
|
2079942ccd | ||
|
|
140b718592 | ||
|
|
2de8c72131 | ||
|
|
089d4b5cee | ||
|
|
e06a015d6e | ||
|
|
b7e546f2f5 | ||
|
|
26ef275ab4 | ||
|
|
416db7c981 | ||
|
|
78079b2e60 | ||
|
|
03bffb725a | ||
|
|
46fc89e247 | ||
|
|
fbbceaa642 | ||
|
|
f5aae25cc8 | ||
|
|
8d03943acb | ||
|
|
853513b926 | ||
|
|
c606a41314 | ||
|
|
35f29ca22b | ||
|
|
ac00f3ebe7 | ||
|
|
6846de98f8 | ||
|
|
881baa818d | ||
|
|
b671145e73 | ||
|
|
8809c7b900 | ||
|
|
ae8f3aa918 | ||
|
|
5d4047c171 | ||
|
|
6f80591afd | ||
|
|
9b6fa8fe8c | ||
|
|
d6c02ebb2c | ||
|
|
788d867ec3 | ||
|
|
3bc3914fd9 | ||
|
|
3d821dacb7 | ||
|
|
e0546c6164 | ||
|
|
be7ccfb209 | ||
|
|
938a8c6f80 | ||
|
|
5cd343cb01 | ||
|
|
ab0094a53b | ||
|
|
2d5e4ebcf0 | ||
|
|
3171ce5aba | ||
|
|
0e1692d26b | ||
|
|
e8cd18eac2 | ||
|
|
bf928692d5 | ||
|
|
792490b629 | ||
|
|
0d1ff35c5e | ||
|
|
67e02fddbd | ||
|
|
09beb6a2ae | ||
|
|
1bd657f07d | ||
|
|
2dba17a7ae | ||
|
|
4900649908 | ||
|
|
c3b33ea37a | ||
|
|
36bd6e649a | ||
|
|
4621c78573 | ||
|
|
c88bbf1ce4 | ||
|
|
d37b25a6f6 | ||
|
|
792268f5ee | ||
|
|
5f2d6f4d5e | ||
|
|
1350a91fba | ||
|
|
acf22ca4fa | ||
|
|
705aac40d7 | ||
|
|
7456052620 |
7
.github/ISSUE_TEMPLATE/bug.yaml
vendored
7
.github/ISSUE_TEMPLATE/bug.yaml
vendored
@@ -9,6 +9,12 @@ body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: "### Mobile app issues report [here](https://github.com/advplyr/audiobookshelf-app/issues/new/choose)."
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: "### Join the [discord server](https://discord.gg/pJsjuNCKRq) for questions or if you are not sure about a bug."
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: "## Be as descriptive as you can. Include screenshots, error logs, browser, file types, everything you can think of that might be relevant."
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
@@ -27,6 +33,7 @@ body:
|
||||
id: version
|
||||
attributes:
|
||||
label: Audiobookshelf version
|
||||
description: Do not put 'Latest version', please put the actual version here
|
||||
placeholder: "e.g. v1.6.60"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
36
.github/workflows/docker-build.yml
vendored
36
.github/workflows/docker-build.yml
vendored
@@ -3,8 +3,15 @@
|
||||
name: Build and Push Docker Image
|
||||
|
||||
on:
|
||||
# Allows you to run workflow manually from Actions tab
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tags:
|
||||
description: 'Docker Tag'
|
||||
required: true
|
||||
default: 'latest'
|
||||
push:
|
||||
branches: [master]
|
||||
branches: [main,master]
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
# Only build when files in these directories have been changed
|
||||
@@ -13,8 +20,6 @@ on:
|
||||
- server/**
|
||||
- index.js
|
||||
- package.json
|
||||
# Allows you to run workflow manually from Actions tab
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -23,24 +28,25 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v3
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: advplyr/audiobookshelf,ghcr.io/${{ github.repository_owner }}/audiobookshelf
|
||||
tags: |
|
||||
type=edge,branch=master
|
||||
type=semver,pattern={{version}}
|
||||
|
||||
- name: Setup QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
uses: docker/setup-qemu-action@v2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Cache Docker layers
|
||||
uses: actions/cache@v2
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: /tmp/.buildx-cache
|
||||
key: ${{ runner.os }}-buildx-${{ github.sha }}
|
||||
@@ -48,28 +54,28 @@ jobs:
|
||||
${{ runner.os }}-buildx-
|
||||
|
||||
- name: Login to Dockerhub
|
||||
uses: docker/login-action@v1
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
|
||||
|
||||
- name: Login to ghcr
|
||||
uses: docker/login-action@v1
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GHCR_PASSWORD }}
|
||||
|
||||
- name: Build image
|
||||
uses: docker/build-push-action@v2
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
tags: ${{ github.event.inputs.tags || steps.meta.outputs.tags }}
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
push: true
|
||||
cache-from: type=local,src=/tmp/.buildx-cache
|
||||
cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
|
||||
|
||||
|
||||
- name: Move cache
|
||||
run: |
|
||||
rm -rf /tmp/.buildx-cache
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -11,6 +11,7 @@ test/
|
||||
/client/.nuxt/
|
||||
/client/dist/
|
||||
/dist/
|
||||
library/
|
||||
|
||||
sw.*
|
||||
.DS_STORE
|
||||
.DS_STORE
|
||||
|
||||
18
Dockerfile
18
Dockerfile
@@ -7,13 +7,23 @@ RUN npm run generate
|
||||
|
||||
### STAGE 1: Build server ###
|
||||
FROM node:16-alpine
|
||||
RUN apk update && apk add --no-cache --update ffmpeg
|
||||
ENV NODE_ENV=production
|
||||
RUN apk update && \
|
||||
apk add --no-cache --update \
|
||||
curl \
|
||||
tzdata \
|
||||
ffmpeg
|
||||
|
||||
COPY --from=build /client/dist /client/dist
|
||||
COPY index.js index.js
|
||||
COPY package-lock.json package-lock.json
|
||||
COPY package.json package.json
|
||||
COPY index.js package* /
|
||||
COPY server server
|
||||
|
||||
RUN npm ci --only=production
|
||||
|
||||
EXPOSE 80
|
||||
HEALTHCHECK \
|
||||
--interval=30s \
|
||||
--timeout=3s \
|
||||
--start-period=10s \
|
||||
CMD curl -f http://127.0.0.1/ping || exit 1
|
||||
CMD ["npm", "start"]
|
||||
|
||||
@@ -225,4 +225,18 @@ Bookshelf Label
|
||||
-webkit-line-clamp: 2;
|
||||
/* number of lines to show */
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
|
||||
/* Padding for toastification toasts in the top right to not cover appbar/toolbar */
|
||||
.app-bar-and-toolbar .Vue-Toastification__container.top-right {
|
||||
padding-top: 104px;
|
||||
}
|
||||
|
||||
.app-bar .Vue-Toastification__container.top-right {
|
||||
padding-top: 64px;
|
||||
}
|
||||
|
||||
.no-bars .Vue-Toastification__container.top-right {
|
||||
padding-top: 8px;
|
||||
}
|
||||
@@ -1,34 +1,40 @@
|
||||
.flip-list-move {
|
||||
transition: transform 0.5s;
|
||||
}
|
||||
|
||||
.no-move {
|
||||
transition: transform 0s;
|
||||
}
|
||||
|
||||
.ghost {
|
||||
opacity: 0.5;
|
||||
background-color: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.list-group {
|
||||
min-height: 30px;
|
||||
}
|
||||
#librariesTable .item {
|
||||
cursor: n-resize;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
cursor: n-resize;
|
||||
}
|
||||
|
||||
.list-group-item:not(.exclude) {
|
||||
cursor: n-resize;
|
||||
}
|
||||
|
||||
.list-group-item.exclude {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.list-group-item:not(.ghost):not(.exclude):hover {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.list-group-item:nth-child(even):not(.ghost):not(.exclude) {
|
||||
background-color: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.list-group-item:nth-child(even):not(.ghost):not(.exclude):hover {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
@@ -36,6 +42,7 @@
|
||||
.list-group-item.exclude:not(.ghost) {
|
||||
background-color: rgba(255, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.list-group-item.exclude:not(.ghost):hover {
|
||||
background-color: rgba(223, 0, 0, 0.25);
|
||||
}
|
||||
@@ -74,7 +74,7 @@
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
|
||||
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
|
||||
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
|
||||
unicode-range: U+1F00-1FFF;
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
|
||||
unicode-range: U+0370-03FF;
|
||||
}
|
||||
|
||||
@@ -114,7 +114,7 @@
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
|
||||
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
|
||||
}
|
||||
|
||||
@@ -124,7 +124,7 @@
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
|
||||
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('truetype');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
@@ -154,7 +154,7 @@
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');
|
||||
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
|
||||
@@ -164,7 +164,7 @@
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');
|
||||
unicode-range: U+1F00-1FFF;
|
||||
}
|
||||
|
||||
@@ -174,7 +174,7 @@
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');
|
||||
unicode-range: U+0370-03FF;
|
||||
}
|
||||
|
||||
@@ -184,7 +184,7 @@
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');
|
||||
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
|
||||
}
|
||||
|
||||
@@ -194,7 +194,7 @@
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');
|
||||
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
@@ -204,7 +204,7 @@
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('truetype');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
@@ -214,7 +214,7 @@
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
|
||||
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||
}
|
||||
|
||||
@@ -224,7 +224,7 @@
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
|
||||
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
|
||||
@@ -234,7 +234,7 @@
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
|
||||
unicode-range: U+1F00-1FFF;
|
||||
}
|
||||
|
||||
@@ -244,7 +244,7 @@
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
|
||||
unicode-range: U+0370-03FF;
|
||||
}
|
||||
|
||||
@@ -254,7 +254,7 @@
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
|
||||
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
|
||||
}
|
||||
|
||||
@@ -264,7 +264,7 @@
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
|
||||
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
@@ -274,7 +274,7 @@
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('truetype');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
@@ -284,7 +284,7 @@
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf');
|
||||
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');
|
||||
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||
}
|
||||
|
||||
@@ -294,7 +294,7 @@
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf');
|
||||
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');
|
||||
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
|
||||
@@ -304,7 +304,7 @@
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf');
|
||||
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');
|
||||
unicode-range: U+1F00-1FFF;
|
||||
}
|
||||
|
||||
@@ -314,7 +314,7 @@
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf');
|
||||
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');
|
||||
unicode-range: U+0370-03FF;
|
||||
}
|
||||
|
||||
@@ -324,7 +324,7 @@
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf');
|
||||
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');
|
||||
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
@@ -334,6 +334,6 @@
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf');
|
||||
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@@ -1,415 +0,0 @@
|
||||
<template>
|
||||
<div class="w-full -mt-6">
|
||||
<div class="w-full relative mb-1">
|
||||
<div class="absolute -top-10 md:top-0 right-0 md:right-2 flex items-center h-full">
|
||||
<controls-volume-control ref="volumeControl" v-model="volume" @input="setVolume" class="mx-2 hidden md:block" />
|
||||
|
||||
<div class="cursor-pointer text-gray-300 mx-1 md:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showSleepTimer')">
|
||||
<span v-if="!sleepTimerSet" class="material-icons" style="font-size: 1.7rem">snooze</span>
|
||||
<div v-else class="flex items-center">
|
||||
<span class="material-icons text-lg text-warning">snooze</span>
|
||||
<p class="text-xl text-warning font-mono font-semibold text-center px-0.5 pb-0.5" style="min-width: 30px">{{ sleepTimerRemainingString }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!isPodcast" class="cursor-pointer text-gray-300 mx-1 md:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showBookmarks')">
|
||||
<span class="material-icons" style="font-size: 1.7rem">{{ bookmarks.length ? 'bookmarks' : 'bookmark_border' }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="chapters.length" class="cursor-pointer text-gray-300 mx-1 md:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="showChapters">
|
||||
<span class="material-icons text-3xl">format_list_bulleted</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex pt-4 pb-2 md:pt-0 md:pb-2">
|
||||
<div class="flex-grow" />
|
||||
<template v-if="!loading">
|
||||
<div class="cursor-pointer flex items-center justify-center text-gray-300 mr-8" @mousedown.prevent @mouseup.prevent @click.stop="restart">
|
||||
<span class="material-icons text-3xl">first_page</span>
|
||||
</div>
|
||||
<div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpBackward">
|
||||
<span class="material-icons text-3xl">replay_10</span>
|
||||
</div>
|
||||
<div class="cursor-pointer p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-8" :class="seekLoading ? 'animate-spin' : ''" @mousedown.prevent @mouseup.prevent @click.stop="playPause">
|
||||
<span class="material-icons">{{ seekLoading ? 'autorenew' : paused ? 'play_arrow' : 'pause' }}</span>
|
||||
</div>
|
||||
<div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpForward">
|
||||
<span class="material-icons text-3xl">forward_10</span>
|
||||
</div>
|
||||
<controls-playback-speed-control v-model="playbackRate" @input="playbackRateUpdated" @change="playbackRateChanged" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="cursor-pointer p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-8 animate-spin">
|
||||
<span class="material-icons">autorenew</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex-grow" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<!-- Track -->
|
||||
<div ref="track" class="w-full h-2 bg-gray-700 relative cursor-pointer transform duration-100 hover:scale-y-125 overflow-hidden" @mousemove="mousemoveTrack" @mouseleave="mouseleaveTrack" @click.stop="clickTrack">
|
||||
<div ref="readyTrack" class="h-full bg-gray-600 absolute top-0 left-0 pointer-events-none" />
|
||||
<div ref="bufferTrack" class="h-full bg-gray-400 absolute top-0 left-0 pointer-events-none" />
|
||||
<div ref="playedTrack" class="h-full bg-gray-200 absolute top-0 left-0 pointer-events-none" />
|
||||
<div ref="trackCursor" class="h-full w-0.5 bg-gray-50 absolute top-0 left-0 opacity-0 pointer-events-none" />
|
||||
<div v-if="loading" class="h-full w-1/4 absolute left-0 top-0 loadingTrack pointer-events-none bg-white bg-opacity-25" />
|
||||
</div>
|
||||
<div ref="track" class="w-full h-2 relative overflow-hidden">
|
||||
<template v-for="(tick, index) in chapterTicks">
|
||||
<div :key="index" :style="{ left: tick.left + 'px' }" class="absolute top-0 w-px bg-white bg-opacity-30 h-1 pointer-events-none" />
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Hover timestamp -->
|
||||
<div ref="hoverTimestamp" class="absolute -top-8 left-0 bg-white text-black rounded-full opacity-0 pointer-events-none">
|
||||
<p ref="hoverTimestampText" class="text-xs font-mono text-center px-2 py-0.5 truncate whitespace-nowrap">00:00</p>
|
||||
</div>
|
||||
<div ref="hoverTimestampArrow" class="absolute -top-3 left-0 bg-white text-black rounded-full opacity-0 pointer-events-none">
|
||||
<div class="absolute -bottom-1.5 left-0 right-0 w-full flex justify-center">
|
||||
<div class="arrow-down" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<p ref="currentTimestamp" class="font-mono text-sm text-gray-100 pointer-events-auto">00:00:00</p>
|
||||
<p class="font-mono text-sm text-gray-100 pointer-events-auto"> / {{ progressPercent }}%</p>
|
||||
<div class="flex-grow" />
|
||||
<p class="text-sm text-gray-300 pt-0.5">{{ currentChapterName }}</p>
|
||||
<div class="flex-grow" />
|
||||
<p class="font-mono text-sm text-gray-100 pointer-events-auto">{{ timeRemainingPretty }}</p>
|
||||
</div>
|
||||
|
||||
<modals-chapters-modal v-model="showChaptersModal" :current-chapter="currentChapter" :chapters="chapters" @select="selectChapter" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
loading: Boolean,
|
||||
paused: Boolean,
|
||||
chapters: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
bookmarks: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
sleepTimerSet: Boolean,
|
||||
sleepTimerRemaining: Number,
|
||||
isPodcast: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
volume: 1,
|
||||
playbackRate: 1,
|
||||
trackWidth: 0,
|
||||
playedTrackWidth: 0,
|
||||
bufferTrackWidth: 0,
|
||||
readyTrackWidth: 0,
|
||||
audioEl: null,
|
||||
seekLoading: false,
|
||||
showChaptersModal: false,
|
||||
currentTime: 0,
|
||||
trackOffsetLeft: 16, // Track is 16px from edge
|
||||
duration: 0,
|
||||
chapterTicks: []
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
sleepTimerRemainingString() {
|
||||
var rounded = Math.round(this.sleepTimerRemaining)
|
||||
if (rounded < 90) {
|
||||
return `${rounded}s`
|
||||
}
|
||||
var minutesRounded = Math.round(rounded / 60)
|
||||
if (minutesRounded < 90) {
|
||||
return `${minutesRounded}m`
|
||||
}
|
||||
var hoursRounded = Math.round(minutesRounded / 60)
|
||||
return `${hoursRounded}h`
|
||||
},
|
||||
token() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
timeRemaining() {
|
||||
return (this.duration - this.currentTime) / this.playbackRate
|
||||
},
|
||||
timeRemainingPretty() {
|
||||
if (this.timeRemaining < 0) {
|
||||
return this.$secondsToTimestamp(this.timeRemaining * -1)
|
||||
}
|
||||
return '-' + this.$secondsToTimestamp(this.timeRemaining)
|
||||
},
|
||||
progressPercent() {
|
||||
if (!this.duration) return 0
|
||||
return Math.round((100 * this.currentTime) / this.duration)
|
||||
},
|
||||
currentChapter() {
|
||||
return this.chapters.find((chapter) => chapter.start <= this.currentTime && this.currentTime < chapter.end)
|
||||
},
|
||||
currentChapterName() {
|
||||
return this.currentChapter ? this.currentChapter.title : ''
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
setDuration(duration) {
|
||||
this.duration = duration
|
||||
|
||||
this.chapterTicks = this.chapters.map((chap) => {
|
||||
var perc = chap.start / this.duration
|
||||
return {
|
||||
title: chap.title,
|
||||
left: perc * this.trackWidth
|
||||
}
|
||||
})
|
||||
},
|
||||
setCurrentTime(time) {
|
||||
this.currentTime = time
|
||||
this.updateTimestamp()
|
||||
this.updatePlayedTrack()
|
||||
},
|
||||
playPause() {
|
||||
this.$emit('playPause')
|
||||
},
|
||||
jumpBackward() {
|
||||
this.$emit('jumpBackward')
|
||||
},
|
||||
jumpForward() {
|
||||
this.$emit('jumpForward')
|
||||
},
|
||||
increaseVolume() {
|
||||
if (this.volume >= 1) return
|
||||
this.volume = Math.min(1, this.volume + 0.1)
|
||||
this.setVolume(this.volume)
|
||||
},
|
||||
decreaseVolume() {
|
||||
if (this.volume <= 0) return
|
||||
this.volume = Math.max(0, this.volume - 0.1)
|
||||
this.setVolume(this.volume)
|
||||
},
|
||||
setVolume(volume) {
|
||||
this.$emit('setVolume', volume)
|
||||
},
|
||||
toggleMute() {
|
||||
if (this.$refs.volumeControl && this.$refs.volumeControl.toggleMute) {
|
||||
this.$refs.volumeControl.toggleMute()
|
||||
}
|
||||
},
|
||||
increasePlaybackRate() {
|
||||
var rates = [0.25, 0.5, 0.8, 1, 1.3, 1.5, 2, 2.5, 3]
|
||||
var currentRateIndex = rates.findIndex((r) => r === this.playbackRate)
|
||||
if (currentRateIndex >= rates.length - 1) return
|
||||
this.playbackRate = rates[currentRateIndex + 1] || 1
|
||||
this.playbackRateChanged(this.playbackRate)
|
||||
},
|
||||
decreasePlaybackRate() {
|
||||
var rates = [0.25, 0.5, 0.8, 1, 1.3, 1.5, 2, 2.5, 3]
|
||||
var currentRateIndex = rates.findIndex((r) => r === this.playbackRate)
|
||||
if (currentRateIndex <= 0) return
|
||||
this.playbackRate = rates[currentRateIndex - 1] || 1
|
||||
this.playbackRateChanged(this.playbackRate)
|
||||
},
|
||||
setPlaybackRate(playbackRate) {
|
||||
this.$emit('setPlaybackRate', playbackRate)
|
||||
},
|
||||
selectChapter(chapter) {
|
||||
this.seek(chapter.start)
|
||||
this.showChaptersModal = false
|
||||
},
|
||||
seek(time) {
|
||||
this.$emit('seek', time)
|
||||
},
|
||||
playbackRateUpdated(playbackRate) {
|
||||
this.setPlaybackRate(playbackRate)
|
||||
},
|
||||
playbackRateChanged(playbackRate) {
|
||||
this.setPlaybackRate(playbackRate)
|
||||
this.$store.dispatch('user/updateUserSettings', { playbackRate }).catch((err) => {
|
||||
console.error('Failed to update settings', err)
|
||||
})
|
||||
},
|
||||
mousemoveTrack(e) {
|
||||
var offsetX = e.offsetX
|
||||
var time = (offsetX / this.trackWidth) * this.duration
|
||||
if (this.$refs.hoverTimestamp) {
|
||||
var width = this.$refs.hoverTimestamp.clientWidth
|
||||
this.$refs.hoverTimestamp.style.opacity = 1
|
||||
var posLeft = offsetX - width / 2
|
||||
if (posLeft + width + this.trackOffsetLeft > window.innerWidth) {
|
||||
posLeft = window.innerWidth - width - this.trackOffsetLeft
|
||||
} else if (posLeft < -this.trackOffsetLeft) {
|
||||
posLeft = -this.trackOffsetLeft
|
||||
}
|
||||
this.$refs.hoverTimestamp.style.left = posLeft + 'px'
|
||||
}
|
||||
|
||||
if (this.$refs.hoverTimestampArrow) {
|
||||
var width = this.$refs.hoverTimestampArrow.clientWidth
|
||||
var posLeft = offsetX - width / 2
|
||||
this.$refs.hoverTimestampArrow.style.opacity = 1
|
||||
this.$refs.hoverTimestampArrow.style.left = posLeft + 'px'
|
||||
}
|
||||
if (this.$refs.hoverTimestampText) {
|
||||
var hoverText = this.$secondsToTimestamp(time)
|
||||
|
||||
var chapter = this.chapters.find((chapter) => chapter.start <= time && time < chapter.end)
|
||||
if (chapter && chapter.title) {
|
||||
hoverText += ` - ${chapter.title}`
|
||||
}
|
||||
this.$refs.hoverTimestampText.innerText = hoverText
|
||||
}
|
||||
if (this.$refs.trackCursor) {
|
||||
this.$refs.trackCursor.style.opacity = 1
|
||||
this.$refs.trackCursor.style.left = offsetX - 1 + 'px'
|
||||
}
|
||||
},
|
||||
mouseleaveTrack() {
|
||||
if (this.$refs.hoverTimestamp) {
|
||||
this.$refs.hoverTimestamp.style.opacity = 0
|
||||
}
|
||||
if (this.$refs.hoverTimestampArrow) {
|
||||
this.$refs.hoverTimestampArrow.style.opacity = 0
|
||||
}
|
||||
if (this.$refs.trackCursor) {
|
||||
this.$refs.trackCursor.style.opacity = 0
|
||||
}
|
||||
},
|
||||
restart() {
|
||||
this.seek(0)
|
||||
},
|
||||
setStreamReady() {
|
||||
this.readyTrackWidth = this.trackWidth
|
||||
this.$refs.readyTrack.style.width = this.trackWidth + 'px'
|
||||
},
|
||||
setChunksReady(chunks, numSegments) {
|
||||
var largestSeg = 0
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
var chunk = chunks[i]
|
||||
if (typeof chunk === 'string') {
|
||||
var chunkRange = chunk.split('-').map((c) => Number(c))
|
||||
if (chunkRange.length < 2) continue
|
||||
if (chunkRange[1] > largestSeg) largestSeg = chunkRange[1]
|
||||
} else if (chunk > largestSeg) {
|
||||
largestSeg = chunk
|
||||
}
|
||||
}
|
||||
var percentageReady = largestSeg / numSegments
|
||||
var widthReady = Math.round(this.trackWidth * percentageReady)
|
||||
if (this.readyTrackWidth === widthReady) return
|
||||
this.readyTrackWidth = widthReady
|
||||
this.$refs.readyTrack.style.width = widthReady + 'px'
|
||||
},
|
||||
updateTimestamp() {
|
||||
var ts = this.$refs.currentTimestamp
|
||||
if (!ts) {
|
||||
console.error('No timestamp el')
|
||||
return
|
||||
}
|
||||
var currTimeClean = this.$secondsToTimestamp(this.currentTime)
|
||||
ts.innerText = currTimeClean
|
||||
},
|
||||
updatePlayedTrack() {
|
||||
var perc = this.currentTime / this.duration
|
||||
var ptWidth = Math.round(perc * this.trackWidth)
|
||||
if (this.playedTrackWidth === ptWidth) {
|
||||
return
|
||||
}
|
||||
this.$refs.playedTrack.style.width = ptWidth + 'px'
|
||||
this.playedTrackWidth = ptWidth
|
||||
},
|
||||
clickTrack(e) {
|
||||
if (this.loading) return
|
||||
|
||||
var offsetX = e.offsetX
|
||||
var perc = offsetX / this.trackWidth
|
||||
var time = perc * this.duration
|
||||
if (isNaN(time) || time === null) {
|
||||
console.error('Invalid time', perc, time)
|
||||
return
|
||||
}
|
||||
this.seek(time)
|
||||
},
|
||||
setBufferTime(bufferTime) {
|
||||
if (!this.audioEl) {
|
||||
return
|
||||
}
|
||||
var bufferlen = (bufferTime / this.duration) * this.trackWidth
|
||||
bufferlen = Math.round(bufferlen)
|
||||
if (this.bufferTrackWidth === bufferlen || !this.$refs.bufferTrack) return
|
||||
this.$refs.bufferTrack.style.width = bufferlen + 'px'
|
||||
this.bufferTrackWidth = bufferlen
|
||||
},
|
||||
showChapters() {
|
||||
if (!this.chapters.length) return
|
||||
this.showChaptersModal = !this.showChaptersModal
|
||||
},
|
||||
init() {
|
||||
this.playbackRate = this.$store.getters['user/getUserSetting']('playbackRate') || 1
|
||||
this.$emit('setPlaybackRate', this.playbackRate)
|
||||
this.setTrackWidth()
|
||||
},
|
||||
setTrackWidth() {
|
||||
if (this.$refs.track) {
|
||||
this.trackWidth = this.$refs.track.clientWidth
|
||||
} else {
|
||||
console.error('Track not loaded', this.$refs)
|
||||
}
|
||||
},
|
||||
settingsUpdated(settings) {
|
||||
if (settings.playbackRate && this.playbackRate !== settings.playbackRate) {
|
||||
this.setPlaybackRate(settings.playbackRate)
|
||||
}
|
||||
},
|
||||
closePlayer() {
|
||||
if (this.loading) return
|
||||
this.$emit('close')
|
||||
},
|
||||
hotkey(action) {
|
||||
if (action === this.$hotkeys.AudioPlayer.PLAY_PAUSE) this.playPause()
|
||||
else if (action === this.$hotkeys.AudioPlayer.JUMP_FORWARD) this.jumpForward()
|
||||
else if (action === this.$hotkeys.AudioPlayer.JUMP_BACKWARD) this.jumpBackward()
|
||||
else if (action === this.$hotkeys.AudioPlayer.VOLUME_UP) this.increaseVolume()
|
||||
else if (action === this.$hotkeys.AudioPlayer.VOLUME_DOWN) this.decreaseVolume()
|
||||
else if (action === this.$hotkeys.AudioPlayer.MUTE_UNMUTE) this.toggleMute()
|
||||
else if (action === this.$hotkeys.AudioPlayer.SHOW_CHAPTERS) this.showChapters()
|
||||
else if (action === this.$hotkeys.AudioPlayer.INCREASE_PLAYBACK_RATE) this.increasePlaybackRate()
|
||||
else if (action === this.$hotkeys.AudioPlayer.DECREASE_PLAYBACK_RATE) this.decreasePlaybackRate()
|
||||
else if (action === this.$hotkeys.AudioPlayer.CLOSE) this.closePlayer()
|
||||
},
|
||||
windowResize() {
|
||||
this.setTrackWidth()
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
window.addEventListener('resize', this.windowResize)
|
||||
this.$store.commit('user/addSettingsListener', { id: 'audioplayer', meth: this.settingsUpdated })
|
||||
this.init()
|
||||
this.$eventBus.$on('player-hotkey', this.hotkey)
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('resize', this.windowResize)
|
||||
this.$store.commit('user/removeSettingsListener', 'audioplayer')
|
||||
this.$eventBus.$off('player-hotkey', this.hotkey)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.loadingTrack {
|
||||
animation-name: loadingTrack;
|
||||
animation-duration: 1s;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
@keyframes loadingTrack {
|
||||
0% {
|
||||
left: -25%;
|
||||
}
|
||||
100% {
|
||||
left: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -3,7 +3,7 @@
|
||||
<div id="appbar" class="absolute top-0 bottom-0 left-0 w-full h-full px-2 md:px-6 py-1 z-50">
|
||||
<div class="flex h-full items-center">
|
||||
<nuxt-link to="/">
|
||||
<img src="/icon48.png" class="w-10 h-10 md:w-12 md:h-12 mr-4" />
|
||||
<img src="/icon48.png" class="w-8 h-8 mr-8 sm:w-12 sm:h-12 sm:mr-4" />
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link to="/">
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
<ui-libraries-dropdown />
|
||||
|
||||
<controls-global-search v-if="currentLibrary" class="hidden md:block" />
|
||||
<controls-global-search v-if="currentLibrary" class="" />
|
||||
<div class="flex-grow" />
|
||||
|
||||
<span v-if="showExperimentalFeatures" class="material-icons text-4xl text-warning pr-0 sm:pr-2 md:pr-4">logo_dev</span>
|
||||
@@ -20,11 +20,11 @@
|
||||
<ui-tooltip v-if="isChromecastInitialized && !isHttps" direction="bottom" text="Casting requires a secure connection" class="flex items-center">
|
||||
<span class="material-icons-outlined text-warning text-opacity-50"> cast </span>
|
||||
</ui-tooltip>
|
||||
<div v-if="isChromecastInitialized" class="w-6 h-6 mr-2 cursor-pointer">
|
||||
<div v-if="isChromecastInitialized" class="w-6 min-w-6 h-6 ml-2 mr-1 sm:mx-2 cursor-pointer">
|
||||
<google-cast-launcher></google-cast-launcher>
|
||||
</div>
|
||||
|
||||
<nuxt-link v-if="currentLibrary" to="/config/stats" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
|
||||
<nuxt-link v-if="currentLibrary" to="/config/stats" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 hidden sm:flex items-center justify-center mx-1">
|
||||
<span class="material-icons" aria-label="User Stats" role="button">equalizer</span>
|
||||
</nuxt-link>
|
||||
|
||||
@@ -147,9 +147,6 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
toggleBookshelfTexture() {
|
||||
this.$store.dispatch('setBookshelfTexture', 'wood2.png')
|
||||
},
|
||||
cancelSelectionMode() {
|
||||
if (this.processingBatchDelete) return
|
||||
this.$store.commit('setSelectedLibraryItems', [])
|
||||
|
||||
@@ -2,10 +2,6 @@
|
||||
<div id="bookshelf" ref="wrapper" class="w-full max-w-full h-full overflow-y-scroll relative">
|
||||
<!-- Cover size widget -->
|
||||
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-30" />
|
||||
<!-- Experimental Bookshelf Texture -->
|
||||
<div v-show="showExperimentalFeatures && !isAlternativeBookshelfView" class="fixed bottom-4 right-28 z-40">
|
||||
<div class="rounded-full py-1 bg-primary hover:bg-bg cursor-pointer px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent @click="showBookshelfTextureModal"><p class="text-sm py-0.5">Texture</p></div>
|
||||
</div>
|
||||
|
||||
<div v-if="loaded && !shelves.length && !search" class="w-full flex flex-col items-center justify-center py-12">
|
||||
<p class="text-center text-2xl font-book mb-4 py-4">{{ libraryName }} Library is empty!</p>
|
||||
@@ -100,9 +96,6 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
showBookshelfTextureModal() {
|
||||
this.$store.commit('globals/setShowBookshelfTextureModal', true)
|
||||
},
|
||||
async init() {
|
||||
this.wrapperClientWidth = this.$refs.wrapper ? this.$refs.wrapper.clientWidth : 0
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="absolute text-center categoryPlacard font-book transform z-30 bottom-0.5 left-4 md:left-8 w-36 rounded-md" style="height: 22px">
|
||||
<div class="absolute text-center categoryPlacard font-book transform z-30 bottom-px left-4 md:left-8 w-44 rounded-md" style="height: 22px">
|
||||
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border">
|
||||
<p class="transform text-sm">{{ shelf.label }}</p>
|
||||
</div>
|
||||
|
||||
@@ -2,13 +2,19 @@
|
||||
<div class="w-full h-20 md:h-10 relative">
|
||||
<div class="flex md:hidden h-10 items-center">
|
||||
<nuxt-link :to="`/library/${currentLibraryId}`" class="flex-grow h-full flex justify-center items-center" :class="homePage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
|
||||
<p>Home</p>
|
||||
<p class="text-sm">Home</p>
|
||||
</nuxt-link>
|
||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf`" class="flex-grow h-full flex justify-center items-center" :class="showLibrary ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
|
||||
<p>Library</p>
|
||||
<p class="text-sm">Library</p>
|
||||
</nuxt-link>
|
||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf/series`" class="flex-grow h-full flex justify-center items-center" :class="paramId === 'series' ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
|
||||
<p>Series</p>
|
||||
<nuxt-link v-if="!isPodcastLibrary" :to="`/library/${currentLibraryId}/bookshelf/series`" class="flex-grow h-full flex justify-center items-center" :class="paramId === 'series' ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
|
||||
<p class="text-sm">Series</p>
|
||||
</nuxt-link>
|
||||
<nuxt-link v-if="!isPodcastLibrary" :to="`/library/${currentLibraryId}/bookshelf/collections`" class="flex-grow h-full flex justify-center items-center" :class="paramId === 'collections' ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
|
||||
<p class="text-sm">Collections</p>
|
||||
</nuxt-link>
|
||||
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/search`" class="flex-grow h-full flex justify-center items-center" :class="isPodcastSearchPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
|
||||
<p class="text-sm">Search</p>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
<div id="toolbar" class="absolute top-10 md:top-0 left-0 w-full h-10 md:h-full z-30 flex items-center justify-end md:justify-start px-2 md:px-8">
|
||||
@@ -98,6 +104,9 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
userIsAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
userCanDelete() {
|
||||
return this.$store.getters['user/getUserCanDelete']
|
||||
},
|
||||
@@ -129,6 +138,12 @@ export default {
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
currentLibraryMediaType() {
|
||||
return this.$store.getters['libraries/getCurrentLibraryMediaType']
|
||||
},
|
||||
isPodcastLibrary() {
|
||||
return this.currentLibraryMediaType === 'podcast'
|
||||
},
|
||||
homePage() {
|
||||
return this.$route.name === 'library-library'
|
||||
},
|
||||
@@ -156,6 +171,9 @@ export default {
|
||||
},
|
||||
isIssuesFilter() {
|
||||
return this.filterBy === 'issues' && this.$route.query.filter === 'issues'
|
||||
},
|
||||
isPodcastSearchPage() {
|
||||
return this.$route.name === 'library-library-podcast-search'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -4,19 +4,21 @@
|
||||
<span class="material-icons text-2xl">arrow_back</span>
|
||||
</div>
|
||||
|
||||
<nuxt-link v-for="route in configRoutes" :key="route.id" :to="route.path" class="w-full px-4 h-12 border-b border-opacity-0 flex items-center cursor-pointer relative" :class="routeName === route.id ? 'bg-primary bg-opacity-70' : 'hover:bg-primary hover:bg-opacity-30'">
|
||||
<nuxt-link v-for="route in configRoutes" :key="route.id" :to="route.path" class="w-full px-4 h-12 border-b border-primary border-opacity-30 flex items-center cursor-pointer relative" :class="routeName === route.id ? 'bg-primary bg-opacity-70' : 'hover:bg-primary hover:bg-opacity-30'">
|
||||
<p>{{ route.title }}</p>
|
||||
<div v-show="routeName === route.iod" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link>
|
||||
|
||||
<div class="w-full h-12 px-4 border-t border-black border-opacity-20 absolute left-0 flex flex-col justify-center" :style="{ bottom: streamLibraryItem && isMobileLandscape ? '300px' : '65px' }">
|
||||
<div class="flex justify-between">
|
||||
<p class="font-mono text-sm">v{{ $config.version }}</p>
|
||||
<p class="underline font-mono text-sm" @click="clickChangelog">v{{ $config.version }}</p>
|
||||
|
||||
<p class="font-mono text-xs text-gray-300 italic">{{ Source }}</p>
|
||||
</div>
|
||||
<a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-xs">Latest: {{ latestVersion }}</a>
|
||||
</div>
|
||||
|
||||
<modals-changelog-view-modal v-model="showChangelogModal" :changelog="currentVersionChangelog" :currentVersion="$config.version"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -26,7 +28,9 @@ export default {
|
||||
isOpen: Boolean
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
return {
|
||||
showChangelogModal: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
Source() {
|
||||
@@ -64,6 +68,11 @@ export default {
|
||||
title: 'Users',
|
||||
path: '/config/users'
|
||||
},
|
||||
{
|
||||
id: 'config-sessions',
|
||||
title: 'Listening Sessions',
|
||||
path: '/config/sessions'
|
||||
},
|
||||
{
|
||||
id: 'config-backups',
|
||||
title: 'Backups',
|
||||
@@ -71,7 +80,7 @@ export default {
|
||||
},
|
||||
{
|
||||
id: 'config-log',
|
||||
title: 'Log',
|
||||
title: 'Logs',
|
||||
path: '/config/log'
|
||||
}
|
||||
]
|
||||
@@ -124,18 +133,24 @@ export default {
|
||||
githubTagUrl() {
|
||||
return this.versionData.githubTagUrl
|
||||
},
|
||||
currentVersionChangelog() {
|
||||
return this.versionData.currentVersionChangelog || 'No Changelog Available'
|
||||
},
|
||||
streamLibraryItem() {
|
||||
return this.$store.state.streamLibraryItem
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickChangelog(){
|
||||
this.showChangelogModal = true
|
||||
},
|
||||
clickOutside() {
|
||||
if (!this.isOpen) return
|
||||
this.closeDrawer()
|
||||
},
|
||||
closeDrawer() {
|
||||
this.$emit('update:isOpen', false)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -22,13 +22,6 @@
|
||||
</div>
|
||||
|
||||
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-30" />
|
||||
|
||||
<!-- Experimental Bookshelf Texture -->
|
||||
<div v-show="showExperimentalFeatures && !isAlternativeBookshelfView" class="fixed bottom-4 right-28 z-40">
|
||||
<div class="rounded-full py-1 bg-primary hover:bg-bg cursor-pointer px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent @click="showBookshelfTextureModal">
|
||||
<p class="text-sm py-0.5">Texture</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -206,9 +199,6 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
showBookshelfTextureModal() {
|
||||
this.$store.commit('globals/setShowBookshelfTextureModal', true)
|
||||
},
|
||||
clearFilter() {
|
||||
this.$store.dispatch('user/updateUserSettings', { filterBy: 'all' })
|
||||
},
|
||||
|
||||
@@ -73,15 +73,31 @@
|
||||
<p class="text-xs font-mono pb-0.5">{{ numIssues }}</p>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
|
||||
<div class="w-full h-12 px-1 py-2 border-t border-black border-opacity-20 absolute left-0" :style="{ bottom: streamLibraryItem ? '240px' : '65px' }">
|
||||
<p class="underline font-mono text-xs text-center text-gray-300 leading-3 mb-1" @click="clickChangelog">v{{ $config.version }}</p>
|
||||
<a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-xxs text-center block leading-3">Update</a>
|
||||
<p v-else class="text-xxs text-gray-400 leading-3 text-center italic">{{ Source }}</p>
|
||||
</div>
|
||||
|
||||
<modals-changelog-view-modal v-model="showChangelogModal" :changelog="currentVersionChangelog" :currentVersion="$config.version"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {}
|
||||
return {
|
||||
showChangelogModal: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
Source() {
|
||||
return this.$store.state.Source
|
||||
},
|
||||
isMobileLandscape() {
|
||||
return this.$store.state.globals.isMobileLandscape
|
||||
},
|
||||
isShowingBookshelfToolbar() {
|
||||
if (!this.$route.name) return false
|
||||
return this.$route.name.startsWith('library')
|
||||
@@ -131,9 +147,28 @@ export default {
|
||||
},
|
||||
numIssues() {
|
||||
return this.$store.state.libraries.issues || 0
|
||||
},
|
||||
versionData() {
|
||||
return this.$store.state.versionData || {}
|
||||
},
|
||||
hasUpdate() {
|
||||
return !!this.versionData.hasUpdate
|
||||
},
|
||||
githubTagUrl() {
|
||||
return this.versionData.githubTagUrl
|
||||
},
|
||||
currentVersionChangelog() {
|
||||
return this.versionData.currentVersionChangelog || 'No Changelog Available'
|
||||
},
|
||||
streamLibraryItem() {
|
||||
return this.$store.state.streamLibraryItem
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
methods: {
|
||||
clickChangelog(){
|
||||
this.showChangelogModal = true
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -1,31 +1,32 @@
|
||||
<template>
|
||||
<div v-if="streamLibraryItem" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-48 sm:h-44 md:h-40 z-40 bg-primary px-4 pb-1 md:pb-4 pt-2">
|
||||
<nuxt-link :to="`/item/${streamLibraryItem.id}`" class="absolute left-4 cursor-pointer" :style="{ top: bookCoverPosTop + 'px' }">
|
||||
<div id="videoDock" />
|
||||
<nuxt-link v-if="!playerHandler.isVideo" :to="`/item/${streamLibraryItem.id}`" class="absolute left-1 sm:left-4 cursor-pointer" :style="{ top: bookCoverPosTop + 'px' }">
|
||||
<covers-book-cover :library-item="streamLibraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
</nuxt-link>
|
||||
<div class="flex items-start pl-24 mb-6 md:mb-0">
|
||||
<div class="flex items-start mb-6 md:mb-0" :class="playerHandler.isVideo ? 'ml-4 pl-96' : 'pl-20 sm:pl-24'">
|
||||
<div>
|
||||
<nuxt-link :to="`/item/${streamLibraryItem.id}`" class="hover:underline cursor-pointer text-base sm:text-lg">
|
||||
<nuxt-link :to="`/item/${streamLibraryItem.id}`" class="hover:underline cursor-pointer text-sm sm:text-lg">
|
||||
{{ title }}
|
||||
</nuxt-link>
|
||||
<div class="text-gray-400 flex items-center">
|
||||
<div v-if="!playerHandler.isVideo" class="text-gray-400 flex items-center">
|
||||
<span class="material-icons text-sm">person</span>
|
||||
<p v-if="podcastAuthor">{{ podcastAuthor }}</p>
|
||||
<p v-else-if="authors.length" class="pl-1.5 text-sm sm:text-base">
|
||||
<p v-if="podcastAuthor" class="pl-1 sm:pl-1.5 text-xs sm:text-base">{{ podcastAuthor }}</p>
|
||||
<p v-else-if="authors.length" class="pl-1 sm:pl-1.5 text-xs sm:text-base">
|
||||
<nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">, </span></nuxt-link>
|
||||
</p>
|
||||
<p v-else class="text-sm sm:text-base cursor-pointer pl-2">Unknown</p>
|
||||
<p v-else class="text-xs sm:text-base cursor-pointer pl-1 sm:pl-1.5">Unknown</p>
|
||||
</div>
|
||||
|
||||
<div class="text-gray-400 flex items-center">
|
||||
<span class="material-icons text-xs">schedule</span>
|
||||
<p class="font-mono text-sm pl-2 pb-px">{{ totalDurationPretty }}</p>
|
||||
<p class="font-mono text-xs sm:text-sm pl-1 sm:pl-1.5 pb-px">{{ totalDurationPretty }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<span class="material-icons px-2 py-1 md:p-4 cursor-pointer" @click="closePlayer">close</span>
|
||||
<span class="material-icons sm:px-2 py-1 md:p-4 cursor-pointer text-xl sm:text-2xl" @click="closePlayer">close</span>
|
||||
</div>
|
||||
<audio-player
|
||||
<player-ui
|
||||
ref="audioPlayer"
|
||||
:chapters="chapters"
|
||||
:paused="!isPlaying"
|
||||
@@ -70,7 +71,8 @@ export default {
|
||||
sleepTimerRemaining: 0,
|
||||
sleepTimer: null,
|
||||
displayTitle: null,
|
||||
initialPlaybackRate: 1
|
||||
initialPlaybackRate: 1,
|
||||
syncFailedToast: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -375,10 +377,18 @@ export default {
|
||||
libraryItem,
|
||||
episodeId
|
||||
})
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.audioPlayer) this.$refs.audioPlayer.checkUpdateChapterTrack()
|
||||
})
|
||||
|
||||
this.playerHandler.load(libraryItem, episodeId, true, this.initialPlaybackRate)
|
||||
},
|
||||
pauseItem() {
|
||||
this.playerHandler.pause()
|
||||
},
|
||||
showFailedProgressSyncs() {
|
||||
if (!isNaN(this.syncFailedToast)) this.$toast.dismiss(this.syncFailedToast)
|
||||
this.syncFailedToast = this.$toast('Progress is not being synced. Restart playback', { timeout: false, type: 'error' })
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
|
||||
@@ -6,11 +6,18 @@
|
||||
</div>
|
||||
<div v-if="!isPodcast" class="px-4 flex-grow">
|
||||
<div class="flex items-center">
|
||||
<h1>{{ book.title }}</h1>
|
||||
<h1 class="text-base">{{ book.title }}</h1>
|
||||
<div class="flex-grow" />
|
||||
<p>{{ book.publishedYear }}</p>
|
||||
</div>
|
||||
<p class="text-gray-400">{{ book.author }}</p>
|
||||
<p class="text-gray-300 text-sm">{{ book.author }}</p>
|
||||
<div v-if="book.series && book.series.length" class="flex py-1 -mx-1">
|
||||
<div v-for="(series, index) in book.series" :key="index" class="bg-white bg-opacity-10 rounded-full px-1 py-0.5 mx-1">
|
||||
<p class="leading-3 text-xs text-gray-400">
|
||||
{{ series.series }}<span v-if="series.volumeNumber"> #{{ series.volumeNumber }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full max-h-12 overflow-hidden">
|
||||
<p class="text-gray-500 text-xs">{{ book.description }}</p>
|
||||
</div>
|
||||
|
||||
@@ -62,21 +62,10 @@ export default {
|
||||
matchHtml() {
|
||||
if (!this.matchText || !this.search) return ''
|
||||
if (this.matchKey === 'subtitle') return ''
|
||||
var matchSplit = this.matchText.toLowerCase().split(this.search.toLowerCase().trim())
|
||||
if (matchSplit.length < 2) return ''
|
||||
|
||||
var html = ''
|
||||
var totalLenSoFar = 0
|
||||
for (let i = 0; i < matchSplit.length - 1; i++) {
|
||||
var indexOf = matchSplit[i].length
|
||||
var firstPart = this.matchText.substr(totalLenSoFar, indexOf)
|
||||
var actualWasThere = this.matchText.substr(totalLenSoFar + indexOf, this.search.length)
|
||||
totalLenSoFar += indexOf + this.search.length
|
||||
|
||||
html += `${firstPart}<strong class="text-warning">${actualWasThere}</strong>`
|
||||
}
|
||||
var lastPart = this.matchText.substr(totalLenSoFar)
|
||||
html += lastPart
|
||||
// This used to highlight the part of the search found
|
||||
// but with removing commas periods etc this is no longer plausible
|
||||
const html = this.matchText
|
||||
|
||||
if (this.matchKey === 'tags') return `<p class="truncate">Tags: ${html}</p>`
|
||||
if (this.matchKey === 'authors') return `by ${html}`
|
||||
|
||||
@@ -144,6 +144,9 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
dateFormat() {
|
||||
return this.store.state.serverSettings.dateFormat
|
||||
},
|
||||
showExperimentalFeatures() {
|
||||
return this.store.state.showExperimentalFeatures
|
||||
},
|
||||
@@ -245,13 +248,17 @@ export default {
|
||||
return this.mediaMetadata.authorNameLF
|
||||
},
|
||||
displayTitle() {
|
||||
if (this.recentEpisode) return this.recentEpisode.title
|
||||
if (this.collapsedSeries) return this.collapsedSeries.name
|
||||
if (this.orderBy === 'media.metadata.title' && this.sortingIgnorePrefix) {
|
||||
return this.mediaMetadata.titleIgnorePrefix
|
||||
}
|
||||
return this.title
|
||||
},
|
||||
displayLineTwo() {
|
||||
if (this.recentEpisode) return this.title
|
||||
if (this.isPodcast) return this.author
|
||||
if (this.collapsedSeries) return ''
|
||||
if (this.isAuthorBookshelfView) {
|
||||
return this.mediaMetadata.publishedYear || ''
|
||||
}
|
||||
@@ -259,9 +266,10 @@ export default {
|
||||
return this.author
|
||||
},
|
||||
displaySortLine() {
|
||||
if (this.orderBy === 'mtimeMs') return 'Modified ' + this.$formatDate(this._libraryItem.mtimeMs)
|
||||
if (this.orderBy === 'birthtimeMs') return 'Born ' + this.$formatDate(this._libraryItem.birthtimeMs)
|
||||
if (this.orderBy === 'addedAt') return 'Added ' + this.$formatDate(this._libraryItem.addedAt)
|
||||
if (this.collapsedSeries) return null
|
||||
if (this.orderBy === 'mtimeMs') return 'Modified ' + this.$formatDate(this._libraryItem.mtimeMs, this.dateFormat)
|
||||
if (this.orderBy === 'birthtimeMs') return 'Born ' + this.$formatDate(this._libraryItem.birthtimeMs, this.dateFormat)
|
||||
if (this.orderBy === 'addedAt') return 'Added ' + this.$formatDate(this._libraryItem.addedAt, this.dateFormat)
|
||||
if (this.orderBy === 'media.duration') return 'Duration: ' + this.$elapsedPrettyExtended(this.media.duration, false)
|
||||
if (this.orderBy === 'size') return 'Size: ' + this.$bytesPretty(this._libraryItem.size)
|
||||
if (this.orderBy === 'media.numTracks') return `${this.numEpisodes} Episodes`
|
||||
|
||||
@@ -214,7 +214,7 @@ export default {
|
||||
return this.filterData.languages || []
|
||||
},
|
||||
progress() {
|
||||
return ['Finished', 'In Progress', 'Not Started']
|
||||
return ['Finished', 'In Progress', 'Not Started', 'Not Finished']
|
||||
},
|
||||
missing() {
|
||||
return ['ASIN', 'ISBN', 'Subtitle', 'Author', 'Publish Year', 'Series', 'Description', 'Genres', 'Tags', 'Narrator', 'Publisher', 'Language']
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="w-80 ml-6 relative">
|
||||
<div class="sm:w-80 w-full sm:ml-6 relative">
|
||||
<form @submit.prevent="submitSearch">
|
||||
<ui-text-input ref="input" v-model="search" placeholder="Search.." @input="inputUpdate" @focus="focussed" @blur="blurred" class="w-full h-8 text-sm" />
|
||||
</form>
|
||||
@@ -7,7 +7,7 @@
|
||||
<span v-if="!search" class="material-icons" style="font-size: 1.2rem">search</span>
|
||||
<span v-else class="material-icons" style="font-size: 1.2rem">close</span>
|
||||
</div>
|
||||
<div v-show="showMenu && (lastSearch || isTyping)" class="absolute z-40 -mt-px w-full bg-bg border border-black-200 shadow-lg rounded-md py-1 px-2 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm globalSearchMenu">
|
||||
<div v-show="showMenu && (lastSearch || isTyping)" class="absolute z-40 -mt-px w-40 sm:w-full bg-bg border border-black-200 shadow-lg rounded-md py-1 px-2 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm globalSearchMenu">
|
||||
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||
<li v-if="isTyping" class="py-2 px-2">
|
||||
<p>Thinking...</p>
|
||||
@@ -21,7 +21,7 @@
|
||||
<template v-else>
|
||||
<p v-if="bookResults.length" class="uppercase text-xs text-gray-400 my-1 px-1 font-semibold">Books</p>
|
||||
<template v-for="item in bookResults">
|
||||
<li :key="item.libraryItem.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
|
||||
<li :key="item.libraryItem.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
|
||||
<nuxt-link :to="`/item/${item.libraryItem.id}`">
|
||||
<cards-item-search-card :library-item="item.libraryItem" :match-key="item.matchKey" :match-text="item.matchText" :search="lastSearch" />
|
||||
</nuxt-link>
|
||||
@@ -30,7 +30,7 @@
|
||||
|
||||
<p v-if="podcastResults.length" class="uppercase text-xs text-gray-400 my-1 px-1 font-semibold">Podcasts</p>
|
||||
<template v-for="item in podcastResults">
|
||||
<li :key="item.libraryItem.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
|
||||
<li :key="item.libraryItem.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
|
||||
<nuxt-link :to="`/item/${item.libraryItem.id}`">
|
||||
<cards-item-search-card :library-item="item.libraryItem" :match-key="item.matchKey" :match-text="item.matchText" :search="lastSearch" />
|
||||
</nuxt-link>
|
||||
@@ -39,7 +39,7 @@
|
||||
|
||||
<p v-if="authorResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">Authors</p>
|
||||
<template v-for="item in authorResults">
|
||||
<li :key="item.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
|
||||
<li :key="item.id" 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=authors.${$encode(item.id)}`">
|
||||
<cards-author-search-card :author="item" />
|
||||
</nuxt-link>
|
||||
@@ -48,7 +48,7 @@
|
||||
|
||||
<p v-if="seriesResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">Series</p>
|
||||
<template v-for="item in seriesResults">
|
||||
<li :key="item.series.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
|
||||
<li :key="item.series.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
|
||||
<nuxt-link :to="`/library/${currentLibraryId}/series/${item.series.id}`">
|
||||
<cards-series-search-card :series="item.series" :book-items="item.books" />
|
||||
</nuxt-link>
|
||||
@@ -57,7 +57,7 @@
|
||||
|
||||
<p v-if="tagResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">Tags</p>
|
||||
<template v-for="item in tagResults">
|
||||
<li :key="item.name" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
|
||||
<li :key="item.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=tags.${$encode(item.name)}`">
|
||||
<cards-tag-search-card :tag="item.name" />
|
||||
</nuxt-link>
|
||||
@@ -97,6 +97,9 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickOption() {
|
||||
this.clearResults()
|
||||
},
|
||||
submitSearch() {
|
||||
if (!this.search) return
|
||||
var search = this.search
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<div class="relative ml-8" v-click-outside="clickOutside">
|
||||
<div ref="wrapper" class="relative ml-4 sm:ml-8" v-click-outside="clickOutside">
|
||||
<div class="flex items-center justify-center text-gray-300 cursor-pointer h-full" @mousedown.prevent @mouseup.prevent @click="setShowMenu(true)">
|
||||
<span class="font-mono uppercase text-gray-200">{{ playbackRate.toFixed(1) }}<span class="text-lg">⨯</span></span>
|
||||
<span class="font-mono uppercase text-gray-200 text-sm sm:text-base">{{ playbackRate.toFixed(1) }}<span class="text-base sm:text-lg">⨯</span></span>
|
||||
</div>
|
||||
<div v-show="showMenu" class="absolute -top-20 left-0 z-20 bg-bg border-black-200 border shadow-xl rounded-lg" style="left: -92px">
|
||||
<div class="absolute -bottom-2 left-0 right-0 w-full flex justify-center">
|
||||
<div v-show="showMenu" class="absolute -top-20 z-20 bg-bg border-black-200 border shadow-xl rounded-lg" :style="{ left: menuLeft + 'px' }">
|
||||
<div class="absolute -bottom-1.5 right-0 w-full flex justify-center" :style="{ left: arrowLeft + 'px' }">
|
||||
<div class="arrow-down" />
|
||||
</div>
|
||||
<div class="flex items-center h-9 relative overflow-hidden rounded-lg" style="width: 220px">
|
||||
@@ -19,7 +19,7 @@
|
||||
<div class="w-full py-1 px-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<ui-icon-btn :disabled="!canDecrement" icon="remove" @click="decrement" />
|
||||
<p class="px-2 text-3xl">{{ playbackRate }}<span class="text-2xl">⨯</span></p>
|
||||
<p class="px-2 text-2xl sm:text-3xl">{{ playbackRate }}<span class="text-2xl">⨯</span></p>
|
||||
<ui-icon-btn :disabled="!canIncrement" icon="add" @click="increment" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -40,7 +40,9 @@ export default {
|
||||
showMenu: false,
|
||||
currentPlaybackRate: 0,
|
||||
MIN_SPEED: 0.5,
|
||||
MAX_SPEED: 3
|
||||
MAX_SPEED: 3,
|
||||
menuLeft: -92,
|
||||
arrowLeft: 0
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -80,8 +82,22 @@ export default {
|
||||
var newPlaybackRate = this.playbackRate - 0.1
|
||||
this.playbackRate = Number(newPlaybackRate.toFixed(1))
|
||||
},
|
||||
updateMenuPositions() {
|
||||
if (!this.$refs.wrapper) return
|
||||
const boundingBox = this.$refs.wrapper.getBoundingClientRect()
|
||||
|
||||
if (boundingBox.left + 110 > window.innerWidth - 10) {
|
||||
this.menuLeft = window.innerWidth - 230 - boundingBox.left
|
||||
|
||||
this.arrowLeft = Math.abs(this.menuLeft) - 92
|
||||
} else {
|
||||
this.menuLeft = -92
|
||||
this.arrowLeft = 0
|
||||
}
|
||||
},
|
||||
setShowMenu(val) {
|
||||
if (val) {
|
||||
this.updateMenuPositions()
|
||||
this.currentPlaybackRate = this.playbackRate
|
||||
} else if (this.currentPlaybackRate !== this.playbackRate) {
|
||||
this.$emit('change', this.playbackRate)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="relative" v-click-outside="clickOutside" @mouseover="mouseover" @mouseleave="mouseleave">
|
||||
<div class="cursor-pointer" @mousedown.prevent @mouseup.prevent @click="clickVolumeIcon">
|
||||
<span class="material-icons text-3xl">{{ volumeIcon }}</span>
|
||||
<div class="cursor-pointer text-gray-300 hover:text-white" @mousedown.prevent @mouseup.prevent @click="clickVolumeIcon">
|
||||
<span class="material-icons text-2xl sm:text-3xl">{{ volumeIcon }}</span>
|
||||
</div>
|
||||
<transition name="menux">
|
||||
<div v-show="isOpen" class="volumeMenu h-6 absolute bottom-2 w-28 px-2 bg-bg shadow-sm rounded-lg" style="left: -116px">
|
||||
|
||||
@@ -146,7 +146,6 @@ export default {
|
||||
watch: {
|
||||
show: {
|
||||
handler(newVal) {
|
||||
console.log('accoutn modal show change', newVal)
|
||||
if (newVal) {
|
||||
this.init()
|
||||
}
|
||||
@@ -162,6 +161,9 @@ export default {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
user() {
|
||||
return this.$store.state.user.user
|
||||
},
|
||||
title() {
|
||||
return this.isNew ? 'Add New Account' : `Update ${(this.account || {}).username}`
|
||||
},
|
||||
@@ -250,6 +252,12 @@ export default {
|
||||
this.$toast.error(`Failed to update account: ${data.error}`)
|
||||
} else {
|
||||
console.log('Account updated', data.user)
|
||||
|
||||
if (data.user.id === this.user.id && data.user.token !== this.user.token) {
|
||||
console.log('Current user token was updated')
|
||||
this.$store.commit('user/setUserToken', data.user.token)
|
||||
}
|
||||
|
||||
this.$toast.success('Account updated')
|
||||
this.show = false
|
||||
}
|
||||
@@ -305,7 +313,6 @@ export default {
|
||||
|
||||
this.isNew = !this.account
|
||||
if (this.account) {
|
||||
console.log(this.account)
|
||||
this.newUser = {
|
||||
username: this.account.username,
|
||||
password: this.account.password,
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" name="textures" :width="'40vw'" :height="'unset'" :bg-opacity="10" :processing="processing">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||
<p class="font-book text-3xl text-white truncate">Bookshelf Texture</p>
|
||||
</div>
|
||||
</template>
|
||||
<div class="px-4 w-full max-w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300" @mousedown.prevent @mouseup.prevent @mousemove.prevent>
|
||||
<h1 class="text-2xl mb-2">Select a bookshelf texture (For testing only)</h1>
|
||||
<div class="overflow-y-hidden overflow-x-auto">
|
||||
<div class="flex -mx-1">
|
||||
<template v-for="texture in textures">
|
||||
<div :key="texture" class="relative mx-1" style="height: 180px; width: 180px; min-width: 180px" @mousedown.prevent @mouseup.prevent>
|
||||
<img :src="texture" class="h-full object-cover cursor-pointer" @click="setTexture(texture)" />
|
||||
<div v-if="texture === selectedBookshelfTexture" class="absolute top-0 left-0 flex items-center justify-center w-full h-full bg-black bg-opacity-10">
|
||||
<span class="material-icons text-4xl text-success">check</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<!-- <div class="flex pt-4">
|
||||
<div class="flex-grow" />
|
||||
<ui-btn color="success" type="submit">Submit</ui-btn>
|
||||
</div> -->
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
textures: ['/textures/wood_default.jpg', '/textures/wood1.png', '/textures/wood2.png', '/textures/wood3.png', '/textures/wood4.png', '/textures/leather1.jpg'],
|
||||
processing: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
show: {
|
||||
handler(newVal) {
|
||||
if (newVal) {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.$store.state.globals.showBookshelfTextureModal
|
||||
},
|
||||
set(val) {
|
||||
this.$store.commit('globals/setShowBookshelfTextureModal', val)
|
||||
}
|
||||
},
|
||||
selectedBookshelfTexture() {
|
||||
return this.$store.state.selectedBookshelfTexture
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
init() {},
|
||||
setTexture(img) {
|
||||
this.$store.dispatch('setBookshelfTexture', img)
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -1,11 +1,14 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" name="chapters" :width="500" :height="'unset'">
|
||||
<div ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
||||
<modals-modal v-model="show" name="chapters" :width="600" :height="'unset'">
|
||||
<div id="chapter-modal-wrapper" ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
||||
<template v-for="chap in chapters">
|
||||
<div :key="chap.id" :id="`chapter-row-${chap.id}`" class="flex items-center px-6 py-3 justify-start cursor-pointer hover:bg-bg relative" :class="chap.id === currentChapterId ? 'bg-yellow-400 bg-opacity-10' : chap.end <= currentChapterStart ? 'bg-success bg-opacity-5' : 'bg-opacity-20'" @click="clickChapter(chap)">
|
||||
{{ chap.title }}
|
||||
<p class="chapter-title truncate text-sm md:text-base">
|
||||
{{ chap.title }}
|
||||
</p>
|
||||
<span class="font-mono text-xxs sm:text-xs text-gray-400 pl-2 whitespace-nowrap">{{ $elapsedPrettyExtended(chap.end - chap.start) }}</span>
|
||||
<span class="flex-grow" />
|
||||
<span class="font-mono text-sm text-gray-300">{{ $secondsToTimestamp(chap.start) }}</span>
|
||||
<span class="font-mono text-xs sm:text-sm text-gray-300">{{ $secondsToTimestamp(chap.start) }}</span>
|
||||
|
||||
<div v-show="chap.id === currentChapterId" class="w-0.5 h-full absolute top-0 left-0 bg-yellow-400" />
|
||||
</div>
|
||||
@@ -70,4 +73,15 @@ export default {
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#chapter-modal-wrapper .chapter-title {
|
||||
max-width: calc(100% - 120px);
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
#chapter-modal-wrapper .chapter-title {
|
||||
max-width: calc(100% - 150px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
56
client/components/modals/Dialog.vue
Normal file
56
client/components/modals/Dialog.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" :width="300" height="100%">
|
||||
<template #outer>
|
||||
<div v-if="title" class="absolute top-7 left-4 z-40" style="max-width: 80%">
|
||||
<p class="text-white text-lg truncate">{{ title }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
|
||||
<div ref="container" class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
|
||||
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||
<template v-for="item in items">
|
||||
<li :key="item.value" class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" :class="selected === item.value ? 'bg-success bg-opacity-10' : ''" role="option" @click="clickedOption(item.value)">
|
||||
<div class="relative flex items-center px-3">
|
||||
<p class="font-normal block truncate text-base text-white text-opacity-80">{{ item.text }}</p>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
title: String,
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
selected: String // optional
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickedOption(action) {
|
||||
this.$emit('action', action)
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -1,16 +1,16 @@
|
||||
<template>
|
||||
<div ref="wrapper" class="hidden absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 rounded-lg items-center justify-center" style="z-index: 51" @click="clickClose">
|
||||
<div class="absolute top-5 right-5 h-12 w-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300">
|
||||
<span class="material-icons text-4xl">close</span>
|
||||
<div class="absolute top-3 right-3 md:top-5 md:right-5 h-8 w-8 md:h-12 md:w-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300">
|
||||
<span class="material-icons text-2xl md:text-4xl">close</span>
|
||||
</div>
|
||||
<div ref="content" class="text-white">
|
||||
<form v-if="selectedSeries" @submit.prevent="submitSeriesForm">
|
||||
<div class="bg-bg rounded-lg p-8" @click.stop>
|
||||
<div class="bg-bg rounded-lg px-2 py-6 sm:p-6 md:p-8" @click.stop>
|
||||
<div class="flex">
|
||||
<div class="flex-grow p-1 min-w-80">
|
||||
<div class="flex-grow p-1 min-w-48 sm:min-w-64 md:min-w-80">
|
||||
<ui-input-dropdown ref="newSeriesSelect" v-model="selectedSeries.name" :items="existingSeriesNames" :disabled="!selectedSeries.id.startsWith('new')" label="Series Name" />
|
||||
</div>
|
||||
<div class="w-40 p-1">
|
||||
<div class="w-24 sm:w-28 md:w-40 p-1">
|
||||
<ui-text-input-with-label v-model="selectedSeries.sequence" label="Sequence" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<p class="font-book text-3xl text-white truncate">Session {{ _session.id }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<div ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden p-6" style="max-height: 80vh">
|
||||
<div ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden p-6" style="max-height: 80vh">
|
||||
<div class="flex items-center">
|
||||
<p class="text-base text-gray-200">{{ _session.displayTitle }}</p>
|
||||
<p v-if="_session.displayAuthor" class="text-xs text-gray-400 px-4">by {{ _session.displayAuthor }}</p>
|
||||
@@ -87,7 +87,7 @@
|
||||
<p class="mb-1">{{ playMethodName }}</p>
|
||||
<p class="mb-1">{{ _session.mediaPlayer }}</p>
|
||||
|
||||
<p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mt-6 mb-2">Device</p>
|
||||
<p v-if="hasDeviceInfo" class="font-semibold uppercase text-xs text-gray-400 tracking-wide mt-6 mb-2">Device</p>
|
||||
<p v-if="deviceInfo.ipAddress" class="mb-1">{{ deviceInfo.ipAddress }}</p>
|
||||
<p v-if="osDisplayName" class="mb-1">{{ osDisplayName }}</p>
|
||||
<p v-if="deviceInfo.browserName" class="mb-1">{{ deviceInfo.browserName }}</p>
|
||||
@@ -127,6 +127,9 @@ export default {
|
||||
deviceInfo() {
|
||||
return this._session.deviceInfo || {}
|
||||
},
|
||||
hasDeviceInfo() {
|
||||
return Object.keys(this.deviceInfo).length
|
||||
},
|
||||
osDisplayName() {
|
||||
if (!this.deviceInfo.osName) return null
|
||||
return `${this.deviceInfo.osName} ${this.deviceInfo.osVersion}`
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
<div ref="wrapper" class="modal modal-bg w-full h-full fixed top-0 left-0 bg-primary items-center justify-center opacity-0 hidden" :class="`z-${zIndex} bg-opacity-${bgOpacity}`">
|
||||
<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-5 right-5 h-12 w-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300" @click="clickClose">
|
||||
<span class="material-icons text-4xl">close</span>
|
||||
<div class="absolute top-3 right-3 landscape:top-2 landscape:right-2 md:portrait:top-5 md:portrait:right-5 lg:top-5 lg:right-5 h-8 w-8 landscape:h-8 landscape:w-8 md:portrait:h-12 md:portrait:w-12 lg:w-12 lg:h-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300" @click="clickClose">
|
||||
<span class="material-icons text-2xl landscape:text-2xl md:portrait:text-4xl lg:text-4xl">close</span>
|
||||
</div>
|
||||
<slot name="outer" />
|
||||
<div ref="content" style="min-width: 400px; min-height: 200px" class="relative text-white" :style="{ height: modalHeight, width: modalWidth, marginTop: contentMarginTop + 'px' }" @mousedown="mousedownModal" @mouseup="mouseupModal" v-click-outside="clickBg">
|
||||
<div ref="content" style="min-width: 380px; min-height: 200px; max-width: 100vw" class="relative text-white" :style="{ height: modalHeight, width: modalWidth, marginTop: contentMarginTop + 'px' }" @mousedown="mousedownModal" @mouseup="mouseupModal" v-click-outside="clickBg">
|
||||
<slot />
|
||||
<div v-if="processing" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-black bg-opacity-60 rounded-lg flex items-center justify-center">
|
||||
<ui-loading-indicator />
|
||||
|
||||
@@ -25,6 +25,9 @@
|
||||
<ui-text-input-with-label v-model="authorCopy.asin" :disabled="processing" label="ASIN" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-2">
|
||||
<ui-text-input-with-label v-model="authorCopy.imagePath" :disabled="processing" label="Photo Path/URL" />
|
||||
</div>
|
||||
<div class="p-2">
|
||||
<ui-textarea-with-label v-model="authorCopy.description" :disabled="processing" label="Description" :rows="8" />
|
||||
</div>
|
||||
@@ -43,19 +46,13 @@
|
||||
|
||||
<script>
|
||||
export default {
|
||||
// props: {
|
||||
// value: Boolean,
|
||||
// author: {
|
||||
// type: Object,
|
||||
// default: () => {}
|
||||
// }
|
||||
// },
|
||||
data() {
|
||||
return {
|
||||
authorCopy: {
|
||||
name: '',
|
||||
asin: '',
|
||||
description: ''
|
||||
description: '',
|
||||
imagePath: ''
|
||||
},
|
||||
processing: false
|
||||
}
|
||||
@@ -95,9 +92,10 @@ export default {
|
||||
this.authorCopy.name = this.author.name
|
||||
this.authorCopy.asin = this.author.asin
|
||||
this.authorCopy.description = this.author.description
|
||||
this.authorCopy.imagePath = this.author.imagePath
|
||||
},
|
||||
async submitForm() {
|
||||
var keysToCheck = ['name', 'asin', 'description']
|
||||
var keysToCheck = ['name', 'asin', 'description', 'imagePath']
|
||||
var updatePayload = {}
|
||||
keysToCheck.forEach((key) => {
|
||||
if (this.authorCopy[key] !== this.author[key]) {
|
||||
@@ -167,4 +165,4 @@ export default {
|
||||
mounted() {},
|
||||
beforeDestroy() {}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
73
client/components/modals/changelog/ViewModal.vue
Normal file
73
client/components/modals/changelog/ViewModal.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" name="changelog" :width="800" :height="'unset'">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||
<p class="font-book text-3xl text-white truncate">Changelog</p>
|
||||
</div>
|
||||
</template>
|
||||
<div class="px-8 py-6 w-full rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-y-scroll" style="max-height: 80vh">
|
||||
<p class="text-xl font-bold pb-4">Changelog v{{ currentVersionNumber }}</p>
|
||||
<div class="custom-text" v-html="compiledMarkedown" />
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { marked } from '@/static/libs/marked/index.js'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
changelog: String,
|
||||
currentVersion: String
|
||||
},
|
||||
watch: {
|
||||
show: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
if (newVal) {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
compiledMarkedown() {
|
||||
return marked.parse(this.changelog, { gfm: true, breaks: true })
|
||||
},
|
||||
currentVersionNumber() {
|
||||
return this.currentVersion
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
init() {}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/*
|
||||
1. we need to manually define styles to apply to the parsed markdown elements,
|
||||
since we don't have access to the actual elements in this component
|
||||
|
||||
2. v-deep allows these to take effect on the content passed in to the v-html in the div above
|
||||
*/
|
||||
.custom-text ::v-deep > h2 {
|
||||
@apply text-lg font-bold;
|
||||
}
|
||||
.custom-text ::v-deep > h3 {
|
||||
@apply text-lg font-bold;
|
||||
}
|
||||
.custom-text ::v-deep > ul {
|
||||
@apply list-disc list-inside pb-4;
|
||||
}
|
||||
</style>
|
||||
@@ -1,13 +1,13 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" name="edit-book" :width="800" :height="height" :processing="processing" :content-margin-top="75">
|
||||
<modals-modal v-model="show" name="edit-book" :width="800" :height="height" :processing="processing" :content-margin-top="marginTop">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden pointer-events-none">
|
||||
<p class="font-book text-3xl text-white truncate pointer-events-none">{{ title }}</p>
|
||||
<div class="absolute top-0 left-0 p-4 landscape:px-4 landscape:py-2 md:portrait:p-5 lg:p-5 w-2/3 overflow-hidden pointer-events-none">
|
||||
<p class="font-book text-xl md:portrait:text-3xl md:landscape:text-lg lg:text-3xl text-white truncate pointer-events-none">{{ title }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<div class="absolute -top-10 left-0 z-10 w-full flex">
|
||||
<template v-for="tab in availableTabs">
|
||||
<div :key="tab.id" class="w-28 rounded-t-lg flex items-center justify-center mr-1 cursor-pointer hover:bg-bg font-book border-t border-l border-r border-black-300 tab text-xs sm:text-base" :class="selectedTab === tab.id ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab(tab.id)">{{ tab.title }}</div>
|
||||
<div :key="tab.id" class="w-28 rounded-t-lg flex items-center justify-center mr-0.5 sm:mr-1 cursor-pointer hover:bg-bg font-book border-t border-l border-r border-black-300 tab text-xs sm:text-base" :class="selectedTab === tab.id ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab(tab.id)">{{ tab.title }}</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -30,6 +30,8 @@ export default {
|
||||
return {
|
||||
processing: false,
|
||||
libraryItem: null,
|
||||
availableHeight: 0,
|
||||
marginTop: 0,
|
||||
tabs: [
|
||||
{
|
||||
id: 'details',
|
||||
@@ -133,8 +135,7 @@ export default {
|
||||
})
|
||||
},
|
||||
height() {
|
||||
var maxHeightAllowed = window.innerHeight - 150
|
||||
return Math.min(maxHeightAllowed, 650)
|
||||
return Math.min(this.availableHeight, 650)
|
||||
},
|
||||
tabName() {
|
||||
var _tab = this.tabs.find((t) => t.id === this.selectedTab)
|
||||
@@ -246,15 +247,29 @@ export default {
|
||||
}
|
||||
},
|
||||
registerListeners() {
|
||||
window.addEventListener('orientationchange', this.orientationChange)
|
||||
this.$eventBus.$on('modal-hotkey', this.hotkey)
|
||||
this.$eventBus.$on(`${this.selectedLibraryItemId}_updated`, this.libraryItemUpdated)
|
||||
},
|
||||
unregisterListeners() {
|
||||
window.removeEventListener('orientationchange', this.orientationChange)
|
||||
this.$eventBus.$off('modal-hotkey', this.hotkey)
|
||||
this.$eventBus.$off(`${this.selectedLibraryItemId}_updated`, this.libraryItemUpdated)
|
||||
},
|
||||
orientationChange() {
|
||||
setTimeout(this.setHeight, 50)
|
||||
},
|
||||
setHeight() {
|
||||
const smAndBelow = window.innerWidth < 1024 && window.innerWidth > window.innerHeight
|
||||
|
||||
this.marginTop = smAndBelow ? 90 : 75
|
||||
const heightModifier = smAndBelow ? 95 : 150
|
||||
this.availableHeight = window.innerHeight - heightModifier
|
||||
}
|
||||
},
|
||||
mounted() {},
|
||||
mounted() {
|
||||
this.setHeight()
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.unregisterListeners()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6 relative">
|
||||
<div class="flex">
|
||||
<div class="w-full h-full overflow-hidden overflow-y-auto px-2 sm:px-4 py-6 relative">
|
||||
<div class="flex flex-wrap">
|
||||
<div class="relative">
|
||||
<covers-book-cover :library-item="libraryItem" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
<!-- book cover overlay -->
|
||||
@@ -11,14 +11,14 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow pl-6 pr-2">
|
||||
<div class="flex-grow sm:pl-2 md:pl-6 sm:pr-2 mt-2 md:mt-0">
|
||||
<div class="flex items-center">
|
||||
<div v-if="userCanUpload" class="w-40 pr-2 pt-4" style="min-width: 160px">
|
||||
<ui-file-input ref="fileInput" @change="fileUploadSelected">Upload Cover</ui-file-input>
|
||||
<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"><span class="hidden md:inline-block">Upload Cover</span><span class="material-icons inline-block md:!hidden">upload</span></ui-file-input>
|
||||
</div>
|
||||
<form @submit.prevent="submitForm" class="flex flex-grow">
|
||||
<ui-text-input-with-label v-model="imageUrl" label="Cover Image URL" />
|
||||
<ui-btn color="success" type="submit" :padding-x="4" class="mt-5 ml-3 w-24">Update</ui-btn>
|
||||
<ui-btn color="success" type="submit" :padding-x="4" class="mt-5 ml-2 sm:ml-3 w-24">Update</ui-btn>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,23 +5,24 @@
|
||||
<widgets-podcast-details-edit v-else ref="itemDetailsEdit" :library-item="libraryItem" @submit="saveAndClose" />
|
||||
</div>
|
||||
|
||||
<div class="absolute bottom-0 left-0 w-full 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">
|
||||
<ui-btn v-if="userCanDelete" color="error" type="button" class="h-8" :padding-x="3" small @click.stop.prevent="removeItem">Remove</ui-btn>
|
||||
<ui-btn v-if="userCanDelete" color="error" type="button" class="h-8 hidden md:block" :padding-x="3" small @click.stop.prevent="removeItem">Remove</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 v-if="mediaType == 'book'" :disabled="!!quickMatching" :text="`(Root User Only) Populate empty book details & cover with first book result from '${libraryProvider}'. Does not overwrite details.`" direction="bottom" class="mr-4">
|
||||
<ui-tooltip v-if="mediaType == 'book'" :disabled="!!quickMatching" :text="`(Root User Only) Populate empty book details & cover with first book result from '${libraryProvider}'. Does not overwrite details.`" 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">Quick Match</ui-btn>
|
||||
</ui-tooltip>
|
||||
|
||||
<ui-tooltip :disabled="!!libraryScan" text="(Root User Only) Rescan audiobook including metadata" direction="bottom" class="mr-4">
|
||||
<ui-tooltip :disabled="!!libraryScan" text="(Root User Only) Rescan audiobook including metadata" direction="bottom" class="mr-2 md:mr-4">
|
||||
<ui-btn v-if="userIsAdminOrUp && !isFile" :loading="rescanning" :disabled="!!libraryScan" color="bg" type="button" class="h-full" small @click.stop.prevent="rescan">Re-Scan</ui-btn>
|
||||
</ui-tooltip>
|
||||
|
||||
<ui-btn @click="save" class="mx-2">Save</ui-btn>
|
||||
<ui-btn @click="save" class="mx-2 hidden md:block">Save</ui-btn>
|
||||
|
||||
<ui-btn @click="saveAndClose">Save & Close</ui-btn>
|
||||
<ui-btn @click="saveAndClose">Save<span class="hidden md:inline-block"> & Close</span></ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
<div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6">
|
||||
<!-- Merge to m4b -->
|
||||
<div v-if="showM4bDownload" class="w-full border border-black-200 p-4 my-8">
|
||||
<div class="flex items-center">
|
||||
<div class="flex flex-wrap items-center">
|
||||
<div>
|
||||
<p class="text-lg">Make M4B Audiobook File <span class="text-error">*</span></p>
|
||||
<p class="max-w-sm text-sm pt-2 text-gray-300">Generate a .M4B audiobook file with embedded metadata, cover image, and chapters. <br /><span class="text-warning">*</span> Does not delete existing audio files.</p>
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<div>
|
||||
<div class="mt-2 md:mt-0">
|
||||
<p v-if="abmergeStatus === $constants.DownloadStatus.FAILED" class="text-error mb-2">Download Failed</p>
|
||||
<p v-if="abmergeStatus === $constants.DownloadStatus.READY" class="text-success mb-2">Download Ready!</p>
|
||||
<p v-if="abmergeStatus === $constants.DownloadStatus.EXPIRED" class="text-error mb-2">Download Expired</p>
|
||||
@@ -69,7 +69,7 @@
|
||||
|
||||
<p v-if="showM4bDownload" class="text-left text-base mb-4 py-4">
|
||||
<span class="text-error">* <strong>Experimental</strong></span
|
||||
> - M4b merge can take several minutes and will be stored in <span class="bg-primary bg-opacity-75 font-mono p-1 text-base">/metadata/downloads</span>. After the download is ready, it will remain available for 60 minutes, then be deleted. Download will timeout after 20 minutes.
|
||||
> - M4b merge can take several minutes and will be stored in <span class="bg-primary bg-opacity-75 font-mono p-1 text-base">/metadata/downloads</span>. After the download is ready, it will remain available for 60 minutes, then be deleted. Download will timeout after 30 minutes.
|
||||
</p>
|
||||
|
||||
<!-- <p v-if="isSingleM4b" class="text-lg text-center my-8">Audiobook is already a single m4b!</p> -->
|
||||
|
||||
@@ -365,17 +365,27 @@ export default {
|
||||
else this.provider = localStorage.getItem('book-provider') || 'google'
|
||||
},
|
||||
selectMatch(match) {
|
||||
if (match && match.series) {
|
||||
match.series = match.series.map((se) => {
|
||||
return {
|
||||
id: `new-${Math.floor(Math.random() * 10000)}`,
|
||||
displayName: se.volumeNumber ? `${se.series} #${se.volumeNumber}` : se.series,
|
||||
name: se.series,
|
||||
sequence: se.volumeNumber || ''
|
||||
if (match) {
|
||||
if (match.series) {
|
||||
if (!match.series.length) {
|
||||
delete match.series
|
||||
} else {
|
||||
match.series = match.series.map((se) => {
|
||||
return {
|
||||
id: `new-${Math.floor(Math.random() * 10000)}`,
|
||||
displayName: se.volumeNumber ? `${se.series} #${se.volumeNumber}` : se.series,
|
||||
name: se.series,
|
||||
sequence: se.volumeNumber || ''
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
if (match.genres && Array.isArray(match.genres)) {
|
||||
match.genres = match.genres.join(',')
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Select Match', match)
|
||||
this.selectedMatch = match
|
||||
},
|
||||
buildMatchUpdatePayload() {
|
||||
@@ -405,9 +415,12 @@ export default {
|
||||
|
||||
updatePayload.metadata.series = seriesPayload
|
||||
} else if (key === 'author' && !this.isPodcast) {
|
||||
if (!Array.isArray(this.selectedMatch[key])) this.selectedMatch[key] = [this.selectedMatch[key]]
|
||||
var authors = this.selectedMatch[key]
|
||||
if (!Array.isArray(authors)) {
|
||||
authors = authors.split(',').map((au) => au.trim())
|
||||
}
|
||||
var authorPayload = []
|
||||
this.selectedMatch[key].forEach((authorName) =>
|
||||
authors.forEach((authorName) =>
|
||||
authorPayload.push({
|
||||
id: `new-${Math.floor(Math.random() * 10000)}`,
|
||||
name: authorName
|
||||
@@ -415,11 +428,11 @@ export default {
|
||||
)
|
||||
updatePayload.metadata.authors = authorPayload
|
||||
} else if (key === 'narrator') {
|
||||
updatePayload.metadata.narrators = [this.selectedMatch[key]]
|
||||
updatePayload.metadata.narrators = this.selectedMatch[key].split(',').map((v) => v.trim())
|
||||
} else if (key === 'genres') {
|
||||
updatePayload.metadata.genres = this.selectedMatch[key].split(',')
|
||||
updatePayload.metadata.genres = this.selectedMatch[key].split(',').map((v) => v.trim())
|
||||
} else if (key === 'tags') {
|
||||
updatePayload.tags = this.selectedMatch[key].split(',')
|
||||
updatePayload.tags = this.selectedMatch[key].split(',').map((v) => v.trim())
|
||||
} else if (key === 'itunesId') {
|
||||
updatePayload.metadata.itunesId = Number(this.selectedMatch[key])
|
||||
} else {
|
||||
@@ -435,9 +448,11 @@ export default {
|
||||
if (!Object.keys(updatePayload).length) {
|
||||
return
|
||||
}
|
||||
|
||||
console.log('Match payload', updatePayload)
|
||||
this.isProcessing = true
|
||||
|
||||
if (updatePayload.cover) {
|
||||
if (updatePayload.metadata.cover) {
|
||||
var coverPayload = {
|
||||
url: updatePayload.metadata.cover
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
<p v-if="hasEpisodesWithoutPubDate" class="w-full pt-2 text-warning text-xs">Warning: 1 or more of your episodes do not have a Pub Date. Some podcast apps require this.</p>
|
||||
</div>
|
||||
<div v-show="userIsAdminOrUp" class="flex items-center pt-6">
|
||||
<p class="text-xs text-gray-300">Note: RSS feed URLs are not authenticated</p>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn v-if="currentFeedUrl" color="error" small @click="closeFeed">Close RSS Feed</ui-btn>
|
||||
<ui-btn v-else color="success" small @click="openFeed">Open RSS Feed</ui-btn>
|
||||
|
||||
82
client/components/player/PlayerPlaybackControls.vue
Normal file
82
client/components/player/PlayerPlaybackControls.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<div class="flex pt-4 pb-2 md:pt-0 md:pb-2">
|
||||
<div class="flex-grow" />
|
||||
<template v-if="!loading">
|
||||
<div class="cursor-pointer flex items-center justify-center text-gray-300 mr-4 md:mr-8" @mousedown.prevent @mouseup.prevent @click.stop="prevChapter">
|
||||
<span class="material-icons text-2xl sm:text-3xl">first_page</span>
|
||||
</div>
|
||||
<div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpBackward">
|
||||
<span class="material-icons text-2xl sm:text-3xl">replay_10</span>
|
||||
</div>
|
||||
<div class="cursor-pointer p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-4 md:mx-8" :class="seekLoading ? 'animate-spin' : ''" @mousedown.prevent @mouseup.prevent @click.stop="playPause">
|
||||
<span class="material-icons">{{ seekLoading ? 'autorenew' : paused ? 'play_arrow' : 'pause' }}</span>
|
||||
</div>
|
||||
<div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpForward">
|
||||
<span class="material-icons text-2xl sm:text-3xl">forward_10</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-center ml-4 md:ml-8" :class="hasNextChapter ? 'text-gray-300 cursor-pointer' : 'text-gray-500'" @mousedown.prevent @mouseup.prevent @click.stop="nextChapter">
|
||||
<span class="material-icons text-2xl sm:text-3xl">last_page</span>
|
||||
</div>
|
||||
<controls-playback-speed-control v-model="playbackRateInput" @input="playbackRateUpdated" @change="playbackRateChanged" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="cursor-pointer p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-8 animate-spin">
|
||||
<span class="material-icons">autorenew</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="flex-grow" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
loading: Boolean,
|
||||
seekLoading: Boolean,
|
||||
playbackRate: Number,
|
||||
paused: Boolean,
|
||||
hasNextChapter: Boolean
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
playbackRateInput: {
|
||||
get() {
|
||||
return this.playbackRate
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('update:playbackRate', val)
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
playPause() {
|
||||
this.$emit('playPause')
|
||||
},
|
||||
prevChapter() {
|
||||
this.$emit('prevChapter')
|
||||
},
|
||||
nextChapter() {
|
||||
if (!this.hasNextChapter) return
|
||||
this.$emit('nextChapter')
|
||||
},
|
||||
jumpBackward() {
|
||||
this.$emit('jumpBackward')
|
||||
},
|
||||
jumpForward() {
|
||||
this.$emit('jumpForward')
|
||||
},
|
||||
playbackRateUpdated(playbackRate) {
|
||||
this.$emit('setPlaybackRate', playbackRate)
|
||||
},
|
||||
playbackRateChanged(playbackRate) {
|
||||
this.$emit('setPlaybackRate', playbackRate)
|
||||
this.$store.dispatch('user/updateUserSettings', { playbackRate }).catch((err) => {
|
||||
console.error('Failed to update settings', err)
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
213
client/components/player/PlayerTrackBar.vue
Normal file
213
client/components/player/PlayerTrackBar.vue
Normal file
@@ -0,0 +1,213 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<!-- Track -->
|
||||
<div ref="track" class="w-full h-2 bg-gray-700 relative cursor-pointer transform duration-100 hover:scale-y-125 overflow-hidden" @mousemove="mousemoveTrack" @mouseleave="mouseleaveTrack" @click.stop="clickTrack">
|
||||
<div ref="readyTrack" class="h-full bg-gray-600 absolute top-0 left-0 pointer-events-none" />
|
||||
<div ref="bufferTrack" class="h-full bg-gray-500 absolute top-0 left-0 pointer-events-none" />
|
||||
<div ref="playedTrack" class="h-full bg-gray-200 absolute top-0 left-0 pointer-events-none" />
|
||||
<div ref="trackCursor" class="h-full w-0.5 bg-gray-50 absolute top-0 left-0 opacity-0 pointer-events-none" />
|
||||
<div v-if="loading" class="h-full w-1/4 absolute left-0 top-0 loadingTrack pointer-events-none bg-white bg-opacity-25" />
|
||||
</div>
|
||||
<div class="w-full h-2 relative overflow-hidden" :class="useChapterTrack ? 'opacity-0' : ''">
|
||||
<template v-for="(tick, index) in chapterTicks">
|
||||
<div :key="index" :style="{ left: tick.left + 'px' }" class="absolute top-0 w-px bg-white bg-opacity-30 h-1 pointer-events-none" />
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Hover timestamp -->
|
||||
<div ref="hoverTimestamp" class="absolute -top-8 left-0 bg-white text-black rounded-full opacity-0 pointer-events-none">
|
||||
<p ref="hoverTimestampText" class="text-xs font-mono text-center px-2 py-0.5 truncate whitespace-nowrap">00:00</p>
|
||||
</div>
|
||||
<div ref="hoverTimestampArrow" class="absolute -top-3 left-0 bg-white text-black rounded-full opacity-0 pointer-events-none">
|
||||
<div class="absolute -bottom-1.5 left-0 right-0 w-full flex justify-center">
|
||||
<div class="arrow-down" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
loading: Boolean,
|
||||
duration: Number,
|
||||
chapters: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
currentChapter: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
trackWidth: 0,
|
||||
currentTime: 0,
|
||||
percentReady: 0,
|
||||
bufferTime: 0,
|
||||
chapterTicks: [],
|
||||
trackOffsetLeft: 16, // Track is 16px from edge
|
||||
playedTrackWidth: 0,
|
||||
readyTrackWidth: 0,
|
||||
bufferTrackWidth: 0,
|
||||
useChapterTrack: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
duration: {
|
||||
immediate: true,
|
||||
handler() {
|
||||
this.setChapterTicks()
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
currentChapterDuration() {
|
||||
if (!this.currentChapter) return 0
|
||||
return this.currentChapter.end - this.currentChapter.start
|
||||
},
|
||||
currentChapterStart() {
|
||||
if (!this.currentChapter) return 0
|
||||
return this.currentChapter.start
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
setUseChapterTrack(useChapterTrack) {
|
||||
this.useChapterTrack = useChapterTrack
|
||||
this.updateBufferTrack()
|
||||
this.updatePlayedTrackWidth()
|
||||
},
|
||||
clickTrack(e) {
|
||||
if (this.loading) return
|
||||
|
||||
var offsetX = e.offsetX
|
||||
var perc = offsetX / this.trackWidth
|
||||
const baseTime = this.useChapterTrack ? this.currentChapterStart : 0;
|
||||
const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration;
|
||||
const time = baseTime + (perc * duration);
|
||||
if (isNaN(time) || time === null) {
|
||||
console.error('Invalid time', perc, time)
|
||||
return
|
||||
}
|
||||
this.$emit('seek', time)
|
||||
},
|
||||
setBufferTime(time) {
|
||||
this.bufferTime = time
|
||||
this.updateBufferTrack()
|
||||
},
|
||||
updateBufferTrack() {
|
||||
const time = this.useChapterTrack ? Math.max(0, this.bufferTime - this.currentChapterStart) : this.bufferTime
|
||||
const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration
|
||||
|
||||
var bufferlen = (time / duration) * this.trackWidth
|
||||
bufferlen = Math.round(bufferlen)
|
||||
if (this.bufferTrackWidth === bufferlen || !this.$refs.bufferTrack) return
|
||||
if (this.$refs.bufferTrack) this.$refs.bufferTrack.style.width = bufferlen + 'px'
|
||||
this.bufferTrackWidth = bufferlen
|
||||
},
|
||||
setPercentageReady(percent) {
|
||||
this.percentReady = percent
|
||||
this.updateReadyTrack()
|
||||
},
|
||||
updateReadyTrack() {
|
||||
var widthReady = Math.round(this.trackWidth * this.percentReady)
|
||||
if (this.readyTrackWidth === widthReady) return
|
||||
this.readyTrackWidth = widthReady
|
||||
if (this.$refs.readyTrack) this.$refs.readyTrack.style.width = widthReady + 'px'
|
||||
},
|
||||
setCurrentTime(time) {
|
||||
this.currentTime = time
|
||||
this.updatePlayedTrackWidth()
|
||||
},
|
||||
updatePlayedTrackWidth() {
|
||||
const time = this.useChapterTrack ? Math.max(0, this.currentTime - this.currentChapterStart) : this.currentTime
|
||||
const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration
|
||||
|
||||
var ptWidth = Math.round((time / duration) * this.trackWidth)
|
||||
if (this.playedTrackWidth === ptWidth) {
|
||||
return
|
||||
}
|
||||
if (this.$refs.playedTrack) this.$refs.playedTrack.style.width = ptWidth + 'px'
|
||||
this.playedTrackWidth = ptWidth
|
||||
},
|
||||
setChapterTicks() {
|
||||
this.chapterTicks = this.chapters.map((chap) => {
|
||||
var perc = chap.start / this.duration
|
||||
return {
|
||||
title: chap.title,
|
||||
left: perc * this.trackWidth
|
||||
}
|
||||
})
|
||||
},
|
||||
mousemoveTrack(e) {
|
||||
var offsetX = e.offsetX
|
||||
|
||||
const baseTime = this.useChapterTrack ? this.currentChapterStart : 0;
|
||||
const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration;
|
||||
const progressTime = (offsetX / this.trackWidth) * duration;
|
||||
const totalTime = baseTime + progressTime;
|
||||
|
||||
if (this.$refs.hoverTimestamp) {
|
||||
var width = this.$refs.hoverTimestamp.clientWidth
|
||||
this.$refs.hoverTimestamp.style.opacity = 1
|
||||
var posLeft = offsetX - width / 2
|
||||
if (posLeft + width + this.trackOffsetLeft > window.innerWidth) {
|
||||
posLeft = window.innerWidth - width - this.trackOffsetLeft
|
||||
} else if (posLeft < -this.trackOffsetLeft) {
|
||||
posLeft = -this.trackOffsetLeft
|
||||
}
|
||||
this.$refs.hoverTimestamp.style.left = posLeft + 'px'
|
||||
}
|
||||
|
||||
if (this.$refs.hoverTimestampArrow) {
|
||||
var width = this.$refs.hoverTimestampArrow.clientWidth
|
||||
var posLeft = offsetX - width / 2
|
||||
this.$refs.hoverTimestampArrow.style.opacity = 1
|
||||
this.$refs.hoverTimestampArrow.style.left = posLeft + 'px'
|
||||
}
|
||||
if (this.$refs.hoverTimestampText) {
|
||||
var hoverText = this.$secondsToTimestamp(progressTime)
|
||||
|
||||
var chapter = this.chapters.find((chapter) => chapter.start <= totalTime && totalTime < chapter.end)
|
||||
if (chapter && chapter.title) {
|
||||
hoverText += ` - ${chapter.title}`
|
||||
}
|
||||
this.$refs.hoverTimestampText.innerText = hoverText
|
||||
}
|
||||
if (this.$refs.trackCursor) {
|
||||
this.$refs.trackCursor.style.opacity = 1
|
||||
this.$refs.trackCursor.style.left = offsetX - 1 + 'px'
|
||||
}
|
||||
},
|
||||
mouseleaveTrack() {
|
||||
if (this.$refs.hoverTimestamp) {
|
||||
this.$refs.hoverTimestamp.style.opacity = 0
|
||||
}
|
||||
if (this.$refs.hoverTimestampArrow) {
|
||||
this.$refs.hoverTimestampArrow.style.opacity = 0
|
||||
}
|
||||
if (this.$refs.trackCursor) {
|
||||
this.$refs.trackCursor.style.opacity = 0
|
||||
}
|
||||
},
|
||||
setTrackWidth() {
|
||||
if (this.$refs.track) {
|
||||
this.trackWidth = this.$refs.track.clientWidth
|
||||
} else {
|
||||
console.error('Track not loaded', this.$refs)
|
||||
}
|
||||
},
|
||||
windowResize() {
|
||||
this.setTrackWidth()
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.setTrackWidth()
|
||||
window.addEventListener('resize', this.windowResize)
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('resize', this.windowResize)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
356
client/components/player/PlayerUi.vue
Normal file
356
client/components/player/PlayerUi.vue
Normal file
@@ -0,0 +1,356 @@
|
||||
<template>
|
||||
<div class="w-full -mt-6">
|
||||
<div class="w-full relative mb-1">
|
||||
<div class="absolute -top-10 md:top-0 right-0 lg:right-2 flex items-center h-full">
|
||||
<!-- <span class="material-icons text-2xl cursor-pointer" @click="toggleFullscreen(true)">expand_less</span> -->
|
||||
|
||||
<controls-volume-control ref="volumeControl" v-model="volume" @input="setVolume" class="mx-2 hidden md:block" />
|
||||
|
||||
<div class="cursor-pointer text-gray-300 hover:text-white mx-1 lg:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showSleepTimer')">
|
||||
<span v-if="!sleepTimerSet" class="material-icons text-2xl sm:text-2.5xl">snooze</span>
|
||||
<div v-else class="flex items-center">
|
||||
<span class="material-icons text-lg text-warning">snooze</span>
|
||||
<p class="text-xl text-warning font-mono font-semibold text-center px-0.5 pb-0.5" style="min-width: 30px">{{ sleepTimerRemainingString }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!isPodcast" class="cursor-pointer text-gray-300 hover:text-white mx-1 lg:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showBookmarks')">
|
||||
<span class="material-icons text-2xl sm:text-2.5xl">{{ bookmarks.length ? 'bookmarks' : 'bookmark_border' }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="chapters.length" class="cursor-pointer text-gray-300 hover:text-white mx-1 lg:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="showChapters">
|
||||
<span class="material-icons text-2xl sm:text-3xl">format_list_bulleted</span>
|
||||
</div>
|
||||
|
||||
<ui-tooltip v-if="chapters.length" direction="top" :text="useChapterTrack ? 'Use full track' : 'Use chapter track'">
|
||||
<div class="cursor-pointer text-gray-300 mx-1 lg:mx-2 hover:text-white" @mousedown.prevent @mouseup.prevent @click.stop="setUseChapterTrack">
|
||||
<span class="material-icons text-2xl sm:text-3xl transform transition-transform" :class="useChapterTrack ? 'rotate-180' : ''">timelapse</span>
|
||||
</div>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<player-playback-controls :loading="loading" :seek-loading="seekLoading" :playback-rate.sync="playbackRate" :paused="paused" :has-next-chapter="hasNextChapter" @prevChapter="prevChapter" @nextChapter="nextChapter" @jumpForward="jumpForward" @jumpBackward="jumpBackward" @setPlaybackRate="setPlaybackRate" @playPause="playPause" />
|
||||
</div>
|
||||
|
||||
<player-track-bar ref="trackbar" :loading="loading" :chapters="chapters" :duration="duration" :current-chapter="currentChapter" @seek="seek" />
|
||||
|
||||
<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 class="font-mono text-sm hidden sm:block text-gray-100 pointer-events-auto"> / {{ progressPercent }}%</p>
|
||||
<div class="flex-grow" />
|
||||
<p class="text-xs sm:text-sm text-gray-300 pt-0.5">
|
||||
{{ currentChapterName }} <span v-if="useChapterTrack" class="text-xs text-gray-400"> ({{ currentChapterIndex + 1 }} of {{ chapters.length }})</span>
|
||||
</p>
|
||||
<div class="flex-grow" />
|
||||
<p class="font-mono text-xxs sm:text-sm text-gray-100 pointer-events-auto">{{ timeRemainingPretty }}</p>
|
||||
</div>
|
||||
|
||||
<modals-chapters-modal v-model="showChaptersModal" :current-chapter="currentChapter" :chapters="chapters" @select="selectChapter" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
loading: Boolean,
|
||||
paused: Boolean,
|
||||
chapters: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
bookmarks: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
sleepTimerSet: Boolean,
|
||||
sleepTimerRemaining: Number,
|
||||
isPodcast: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
volume: 1,
|
||||
playbackRate: 1,
|
||||
audioEl: null,
|
||||
seekLoading: false,
|
||||
showChaptersModal: false,
|
||||
currentTime: 0,
|
||||
duration: 0,
|
||||
useChapterTrack: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
sleepTimerRemainingString() {
|
||||
var rounded = Math.round(this.sleepTimerRemaining)
|
||||
if (rounded < 90) {
|
||||
return `${rounded}s`
|
||||
}
|
||||
var minutesRounded = Math.round(rounded / 60)
|
||||
if (minutesRounded < 90) {
|
||||
return `${minutesRounded}m`
|
||||
}
|
||||
var hoursRounded = Math.round(minutesRounded / 60)
|
||||
return `${hoursRounded}h`
|
||||
},
|
||||
token() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
timeRemaining() {
|
||||
if (this.useChapterTrack && this.currentChapter) {
|
||||
var currChapTime = this.currentTime - this.currentChapter.start
|
||||
return (this.currentChapterDuration - currChapTime) / this.playbackRate
|
||||
}
|
||||
return (this.duration - this.currentTime) / this.playbackRate
|
||||
},
|
||||
timeRemainingPretty() {
|
||||
if (this.timeRemaining < 0) {
|
||||
return this.$secondsToTimestamp(this.timeRemaining * -1)
|
||||
}
|
||||
return '-' + this.$secondsToTimestamp(this.timeRemaining)
|
||||
},
|
||||
progressPercent() {
|
||||
const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration
|
||||
const time = this.useChapterTrack ? Math.max(this.currentTime - this.currentChapterStart) : this.currentTime
|
||||
|
||||
if (!duration) return 0
|
||||
return Math.round((100 * time) / duration)
|
||||
},
|
||||
currentChapter() {
|
||||
return this.chapters.find((chapter) => chapter.start <= this.currentTime && this.currentTime < chapter.end)
|
||||
},
|
||||
currentChapterName() {
|
||||
return this.currentChapter ? this.currentChapter.title : ''
|
||||
},
|
||||
currentChapterDuration() {
|
||||
if (!this.currentChapter) return 0
|
||||
return this.currentChapter.end - this.currentChapter.start
|
||||
},
|
||||
currentChapterStart() {
|
||||
if (!this.currentChapter) return 0
|
||||
return this.currentChapter.start
|
||||
},
|
||||
isFullscreen() {
|
||||
return this.$store.state.playerIsFullscreen
|
||||
},
|
||||
currentChapterIndex() {
|
||||
if (!this.currentChapter) return 0
|
||||
return this.chapters.findIndex((ch) => ch.id === this.currentChapter.id)
|
||||
},
|
||||
hasNextChapter() {
|
||||
if (!this.chapters.length) return false
|
||||
return this.currentChapterIndex < this.chapters.length - 1
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
toggleFullscreen(isFullscreen) {
|
||||
this.$store.commit('setPlayerIsFullscreen', isFullscreen)
|
||||
|
||||
var videoPlayerEl = document.getElementById('video-player')
|
||||
if (videoPlayerEl) {
|
||||
if (isFullscreen) {
|
||||
videoPlayerEl.style.width = '100vw'
|
||||
videoPlayerEl.style.height = '100vh'
|
||||
videoPlayerEl.style.top = '0px'
|
||||
videoPlayerEl.style.left = '0px'
|
||||
} else {
|
||||
videoPlayerEl.style.width = '384px'
|
||||
videoPlayerEl.style.height = '216px'
|
||||
videoPlayerEl.style.top = 'unset'
|
||||
videoPlayerEl.style.bottom = '80px'
|
||||
videoPlayerEl.style.left = '16px'
|
||||
}
|
||||
}
|
||||
},
|
||||
setDuration(duration) {
|
||||
this.duration = duration
|
||||
},
|
||||
setCurrentTime(time) {
|
||||
this.currentTime = time
|
||||
this.updateTimestamp()
|
||||
if (this.$refs.trackbar) this.$refs.trackbar.setCurrentTime(time)
|
||||
},
|
||||
playPause() {
|
||||
this.$emit('playPause')
|
||||
},
|
||||
jumpBackward() {
|
||||
this.$emit('jumpBackward')
|
||||
},
|
||||
jumpForward() {
|
||||
this.$emit('jumpForward')
|
||||
},
|
||||
increaseVolume() {
|
||||
if (this.volume >= 1) return
|
||||
this.volume = Math.min(1, this.volume + 0.1)
|
||||
this.setVolume(this.volume)
|
||||
},
|
||||
decreaseVolume() {
|
||||
if (this.volume <= 0) return
|
||||
this.volume = Math.max(0, this.volume - 0.1)
|
||||
this.setVolume(this.volume)
|
||||
},
|
||||
setVolume(volume) {
|
||||
this.$emit('setVolume', volume)
|
||||
},
|
||||
toggleMute() {
|
||||
if (this.$refs.volumeControl && this.$refs.volumeControl.toggleMute) {
|
||||
this.$refs.volumeControl.toggleMute()
|
||||
}
|
||||
},
|
||||
increasePlaybackRate() {
|
||||
var rates = [0.25, 0.5, 0.8, 1, 1.3, 1.5, 2, 2.5, 3]
|
||||
var currentRateIndex = rates.findIndex((r) => r === this.playbackRate)
|
||||
if (currentRateIndex >= rates.length - 1) return
|
||||
this.playbackRate = rates[currentRateIndex + 1] || 1
|
||||
this.playbackRateChanged(this.playbackRate)
|
||||
},
|
||||
decreasePlaybackRate() {
|
||||
var rates = [0.25, 0.5, 0.8, 1, 1.3, 1.5, 2, 2.5, 3]
|
||||
var currentRateIndex = rates.findIndex((r) => r === this.playbackRate)
|
||||
if (currentRateIndex <= 0) return
|
||||
this.playbackRate = rates[currentRateIndex - 1] || 1
|
||||
this.playbackRateChanged(this.playbackRate)
|
||||
},
|
||||
setPlaybackRate(playbackRate) {
|
||||
this.$emit('setPlaybackRate', playbackRate)
|
||||
},
|
||||
selectChapter(chapter) {
|
||||
this.seek(chapter.start)
|
||||
this.showChaptersModal = false
|
||||
},
|
||||
setUseChapterTrack() {
|
||||
var useChapterTrack = !this.useChapterTrack
|
||||
this.useChapterTrack = useChapterTrack
|
||||
if (this.$refs.trackbar) this.$refs.trackbar.setUseChapterTrack(useChapterTrack)
|
||||
|
||||
this.$store.dispatch('user/updateUserSettings', { useChapterTrack }).catch((err) => {
|
||||
console.error('Failed to update settings', err)
|
||||
})
|
||||
this.updateTimestamp()
|
||||
},
|
||||
checkUpdateChapterTrack() {
|
||||
// Changing media in player may not have chapters
|
||||
if (!this.chapters.length && this.useChapterTrack) {
|
||||
this.useChapterTrack = false
|
||||
if (this.$refs.trackbar) this.$refs.trackbar.setUseChapterTrack(this.useChapterTrack)
|
||||
}
|
||||
},
|
||||
seek(time) {
|
||||
this.$emit('seek', time)
|
||||
},
|
||||
restart() {
|
||||
this.seek(0)
|
||||
},
|
||||
prevChapter() {
|
||||
if (!this.currentChapter || this.currentChapterIndex === 0) {
|
||||
return this.restart()
|
||||
}
|
||||
var timeInCurrentChapter = this.currentTime - this.currentChapter.start
|
||||
if (timeInCurrentChapter <= 3 && this.chapters[this.currentChapterIndex - 1]) {
|
||||
var prevChapter = this.chapters[this.currentChapterIndex - 1]
|
||||
this.seek(prevChapter.start)
|
||||
} else {
|
||||
this.seek(this.currentChapter.start)
|
||||
}
|
||||
},
|
||||
nextChapter() {
|
||||
if (!this.currentChapter || !this.hasNextChapter) return
|
||||
var nextChapter = this.chapters[this.currentChapterIndex + 1]
|
||||
this.seek(nextChapter.start)
|
||||
},
|
||||
setStreamReady() {
|
||||
if (this.$refs.trackbar) this.$refs.trackbar.setPercentageReady(1)
|
||||
},
|
||||
setChunksReady(chunks, numSegments) {
|
||||
var largestSeg = 0
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
var chunk = chunks[i]
|
||||
if (typeof chunk === 'string') {
|
||||
var chunkRange = chunk.split('-').map((c) => Number(c))
|
||||
if (chunkRange.length < 2) continue
|
||||
if (chunkRange[1] > largestSeg) largestSeg = chunkRange[1]
|
||||
} else if (chunk > largestSeg) {
|
||||
largestSeg = chunk
|
||||
}
|
||||
}
|
||||
var percentageReady = largestSeg / numSegments
|
||||
if (this.$refs.trackbar) this.$refs.trackbar.setPercentageReady(percentageReady)
|
||||
},
|
||||
updateTimestamp() {
|
||||
var ts = this.$refs.currentTimestamp
|
||||
if (!ts) {
|
||||
console.error('No timestamp el')
|
||||
return
|
||||
}
|
||||
const time = this.useChapterTrack ? Math.max(0, this.currentTime - this.currentChapterStart) : this.currentTime
|
||||
var currTimeClean = this.$secondsToTimestamp(time)
|
||||
ts.innerText = currTimeClean
|
||||
},
|
||||
setBufferTime(bufferTime) {
|
||||
if (this.$refs.trackbar) this.$refs.trackbar.setBufferTime(bufferTime)
|
||||
},
|
||||
showChapters() {
|
||||
if (!this.chapters.length) return
|
||||
this.showChaptersModal = !this.showChaptersModal
|
||||
},
|
||||
init() {
|
||||
this.playbackRate = this.$store.getters['user/getUserSetting']('playbackRate') || 1
|
||||
|
||||
var _useChapterTrack = this.$store.getters['user/getUserSetting']('useChapterTrack') || false
|
||||
this.useChapterTrack = this.chapters.length ? _useChapterTrack : false
|
||||
|
||||
if (this.$refs.trackbar) this.$refs.trackbar.setUseChapterTrack(this.useChapterTrack)
|
||||
this.$emit('setPlaybackRate', this.playbackRate)
|
||||
},
|
||||
settingsUpdated(settings) {
|
||||
if (settings.playbackRate && this.playbackRate !== settings.playbackRate) {
|
||||
this.setPlaybackRate(settings.playbackRate)
|
||||
}
|
||||
},
|
||||
closePlayer() {
|
||||
if (this.isFullscreen) {
|
||||
this.toggleFullscreen(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (this.loading) return
|
||||
this.$emit('close')
|
||||
},
|
||||
hotkey(action) {
|
||||
if (action === this.$hotkeys.AudioPlayer.PLAY_PAUSE) this.playPause()
|
||||
else if (action === this.$hotkeys.AudioPlayer.JUMP_FORWARD) this.jumpForward()
|
||||
else if (action === this.$hotkeys.AudioPlayer.JUMP_BACKWARD) this.jumpBackward()
|
||||
else if (action === this.$hotkeys.AudioPlayer.VOLUME_UP) this.increaseVolume()
|
||||
else if (action === this.$hotkeys.AudioPlayer.VOLUME_DOWN) this.decreaseVolume()
|
||||
else if (action === this.$hotkeys.AudioPlayer.MUTE_UNMUTE) this.toggleMute()
|
||||
else if (action === this.$hotkeys.AudioPlayer.SHOW_CHAPTERS) this.showChapters()
|
||||
else if (action === this.$hotkeys.AudioPlayer.INCREASE_PLAYBACK_RATE) this.increasePlaybackRate()
|
||||
else if (action === this.$hotkeys.AudioPlayer.DECREASE_PLAYBACK_RATE) this.decreasePlaybackRate()
|
||||
else if (action === this.$hotkeys.AudioPlayer.CLOSE) this.closePlayer()
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.$store.commit('user/addSettingsListener', { id: 'audioplayer', meth: this.settingsUpdated })
|
||||
this.init()
|
||||
this.$eventBus.$on('player-hotkey', this.hotkey)
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$store.commit('user/removeSettingsListener', 'audioplayer')
|
||||
this.$eventBus.$off('player-hotkey', this.hotkey)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.loadingTrack {
|
||||
animation-name: loadingTrack;
|
||||
animation-duration: 1s;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
@keyframes loadingTrack {
|
||||
0% {
|
||||
left: -25%;
|
||||
}
|
||||
100% {
|
||||
left: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -2,7 +2,7 @@
|
||||
<div id="heatmap" class="w-full">
|
||||
<div class="mx-auto" :style="{ height: innerHeight + 160 + 'px', width: innerWidth + 52 + 'px' }" style="background-color: rgba(13, 17, 23, 0)">
|
||||
<p class="mb-2 px-1 text-sm text-gray-200">{{ Object.values(daysListening).length }} listening sessions in the last year</p>
|
||||
<div class="border border-opacity-25 rounded py-2 w-full" style="background-color: #232323" :style="{ height: innerHeight + 80 + 'px' }">
|
||||
<div class="border border-white border-opacity-25 rounded py-2 w-full" style="background-color: #232323" :style="{ height: innerHeight + 80 + 'px' }">
|
||||
<div :style="{ width: innerWidth + 'px', height: innerHeight + 'px' }" class="ml-10 mt-5 absolute" @mouseover="mouseover" @mouseout="mouseout">
|
||||
<div v-for="dayLabel in dayLabels" :key="dayLabel.label" :style="dayLabel.style" class="absolute top-0 left-0 text-gray-300">{{ dayLabel.label }}</div>
|
||||
|
||||
|
||||
@@ -192,7 +192,11 @@ export default {
|
||||
}
|
||||
|
||||
#accounts tr:nth-child(even) {
|
||||
background-color: #3a3a3a;
|
||||
background-color: #373838;
|
||||
}
|
||||
|
||||
#accounts tr:nth-child(odd) {
|
||||
background-color: #2f2f2f;
|
||||
}
|
||||
|
||||
#accounts tr:hover {
|
||||
@@ -204,6 +208,6 @@ export default {
|
||||
font-weight: 600;
|
||||
padding-top: 5px;
|
||||
padding-bottom: 5px;
|
||||
background-color: #333;
|
||||
background-color: #272727
|
||||
}
|
||||
</style>
|
||||
@@ -6,10 +6,10 @@
|
||||
<span class="material-icons" style="font-size: 1.4rem">add</span>
|
||||
</div>
|
||||
</div>
|
||||
<draggable v-if="libraryCopies.length" :list="libraryCopies" v-bind="dragOptions" class="list-group" draggable=".item" tag="div" @start="startDrag" @end="endDrag">
|
||||
<draggable v-if="libraryCopies.length" :list="libraryCopies" v-bind="dragOptions" class="list-group" handle=".drag-handle" draggable=".item" tag="div" @start="startDrag" @end="endDrag">
|
||||
<template v-for="library in libraryCopies">
|
||||
<div :key="library.id" class="item">
|
||||
<tables-library-item :library="library" :selected="currentLibraryId === library.id" :show-edit="true" :dragging="drag" @edit="editLibrary" @click="setLibrary" />
|
||||
<tables-library-item :library="library" :selected="currentLibraryId === library.id" :dragging="drag" @edit="editLibrary" @click="setLibrary" />
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
|
||||
@@ -7,18 +7,26 @@
|
||||
</svg>
|
||||
<p class="text-xl font-book pl-4 hover:underline cursor-pointer" @click.stop="$emit('click', library)">{{ library.name }}</p>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn v-show="isHovering && !libraryScan" small color="success" @click.stop="scan">Scan</ui-btn>
|
||||
<ui-btn v-show="isHovering && !libraryScan" small color="bg" class="ml-2" @click.stop="forceScan">Force Re-Scan</ui-btn>
|
||||
<ui-btn v-show="isHovering && !libraryScan" class="hidden md:block" small color="success" @click.stop="scan">Scan</ui-btn>
|
||||
<ui-btn v-show="isHovering && !libraryScan" small color="bg" class="ml-2 hidden md:block" @click.stop="forceScan">Force Re-Scan</ui-btn>
|
||||
|
||||
<ui-btn v-show="isHovering && !libraryScan && isBookLibrary" small color="bg" class="ml-2" @click.stop="matchAll">Match Books</ui-btn>
|
||||
<ui-btn v-show="isHovering && !libraryScan && isBookLibrary" small color="bg" class="ml-2 hidden md:block" @click.stop="matchAll">Match Books</ui-btn>
|
||||
|
||||
<span v-show="isHovering && !libraryScan && showEdit" class="material-icons text-xl text-gray-300 hover:text-gray-50 ml-4 cursor-pointer" @click.stop="editClick">edit</span>
|
||||
<span v-show="!libraryScan && isHovering && showEdit && !isDeleting" class="material-icons text-xl text-gray-300 ml-3" :class="isMain ? 'text-opacity-5 cursor-not-allowed' : 'hover:text-gray-50 cursor-pointer'" @click.stop="deleteClick">delete</span>
|
||||
<span v-if="isHovering && !libraryScan" class="!hidden md:!block material-icons text-xl text-gray-300 hover:text-gray-50 ml-4 cursor-pointer" @click.stop="editClick">edit</span>
|
||||
<span v-if="!libraryScan && isHovering && !isDeleting" class="!hidden md:!block material-icons text-xl text-gray-300 ml-3 hover:text-gray-50 cursor-pointer" @click.stop="deleteClick">delete</span>
|
||||
|
||||
<!-- For mobile -->
|
||||
<span v-if="!libraryScan" class="!block md:!hidden material-icons text-xl text-gray-300 ml-4 cursor-pointer" @click.stop="editClick">edit</span>
|
||||
<span v-if="!libraryScan && !isDeleting" class="!block md:!hidden material-icons text-2xl text-gray-300 ml-3 cursor-pointer" @click.stop="showMenu">more_vert</span>
|
||||
<div v-show="isDeleting" class="text-xl text-gray-300 ml-3 animate-spin">
|
||||
<svg viewBox="0 0 24 24" class="w-6 h-6">
|
||||
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="material-icons drag-handle text-xl text-gray-400 hover:text-gray-50 ml-4">reorder</span>
|
||||
|
||||
<!-- For mobile -->
|
||||
<modals-dialog v-model="showMobileMenu" :title="menuTitle" :items="mobileMenuItems" @action="mobileMenuAction" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -30,22 +38,19 @@ export default {
|
||||
default: () => {}
|
||||
},
|
||||
selected: Boolean,
|
||||
showEdit: Boolean,
|
||||
dragging: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
mouseover: false,
|
||||
isDeleting: false
|
||||
isDeleting: false,
|
||||
showMobileMenu: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isHovering() {
|
||||
return this.mouseover && !this.dragging
|
||||
},
|
||||
isMain() {
|
||||
return this.library.id === 'main'
|
||||
},
|
||||
libraryScan() {
|
||||
return this.$store.getters['scanners/getLibraryScan'](this.library.id)
|
||||
},
|
||||
@@ -54,9 +59,50 @@ export default {
|
||||
},
|
||||
isBookLibrary() {
|
||||
return this.mediaType === 'book'
|
||||
},
|
||||
menuTitle() {
|
||||
return this.library.name
|
||||
},
|
||||
mobileMenuItems() {
|
||||
const items = [
|
||||
{
|
||||
text: 'Scan',
|
||||
value: 'scan'
|
||||
},
|
||||
{
|
||||
text: 'Force Re-Scan',
|
||||
value: 'force-scan'
|
||||
}
|
||||
]
|
||||
if (this.isBookLibrary) {
|
||||
items.push({
|
||||
text: 'Match Books',
|
||||
value: 'match-books'
|
||||
})
|
||||
}
|
||||
items.push({
|
||||
text: 'Delete',
|
||||
value: 'delete'
|
||||
})
|
||||
return items
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
mobileMenuAction(action) {
|
||||
this.showMobileMenu = false
|
||||
if (action === 'scan') {
|
||||
this.scan()
|
||||
} else if (action === 'force-scan') {
|
||||
this.forceScan()
|
||||
} else if (action === 'match-books') {
|
||||
this.matchAll()
|
||||
} else if (action === 'delete') {
|
||||
this.deleteClick()
|
||||
}
|
||||
},
|
||||
showMenu() {
|
||||
this.showMobileMenu = true
|
||||
},
|
||||
matchAll() {
|
||||
this.$axios
|
||||
.$post(`/api/libraries/${this.library.id}/matchall`)
|
||||
@@ -97,7 +143,6 @@ export default {
|
||||
}
|
||||
},
|
||||
deleteClick() {
|
||||
if (this.isMain) return
|
||||
if (confirm(`Are you sure you want to permanently delete library "${this.library.name}"?`)) {
|
||||
this.isDeleting = true
|
||||
this.$axios
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<template>
|
||||
<div>
|
||||
<input ref="fileInput" type="file" :accept="accept" class="hidden" @change="inputChanged" />
|
||||
<ui-btn @click="clickUpload" color="primary" type="text"><slot /></ui-btn>
|
||||
<ui-btn @click="clickUpload" color="primary" class="hidden md:block" type="text"><slot /></ui-btn>
|
||||
<ui-icon-btn @click="clickUpload" icon="upload" class="block md:hidden" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<button class="icon-btn rounded-md flex items-center justify-center h-9 w-9 relative" @mousedown.prevent :disabled="disabled || loading" :class="className" @click="clickBtn">
|
||||
<button class="icon-btn rounded-md flex items-center justify-center relative" @mousedown.prevent :disabled="disabled || loading" :class="className" @click="clickBtn">
|
||||
<div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100">
|
||||
<svg class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
|
||||
@@ -20,20 +20,29 @@ export default {
|
||||
},
|
||||
outlined: Boolean,
|
||||
borderless: Boolean,
|
||||
loading: Boolean
|
||||
loading: Boolean,
|
||||
iconFontSize: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
size: {
|
||||
type: Number,
|
||||
default: 9
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
className() {
|
||||
var classes = []
|
||||
var classes = [`h-${this.size} w-${this.size}`]
|
||||
if (!this.borderless) {
|
||||
classes.push(`bg-${this.bgColor} border border-gray-600`)
|
||||
}
|
||||
return classes.join(' ')
|
||||
},
|
||||
fontSize() {
|
||||
if (this.iconFontSize) return this.iconFontSize
|
||||
if (this.icon === 'edit') return '1.25rem'
|
||||
return '1.4rem'
|
||||
}
|
||||
|
||||
@@ -42,7 +42,8 @@ export default {
|
||||
editable: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
},
|
||||
showAllWhenEmpty: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -72,6 +73,7 @@ export default {
|
||||
itemsToShow() {
|
||||
if (!this.editable) return this.items
|
||||
if (!this.textInput || this.textInput === this.input) {
|
||||
if (this.showAllWhenEmpty) return this.items
|
||||
return []
|
||||
}
|
||||
return this.items.filter((i) => {
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<template>
|
||||
<div v-if="currentLibrary" class="relative w-36 h-8" v-click-outside="clickOutsideObj">
|
||||
<button type="button" :disabled="disabled" class="relative h-full w-full border border-white border-opacity-10 hover:border-opacity-20 rounded shadow-sm pl-3 pr-3 text-left focus:outline-none cursor-pointer bg-black bg-opacity-20 text-gray-400 hover:text-gray-200" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
|
||||
<span class="flex items-center">
|
||||
<widgets-library-icon :icon="currentLibraryIcon" class="mr-2" />
|
||||
<span class="block truncate text-sm">{{ currentLibrary.name }}</span>
|
||||
<div v-if="currentLibrary" class="relative sm:w-36 h-8 px-1.5" v-click-outside="clickOutsideObj">
|
||||
<button type="button" :disabled="disabled" class="w-10 sm:w-36 relative h-full border border-white border-opacity-10 hover:border-opacity-20 rounded shadow-sm px-2 text-left text-sm focus:outline-none cursor-pointer bg-black bg-opacity-20 text-gray-400 hover:text-gray-200" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
|
||||
<span class="flex items-center justify-center sm:justify-start">
|
||||
<widgets-library-icon :icon="currentLibraryIcon" class="sm:mr-2" />
|
||||
<span class="hidden sm:block">{{ currentLibrary.name }}</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 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox">
|
||||
<ul v-show="showMenu" class="absolute z-10 -mt-px w-36 bg-primary border border-black-200 shadow-lg max-h-56 rounded-b-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox">
|
||||
<template v-for="library in librariesFiltered">
|
||||
<li :key="library.id" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="selectLibrary(library)">
|
||||
<div class="flex items-center px-3">
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<div ref="wrapper" class="relative">
|
||||
<form @submit.prevent="submitForm">
|
||||
<div ref="inputWrapper" style="min-height: 36px" class="flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-1" :class="wrapperClass" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent>
|
||||
<div v-for="item in selected" :key="item" class="rounded-full px-2 py-1 mx-0.5 text-xs bg-bg flex flex-nowrap whitespace-nowrap items-center relative">
|
||||
<div v-for="item in selected" :key="item" class="rounded-full px-2 py-1 mx-0.5 my-0.5 text-xs bg-bg flex flex-nowrap break-all items-center relative">
|
||||
<div class="w-full h-full rounded-full absolute top-0 left-0 opacity-0 hover:opacity-100 px-1 bg-bg bg-opacity-75 flex items-center justify-end cursor-pointer">
|
||||
<span v-if="showEdit" class="material-icons text-white hover:text-warning" style="font-size: 1.1rem" @click.stop="editItem(item)">edit</span>
|
||||
<span class="material-icons text-white hover:text-error" style="font-size: 1.1rem" @click.stop="removeItem(item)">close</span>
|
||||
|
||||
@@ -53,7 +53,7 @@ export default {
|
||||
var tooltip = document.createElement('div')
|
||||
this.tooltipId = String(Math.floor(Math.random() * 10000))
|
||||
tooltip.id = this.tooltipId
|
||||
tooltip.className = 'tooltip-wrapper absolute px-2 py-1 text-white pointer-events-none text-xs rounded shadow-lg max-w-xs'
|
||||
tooltip.className = 'tooltip-wrapper absolute px-2 py-1 text-white pointer-events-none text-xs rounded shadow-lg max-w-xs text-center hidden sm:block'
|
||||
tooltip.style.zIndex = 100
|
||||
tooltip.style.backgroundColor = 'rgba(0,0,0,0.85)'
|
||||
tooltip.innerHTML = this.text
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
<template>
|
||||
<div class="w-full h-full relative">
|
||||
<form class="w-full h-full px-4 py-6" @submit.prevent="submitForm">
|
||||
<div class="flex -mx-1">
|
||||
<div class="w-1/2 px-1">
|
||||
<form class="w-full h-full px-2 md:px-4 py-6" @submit.prevent="submitForm">
|
||||
<div class="flex flex-wrap -mx-1">
|
||||
<div class="w-full md:w-1/2 px-1">
|
||||
<ui-text-input-with-label ref="titleInput" v-model="details.title" label="Title" />
|
||||
</div>
|
||||
<div class="flex-grow px-1">
|
||||
<div class="flex-grow px-1 mt-2 md:mt-0">
|
||||
<ui-text-input-with-label ref="subtitleInput" v-model="details.subtitle" label="Subtitle" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-3/4 px-1">
|
||||
<div class="flex flex-wrap mt-2 -mx-1">
|
||||
<div class="w-full md:w-3/4 px-1">
|
||||
<!-- Authors filter only contains authors in this library, use query input to query all authors -->
|
||||
<ui-multi-select-query-input ref="authorsSelect" v-model="details.authors" label="Authors" endpoint="authors/search" />
|
||||
</div>
|
||||
<div class="flex-grow px-1">
|
||||
<div class="flex-grow px-1 mt-2 md:mt-0">
|
||||
<ui-text-input-with-label ref="publishYearInput" v-model="details.publishedYear" type="number" label="Publish Year" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -28,35 +28,35 @@
|
||||
|
||||
<ui-textarea-with-label ref="descriptionInput" v-model="details.description" :rows="3" label="Description" class="mt-2" />
|
||||
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-1/2 px-1">
|
||||
<div class="flex flex-wrap mt-2 -mx-1">
|
||||
<div class="w-full md:w-1/2 px-1">
|
||||
<ui-multi-select ref="genresSelect" v-model="details.genres" label="Genres" :items="genres" />
|
||||
</div>
|
||||
<div class="flex-grow px-1">
|
||||
<div class="flex-grow px-1 mt-2 md:mt-0">
|
||||
<ui-multi-select ref="tagsSelect" v-model="newTags" label="Tags" :items="tags" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-1/2 px-1">
|
||||
<div class="flex flex-wrap mt-2 -mx-1">
|
||||
<div class="w-full md:w-1/2 px-1">
|
||||
<ui-multi-select ref="narratorsSelect" v-model="details.narrators" label="Narrators" :items="narrators" />
|
||||
</div>
|
||||
<div class="w-1/4 px-1">
|
||||
<div class="w-1/2 md:w-1/4 px-1 mt-2 md:mt-0">
|
||||
<ui-text-input-with-label ref="isbnInput" v-model="details.isbn" label="ISBN" />
|
||||
</div>
|
||||
<div class="w-1/4 px-1">
|
||||
<div class="w-1/2 md:w-1/4 px-1 mt-2 md:mt-0">
|
||||
<ui-text-input-with-label ref="asinInput" v-model="details.asin" label="ASIN" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-1/2 px-1">
|
||||
<div class="flex flex-wrap mt-2 -mx-1">
|
||||
<div class="w-full md:w-1/2 px-1">
|
||||
<ui-text-input-with-label ref="publisherInput" v-model="details.publisher" label="Publisher" />
|
||||
</div>
|
||||
<div class="w-1/4 px-1">
|
||||
<div class="w-1/2 md:w-1/4 px-1 mt-2 md:mt-0">
|
||||
<ui-text-input-with-label ref="languageInput" v-model="details.language" label="Language" />
|
||||
</div>
|
||||
<div class="flex-grow px-1 pt-6">
|
||||
<div class="flex-grow px-1 pt-6 mt-2 md:mt-0">
|
||||
<div class="flex justify-center">
|
||||
<ui-checkbox v-model="details.explicit" label="Explicit" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
|
||||
</div>
|
||||
|
||||
@@ -5,5 +5,10 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {}
|
||||
export default {
|
||||
mounted() {
|
||||
document.body.classList.remove('app-bar', 'app-bar-and-toolbar')
|
||||
document.body.classList.add('no-bars')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -12,7 +12,6 @@
|
||||
<modals-item-edit-modal />
|
||||
<modals-user-collections-modal />
|
||||
<modals-edit-collection-modal />
|
||||
<modals-bookshelf-texture-modal />
|
||||
<modals-podcast-edit-episode />
|
||||
<modals-podcast-view-episode />
|
||||
<modals-authors-edit-modal />
|
||||
@@ -41,6 +40,7 @@ export default {
|
||||
if (this.$store.state.selectedLibraryItems) {
|
||||
this.$store.commit('setSelectedLibraryItems', [])
|
||||
}
|
||||
this.updateBodyClass()
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -54,11 +54,23 @@ export default {
|
||||
if (!this.$route.name) return false
|
||||
return !this.$route.name.startsWith('config') && this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
isShowingToolbar() {
|
||||
return this.isShowingSideRail && this.$route.name !== 'upload' && this.$route.name !== 'account'
|
||||
},
|
||||
appContentMarginLeft() {
|
||||
return this.isShowingSideRail ? 80 : 0
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
updateBodyClass() {
|
||||
if (this.isShowingToolbar) {
|
||||
document.body.classList.remove('no-bars', 'app-bar')
|
||||
document.body.classList.add('app-bar-and-toolbar')
|
||||
} else {
|
||||
document.body.classList.remove('no-bars', 'app-bar-and-toolbar')
|
||||
document.body.classList.add('app-bar')
|
||||
}
|
||||
},
|
||||
updateSocketConnectionToast(content, type, timeout) {
|
||||
if (this.socketConnectionToastId !== null && this.socketConnectionToastId !== undefined) {
|
||||
this.$toast.update(this.socketConnectionToastId, { content: content, options: { timeout: timeout, type: type, closeButton: false, position: 'bottom-center', onClose: () => null, closeOnClick: timeout !== null } }, false)
|
||||
@@ -111,13 +123,7 @@ export default {
|
||||
}
|
||||
console.log('Init Payload', payload)
|
||||
if (payload.session) {
|
||||
if (this.$refs.streamContainer) {
|
||||
this.$refs.streamContainer.sessionOpen(payload.session)
|
||||
} else {
|
||||
console.warn('Stream Container not mounted')
|
||||
}
|
||||
}
|
||||
if (payload.serverSettings) {
|
||||
this.$refs.streamContainer.sessionOpen(payload.session)
|
||||
}
|
||||
|
||||
// Start scans currently running
|
||||
@@ -244,9 +250,9 @@ export default {
|
||||
|
||||
var existingScan = this.$store.getters['scanners/getLibraryScan'](data.id)
|
||||
if (existingScan && !isNaN(existingScan.toastId)) {
|
||||
this.$toast.update(existingScan.toastId, { content: message, options: { timeout: 5000, type: 'success', closeButton: false, position: 'bottom-center', onClose: () => null } }, true)
|
||||
this.$toast.update(existingScan.toastId, { content: message, options: { timeout: 5000, type: 'success', closeButton: false, onClose: () => null } }, true)
|
||||
} else {
|
||||
this.$toast.success(message, { timeout: 5000, position: 'bottom-center' })
|
||||
this.$toast.success(message, { timeout: 5000 })
|
||||
}
|
||||
|
||||
this.$store.commit('scanners/remove', data)
|
||||
@@ -255,7 +261,7 @@ export default {
|
||||
this.$root.socket.emit('cancel_scan', id)
|
||||
},
|
||||
scanStart(data) {
|
||||
data.toastId = this.$toast(`${data.type === 'match' ? 'Matching' : 'Scanning'} "${data.name}"...`, { timeout: false, type: 'info', draggable: false, closeOnClick: false, closeButton: CloseButton, closeButtonClassName: 'cancel-scan-btn', showCloseButtonOnHover: false, position: 'bottom-center', onClose: () => this.onScanToastCancel(data.id) })
|
||||
data.toastId = this.$toast(`${data.type === 'match' ? 'Matching' : 'Scanning'} "${data.name}"...`, { timeout: false, type: 'info', draggable: false, closeOnClick: false, closeButton: CloseButton, closeButtonClassName: 'cancel-scan-btn', showCloseButtonOnHover: false, onClose: () => this.onScanToastCancel(data.id) })
|
||||
this.$store.commit('scanners/addUpdate', data)
|
||||
},
|
||||
scanProgress(data) {
|
||||
@@ -264,7 +270,7 @@ export default {
|
||||
data.toastId = existingScan.toastId
|
||||
this.$toast.update(existingScan.toastId, { content: `Scanning "${existingScan.name}"... ${data.progress.progress || 0}%`, options: { timeout: false } }, true)
|
||||
} else {
|
||||
data.toastId = this.$toast(`Scanning "${data.name}"...`, { timeout: false, type: 'info', draggable: false, closeOnClick: false, closeButton: CloseButton, closeButtonClassName: 'cancel-scan-btn', showCloseButtonOnHover: false, position: 'bottom-center', onClose: () => this.onScanToastCancel(data.id) })
|
||||
data.toastId = this.$toast(`Scanning "${data.name}"...`, { timeout: false, type: 'info', draggable: false, closeOnClick: false, closeButton: CloseButton, closeButtonClassName: 'cancel-scan-btn', showCloseButtonOnHover: false, onClose: () => this.onScanToastCancel(data.id) })
|
||||
}
|
||||
|
||||
this.$store.commit('scanners/addUpdate', data)
|
||||
@@ -516,29 +522,19 @@ export default {
|
||||
this.$store.commit('globals/updateWindowSize', { width: window.innerWidth, height: window.innerHeight })
|
||||
},
|
||||
checkVersionUpdate() {
|
||||
// Version check is only run if time since last check was 5 minutes
|
||||
const VERSION_CHECK_BUFF = 1000 * 60 * 5 // 5 minutes
|
||||
var lastVerCheck = localStorage.getItem('lastVerCheck') || 0
|
||||
if (Date.now() - Number(lastVerCheck) > VERSION_CHECK_BUFF) {
|
||||
this.$store
|
||||
.dispatch('checkForUpdate')
|
||||
.then((res) => {
|
||||
localStorage.setItem('lastVerCheck', Date.now())
|
||||
if (res && res.hasUpdate) this.showUpdateToast(res)
|
||||
})
|
||||
.catch((err) => console.error(err))
|
||||
|
||||
if (this.$route.query.error) {
|
||||
this.$toast.error(this.$route.query.error)
|
||||
this.$router.replace(this.$route.path)
|
||||
}
|
||||
}
|
||||
this.$store
|
||||
.dispatch('checkForUpdate')
|
||||
.then((res) => {
|
||||
if (res && res.hasUpdate) this.showUpdateToast(res)
|
||||
})
|
||||
.catch((err) => console.error(err))
|
||||
}
|
||||
},
|
||||
beforeMount() {
|
||||
this.initializeSocket()
|
||||
},
|
||||
mounted() {
|
||||
this.updateBodyClass()
|
||||
this.resize()
|
||||
window.addEventListener('resize', this.resize)
|
||||
window.addEventListener('keydown', this.keyDown)
|
||||
@@ -552,6 +548,11 @@ export default {
|
||||
}
|
||||
|
||||
this.checkVersionUpdate()
|
||||
|
||||
if (this.$route.query.error) {
|
||||
this.$toast.error(this.$route.query.error)
|
||||
this.$router.replace(this.$route.path)
|
||||
}
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('resize', this.resize)
|
||||
|
||||
@@ -28,11 +28,11 @@ export default {
|
||||
var validOtherFiles = []
|
||||
var ignoredFiles = []
|
||||
files.forEach((file) => {
|
||||
var filetype = this.checkFileType(file.name)
|
||||
if (!filetype) ignoredFiles.push(file)
|
||||
// var filetype = this.checkFileType(file.name)
|
||||
if (!file.filetype) ignoredFiles.push(file)
|
||||
else {
|
||||
file.filetype = filetype
|
||||
if (filetype === 'audio' || (filetype === 'ebook' && mediaType === 'book')) validItemFiles.push(file)
|
||||
// file.filetype = filetype
|
||||
if (file.filetype === 'audio' || (file.filetype === 'ebook' && mediaType === 'book')) validItemFiles.push(file)
|
||||
else validOtherFiles.push(file)
|
||||
}
|
||||
})
|
||||
@@ -82,12 +82,18 @@ export default {
|
||||
items: itemResults,
|
||||
ignoredFiles: ignoredFilesInRoot
|
||||
}
|
||||
} else {
|
||||
} else if (filetree.some((f) => f.filetype !== 'audio') || mediaType !== 'book') {
|
||||
// Single Book drop
|
||||
return {
|
||||
items: this.itemFromTreeItems(filetree, mediaType),
|
||||
ignoredFiles: []
|
||||
}
|
||||
} else {
|
||||
// Only audio files dropped so treat each one as an audiobook
|
||||
return {
|
||||
items: filetree.map((audioFile) => ({ itemFiles: [audioFile], otherFiles: [], ignoredFiles: [] })),
|
||||
ignoredFiles: []
|
||||
}
|
||||
}
|
||||
},
|
||||
getFilesDropped(dataTransferItems) {
|
||||
@@ -95,11 +101,12 @@ export default {
|
||||
path: '/',
|
||||
items: []
|
||||
}
|
||||
function traverseFileTreePromise(item, currtreemap) {
|
||||
function traverseFileTreePromise(item, currtreemap, checkFileType) {
|
||||
return new Promise((resolve) => {
|
||||
if (item.isFile) {
|
||||
item.file((file) => {
|
||||
file.filepath = currtreemap.path + file.name //save full path
|
||||
file.filetype = checkFileType(file.name)
|
||||
currtreemap.items.push(file)
|
||||
resolve(file)
|
||||
})
|
||||
@@ -119,7 +126,7 @@ export default {
|
||||
dirReader.readEntries((entries) => {
|
||||
if (entries.length > 0) {
|
||||
for (let entr of entries) {
|
||||
entriesPromises.push(traverseFileTreePromise(entr, newtreemap))
|
||||
entriesPromises.push(traverseFileTreePromise(entr, newtreemap, checkFileType))
|
||||
}
|
||||
readEntries()
|
||||
} else {
|
||||
@@ -135,7 +142,7 @@ export default {
|
||||
return new Promise((resolve, reject) => {
|
||||
let entriesPromises = []
|
||||
for (let it of dataTransferItems) {
|
||||
var filetree = traverseFileTreePromise(it.webkitGetAsEntry(), treemap)
|
||||
var filetree = traverseFileTreePromise(it.webkitGetAsEntry(), treemap, this.checkFileType)
|
||||
entriesPromises.push(filetree)
|
||||
}
|
||||
Promise.all(entriesPromises).then(() => {
|
||||
@@ -152,7 +159,9 @@ export default {
|
||||
...book
|
||||
}
|
||||
var firstBookFile = book.itemFiles[0]
|
||||
if (!firstBookFile.filepath) return audiobook // No path
|
||||
if (!firstBookFile.filepath) {
|
||||
return audiobook // No path
|
||||
}
|
||||
|
||||
var firstBookPath = Path.dirname(firstBookFile.filepath)
|
||||
|
||||
@@ -165,6 +174,9 @@ export default {
|
||||
if (dirs.length) {
|
||||
audiobook.author = dirs.pop()
|
||||
}
|
||||
} else {
|
||||
// Use file basename as title
|
||||
audiobook.title = Path.basename(firstBookFile.name, Path.extname(firstBookFile.name))
|
||||
}
|
||||
return audiobook
|
||||
},
|
||||
@@ -178,7 +190,12 @@ export default {
|
||||
if (!firstAudioFile.filepath) return podcast // No path
|
||||
var firstPath = Path.dirname(firstAudioFile.filepath)
|
||||
var dirs = firstPath.split('/').filter(d => !!d && d !== '.')
|
||||
podcast.title = dirs.length > 1 ? dirs[1] : dirs[0]
|
||||
if (dirs.length) {
|
||||
podcast.title = dirs.length > 1 ? dirs[1] : dirs[0]
|
||||
} else {
|
||||
podcast.title = Path.basename(firstAudioFile.name, Path.extname(firstAudioFile.name))
|
||||
}
|
||||
|
||||
return podcast
|
||||
},
|
||||
cleanItem(item, mediaType, index) {
|
||||
@@ -188,6 +205,7 @@ export default {
|
||||
async getItemsFromDataTransferItems(dataTransferItems, mediaType) {
|
||||
var files = await this.getFilesDropped(dataTransferItems)
|
||||
if (!files || !files.length) return { error: 'No files found ' }
|
||||
|
||||
var itemData = this.fileTreeToItems(files, mediaType)
|
||||
if (!itemData.items.length && !itemData.ignoredFiles.length) {
|
||||
return { error: 'Invalid file drop' }
|
||||
@@ -218,9 +236,12 @@ export default {
|
||||
else {
|
||||
file.filetype = filetype
|
||||
if (file.webkitRelativePath) file.filepath = file.webkitRelativePath
|
||||
else file.filepath = file.name
|
||||
|
||||
if (filetype === 'audio' || (filetype === 'ebook' && mediaType === 'book')) {
|
||||
var dir = file.filepath ? Path.dirname(file.filepath) : ''
|
||||
if (dir === '.') dir = ''
|
||||
|
||||
if (!itemMap[dir]) {
|
||||
itemMap[dir] = {
|
||||
path: dir,
|
||||
@@ -246,8 +267,17 @@ export default {
|
||||
}
|
||||
})
|
||||
|
||||
var items = []
|
||||
var index = 1
|
||||
var items = Object.values(itemMap).map(i => this.cleanItem(i, mediaType, index++))
|
||||
// If book media type and all files are audio files then treat each one as an audiobook
|
||||
if (itemMap[''] && !otherFiles.length && mediaType === 'book' && !itemMap[''].itemFiles.some(f => f.filetype !== 'audio')) {
|
||||
items = itemMap[''].itemFiles.map((audioFile) => {
|
||||
return this.cleanItem({ itemFiles: [audioFile], otherFiles: [], ignoredFiles: [] }, mediaType, index++)
|
||||
})
|
||||
} else {
|
||||
items = Object.values(itemMap).map(i => this.cleanItem(i, mediaType, index++))
|
||||
}
|
||||
|
||||
return {
|
||||
items,
|
||||
ignoredFiles: ignoredFiles
|
||||
|
||||
@@ -58,7 +58,8 @@ module.exports = {
|
||||
buildModules: [
|
||||
// https://go.nuxtjs.dev/tailwindcss
|
||||
'@nuxtjs/tailwindcss',
|
||||
'@nuxtjs/pwa'
|
||||
'@nuxtjs/pwa',
|
||||
'@nuxt/postcss8'
|
||||
],
|
||||
|
||||
// Modules: https://go.nuxtjs.dev/config-modules
|
||||
@@ -119,11 +120,21 @@ module.exports = {
|
||||
sizes: "512x512"
|
||||
}
|
||||
]
|
||||
},
|
||||
workbox: {
|
||||
enabled: false,
|
||||
}
|
||||
},
|
||||
|
||||
// Build Configuration: https://go.nuxtjs.dev/config-build
|
||||
build: {},
|
||||
build: {
|
||||
postcss: {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
},
|
||||
watchers: {
|
||||
webpack: {
|
||||
aggregateTimeout: 300,
|
||||
@@ -133,5 +144,13 @@ module.exports = {
|
||||
server: {
|
||||
port: process.env.NODE_ENV === 'production' ? 80 : 3000,
|
||||
host: '0.0.0.0'
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Temporary workaround for @nuxt-community/tailwindcss-module.
|
||||
*
|
||||
* Reported: 2022-05-23
|
||||
* See: [Issue tracker](https://github.com/nuxt-community/tailwindcss-module/issues/480)
|
||||
*/
|
||||
devServerHandlers: [],
|
||||
}
|
||||
|
||||
963
client/package-lock.json
generated
963
client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.0.18",
|
||||
"version": "2.1.0",
|
||||
"description": "Self-hosted audiobook and podcast client",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
@@ -29,8 +29,11 @@
|
||||
"vuedraggable": "^2.24.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nuxt/postcss8": "^1.1.3",
|
||||
"@nuxtjs/pwa": "^3.3.5",
|
||||
"@nuxtjs/tailwindcss": "^4.2.1",
|
||||
"postcss": "^8.3.6"
|
||||
"autoprefixer": "^10.4.7",
|
||||
"postcss": "^8.3.6",
|
||||
"tailwindcss": "^3.1.4"
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
<template>
|
||||
<div id="page-wrapper" class="bg-bg page overflow-y-auto p-8" :class="streamLibraryItem ? 'streaming' : ''">
|
||||
<div id="page-wrapper" class="bg-bg page overflow-y-auto p-4 md:p-8" :class="streamLibraryItem ? 'streaming' : ''">
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<div class="flex mb-6">
|
||||
<div class="flex flex-wrap sm:flex-nowrap justify-center mb-6">
|
||||
<div class="w-48 min-w-48">
|
||||
<div class="w-full h-52">
|
||||
<covers-author-image :author="author" rounded="0" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow px-8">
|
||||
<div class="flex-grow py-4 sm:py-0 px-4 md:px-8">
|
||||
<div class="flex items-center mb-8">
|
||||
<h1 class="text-2xl">{{ author.name }}</h1>
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
dailyBackupsTooltip() {
|
||||
return 'Runs at 1am every day (your server time). Saved in /metadata/backups.'
|
||||
return 'Runs at 1:30am every day (your server time). Saved in /metadata/backups.'
|
||||
},
|
||||
maxBackupSizeTooltip() {
|
||||
return 'As a safeguard against misconfiguration, backups will fail if they exceed the configured size.'
|
||||
@@ -74,7 +74,7 @@ export default {
|
||||
return
|
||||
}
|
||||
var updatePayload = {
|
||||
backupSchedule: this.dailyBackups ? '0 1 * * *' : false,
|
||||
backupSchedule: this.dailyBackups ? '30 1 * * *' : false,
|
||||
backupsToKeep: Number(this.backupsToKeep),
|
||||
maxBackupSize: Number(this.maxBackupSize)
|
||||
}
|
||||
|
||||
@@ -1,150 +1,210 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- <div class="h-0.5 bg-primary bg-opacity-50 w-full" /> -->
|
||||
|
||||
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8">
|
||||
<div class="flex items-center mb-2">
|
||||
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-2">
|
||||
<div class="mb-2">
|
||||
<h1 class="text-xl">Settings</h1>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="newServerSettings.storeCoverWithItem" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('storeCoverWithItem', val)" />
|
||||
<ui-tooltip :text="tooltips.storeCoverWithItem">
|
||||
<p class="pl-4 text-lg">
|
||||
Store covers with item
|
||||
<span class="material-icons icon-text">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
<div class="lg:flex">
|
||||
<div class="flex-1">
|
||||
<div class="pt-4">
|
||||
<h2 class="font-semibold">General</h2>
|
||||
</div>
|
||||
<div class="flex items-end py-2">
|
||||
<ui-toggle-switch v-model="newServerSettings.storeCoverWithItem" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('storeCoverWithItem', val)" />
|
||||
<ui-tooltip :text="tooltips.storeCoverWithItem">
|
||||
<p class="pl-4">
|
||||
Store covers with item
|
||||
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="newServerSettings.storeMetadataWithItem" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('storeMetadataWithItem', val)" />
|
||||
<ui-tooltip :text="tooltips.storeMetadataWithItem">
|
||||
<p class="pl-4 text-lg">
|
||||
Store metadata with item
|
||||
<span class="material-icons icon-text">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="newServerSettings.storeMetadataWithItem" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('storeMetadataWithItem', val)" />
|
||||
<ui-tooltip :text="tooltips.storeMetadataWithItem">
|
||||
<p class="pl-4">
|
||||
Store metadata with item
|
||||
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="useSquareBookCovers" :disabled="updatingServerSettings" @input="updateBookCoverAspectRatio" />
|
||||
<ui-tooltip :text="tooltips.coverAspectRatio">
|
||||
<p class="pl-4 text-lg">
|
||||
Use square book covers
|
||||
<span class="material-icons icon-text">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="newServerSettings.sortingIgnorePrefix" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('sortingIgnorePrefix', val)" />
|
||||
<ui-tooltip :text="tooltips.sortingIgnorePrefix">
|
||||
<p class="pl-4">
|
||||
Ignore prefixes when sorting
|
||||
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
<div v-if="newServerSettings.sortingIgnorePrefix" class="w-72 ml-14 mb-2">
|
||||
<ui-multi-select v-model="newServerSettings.sortingPrefixes" small :items="newServerSettings.sortingPrefixes" label="Prefixes to Ignore (case insensitive)" @input="updateSortingPrefixes" :disabled="updatingServerSettings" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="useAlternativeBookshelfView" :disabled="updatingServerSettings" @input="updateAlternativeBookshelfView" />
|
||||
<ui-tooltip :text="tooltips.bookshelfView">
|
||||
<p class="pl-4 text-lg">
|
||||
Use alternative bookshelf view
|
||||
<span class="material-icons icon-text">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="newServerSettings.chromecastEnabled" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('chromecastEnabled', val)" />
|
||||
<p class="pl-4">Chromecast support</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="newServerSettings.sortingIgnorePrefix" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('sortingIgnorePrefix', val)" />
|
||||
<ui-tooltip :text="tooltips.sortingIgnorePrefix">
|
||||
<p class="pl-4 text-lg">
|
||||
Ignore prefixes when sorting title and series
|
||||
<span class="material-icons icon-text">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
<div v-if="newServerSettings.sortingIgnorePrefix" class="w-72 ml-14 mb-2">
|
||||
<ui-multi-select v-model="newServerSettings.sortingPrefixes" small :items="newServerSettings.sortingPrefixes" label="Prefixes to Ignore (case insensitive)" @input="updateSortingPrefixes" :disabled="updatingServerSettings" />
|
||||
</div>
|
||||
<div class="pt-4">
|
||||
<h2 class="font-semibold">Display</h2>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="newServerSettings.chromecastEnabled" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('chromecastEnabled', val)" />
|
||||
<p class="pl-4 text-lg">Enable Chromecast</p>
|
||||
</div>
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="useSquareBookCovers" :disabled="updatingServerSettings" @input="updateBookCoverAspectRatio" />
|
||||
<ui-tooltip :text="tooltips.coverAspectRatio">
|
||||
<p class="pl-4">
|
||||
Square book covers
|
||||
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center mb-2 mt-8">
|
||||
<h1 class="text-xl">Scanner Settings</h1>
|
||||
</div>
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="useAlternativeBookshelfView" :disabled="updatingServerSettings" @input="updateAlternativeBookshelfView" />
|
||||
<ui-tooltip :text="tooltips.bookshelfView">
|
||||
<p class="pl-4">
|
||||
Alternative bookshelf view
|
||||
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="newServerSettings.scannerParseSubtitle" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerParseSubtitle', val)" />
|
||||
<ui-tooltip :text="tooltips.scannerParseSubtitle">
|
||||
<p class="pl-4 text-lg">
|
||||
Scanner parse subtitles
|
||||
<span class="material-icons icon-text">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
<div class="flex items-center py-2">
|
||||
<p class="pr-4">Date Format</p>
|
||||
<ui-dropdown v-model="newServerSettings.dateFormat" :items="dateFormats" small class="max-w-40" @input="(val) => updateSettingsKey('dateFormat', val)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="newServerSettings.scannerFindCovers" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerFindCovers', val)" />
|
||||
<ui-tooltip :text="tooltips.scannerFindCovers">
|
||||
<p class="pl-4 text-lg">
|
||||
Scanner find covers
|
||||
<span class="material-icons icon-text">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
<div class="flex-grow" />
|
||||
</div>
|
||||
<div v-if="newServerSettings.scannerFindCovers" class="w-44 ml-14 mb-2">
|
||||
<ui-dropdown v-model="newServerSettings.scannerCoverProvider" small :items="providers" label="Cover Provider" @input="updateScannerCoverProvider" :disabled="updatingServerSettings" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="pt-4">
|
||||
<h2 class="font-semibold">Scanner</h2>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="newServerSettings.scannerPreferAudioMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferAudioMetadata', val)" />
|
||||
<ui-tooltip :text="tooltips.scannerPreferAudioMetadata">
|
||||
<p class="pl-4 text-lg">
|
||||
Scanner prefer audio metadata
|
||||
<span class="material-icons icon-text">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="newServerSettings.scannerParseSubtitle" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerParseSubtitle', val)" />
|
||||
<ui-tooltip :text="tooltips.scannerParseSubtitle">
|
||||
<p class="pl-4">
|
||||
Parse subtitles
|
||||
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="newServerSettings.scannerPreferOpfMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferOpfMetadata', val)" />
|
||||
<ui-tooltip :text="tooltips.scannerPreferOpfMetadata">
|
||||
<p class="pl-4 text-lg">
|
||||
Scanner prefer OPF metadata
|
||||
<span class="material-icons icon-text">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="newServerSettings.scannerFindCovers" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerFindCovers', val)" />
|
||||
<ui-tooltip :text="tooltips.scannerFindCovers">
|
||||
<p class="pl-4">
|
||||
Find covers
|
||||
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
<div class="flex-grow" />
|
||||
</div>
|
||||
<div v-if="newServerSettings.scannerFindCovers" class="w-44 ml-14 mb-2">
|
||||
<ui-dropdown v-model="newServerSettings.scannerCoverProvider" small :items="providers" label="Cover Provider" @input="updateScannerCoverProvider" :disabled="updatingServerSettings" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="newServerSettings.scannerPreferMatchedMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferMatchedMetadata', val)" />
|
||||
<ui-tooltip :text="tooltips.scannerPreferMatchedMetadata">
|
||||
<p class="pl-4 text-lg">
|
||||
Scanner prefer matched metadata
|
||||
<span class="material-icons icon-text">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="newServerSettings.scannerPreferOverdriveMediaMarker" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferOverdriveMediaMarker', val)" />
|
||||
<ui-tooltip :text="tooltips.scannerPreferOverdriveMediaMarker">
|
||||
<p class="pl-4">
|
||||
Use Overdrive Media Markers for chapters
|
||||
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="newServerSettings.scannerDisableWatcher" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerDisableWatcher', val)" />
|
||||
<ui-tooltip :text="tooltips.scannerDisableWatcher">
|
||||
<p class="pl-4 text-lg">
|
||||
Disable Watcher
|
||||
<span class="material-icons icon-text">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="newServerSettings.scannerPreferAudioMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferAudioMetadata', val)" />
|
||||
<ui-tooltip :text="tooltips.scannerPreferAudioMetadata">
|
||||
<p class="pl-4">
|
||||
Prefer audio metadata
|
||||
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center mb-2 mt-8">
|
||||
<h1 class="text-xl">Experimental Feature Settings</h1>
|
||||
</div>
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="newServerSettings.scannerPreferOpfMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferOpfMetadata', val)" />
|
||||
<ui-tooltip :text="tooltips.scannerPreferOpfMetadata">
|
||||
<p class="pl-4">
|
||||
Prefer OPF metadata
|
||||
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="newServerSettings.enableEReader" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('enableEReader', val)" />
|
||||
<ui-tooltip :text="tooltips.enableEReader">
|
||||
<p class="pl-4 text-lg">
|
||||
Enable e-reader for all users
|
||||
<span class="material-icons icon-text">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="newServerSettings.scannerPreferMatchedMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferMatchedMetadata', val)" />
|
||||
<ui-tooltip :text="tooltips.scannerPreferMatchedMetadata">
|
||||
<p class="pl-4">
|
||||
Prefer matched metadata
|
||||
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="newServerSettings.scannerDisableWatcher" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerDisableWatcher', val)" />
|
||||
<ui-tooltip :text="tooltips.scannerDisableWatcher">
|
||||
<p class="pl-4">
|
||||
Disable Watcher
|
||||
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<!-- <div class="flex items-center py-2">
|
||||
<ui-text-input type="number" v-model="newServerSettings.scannerMaxThreads" no-spinner :disabled="updatingServerSettings" :padding-x="1" text-center class="w-10" @change="updateScannerMaxThreads" />
|
||||
<ui-tooltip :text="tooltips.scannerMaxThreads">
|
||||
<p class="pl-4">
|
||||
Max # of threads to use
|
||||
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div> -->
|
||||
|
||||
<div class="pt-4">
|
||||
<h2 class="font-semibold">Experimental Features</h2>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<div class="flex items-center">
|
||||
<ui-toggle-switch v-model="showExperimentalFeatures" />
|
||||
<ui-tooltip :text="tooltips.experimentalFeatures">
|
||||
<p class="pl-4">
|
||||
Experimental Features
|
||||
<a href="https://github.com/advplyr/audiobookshelf/discussions/75" target="_blank">
|
||||
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||
</a>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="newServerSettings.enableEReader" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('enableEReader', val)" />
|
||||
<ui-tooltip :text="tooltips.enableEReader">
|
||||
<p class="pl-4">
|
||||
Enable e-reader for all users
|
||||
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<!-- <div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="newServerSettings.scannerUseSingleThreadedProber" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerUseSingleThreadedProber', val)" />
|
||||
<ui-tooltip :text="tooltips.scannerUseSingleThreadedProber">
|
||||
<p class="pl-4">
|
||||
Scanner use old single threaded audio prober
|
||||
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div> -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -152,7 +212,7 @@
|
||||
|
||||
<div class="flex items-center py-4">
|
||||
<ui-btn color="bg" small :padding-x="4" class="hidden lg:block mr-2" :loading="isPurgingCache" @click="purgeCache">Purge Cache</ui-btn>
|
||||
<ui-btn color="bg" small :padding-x="4" class="hidden lg:block" :loading="isResettingLibraryItems" @click="resetLibraryItems">Remove All Library Items</ui-btn>
|
||||
<ui-btn color="bg" small :padding-x="4" class="hidden lg:block mr-2" :loading="isResettingLibraryItems" @click="resetLibraryItems">Remove All Library Items</ui-btn>
|
||||
<div class="flex-grow" />
|
||||
<p class="pr-2 text-sm font-book text-yellow-400">
|
||||
Report bugs, request features, and contribute on
|
||||
@@ -188,30 +248,12 @@
|
||||
|
||||
<div class="h-0.5 bg-primary bg-opacity-30 w-full" />
|
||||
|
||||
<div class="py-12 mb-4 opacity-60 hover:opacity-100">
|
||||
<div class="flex items-center">
|
||||
<div>
|
||||
<div class="flex items-center">
|
||||
<ui-toggle-switch v-model="showExperimentalFeatures" />
|
||||
<ui-tooltip :text="tooltips.experimentalFeatures">
|
||||
<p class="pl-4 text-lg">
|
||||
Experimental Features
|
||||
<a href="https://github.com/advplyr/audiobookshelf/discussions/75" target="_blank">
|
||||
<span class="material-icons icon-text">info_outlined</span>
|
||||
</a>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<prompt-dialog v-model="showConfirmPurgeCache" :width="675">
|
||||
<div class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300">
|
||||
<p class="text-error text-lg font-semibold">Important Notice!</p>
|
||||
<p class="text-lg my-2 text-center">Purge cache will delete the entire directory at <span class="font-mono">/metadata/cache</span>.</p>
|
||||
<p class="text-error font-semibold">Important Notice!</p>
|
||||
<p class="my-2 text-center">Purge cache will delete the entire directory at <span class="font-mono">/metadata/cache</span>.</p>
|
||||
|
||||
<p class="text-lg text-center mb-8">Are you sure you want to remove the cache directory?</p>
|
||||
<p class="text-center mb-8">Are you sure you want to remove the cache directory?</p>
|
||||
<div class="flex px-1 items-center">
|
||||
<ui-btn color="primary" @click="showConfirmPurgeCache = false">Nevermind</ui-btn>
|
||||
<div class="flex-grow" />
|
||||
@@ -245,7 +287,10 @@ export default {
|
||||
storeCoverWithItem: 'By default covers are stored in /metadata/items, enabling this setting will store covers in your library item folder. Only one file named "cover" will be kept',
|
||||
storeMetadataWithItem: 'By default metadata files are stored in /metadata/items, enabling this setting will store metadata files in your library item folders. Uses .abs file extension',
|
||||
coverAspectRatio: 'Prefer to use square covers over standard 1.6:1 book covers',
|
||||
enableEReader: 'E-reader is still a work in progress, but use this setting to open it up to all your users (or use the "Experimental Features" toggle below just for you)'
|
||||
enableEReader: 'E-reader is still a work in progress, but use this setting to open it up to all your users (or use the "Experimental Features" toggle just for use by you)',
|
||||
scannerPreferOverdriveMediaMarker: 'MP3 files from Overdrive come with chapter timings embedded as custom metadata. Enabling this will use these tags for chapter timings automatically',
|
||||
scannerUseSingleThreadedProber: 'The old scanner used a single thread. Leaving it in to use as a comparison for now.',
|
||||
scannerMaxThreads: 'Number of concurrent media files to scan at a time. Value of 1 will be a slower scan but less CPU usage. <br><br>Value of 0 defaults to # of CPU cores for this server times 2 (i.e. 4-core CPU will be 8)'
|
||||
},
|
||||
showConfirmPurgeCache: false
|
||||
}
|
||||
@@ -271,11 +316,31 @@ export default {
|
||||
set(val) {
|
||||
this.$store.commit('setExperimentalFeatures', val)
|
||||
}
|
||||
},
|
||||
dateFormats() {
|
||||
return this.$store.state.globals.dateFormats
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
updateEnableChromecast(val) {
|
||||
this.updateServerSettings({ enableChromecast: val })
|
||||
updateScannerMaxThreads(val) {
|
||||
if (!val || isNaN(val)) {
|
||||
this.$toast.error('Invalid max threads must be a number')
|
||||
this.newServerSettings.scannerMaxThreads = 0
|
||||
return
|
||||
}
|
||||
if (Number(val) < 0) {
|
||||
this.$toast.error('Max threads must be >= 0')
|
||||
this.newServerSettings.scannerMaxThreads = 0
|
||||
return
|
||||
}
|
||||
if (Math.round(Number(val)) !== Number(val)) {
|
||||
this.$toast.error('Max threads must be an integer')
|
||||
this.newServerSettings.scannerMaxThreads = 0
|
||||
return
|
||||
}
|
||||
this.updateServerSettings({
|
||||
scannerMaxThreads: Number(val)
|
||||
})
|
||||
},
|
||||
updateSortingPrefixes(val) {
|
||||
if (!val || !val.length) {
|
||||
@@ -314,10 +379,12 @@ export default {
|
||||
.then((success) => {
|
||||
console.log('Updated Server Settings', success)
|
||||
this.updatingServerSettings = false
|
||||
this.$toast.success('Server settings updated')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to update server settings', error)
|
||||
this.updatingServerSettings = false
|
||||
this.$toast.error('Failed to update server settings')
|
||||
})
|
||||
},
|
||||
initServerSettings() {
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
<template>
|
||||
<div>
|
||||
<p class="text-xl">Stats for library {{ currentLibraryName }}</p>
|
||||
|
||||
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8">
|
||||
<h1 class="text-xl">Stats for library {{ currentLibraryName }}</h1>
|
||||
<stats-preview-icons v-if="totalItems" :library-stats="libraryStats" />
|
||||
|
||||
<div class="flex md:flex-row flex-wrap justify-between flex-col mt-12">
|
||||
<div class="flex md:flex-row flex-wrap justify-between flex-col mt-8">
|
||||
<div class="w-80 my-6 mx-auto">
|
||||
<h1 class="text-2xl mb-4 font-book">Top 5 Genres</h1>
|
||||
<p v-if="!top5Genres.length">No Genres</p>
|
||||
|
||||
@@ -1,24 +1,21 @@
|
||||
<template>
|
||||
<div id="page-wrapper" class="page overflow-y-auto" :class="streamLibraryItem ? 'streaming' : ''">
|
||||
<div class="w-full max-w-4xl mx-auto">
|
||||
<div class="mb-4 flex flex-col sm:flex-row items-start sm:items-end">
|
||||
<p class="text-2xl mr-4 mb-2 sm:mb-0">Logger</p>
|
||||
<div class="w-full h-full">
|
||||
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8">
|
||||
<div class="flex items-center mb-2">
|
||||
<h1 class="text-xl">Logs</h1>
|
||||
</div>
|
||||
<div class="flex justify-between mb-2 place-items-end">
|
||||
<ui-text-input ref="input" v-model="search" placeholder="Search filter.." @input="inputUpdate" clearable class="w-full sm:w-40 h-8 text-sm sm:mb-0" />
|
||||
|
||||
<ui-text-input ref="input" v-model="search" placeholder="Search filter.." @input="inputUpdate" clearable class="w-full sm:w-40 h-8 text-sm mb-2 sm:mb-0" />
|
||||
|
||||
<div class="flex-grow" />
|
||||
|
||||
<div class="w-full sm:w-44">
|
||||
<ui-dropdown v-model="newServerSettings.logLevel" label="Server Log Level" :items="logLevelItems" @input="logLevelUpdated" />
|
||||
</div>
|
||||
<ui-dropdown v-model="newServerSettings.logLevel" label="Server Log Level" :items="logLevelItems" @input="logLevelUpdated" class="w-full sm:w-44" />
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<div ref="container" class="relative w-full h-full bg-primary border-bg overflow-x-hidden overflow-y-auto text-red shadow-inner rounded-md" style="max-height: 550px; min-height: 550px">
|
||||
<div ref="container" class="relative w-full h-full bg-primary border-bg overflow-x-hidden overflow-y-auto text-red shadow-inner rounded-md" style="max-height: 800px; min-height: 550px">
|
||||
<template v-for="(log, index) in logs">
|
||||
<div :key="index" class="flex flex-nowrap px-2 py-1 items-start text-sm bg-opacity-10" :class="`bg-${logColors[log.level]}`">
|
||||
<p class="text-gray-400 w-40 font-mono">{{ log.timestamp.split('.')[0].split('T').join(' ') }}</p>
|
||||
<p class="font-semibold w-12 text-right" :class="`text-${logColors[log.level]}`">{{ log.levelName }}</p>
|
||||
<p class="text-gray-400 w-36 font-mono text-xs">{{ log.timestamp }}</p>
|
||||
<p class="font-semibold w-12 text-right text-sm" :class="`text-${logColors[log.level]}`">{{ log.levelName }}</p>
|
||||
<p class="px-4 logmessage">{{ log.message }}</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -164,6 +161,7 @@ export default {
|
||||
},
|
||||
beforeDestroy() {
|
||||
if (!this.$root.socket) return
|
||||
this.$root.socket.emit('remove_log_listener')
|
||||
this.$root.socket.off('daily_logs', this.dailyLogsLoaded)
|
||||
this.$root.socket.off('log', this.logEvtReceived)
|
||||
}
|
||||
|
||||
196
client/pages/config/sessions.vue
Normal file
196
client/pages/config/sessions.vue
Normal file
@@ -0,0 +1,196 @@
|
||||
<template>
|
||||
<div class="w-full h-full">
|
||||
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8">
|
||||
<div class="flex items-center mb-2">
|
||||
<h1 class="text-xl">Listening Sessions</h1>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end mb-2">
|
||||
<ui-dropdown v-model="selectedUser" :items="userItems" label="Filter by User" small class="max-w-48" @input="updateUserFilter" />
|
||||
</div>
|
||||
|
||||
<div v-if="listeningSessions.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">Item</th>
|
||||
<th class="w-20 min-w-20 text-left hidden md:table-cell">User</th>
|
||||
<th class="w-32 min-w-32 text-left hidden md:table-cell">Play Method</th>
|
||||
<th class="w-32 min-w-32 text-left hidden sm:table-cell">Device Info</th>
|
||||
<th class="w-32 min-w-32">Listened</th>
|
||||
<th class="w-16 min-w-16">Last Time</th>
|
||||
<th class="flex-grow hidden sm:table-cell">Last Update</th>
|
||||
</tr>
|
||||
|
||||
<tr v-for="session in listeningSessions" :key="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">
|
||||
<p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p>
|
||||
</td>
|
||||
<td class="text-center hidden sm:table-cell">
|
||||
<ui-tooltip v-if="session.updatedAt" direction="top" :text="$formatDate(session.updatedAt, 'MMMM do, yyyy HH:mm')">
|
||||
<p class="text-xs text-gray-200">{{ $dateDistanceFromNow(session.updatedAt) }}</p>
|
||||
</ui-tooltip>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div class="flex items-center justify-end my-2">
|
||||
<ui-icon-btn icon="arrow_back_ios_new" :size="7" icon-font-size="1rem" class="mx-1" :disabled="currentPage === 0" @click="prevPage" />
|
||||
<p class="text-sm mx-1">Page {{ currentPage + 1 }} of {{ numPages }}</p>
|
||||
<ui-icon-btn icon="arrow_forward_ios" :size="7" icon-font-size="1rem" class="mx-1" :disabled="currentPage >= numPages - 1" @click="nextPage" />
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="text-white text-opacity-50">No sessions yet...</p>
|
||||
</div>
|
||||
|
||||
<modals-listening-session-modal v-model="showSessionModal" :session="selectedSession" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
async asyncData({ params, redirect, app }) {
|
||||
var users = await app.$axios
|
||||
.$get('/api/users')
|
||||
.then((users) => {
|
||||
return users.sort((a, b) => {
|
||||
return a.createdAt - b.createdAt
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
return []
|
||||
})
|
||||
return {
|
||||
users
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showSessionModal: false,
|
||||
selectedSession: null,
|
||||
listeningSessions: [],
|
||||
numPages: 0,
|
||||
total: 0,
|
||||
currentPage: 0,
|
||||
userFilter: null,
|
||||
selectedUser: ''
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
username() {
|
||||
return this.user.username
|
||||
},
|
||||
userOnline() {
|
||||
return this.$store.getters['users/getIsUserOnline'](this.user.id)
|
||||
},
|
||||
userItems() {
|
||||
var userItems = [{ value: '', text: 'All Users' }]
|
||||
return userItems.concat(this.users.map((u) => ({ value: u.id, text: u.username })))
|
||||
},
|
||||
filteredUserUsername() {
|
||||
if (!this.userFilter) return null
|
||||
var user = this.users.find((u) => u.id === this.userFilter)
|
||||
return user ? user.username : null
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
updateUserFilter() {
|
||||
this.loadSessions(0)
|
||||
},
|
||||
prevPage() {
|
||||
this.loadSessions(this.currentPage - 1)
|
||||
},
|
||||
nextPage() {
|
||||
this.loadSessions(this.currentPage + 1)
|
||||
},
|
||||
showSession(session) {
|
||||
this.selectedSession = session
|
||||
this.showSessionModal = true
|
||||
},
|
||||
getDeviceInfoString(deviceInfo) {
|
||||
if (!deviceInfo) return ''
|
||||
var lines = []
|
||||
if (deviceInfo.osName) lines.push(`${deviceInfo.osName} ${deviceInfo.osVersion}`)
|
||||
if (deviceInfo.browserName) lines.push(deviceInfo.browserName)
|
||||
|
||||
if (deviceInfo.manufacturer && deviceInfo.model) lines.push(`${deviceInfo.manufacturer} ${deviceInfo.model}`)
|
||||
if (deviceInfo.sdkVersion) lines.push(`SDK Version: ${deviceInfo.sdkVersion}`)
|
||||
return lines.join('<br>')
|
||||
},
|
||||
getPlayMethodName(playMethod) {
|
||||
if (playMethod === this.$constants.PlayMethod.DIRECTPLAY) return 'Direct Play'
|
||||
else if (playMethod === this.$constants.PlayMethod.TRANSCODE) return 'Transcode'
|
||||
else if (playMethod === this.$constants.PlayMethod.DIRECTSTREAM) return 'Direct Stream'
|
||||
else if (playMethod === this.$constants.PlayMethod.LOCAL) return 'Local'
|
||||
return 'Unknown'
|
||||
},
|
||||
async loadSessions(page) {
|
||||
var userFilterQuery = this.selectedUser ? `&user=${this.selectedUser}` : ''
|
||||
const data = await this.$axios.$get(`/api/sessions?page=${page}&itemsPerPage=10${userFilterQuery}`).catch((err) => {
|
||||
console.error('Failed to load listening sesions', err)
|
||||
return null
|
||||
})
|
||||
if (!data) {
|
||||
this.$toast.error('Failed to load listening sessions')
|
||||
return
|
||||
}
|
||||
|
||||
this.numPages = data.numPages
|
||||
this.total = data.total
|
||||
this.currentPage = data.page
|
||||
this.listeningSessions = data.sessions
|
||||
this.userFilter = data.userFilter
|
||||
},
|
||||
init() {
|
||||
this.loadSessions(0)
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.userSessionsTable {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
border: 1px solid #474747;
|
||||
}
|
||||
.userSessionsTable tr:first-child {
|
||||
background-color: #272727;
|
||||
}
|
||||
.userSessionsTable tr:not(:first-child) {
|
||||
background-color: #373838;
|
||||
}
|
||||
.userSessionsTable tr:not(:first-child):nth-child(odd) {
|
||||
background-color: #2f2f2f;
|
||||
}
|
||||
.userSessionsTable tr:hover:not(:first-child) {
|
||||
background-color: #474747;
|
||||
}
|
||||
.userSessionsTable td {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
.userSessionsTable th {
|
||||
padding: 4px 8px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,7 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8">
|
||||
<h1 class="text-xl">Stats for {{ username }}</h1>
|
||||
|
||||
<div class="flex justify-center">
|
||||
<div class="flex p-2">
|
||||
<svg class="hidden sm:block h-14 w-14 lg:h-18 lg:w-18" viewBox="0 0 24 24">
|
||||
@@ -46,7 +48,7 @@
|
||||
<template v-for="(item, index) in mostRecentListeningSessions">
|
||||
<div :key="item.id" class="w-full py-0.5">
|
||||
<div class="flex items-center mb-1">
|
||||
<p class="text-sm font-book text-white text-opacity-70 w-6 truncate">{{ index + 1 }}. </p>
|
||||
<p class="text-sm font-book text-white text-opacity-70 w-8">{{ index + 1 }}. </p>
|
||||
<div class="w-56">
|
||||
<p class="text-sm font-book text-white text-opacity-80 truncate">{{ item.mediaMetadata ? item.mediaMetadata.title : '' }}</p>
|
||||
<p class="text-xs text-white text-opacity-50">{{ $dateDistanceFromNow(item.updatedAt) }}</p>
|
||||
@@ -84,6 +86,9 @@ export default {
|
||||
user() {
|
||||
return this.$store.state.user.user
|
||||
},
|
||||
username() {
|
||||
return this.user.username
|
||||
},
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
|
||||
@@ -13,11 +13,12 @@
|
||||
<widgets-online-indicator :value="!!userOnline" />
|
||||
<h1 class="text-xl pl-2">{{ username }}</h1>
|
||||
</div>
|
||||
<div class="cursor-pointer text-gray-400 hover:text-white" @click="copyToClipboard(userToken)">
|
||||
<p v-if="userToken" class="py-2 text-xs">
|
||||
<strong class="text-white">API Token: </strong><br /><span class="text-white">{{ userToken }}</span
|
||||
><span class="material-icons pl-2 text-base">content_copy</span>
|
||||
</p>
|
||||
<div v-if="userToken" class="flex text-xs mt-4">
|
||||
<ui-text-input-with-label label="API Token" :value="userToken" readonly />
|
||||
|
||||
<div class="px-1 mt-8 cursor-pointer" @click="copyToClipboard(userToken)">
|
||||
<span class="material-icons pl-2 text-base">content_copy</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full h-px bg-white bg-opacity-10 my-2" />
|
||||
<div class="py-2">
|
||||
@@ -138,10 +139,15 @@ export default {
|
||||
this.$copyToClipboard(str, this)
|
||||
},
|
||||
async init() {
|
||||
this.listeningSessions = await this.$axios.$get(`/api/users/${this.user.id}/listening-sessions`).catch((err) => {
|
||||
console.error('Failed to load listening sesions', err)
|
||||
return []
|
||||
})
|
||||
this.listeningSessions = await this.$axios
|
||||
.$get(`/api/users/${this.user.id}/listening-sessions?page=0&itemsPerPage=10`)
|
||||
.then((data) => {
|
||||
return data.sessions || []
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Failed to load listening sesions', err)
|
||||
return []
|
||||
})
|
||||
this.listeningStats = await this.$axios.$get(`/api/users/${this.user.id}/listening-stats`).catch((err) => {
|
||||
console.error('Failed to load listening sesions', err)
|
||||
return []
|
||||
|
||||
@@ -17,40 +17,47 @@
|
||||
<div class="w-full h-px bg-white bg-opacity-10 my-2" />
|
||||
|
||||
<div class="py-2">
|
||||
<h1 class="text-lg mb-2 text-white text-opacity-90 px-2 sm:px-0">Listening Sessions ({{ listeningSessions.length }})</h1>
|
||||
<table v-if="listeningSessions.length" class="userSessionsTable">
|
||||
<tr class="bg-primary bg-opacity-40">
|
||||
<th class="flex-grow text-left">Item</th>
|
||||
<th class="w-32 text-left hidden md:table-cell">Play Method</th>
|
||||
<th class="w-40 text-left hidden sm:table-cell">Device Info</th>
|
||||
<th class="w-20">Listened</th>
|
||||
<th class="w-20">Last Time</th>
|
||||
<th class="w-40 hidden sm:table-cell">Last Update</th>
|
||||
</tr>
|
||||
<tr v-for="session in listeningSessions" :key="session.id" class="cursor-pointer" @click="showSession(session)">
|
||||
<td class="py-1">
|
||||
<p class="text-sm text-gray-200">{{ session.displayTitle }}</p>
|
||||
<p class="text-xs text-gray-400">{{ session.displayAuthor }}</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">
|
||||
<p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p>
|
||||
</td>
|
||||
<td class="text-center hidden sm:table-cell">
|
||||
<ui-tooltip v-if="session.updatedAt" direction="top" :text="$formatDate(session.updatedAt, 'MMMM do, yyyy HH:mm')">
|
||||
<p class="text-xs">{{ $dateDistanceFromNow(session.updatedAt) }}</p>
|
||||
</ui-tooltip>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<h1 class="text-lg mb-2 text-white text-opacity-90 px-2 sm:px-0">Listening Sessions</h1>
|
||||
<div v-if="listeningSessions.length">
|
||||
<table class="userSessionsTable">
|
||||
<tr class="bg-primary bg-opacity-40">
|
||||
<th class="w-48 min-w-48 text-left">Item</th>
|
||||
<th class="w-32 min-w-32 text-left hidden md:table-cell">Play Method</th>
|
||||
<th class="w-32 min-w-32 text-left hidden sm:table-cell">Device Info</th>
|
||||
<th class="w-32 min-w-32">Listened</th>
|
||||
<th class="w-16 min-w-16">Last Time</th>
|
||||
<th class="flex-grow hidden sm:table-cell">Last Update</th>
|
||||
</tr>
|
||||
<tr v-for="session in listeningSessions" :key="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 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">
|
||||
<p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p>
|
||||
</td>
|
||||
<td class="text-center hidden sm:table-cell">
|
||||
<ui-tooltip v-if="session.updatedAt" direction="top" :text="$formatDate(session.updatedAt, 'MMMM do, yyyy HH:mm')">
|
||||
<p class="text-xs text-gray-200">{{ $dateDistanceFromNow(session.updatedAt) }}</p>
|
||||
</ui-tooltip>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div class="flex items-center justify-end py-1">
|
||||
<ui-icon-btn icon="arrow_back_ios_new" :size="7" icon-font-size="1rem" class="mx-1" :disabled="currentPage === 0" @click="prevPage" />
|
||||
<p class="text-sm mx-1">Page {{ currentPage + 1 }} of {{ numPages }}</p>
|
||||
<ui-icon-btn icon="arrow_forward_ios" :size="7" icon-font-size="1rem" class="mx-1" :disabled="currentPage >= numPages - 1" @click="nextPage" />
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="text-white text-opacity-50">No sessions yet...</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -75,7 +82,10 @@ export default {
|
||||
return {
|
||||
showSessionModal: false,
|
||||
selectedSession: null,
|
||||
listeningSessions: []
|
||||
listeningSessions: [],
|
||||
numPages: 0,
|
||||
total: 0,
|
||||
currentPage: 0
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -87,6 +97,12 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
prevPage() {
|
||||
this.loadSessions(this.currentPage - 1)
|
||||
},
|
||||
nextPage() {
|
||||
this.loadSessions(this.currentPage + 1)
|
||||
},
|
||||
showSession(session) {
|
||||
this.selectedSession = session
|
||||
this.showSessionModal = true
|
||||
@@ -108,13 +124,23 @@ export default {
|
||||
else if (playMethod === this.$constants.PlayMethod.LOCAL) return 'Local'
|
||||
return 'Unknown'
|
||||
},
|
||||
async init() {
|
||||
console.log(navigator)
|
||||
|
||||
this.listeningSessions = await this.$axios.$get(`/api/users/${this.user.id}/listening-sessions`).catch((err) => {
|
||||
async loadSessions(page) {
|
||||
const data = await this.$axios.$get(`/api/users/${this.user.id}/listening-sessions?page=${page}&itemsPerPage=10`).catch((err) => {
|
||||
console.error('Failed to load listening sesions', err)
|
||||
return []
|
||||
return null
|
||||
})
|
||||
if (!data) {
|
||||
this.$toast.error('Failed to load listening sessions')
|
||||
return
|
||||
}
|
||||
|
||||
this.numPages = data.numPages
|
||||
this.total = data.total
|
||||
this.currentPage = data.page
|
||||
this.listeningSessions = data.sessions
|
||||
},
|
||||
init() {
|
||||
this.loadSessions(0)
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
@@ -123,10 +149,11 @@ export default {
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
<style scoped>
|
||||
.userSessionsTable {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
border: 1px solid #474747;
|
||||
}
|
||||
.userSessionsTable tr:first-child {
|
||||
|
||||
@@ -31,11 +31,13 @@
|
||||
<p v-if="bookSubtitle" class="sm:ml-4 text-gray-400 text-xl md:text-2xl">{{ bookSubtitle }}</p>
|
||||
</div>
|
||||
|
||||
<p v-if="isPodcast" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl">by {{ podcastAuthor || 'Unknown' }}</p>
|
||||
<p v-else-if="authors.length" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl">
|
||||
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>
|
||||
</p>
|
||||
<p v-else class="mb-2 mt-0.5 text-gray-200 text-xl">by Unknown</p>
|
||||
<template v-if="!isVideo">
|
||||
<p v-if="isPodcast" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl">by {{ podcastAuthor || 'Unknown' }}</p>
|
||||
<p v-else-if="authors.length" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl">
|
||||
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>
|
||||
</p>
|
||||
<p v-else class="mb-2 mt-0.5 text-gray-200 text-xl">by Unknown</p>
|
||||
</template>
|
||||
|
||||
<nuxt-link v-for="_series in seriesList" :key="_series.id" :to="`/library/${libraryId}/series/${_series.id}`" class="hover:underline font-sans text-gray-300 text-lg leading-7"> {{ _series.text }}</nuxt-link>
|
||||
|
||||
@@ -116,9 +118,9 @@
|
||||
<!-- Progress -->
|
||||
<div v-if="!isPodcast && progressPercent > 0" class="px-4 py-2 mt-4 bg-primary text-sm font-semibold rounded-md text-gray-100 relative max-w-max mx-auto md:mx-0" :class="resettingProgress ? 'opacity-25' : ''">
|
||||
<p v-if="progressPercent < 1" class="leading-6">Your Progress: {{ Math.round(progressPercent * 100) }}%</p>
|
||||
<p v-else class="text-xs">Finished {{ $formatDate(userProgressFinishedAt, 'MM/dd/yyyy') }}</p>
|
||||
<p v-else class="text-xs">Finished {{ $formatDate(userProgressFinishedAt, dateFormat) }}</p>
|
||||
<p v-if="progressPercent < 1" class="text-gray-200 text-xs">{{ $elapsedPretty(userTimeRemaining) }} remaining</p>
|
||||
<p class="text-gray-400 text-xs pt-1">Started {{ $formatDate(userProgressStartedAt, 'MM/dd/yyyy') }}</p>
|
||||
<p class="text-gray-400 text-xs pt-1">Started {{ $formatDate(userProgressStartedAt, dateFormat) }}</p>
|
||||
|
||||
<div v-if="!resettingProgress" class="absolute -top-1.5 -right-1.5 p-1 w-5 h-5 rounded-full bg-bg hover:bg-error border border-primary flex items-center justify-center cursor-pointer" @click.stop="clearProgressClick">
|
||||
<span class="material-icons text-sm">close</span>
|
||||
@@ -224,6 +226,9 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
dateFormat() {
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
},
|
||||
showExperimentalFeatures() {
|
||||
return this.$store.state.showExperimentalFeatures
|
||||
},
|
||||
@@ -251,6 +256,9 @@ export default {
|
||||
isPodcast() {
|
||||
return this.libraryItem.mediaType === 'podcast'
|
||||
},
|
||||
isVideo() {
|
||||
return this.libraryItem.mediaType === 'video'
|
||||
},
|
||||
isMissing() {
|
||||
return this.libraryItem.isMissing
|
||||
},
|
||||
@@ -258,11 +266,12 @@ export default {
|
||||
return this.libraryItem.isInvalid
|
||||
},
|
||||
invalidAudioFiles() {
|
||||
if (this.isPodcast) return []
|
||||
if (this.isPodcast || this.isVideo) return []
|
||||
return this.libraryItem.media.audioFiles.filter((af) => af.invalid)
|
||||
},
|
||||
showPlayButton() {
|
||||
if (this.isMissing || this.isInvalid) return false
|
||||
if (this.isVideo) return !!this.videoFile
|
||||
if (this.isPodcast) return this.podcastEpisodes.length
|
||||
return this.tracks.length
|
||||
},
|
||||
@@ -348,6 +357,9 @@ export default {
|
||||
ebookFile() {
|
||||
return this.media.ebookFile
|
||||
},
|
||||
videoFile() {
|
||||
return this.media.videoFile
|
||||
},
|
||||
showExperimentalReadAlert() {
|
||||
return !this.tracks.length && this.ebookFile && !this.showExperimentalFeatures && !this.enableEReader
|
||||
},
|
||||
@@ -525,13 +537,13 @@ export default {
|
||||
}
|
||||
},
|
||||
rssFeedOpen(data) {
|
||||
if (data.libraryItemId === this.libraryItemId) {
|
||||
if (data.entityId === this.libraryItemId) {
|
||||
console.log('RSS Feed Opened', data)
|
||||
this.rssFeedUrl = data.feedUrl
|
||||
}
|
||||
},
|
||||
rssFeedClosed(data) {
|
||||
if (data.libraryItemId === this.libraryItemId) {
|
||||
if (data.entityId === this.libraryItemId) {
|
||||
console.log('RSS Feed Closed', data)
|
||||
this.rssFeedUrl = null
|
||||
}
|
||||
|
||||
@@ -2,25 +2,26 @@
|
||||
<div class="page" :class="streamLibraryItem ? 'streaming' : ''">
|
||||
<app-book-shelf-toolbar page="podcast-search" />
|
||||
|
||||
<div class="w-full h-full overflow-y-auto p-12 relative">
|
||||
<div class="w-full h-full overflow-y-auto px-2 py-6 sm:px-4 md:p-12 relative">
|
||||
<div class="w-full max-w-4xl mx-auto flex">
|
||||
<form @submit.prevent="submit" class="flex flex-grow">
|
||||
<ui-text-input v-model="searchInput" :disabled="processing" placeholder="Enter search term or RSS feed URL" class="flex-grow mr-2" />
|
||||
<ui-btn type="submit" :disabled="processing">Submit</ui-btn>
|
||||
<ui-text-input v-model="searchInput" :disabled="processing" placeholder="Enter search term or RSS feed URL" class="flex-grow mr-2 text-sm md:text-base" />
|
||||
<ui-btn type="submit" :disabled="processing" class="hidden md:block">Submit</ui-btn>
|
||||
<ui-btn type="submit" :disabled="processing" class="block md:hidden" small>Submit</ui-btn>
|
||||
</form>
|
||||
<ui-file-input ref="fileInput" :accept="'.opml, .txt'" class="mx-2" @change="opmlFileUpload"> Upload OPML File </ui-file-input>
|
||||
<ui-file-input ref="fileInput" :accept="'.opml, .txt'" class="ml-2" @change="opmlFileUpload"> Upload OPML File </ui-file-input>
|
||||
</div>
|
||||
|
||||
<div class="w-full max-w-3xl mx-auto py-4">
|
||||
<p v-if="termSearched && !results.length && !processing" class="text-center text-xl">No podcasts found</p>
|
||||
<template v-for="podcast in results">
|
||||
<div :key="podcast.id" class="flex p-1 hover:bg-primary hover:bg-opacity-25 cursor-pointer" @click="selectPodcast(podcast)">
|
||||
<div class="w-24 min-w-24 h-24 bg-primary">
|
||||
<div class="w-20 min-w-20 h-20 md:w-24 md:min-w-24 md:h-24 bg-primary">
|
||||
<img v-if="podcast.cover" :src="podcast.cover" class="h-full w-full" />
|
||||
</div>
|
||||
<div class="flex-grow pl-4 max-w-2xl">
|
||||
<a :href="podcast.pageUrl" class="text-lg text-gray-200 hover:underline" target="_blank" @click.stop>{{ podcast.title }}</a>
|
||||
<p class="text-base text-gray-300 whitespace-nowrap truncate">by {{ podcast.artistName }}</p>
|
||||
<a :href="podcast.pageUrl" class="text-base md:text-lg text-gray-200 hover:underline" target="_blank" @click.stop>{{ podcast.title }}</a>
|
||||
<p class="text-sm md:text-base text-gray-300 whitespace-nowrap truncate">by {{ podcast.artistName }}</p>
|
||||
<p class="text-xs text-gray-400 leading-5">{{ podcast.genres.join(', ') }}</p>
|
||||
<p class="text-xs text-gray-400 leading-5">{{ podcast.trackCount }} Episodes</p>
|
||||
</div>
|
||||
|
||||
@@ -29,7 +29,9 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="pt-8 text-center">
|
||||
<p class="text-xs text-white text-opacity-50 font-mono"><strong>Supported File Types: </strong>{{ inputAccept.join(', ') }}</p>
|
||||
<p class="text-xs text-white text-opacity-50 font-mono mb-4"><strong>Supported File Types: </strong>{{ inputAccept.join(', ') }}</p>
|
||||
|
||||
<p class="text-sm text-white text-opacity-70">Folders with media files will be treated as separate library items. <span v-if="selectedLibraryMediaType === 'book'">If uploading only audio files then each audio file will be treated as a separate audiobook.</span></p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Item list header -->
|
||||
|
||||
@@ -124,6 +124,8 @@ export default class CastPlayer extends EventEmitter {
|
||||
|
||||
async seek(time, playWhenReady) {
|
||||
if (!this.player) return
|
||||
|
||||
this.playWhenReady = playWhenReady
|
||||
if (time < this.currentTrack.startOffset || time > this.currentTrack.startOffset + this.currentTrack.duration) {
|
||||
// Change Track
|
||||
var request = buildCastLoadRequest(this.libraryItem, this.coverUrl, this.audioTracks, time, playWhenReady, this.defaultPlaybackRate)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Hls from 'hls.js'
|
||||
import EventEmitter from 'events'
|
||||
|
||||
export default class LocalPlayer extends EventEmitter {
|
||||
export default class LocalAudioPlayer extends EventEmitter {
|
||||
constructor(ctx) {
|
||||
super()
|
||||
|
||||
@@ -71,7 +71,6 @@ export default class LocalPlayer extends EventEmitter {
|
||||
console.log(`[LocalPlayer] Track ended - loading next track ${this.currentTrackIndex + 1}`)
|
||||
// Has next track
|
||||
this.currentTrackIndex++
|
||||
this.playWhenReady = true
|
||||
this.startTime = this.currentTrack.startOffset
|
||||
this.loadCurrentTrack()
|
||||
} else {
|
||||
@@ -89,6 +88,7 @@ export default class LocalPlayer extends EventEmitter {
|
||||
}
|
||||
|
||||
this.emit('stateChange', 'LOADED')
|
||||
|
||||
if (this.playWhenReady) {
|
||||
this.playWhenReady = false
|
||||
this.play()
|
||||
@@ -205,10 +205,12 @@ export default class LocalPlayer extends EventEmitter {
|
||||
}
|
||||
|
||||
play() {
|
||||
this.playWhenReady = true
|
||||
if (this.player) this.player.play()
|
||||
}
|
||||
|
||||
pause() {
|
||||
this.playWhenReady = false
|
||||
if (this.player) this.player.pause()
|
||||
}
|
||||
|
||||
@@ -229,8 +231,11 @@ export default class LocalPlayer extends EventEmitter {
|
||||
this.player.playbackRate = playbackRate
|
||||
}
|
||||
|
||||
seek(time) {
|
||||
seek(time, playWhenReady) {
|
||||
if (!this.player) return
|
||||
|
||||
this.playWhenReady = playWhenReady
|
||||
|
||||
if (this.isHlsTranscode) {
|
||||
// Seeking HLS stream
|
||||
var offsetTime = time - (this.currentTrack.startOffset || 0)
|
||||
@@ -255,7 +260,6 @@ export default class LocalPlayer extends EventEmitter {
|
||||
this.player.currentTime = Math.max(0, offsetTime)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
setVolume(volume) {
|
||||
260
client/players/LocalVideoPlayer.js
Normal file
260
client/players/LocalVideoPlayer.js
Normal file
@@ -0,0 +1,260 @@
|
||||
import Hls from 'hls.js'
|
||||
import EventEmitter from 'events'
|
||||
|
||||
export default class LocalVideoPlayer extends EventEmitter {
|
||||
constructor(ctx) {
|
||||
super()
|
||||
|
||||
this.ctx = ctx
|
||||
this.player = null
|
||||
|
||||
this.libraryItem = null
|
||||
this.videoTrack = null
|
||||
this.isHlsTranscode = null
|
||||
this.hlsInstance = null
|
||||
this.usingNativeplayer = false
|
||||
this.startTime = 0
|
||||
this.playWhenReady = false
|
||||
this.defaultPlaybackRate = 1
|
||||
|
||||
this.playableMimeTypes = []
|
||||
|
||||
this.initialize()
|
||||
}
|
||||
|
||||
initialize() {
|
||||
if (document.getElementById('video-player')) {
|
||||
document.getElementById('video-player').remove()
|
||||
}
|
||||
var videoEl = document.createElement('video')
|
||||
videoEl.id = 'video-player'
|
||||
// videoEl.style.display = 'none'
|
||||
videoEl.className = 'absolute bg-black z-50'
|
||||
videoEl.style.height = '216px'
|
||||
videoEl.style.width = '384px'
|
||||
videoEl.style.bottom = '80px'
|
||||
videoEl.style.left = '16px'
|
||||
document.body.appendChild(videoEl)
|
||||
this.player = videoEl
|
||||
|
||||
this.player.addEventListener('play', this.evtPlay.bind(this))
|
||||
this.player.addEventListener('pause', this.evtPause.bind(this))
|
||||
this.player.addEventListener('progress', this.evtProgress.bind(this))
|
||||
this.player.addEventListener('ended', this.evtEnded.bind(this))
|
||||
this.player.addEventListener('error', this.evtError.bind(this))
|
||||
this.player.addEventListener('loadedmetadata', this.evtLoadedMetadata.bind(this))
|
||||
this.player.addEventListener('timeupdate', this.evtTimeupdate.bind(this))
|
||||
|
||||
var mimeTypes = ['video/mp4']
|
||||
var mimeTypeCanPlayMap = {}
|
||||
mimeTypes.forEach((mt) => {
|
||||
var canPlay = this.player.canPlayType(mt)
|
||||
mimeTypeCanPlayMap[mt] = canPlay
|
||||
if (canPlay) this.playableMimeTypes.push(mt)
|
||||
})
|
||||
console.log(`[LocalVideoPlayer] Supported mime types`, mimeTypeCanPlayMap, this.playableMimeTypes)
|
||||
}
|
||||
|
||||
evtPlay() {
|
||||
this.emit('stateChange', 'PLAYING')
|
||||
}
|
||||
evtPause() {
|
||||
this.emit('stateChange', 'PAUSED')
|
||||
}
|
||||
evtProgress() {
|
||||
var lastBufferTime = this.getLastBufferedTime()
|
||||
this.emit('buffertimeUpdate', lastBufferTime)
|
||||
}
|
||||
evtEnded() {
|
||||
console.log(`[LocalVideoPlayer] Ended`)
|
||||
this.emit('finished')
|
||||
}
|
||||
evtError(error) {
|
||||
console.error('Player error', error)
|
||||
this.emit('error', error)
|
||||
}
|
||||
evtLoadedMetadata(data) {
|
||||
if (!this.isHlsTranscode) {
|
||||
this.player.currentTime = this.startTime
|
||||
}
|
||||
|
||||
this.emit('stateChange', 'LOADED')
|
||||
if (this.playWhenReady) {
|
||||
this.playWhenReady = false
|
||||
this.play()
|
||||
}
|
||||
}
|
||||
evtTimeupdate() {
|
||||
if (this.player.paused) {
|
||||
this.emit('timeupdate', this.getCurrentTime())
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.destroyHlsInstance()
|
||||
if (this.player) {
|
||||
this.player.remove()
|
||||
}
|
||||
}
|
||||
|
||||
set(libraryItem, videoTrack, isHlsTranscode, startTime, playWhenReady = false) {
|
||||
this.libraryItem = libraryItem
|
||||
this.videoTrack = videoTrack
|
||||
this.isHlsTranscode = isHlsTranscode
|
||||
this.playWhenReady = playWhenReady
|
||||
this.startTime = startTime
|
||||
|
||||
if (this.hlsInstance) {
|
||||
this.destroyHlsInstance()
|
||||
}
|
||||
|
||||
if (this.isHlsTranscode) {
|
||||
this.setHlsStream()
|
||||
} else {
|
||||
this.setDirectPlay()
|
||||
}
|
||||
}
|
||||
|
||||
setHlsStream() {
|
||||
// iOS does not support Media Elements but allows for HLS in the native video player
|
||||
if (!Hls.isSupported()) {
|
||||
console.warn('HLS is not supported - fallback to using video element')
|
||||
this.usingNativeplayer = true
|
||||
this.player.src = this.videoTrack.relativeContentUrl
|
||||
this.player.currentTime = this.startTime
|
||||
return
|
||||
}
|
||||
|
||||
var hlsOptions = {
|
||||
startPosition: this.startTime || -1
|
||||
// No longer needed because token is put in a query string
|
||||
// xhrSetup: (xhr) => {
|
||||
// xhr.setRequestHeader('Authorization', `Bearer ${this.token}`)
|
||||
// }
|
||||
}
|
||||
this.hlsInstance = new Hls(hlsOptions)
|
||||
|
||||
this.hlsInstance.attachMedia(this.player)
|
||||
this.hlsInstance.on(Hls.Events.MEDIA_ATTACHED, () => {
|
||||
this.hlsInstance.loadSource(this.videoTrack.relativeContentUrl)
|
||||
|
||||
this.hlsInstance.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
console.log('[HLS] Manifest Parsed')
|
||||
})
|
||||
|
||||
this.hlsInstance.on(Hls.Events.ERROR, (e, data) => {
|
||||
console.error('[HLS] Error', data.type, data.details, data)
|
||||
if (data.details === Hls.ErrorDetails.BUFFER_STALLED_ERROR) {
|
||||
console.error('[HLS] BUFFER STALLED ERROR')
|
||||
}
|
||||
})
|
||||
this.hlsInstance.on(Hls.Events.DESTROYING, () => {
|
||||
console.log('[HLS] Destroying HLS Instance')
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
setDirectPlay() {
|
||||
this.player.src = this.videoTrack.relativeContentUrl
|
||||
console.log(`[LocalVideoPlayer] Loading track src ${this.videoTrack.relativeContentUrl}`)
|
||||
this.player.load()
|
||||
}
|
||||
|
||||
destroyHlsInstance() {
|
||||
if (!this.hlsInstance) return
|
||||
if (this.hlsInstance.destroy) {
|
||||
var temp = this.hlsInstance
|
||||
temp.destroy()
|
||||
}
|
||||
this.hlsInstance = null
|
||||
}
|
||||
|
||||
async resetStream(startTime) {
|
||||
this.destroyHlsInstance()
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
this.set(this.libraryItem, this.videoTrack, this.isHlsTranscode, startTime, true)
|
||||
}
|
||||
|
||||
playPause() {
|
||||
if (!this.player) return
|
||||
if (this.player.paused) this.play()
|
||||
else this.pause()
|
||||
}
|
||||
|
||||
play() {
|
||||
if (this.player) this.player.play()
|
||||
}
|
||||
|
||||
pause() {
|
||||
if (this.player) this.player.pause()
|
||||
}
|
||||
|
||||
getCurrentTime() {
|
||||
return this.player ? this.player.currentTime : 0
|
||||
}
|
||||
|
||||
getDuration() {
|
||||
return this.videoTrack.duration
|
||||
}
|
||||
|
||||
setPlaybackRate(playbackRate) {
|
||||
if (!this.player) return
|
||||
this.defaultPlaybackRate = playbackRate
|
||||
this.player.playbackRate = playbackRate
|
||||
}
|
||||
|
||||
seek(time) {
|
||||
if (!this.player) return
|
||||
this.player.currentTime = Math.max(0, time)
|
||||
}
|
||||
|
||||
setVolume(volume) {
|
||||
if (!this.player) return
|
||||
this.player.volume = volume
|
||||
}
|
||||
|
||||
// Utils
|
||||
isValidDuration(duration) {
|
||||
if (duration && !isNaN(duration) && duration !== Number.POSITIVE_INFINITY && duration !== Number.NEGATIVE_INFINITY) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
getBufferedRanges() {
|
||||
if (!this.player) return []
|
||||
const ranges = []
|
||||
const seekable = this.player.buffered || []
|
||||
|
||||
let offset = 0
|
||||
|
||||
for (let i = 0, length = seekable.length; i < length; i++) {
|
||||
let start = seekable.start(i)
|
||||
let end = seekable.end(i)
|
||||
if (!this.isValidDuration(start)) {
|
||||
start = 0
|
||||
}
|
||||
if (!this.isValidDuration(end)) {
|
||||
end = 0
|
||||
continue
|
||||
}
|
||||
|
||||
ranges.push({
|
||||
start: start + offset,
|
||||
end: end + offset
|
||||
})
|
||||
}
|
||||
return ranges
|
||||
}
|
||||
|
||||
getLastBufferedTime() {
|
||||
var bufferedRanges = this.getBufferedRanges()
|
||||
if (!bufferedRanges.length) return 0
|
||||
|
||||
var buff = bufferedRanges.find((buff) => buff.start < this.player.currentTime && buff.end > this.player.currentTime)
|
||||
if (buff) return buff.end
|
||||
|
||||
var last = bufferedRanges[bufferedRanges.length - 1]
|
||||
return last.end
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import LocalPlayer from './LocalPlayer'
|
||||
import LocalAudioPlayer from './LocalAudioPlayer'
|
||||
import LocalVideoPlayer from './LocalVideoPlayer'
|
||||
import CastPlayer from './CastPlayer'
|
||||
import AudioTrack from './AudioTrack'
|
||||
import VideoTrack from './VideoTrack'
|
||||
|
||||
export default class PlayerHandler {
|
||||
constructor(ctx) {
|
||||
@@ -14,9 +16,11 @@ export default class PlayerHandler {
|
||||
this.player = null
|
||||
this.playerState = 'IDLE'
|
||||
this.isHlsTranscode = false
|
||||
this.isVideo = false
|
||||
this.currentSessionId = null
|
||||
this.startTime = 0
|
||||
|
||||
this.failedProgressSyncs = 0
|
||||
this.lastSyncTime = 0
|
||||
this.lastSyncedAt = 0
|
||||
this.listeningTimeSinceSync = 0
|
||||
@@ -34,7 +38,7 @@ export default class PlayerHandler {
|
||||
return this.libraryItem && (this.player instanceof CastPlayer)
|
||||
}
|
||||
get isPlayingLocalItem() {
|
||||
return this.libraryItem && (this.player instanceof LocalPlayer)
|
||||
return this.libraryItem && (this.player instanceof LocalAudioPlayer)
|
||||
}
|
||||
get userToken() {
|
||||
return this.ctx.$store.getters['user/getToken']
|
||||
@@ -48,16 +52,17 @@ export default class PlayerHandler {
|
||||
}
|
||||
|
||||
load(libraryItem, episodeId, playWhenReady, playbackRate) {
|
||||
if (!this.player) this.switchPlayer()
|
||||
|
||||
this.libraryItem = libraryItem
|
||||
this.episodeId = episodeId
|
||||
this.playWhenReady = playWhenReady
|
||||
this.initialPlaybackRate = playbackRate
|
||||
this.prepare()
|
||||
this.isVideo = libraryItem.mediaType === 'video'
|
||||
|
||||
if (!this.player) this.switchPlayer(playWhenReady)
|
||||
else this.prepare()
|
||||
}
|
||||
|
||||
switchPlayer() {
|
||||
switchPlayer(playWhenReady) {
|
||||
if (this.isCasting && !(this.player instanceof CastPlayer)) {
|
||||
console.log('[PlayerHandler] Switching to cast player')
|
||||
|
||||
@@ -73,10 +78,10 @@ export default class PlayerHandler {
|
||||
|
||||
if (this.libraryItem) {
|
||||
// libraryItem was already loaded - prepare for cast
|
||||
this.playWhenReady = false
|
||||
this.playWhenReady = playWhenReady
|
||||
this.prepare()
|
||||
}
|
||||
} else if (!this.isCasting && !(this.player instanceof LocalPlayer)) {
|
||||
} else if (!this.isCasting && !(this.player instanceof LocalAudioPlayer) && !(this.player instanceof LocalVideoPlayer)) {
|
||||
console.log('[PlayerHandler] Switching to local player')
|
||||
|
||||
this.stopPlayInterval()
|
||||
@@ -85,12 +90,18 @@ export default class PlayerHandler {
|
||||
if (this.player) {
|
||||
this.player.destroy()
|
||||
}
|
||||
this.player = new LocalPlayer(this.ctx)
|
||||
|
||||
if (this.isVideo) {
|
||||
this.player = new LocalVideoPlayer(this.ctx)
|
||||
} else {
|
||||
this.player = new LocalAudioPlayer(this.ctx)
|
||||
}
|
||||
|
||||
this.setPlayerListeners()
|
||||
|
||||
if (this.libraryItem) {
|
||||
// libraryItem was already loaded - prepare for local play
|
||||
this.playWhenReady = false
|
||||
this.playWhenReady = playWhenReady
|
||||
this.prepare()
|
||||
}
|
||||
}
|
||||
@@ -106,7 +117,7 @@ export default class PlayerHandler {
|
||||
|
||||
playerError() {
|
||||
// Switch to HLS stream on error
|
||||
if (!this.isCasting && !this.currentStreamId && (this.player instanceof LocalPlayer)) {
|
||||
if (!this.isCasting && !this.currentStreamId && (this.player instanceof LocalAudioPlayer)) {
|
||||
console.log(`[PlayerHandler] Audio player error switching to HLS stream`)
|
||||
this.prepare(true)
|
||||
}
|
||||
@@ -155,7 +166,7 @@ export default class PlayerHandler {
|
||||
supportedMimeTypes: this.player.playableMimeTypes,
|
||||
mediaPlayer: this.isCasting ? 'chromecast' : 'html5',
|
||||
forceTranscode,
|
||||
forceDirectPlay: this.isCasting // 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`
|
||||
@@ -166,31 +177,47 @@ export default class PlayerHandler {
|
||||
}
|
||||
|
||||
prepareOpenSession(session, playbackRate) { // Session opened on init socket
|
||||
if (!this.player) this.switchPlayer()
|
||||
if (!this.player) this.switchPlayer() // Must set player first for open sessions
|
||||
|
||||
this.libraryItem = session.libraryItem
|
||||
this.isVideo = session.libraryItem.mediaType === 'video'
|
||||
this.playWhenReady = false
|
||||
this.initialPlaybackRate = playbackRate
|
||||
|
||||
this.prepareSession(session)
|
||||
}
|
||||
|
||||
prepareSession(session) {
|
||||
this.failedProgressSyncs = 0
|
||||
this.startTime = session.currentTime
|
||||
this.currentSessionId = session.id
|
||||
this.displayTitle = session.displayTitle
|
||||
this.displayAuthor = session.displayAuthor
|
||||
|
||||
console.log('[PlayerHandler] Preparing Session', session)
|
||||
var audioTracks = session.audioTracks.map(at => new AudioTrack(at, this.userToken))
|
||||
|
||||
this.ctx.playerLoading = true
|
||||
this.isHlsTranscode = true
|
||||
if (session.playMethod === this.ctx.$constants.PlayMethod.DIRECTPLAY) {
|
||||
this.isHlsTranscode = false
|
||||
if (session.videoTrack) {
|
||||
var videoTrack = new VideoTrack(session.videoTrack, this.userToken)
|
||||
|
||||
this.ctx.playerLoading = true
|
||||
this.isHlsTranscode = true
|
||||
if (session.playMethod === this.ctx.$constants.PlayMethod.DIRECTPLAY) {
|
||||
this.isHlsTranscode = false
|
||||
}
|
||||
|
||||
this.player.set(this.libraryItem, videoTrack, this.isHlsTranscode, this.startTime, this.playWhenReady)
|
||||
} else {
|
||||
var audioTracks = session.audioTracks.map(at => new AudioTrack(at, this.userToken))
|
||||
|
||||
this.ctx.playerLoading = true
|
||||
this.isHlsTranscode = true
|
||||
if (session.playMethod === this.ctx.$constants.PlayMethod.DIRECTPLAY) {
|
||||
this.isHlsTranscode = false
|
||||
}
|
||||
|
||||
this.player.set(this.libraryItem, audioTracks, this.isHlsTranscode, this.startTime, this.playWhenReady)
|
||||
}
|
||||
|
||||
this.player.set(this.libraryItem, audioTracks, this.isHlsTranscode, this.startTime, this.playWhenReady)
|
||||
|
||||
// browser media session api
|
||||
this.ctx.setMediaSession()
|
||||
}
|
||||
@@ -262,8 +289,15 @@ export default class PlayerHandler {
|
||||
currentTime
|
||||
}
|
||||
this.listeningTimeSinceSync = 0
|
||||
this.ctx.$axios.$post(`/api/session/${this.currentSessionId}/sync`, syncData, { timeout: 1000 }).catch((error) => {
|
||||
this.ctx.$axios.$post(`/api/session/${this.currentSessionId}/sync`, syncData, { timeout: 1000 }).then(() => {
|
||||
this.failedProgressSyncs = 0
|
||||
}).catch((error) => {
|
||||
console.error('Failed to update session progress', error)
|
||||
this.failedProgressSyncs++
|
||||
if (this.failedProgressSyncs >= 2) {
|
||||
this.ctx.showFailedProgressSyncs()
|
||||
this.failedProgressSyncs = 0
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
32
client/players/VideoTrack.js
Normal file
32
client/players/VideoTrack.js
Normal file
@@ -0,0 +1,32 @@
|
||||
export default class VideoTrack {
|
||||
constructor(track, userToken) {
|
||||
this.index = track.index || 0
|
||||
this.startOffset = track.startOffset || 0 // Total time of all previous tracks
|
||||
this.duration = track.duration || 0
|
||||
this.title = track.title || ''
|
||||
this.contentUrl = track.contentUrl || null
|
||||
this.mimeType = track.mimeType
|
||||
this.metadata = track.metadata || {}
|
||||
|
||||
this.userToken = userToken
|
||||
}
|
||||
|
||||
get fullContentUrl() {
|
||||
if (!this.contentUrl || this.contentUrl.startsWith('http')) return this.contentUrl
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
return `${process.env.serverUrl}${this.contentUrl}?token=${this.userToken}`
|
||||
}
|
||||
return `${window.location.origin}${this.contentUrl}?token=${this.userToken}`
|
||||
}
|
||||
|
||||
get relativeContentUrl() {
|
||||
if (!this.contentUrl || this.contentUrl.startsWith('http')) return this.contentUrl
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
return `${process.env.serverUrl}${this.contentUrl}?token=${this.userToken}`
|
||||
}
|
||||
|
||||
return this.contentUrl + `?token=${this.userToken}`
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ export default (ctx) => {
|
||||
var castContext = cast.framework.CastContext.getInstance()
|
||||
castContext.setOptions({
|
||||
receiverApplicationId: process.env.chromecastReceiver,
|
||||
autoJoinPolicy: chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED
|
||||
autoJoinPolicy: chrome.cast ? chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED : null
|
||||
});
|
||||
|
||||
castContext.addEventListener(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const SupportedFileTypes = {
|
||||
image: ['png', 'jpg', 'jpeg', 'webp'],
|
||||
audio: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'mp4', 'aac', 'wma', 'aiff'],
|
||||
audio: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'oga', 'mp4', 'aac', 'wma', 'aiff', 'wav'],
|
||||
ebook: ['epub', 'pdf', 'mobi', 'azw3', 'cbr', 'cbz'],
|
||||
info: ['nfo'],
|
||||
text: ['txt'],
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Vue from 'vue'
|
||||
import Path from 'path'
|
||||
import vClickOutside from 'v-click-outside'
|
||||
import { formatDistance, format, addDays, isDate } from 'date-fns'
|
||||
|
||||
@@ -119,20 +120,36 @@ Vue.prototype.$sanitizeFilename = (input, colonReplacement = ' - ') => {
|
||||
if (typeof input !== 'string') {
|
||||
return false
|
||||
}
|
||||
|
||||
// Max is actually 255-260 for windows but this leaves padding incase ext wasnt put on yet
|
||||
const MAX_FILENAME_LEN = 240
|
||||
|
||||
var replacement = ''
|
||||
var illegalRe = /[\/\?<>\\:\*\|"]/g;
|
||||
var controlRe = /[\x00-\x1f\x80-\x9f]/g;
|
||||
var reservedRe = /^\.+$/;
|
||||
var windowsReservedRe = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i;
|
||||
var windowsTrailingRe = /[\. ]+$/;
|
||||
var illegalRe = /[\/\?<>\\:\*\|"]/g
|
||||
var controlRe = /[\x00-\x1f\x80-\x9f]/g
|
||||
var reservedRe = /^\.+$/
|
||||
var windowsReservedRe = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i
|
||||
var windowsTrailingRe = /[\. ]+$/
|
||||
var lineBreaks = /[\n\r]/g
|
||||
|
||||
var sanitized = input
|
||||
.replace(':', colonReplacement) // Replace first occurrence of a colon
|
||||
.replace(illegalRe, replacement)
|
||||
.replace(controlRe, replacement)
|
||||
.replace(reservedRe, replacement)
|
||||
.replace(lineBreaks, replacement)
|
||||
.replace(windowsReservedRe, replacement)
|
||||
.replace(windowsTrailingRe, replacement);
|
||||
.replace(windowsTrailingRe, replacement)
|
||||
|
||||
|
||||
if (sanitized.length > MAX_FILENAME_LEN) {
|
||||
var lenToRemove = sanitized.length - MAX_FILENAME_LEN
|
||||
var ext = Path.extname(sanitized)
|
||||
var basename = Path.basename(sanitized, ext)
|
||||
basename = basename.slice(0, basename.length - lenToRemove)
|
||||
sanitized = basename + ext
|
||||
}
|
||||
|
||||
return sanitized
|
||||
}
|
||||
|
||||
@@ -152,7 +169,7 @@ Vue.prototype.$sanitizeSlug = (str) => {
|
||||
str = str.replace(new RegExp(from.charAt(i), 'g'), to.charAt(i))
|
||||
}
|
||||
|
||||
str = str.replace('.', '-') // replace a dot by a dash
|
||||
str = str.replace('.', '-') // replace a dot by a dash
|
||||
.replace(/[^a-z0-9 -_]/g, '') // remove invalid chars
|
||||
.replace(/\s+/g, '-') // collapse whitespace and replace by a dash
|
||||
.replace(/-+/g, '-') // collapse dashes
|
||||
@@ -187,7 +204,6 @@ Vue.prototype.$copyToClipboard = (str, ctx) => {
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
function xmlToJson(xml) {
|
||||
const json = {};
|
||||
for (const res of xml.matchAll(/(?:<(\w*)(?:\s[^>]*)*>)((?:(?!<\1).)*)(?:<\/\1>)|<(\w*)(?:\s*)*\/>/gm)) {
|
||||
|
||||
@@ -23,14 +23,17 @@ function parseSemver(ver) {
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export const currentVersion = packagejson.version
|
||||
|
||||
export async function checkForUpdate() {
|
||||
if (!packagejson.version) {
|
||||
return
|
||||
return null
|
||||
}
|
||||
var currVerObj = parseSemver('v' + packagejson.version)
|
||||
if (!currVerObj) {
|
||||
console.error('Invalid version', packagejson.version)
|
||||
return
|
||||
return null
|
||||
}
|
||||
var largestVer = null
|
||||
await axios.get(`https://api.github.com/repos/advplyr/audiobookshelf/releases`).then((res) => {
|
||||
@@ -44,18 +47,24 @@ export async function checkForUpdate() {
|
||||
largestVer = verObj
|
||||
}
|
||||
}
|
||||
|
||||
if (verObj.version == currVerObj.version) {
|
||||
currVerObj.changelog = release.body
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
})
|
||||
if (!largestVer) {
|
||||
console.error('No valid version tags to compare with')
|
||||
return
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
hasUpdate: largestVer.total > currVerObj.total,
|
||||
latestVersion: largestVer.version,
|
||||
githubTagUrl: `https://github.com/advplyr/audiobookshelf/releases/tag/v${largestVer.version}`,
|
||||
currentVersion: currVerObj.version
|
||||
currentVersion: currVerObj.version,
|
||||
currentVersionChangelog: currVerObj.changelog
|
||||
}
|
||||
}
|
||||
44
client/static/libs/marked/LICENSE
Normal file
44
client/static/libs/marked/LICENSE
Normal file
@@ -0,0 +1,44 @@
|
||||
# License information
|
||||
|
||||
## Contribution License Agreement
|
||||
|
||||
If you contribute code to this project, you are implicitly allowing your code
|
||||
to be distributed under the MIT license. You are also implicitly verifying that
|
||||
all code is your original work. `</legalese>`
|
||||
|
||||
## Marked
|
||||
|
||||
Copyright (c) 2018+, MarkedJS (https://github.com/markedjs/)
|
||||
Copyright (c) 2011-2018, Christopher Jeffrey (https://github.com/chjj/)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
|
||||
## Markdown
|
||||
|
||||
Copyright © 2004, John Gruber
|
||||
http://daringfireball.net/
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
|
||||
* Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
|
||||
* Neither the name “Markdown” nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
|
||||
|
||||
This software is provided by the copyright holders and contributors “as is” and any express or implied warranties, including, but not limited to, the implied warranties of merchantability and fitness for a particular purpose are disclaimed. In no event shall the copyright owner or contributors be liable for any direct, indirect, incidental, special, exemplary, or consequential damages (including, but not limited to, procurement of substitute goods or services; loss of use, data, or profits; or business interruption) however caused and on any theory of liability, whether in contract, strict liability, or tort (including negligence or otherwise) arising in any way out of the use of this software, even if advised of the possibility of such damage.
|
||||
6
client/static/libs/marked/index.js
Normal file
6
client/static/libs/marked/index.js
Normal file
File diff suppressed because one or more lines are too long
Binary file not shown.
|
Before Width: | Height: | Size: 57 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 141 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 209 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 512 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 132 KiB |
@@ -1,4 +1,3 @@
|
||||
|
||||
export const state = () => ({
|
||||
isMobile: false,
|
||||
isMobileLandscape: false,
|
||||
@@ -11,9 +10,22 @@ export const state = () => ({
|
||||
selectedEpisode: null,
|
||||
selectedCollection: null,
|
||||
selectedAuthor: null,
|
||||
showBookshelfTextureModal: false,
|
||||
isCasting: false, // Actively casting
|
||||
isChromecastInitialized: false // Script loaded
|
||||
isChromecastInitialized: false, // Script loaded
|
||||
dateFormats: [
|
||||
{
|
||||
text: 'MM/DD/YYYY',
|
||||
value: 'MM/dd/yyyy'
|
||||
},
|
||||
{
|
||||
text: 'DD/MM/YYYY',
|
||||
value: 'dd/MM/yyyy'
|
||||
},
|
||||
{
|
||||
text: 'YYYY-MM-DD',
|
||||
value: 'yyyy-MM-dd'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
export const getters = {
|
||||
@@ -64,9 +76,6 @@ export const mutations = {
|
||||
setSelectedEpisode(state, episode) {
|
||||
state.selectedEpisode = episode
|
||||
},
|
||||
setShowBookshelfTextureModal(state, val) {
|
||||
state.showBookshelfTextureModal = val
|
||||
},
|
||||
showEditAuthorModal(state, author) {
|
||||
state.selectedAuthor = author
|
||||
state.showEditAuthorModal = true
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { checkForUpdate } from '@/plugins/version'
|
||||
import { checkForUpdate, currentVersion } from '@/plugins/version'
|
||||
import Vue from 'vue'
|
||||
|
||||
export const state = () => ({
|
||||
@@ -8,6 +8,7 @@ export const state = () => ({
|
||||
streamLibraryItem: null,
|
||||
streamEpisodeId: null,
|
||||
streamIsPlaying: false,
|
||||
playerIsFullscreen: false,
|
||||
editModalTab: 'details',
|
||||
showEditModal: false,
|
||||
showEReader: false,
|
||||
@@ -21,7 +22,6 @@ export const state = () => ({
|
||||
bookshelfBookIds: [],
|
||||
openModal: null,
|
||||
innerModalOpen: false,
|
||||
selectedBookshelfTexture: '/textures/wood_default.jpg',
|
||||
lastBookshelfScrollData: {}
|
||||
})
|
||||
|
||||
@@ -65,20 +65,44 @@ export const actions = {
|
||||
})
|
||||
},
|
||||
checkForUpdate({ commit }) {
|
||||
return checkForUpdate()
|
||||
.then((res) => {
|
||||
commit('setVersionData', res)
|
||||
return res
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Update check failed', error)
|
||||
return false
|
||||
})
|
||||
},
|
||||
setBookshelfTexture({ commit, state }, img) {
|
||||
let root = document.documentElement;
|
||||
commit('setBookshelfTexture', img)
|
||||
root.style.setProperty('--bookshelf-texture-img', `url(${img})`);
|
||||
const VERSION_CHECK_BUFF = 1000 * 60 * 5 // 5 minutes
|
||||
var lastVerCheck = localStorage.getItem('lastVerCheck') || 0
|
||||
var savedVersionData = localStorage.getItem('versionData')
|
||||
if (savedVersionData) {
|
||||
try {
|
||||
savedVersionData = JSON.parse(localStorage.getItem('versionData'))
|
||||
} catch (error) {
|
||||
console.error('Failed to parse version data', error)
|
||||
savedVersionData = null
|
||||
localStorage.removeItem('versionData')
|
||||
}
|
||||
}
|
||||
|
||||
var shouldCheckForUpdate = Date.now() - Number(lastVerCheck) > VERSION_CHECK_BUFF
|
||||
if (!shouldCheckForUpdate && savedVersionData && savedVersionData.version !== currentVersion) {
|
||||
// Version mismatch between saved data so check for update anyway
|
||||
shouldCheckForUpdate = true
|
||||
}
|
||||
|
||||
if (shouldCheckForUpdate) {
|
||||
return checkForUpdate()
|
||||
.then((res) => {
|
||||
if (res) {
|
||||
localStorage.setItem('lastVerCheck', Date.now())
|
||||
localStorage.setItem('versionData', JSON.stringify(res))
|
||||
|
||||
commit('setVersionData', res)
|
||||
}
|
||||
return res && res.hasUpdate
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Update check failed', error)
|
||||
return false
|
||||
})
|
||||
} else if (savedVersionData) {
|
||||
commit('setVersionData', savedVersionData)
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +110,9 @@ export const mutations = {
|
||||
setSource(state, source) {
|
||||
state.Source = source
|
||||
},
|
||||
setPlayerIsFullscreen(state, val) {
|
||||
state.playerIsFullscreen = val
|
||||
},
|
||||
setLastBookshelfScrollData(state, { scrollTop, path, name }) {
|
||||
state.lastBookshelfScrollData[name] = { scrollTop, path }
|
||||
},
|
||||
@@ -180,8 +207,5 @@ export const mutations = {
|
||||
},
|
||||
setInnerModalOpen(state, val) {
|
||||
state.innerModalOpen = val
|
||||
},
|
||||
setBookshelfTexture(state, val) {
|
||||
state.selectedBookshelfTexture = val
|
||||
}
|
||||
}
|
||||
@@ -136,6 +136,10 @@ export const mutations = {
|
||||
localStorage.removeItem('token')
|
||||
}
|
||||
},
|
||||
setUserToken(state, token) {
|
||||
state.user.token = token
|
||||
localStorage.setItem('token', user.token)
|
||||
},
|
||||
updateMediaProgress(state, { id, data }) {
|
||||
if (!state.user) return
|
||||
if (!data) {
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
const defaultTheme = require('tailwindcss/defaultTheme')
|
||||
|
||||
module.exports = {
|
||||
purge: {
|
||||
options: {
|
||||
safelist: [
|
||||
'bg-success',
|
||||
'bg-red-600',
|
||||
'text-green-500',
|
||||
'py-1.5',
|
||||
'bg-info',
|
||||
'px-1.5'
|
||||
]
|
||||
}
|
||||
content: [
|
||||
'components/**/*.vue',
|
||||
'layouts/**/*.vue',
|
||||
'pages/**/*.vue',
|
||||
'templates/**/*.vue',
|
||||
'plugins/**/*.js',
|
||||
'nuxt.config.js'
|
||||
],
|
||||
safelist: [
|
||||
'bg-success',
|
||||
'bg-red-600',
|
||||
'text-green-500',
|
||||
'py-1.5',
|
||||
'bg-info',
|
||||
'px-1.5'
|
||||
],
|
||||
},
|
||||
darkMode: false,
|
||||
theme: {
|
||||
extend: {
|
||||
height: {
|
||||
@@ -31,6 +34,7 @@ module.exports = {
|
||||
'20': '5rem',
|
||||
'24': '6rem',
|
||||
'32': '8rem',
|
||||
'40': '10rem',
|
||||
'48': '12rem',
|
||||
'64': '16rem',
|
||||
'80': '20rem'
|
||||
@@ -39,6 +43,7 @@ module.exports = {
|
||||
'6': '1.5rem',
|
||||
'12': '3rem',
|
||||
'16': '4rem',
|
||||
'20': '5rem',
|
||||
'24': '6rem',
|
||||
'32': '8rem',
|
||||
'48': '12rem',
|
||||
@@ -73,12 +78,13 @@ module.exports = {
|
||||
none: 'none'
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Source Sans Pro', ...defaultTheme.fontFamily.sans],
|
||||
mono: ['Ubuntu Mono', ...defaultTheme.fontFamily.mono],
|
||||
sans: ['Source Sans Pro'],
|
||||
mono: ['Ubuntu Mono'],
|
||||
book: ['Gentium Book Basic', 'serif']
|
||||
},
|
||||
fontSize: {
|
||||
xxs: '0.625rem'
|
||||
xxs: '0.625rem',
|
||||
'2.5xl': '1.6875rem'
|
||||
},
|
||||
zIndex: {
|
||||
'50': 50
|
||||
|
||||
1
index.js
1
index.js
@@ -1,4 +1,3 @@
|
||||
if (process.env.TOKEN_SECRET == null) process.env.TOKEN_SECRET = '09f26e402586e2faa8da4c98a35f1b20d6b033c6097befa8be3486a829587fe2f90a832bd3ff9d42710a4da095a2ce285b009f0c3730cd9b8e1af3eb84df6611'
|
||||
const server = require('./server/Server')
|
||||
global.appRoot = __dirname
|
||||
|
||||
|
||||
2126
package-lock.json
generated
2126
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
25
package.json
25
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf",
|
||||
"version": "2.0.18",
|
||||
"version": "2.1.0",
|
||||
"description": "Self-hosted audiobook and podcast server",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
@@ -11,6 +11,9 @@
|
||||
"build-win": "npm run client && pkg -t node16-win-x64 -o ./dist/win/audiobookshelf -C GZip .",
|
||||
"build-linux": "build/linuxpackager",
|
||||
"docker": "docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 --push . -t advplyr/audiobookshelf",
|
||||
"docker-amd64-local": "docker buildx build --platform linux/amd64 --load . -t advplyr/audiobookshelf-amd64-local",
|
||||
"docker-arm64-local": "docker buildx build --platform linux/arm64 --load . -t advplyr/audiobookshelf-arm64-local",
|
||||
"docker-armv7-local": "docker buildx build --platform linux/arm/v7 --load . -t advplyr/audiobookshelf-armv7-local",
|
||||
"deploy": "node dist/autodeploy"
|
||||
},
|
||||
"bin": "prod.js",
|
||||
@@ -27,28 +30,10 @@
|
||||
"author": "advplyr",
|
||||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
"archiver": "^5.3.0",
|
||||
"axios": "^0.26.1",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"command-line-args": "^5.2.0",
|
||||
"date-and-time": "^2.0.1",
|
||||
"express": "^4.17.1",
|
||||
"express-fileupload": "^1.2.1",
|
||||
"express-rate-limit": "^5.3.0",
|
||||
"fast-sort": "^3.1.1",
|
||||
"fluent-ffmpeg": "^2.1.2",
|
||||
"fs-extra": "^10.0.0",
|
||||
"graceful-fs": "^4.2.10",
|
||||
"htmlparser2": "^8.0.1",
|
||||
"image-type": "^4.1.0",
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
"libgen": "^2.1.0",
|
||||
"node-cron": "^3.0.0",
|
||||
"node-ffprobe": "^3.0.0",
|
||||
"node-stream-zip": "^1.15.0",
|
||||
"podcast": "^2.0.0",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
"read-chunk": "^3.1.0",
|
||||
"recursive-readdir-async": "^1.1.8",
|
||||
"socket.io": "^4.4.1",
|
||||
"xml2js": "^0.4.23"
|
||||
}
|
||||
|
||||
2
prod.js
2
prod.js
@@ -6,7 +6,7 @@ const optionDefinitions = [
|
||||
{ name: 'source', alias: 's', type: String }
|
||||
]
|
||||
|
||||
const commandLineArgs = require('command-line-args')
|
||||
const commandLineArgs = require('./server/libs/commandLineArgs')
|
||||
const options = commandLineArgs(optionDefinitions)
|
||||
|
||||
const Path = require('path')
|
||||
|
||||
@@ -69,7 +69,7 @@ docker run -d \
|
||||
-e AUDIOBOOKSHELF_GID=100 \
|
||||
-p 13378:80 \
|
||||
-v </path/to/audiobooks>:/audiobooks \
|
||||
-v </path/to/your/podcasts>:/podcasts \
|
||||
-v </path/to/podcasts>:/podcasts \
|
||||
-v </path/to/config>:/config \
|
||||
-v </path/to/metadata>:/metadata \
|
||||
--name audiobookshelf \
|
||||
@@ -90,6 +90,7 @@ docker start audiobookshelf
|
||||
### docker-compose.yml ###
|
||||
services:
|
||||
audiobookshelf:
|
||||
container_name: audiobookshelf
|
||||
image: ghcr.io/advplyr/audiobookshelf:latest
|
||||
environment:
|
||||
- AUDIOBOOKSHELF_UID=99
|
||||
@@ -97,8 +98,8 @@ services:
|
||||
ports:
|
||||
- 13378:80
|
||||
volumes:
|
||||
- </path/to/your/audiobooks>:/audiobooks
|
||||
- </path/to/your/podcasts>:/podcasts
|
||||
- </path/to/audiobooks>:/audiobooks
|
||||
- </path/to/podcasts>:/podcasts
|
||||
- </path/to/config>:/config
|
||||
- </path/to/metadata>:/metadata
|
||||
```
|
||||
@@ -195,7 +196,7 @@ server
|
||||
proxy_redirect http:// https://;
|
||||
}
|
||||
}
|
||||
```
|
||||
```
|
||||
|
||||
### Apache Reverse Proxy
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const bcrypt = require('bcryptjs')
|
||||
const jwt = require('jsonwebtoken')
|
||||
const bcrypt = require('./libs/bcryptjs')
|
||||
const jwt = require('./libs/jsonwebtoken')
|
||||
const Logger = require('./Logger')
|
||||
|
||||
class Auth {
|
||||
@@ -20,7 +20,9 @@ class Auth {
|
||||
cors(req, res, next) {
|
||||
res.header('Access-Control-Allow-Origin', '*')
|
||||
res.header("Access-Control-Allow-Methods", 'GET, POST, PATCH, PUT, DELETE, OPTIONS')
|
||||
res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept, Accept-Encoding, Range, Authorization")
|
||||
res.header('Access-Control-Allow-Headers', '*')
|
||||
// TODO: Make sure allowing all headers is not a security concern. It is required for adding custom headers for SSO
|
||||
// res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept, Accept-Encoding, Range, Authorization")
|
||||
res.header('Access-Control-Allow-Credentials', true)
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.sendStatus(200)
|
||||
@@ -29,6 +31,26 @@ class Auth {
|
||||
}
|
||||
}
|
||||
|
||||
async initTokenSecret() {
|
||||
if (process.env.TOKEN_SECRET) { // User can supply their own token secret
|
||||
Logger.debug(`[Auth] Setting token secret - using user passed in TOKEN_SECRET env var`)
|
||||
this.db.serverSettings.tokenSecret = process.env.TOKEN_SECRET
|
||||
} else {
|
||||
Logger.debug(`[Auth] Setting token secret - using random bytes`)
|
||||
this.db.serverSettings.tokenSecret = require('crypto').randomBytes(256).toString('base64')
|
||||
}
|
||||
await this.db.updateServerSettings()
|
||||
|
||||
// New token secret creation added in v2.1.0 so generate new API tokens for each user
|
||||
if (this.db.users.length) {
|
||||
for (const user of this.db.users) {
|
||||
user.token = await this.generateAccessToken({ userId: user.id, username: user.username })
|
||||
Logger.warn(`[Auth] User ${user.username} api token has been updated using new token secret`)
|
||||
}
|
||||
await this.db.updateEntities('user', this.db.users)
|
||||
}
|
||||
}
|
||||
|
||||
async authMiddleware(req, res, next) {
|
||||
var token = null
|
||||
|
||||
@@ -72,7 +94,7 @@ class Auth {
|
||||
}
|
||||
|
||||
generateAccessToken(payload) {
|
||||
return jwt.sign(payload, process.env.TOKEN_SECRET);
|
||||
return jwt.sign(payload, global.ServerSettings.tokenSecret);
|
||||
}
|
||||
|
||||
authenticateUser(token) {
|
||||
@@ -81,12 +103,12 @@ class Auth {
|
||||
|
||||
verifyToken(token) {
|
||||
return new Promise((resolve) => {
|
||||
jwt.verify(token, process.env.TOKEN_SECRET, (err, payload) => {
|
||||
jwt.verify(token, global.ServerSettings.tokenSecret, (err, payload) => {
|
||||
if (!payload || err) {
|
||||
Logger.error('JWT Verify Token Failed', err)
|
||||
return resolve(null)
|
||||
}
|
||||
var user = this.users.find(u => u.id === payload.userId)
|
||||
var user = this.users.find(u => u.id === payload.userId && u.username === payload.username)
|
||||
resolve(user || null)
|
||||
})
|
||||
})
|
||||
@@ -96,7 +118,7 @@ class Auth {
|
||||
return {
|
||||
user: user.toJSONForBrowser(),
|
||||
userDefaultLibraryId: user.getDefaultLibraryId(this.db.libraries),
|
||||
serverSettings: this.db.serverSettings.toJSON(),
|
||||
serverSettings: this.db.serverSettings.toJSONForBrowser(),
|
||||
Source: global.Source
|
||||
}
|
||||
}
|
||||
|
||||
40
server/Db.js
40
server/Db.js
@@ -1,6 +1,5 @@
|
||||
const Path = require('path')
|
||||
const njodb = require('./njodb')
|
||||
const jwt = require('jsonwebtoken')
|
||||
const njodb = require('./libs/njodb')
|
||||
const Logger = require('./Logger')
|
||||
const { version } = require('../package.json')
|
||||
const LibraryItem = require('./objects/LibraryItem')
|
||||
@@ -22,6 +21,7 @@ class Db {
|
||||
this.CollectionsPath = Path.join(global.ConfigPath, 'collections')
|
||||
this.AuthorsPath = Path.join(global.ConfigPath, 'authors')
|
||||
this.SeriesPath = Path.join(global.ConfigPath, 'series')
|
||||
this.FeedsPath = Path.join(global.ConfigPath, 'feeds')
|
||||
|
||||
this.libraryItemsDb = new njodb.Database(this.LibraryItemsPath)
|
||||
this.usersDb = new njodb.Database(this.UsersPath)
|
||||
@@ -31,6 +31,7 @@ class Db {
|
||||
this.collectionsDb = new njodb.Database(this.CollectionsPath, { datastores: 2 })
|
||||
this.authorsDb = new njodb.Database(this.AuthorsPath)
|
||||
this.seriesDb = new njodb.Database(this.SeriesPath, { datastores: 2 })
|
||||
this.feedsDb = new njodb.Database(this.FeedsPath, { datastores: 2 })
|
||||
|
||||
this.libraryItems = []
|
||||
this.users = []
|
||||
@@ -59,6 +60,7 @@ class Db {
|
||||
else if (entityName === 'collection') return this.collectionsDb
|
||||
else if (entityName === 'author') return this.authorsDb
|
||||
else if (entityName === 'series') return this.seriesDb
|
||||
else if (entityName === 'feed') return this.feedsDb
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -71,6 +73,7 @@ class Db {
|
||||
else if (entityName === 'collection') return 'collections'
|
||||
else if (entityName === 'author') return 'authors'
|
||||
else if (entityName === 'series') return 'series'
|
||||
else if (entityName === 'feed') return 'feeds'
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -83,6 +86,7 @@ class Db {
|
||||
this.collectionsDb = new njodb.Database(this.CollectionsPath, { datastores: 2 })
|
||||
this.authorsDb = new njodb.Database(this.AuthorsPath)
|
||||
this.seriesDb = new njodb.Database(this.SeriesPath, { datastores: 2 })
|
||||
this.feedsDb = new njodb.Database(this.FeedsPath, { datastores: 2 })
|
||||
return this.init()
|
||||
}
|
||||
|
||||
@@ -116,21 +120,6 @@ class Db {
|
||||
async init() {
|
||||
await this.load()
|
||||
|
||||
// Insert Defaults
|
||||
// var rootUser = this.users.find(u => u.type === 'root')
|
||||
// if (!rootUser) {
|
||||
// var token = await jwt.sign({ userId: 'root' }, process.env.TOKEN_SECRET)
|
||||
// Logger.debug('Generated default token', token)
|
||||
// Logger.info('[Db] Root user created')
|
||||
// await this.insertEntity('user', this.getDefaultUser(token))
|
||||
// } else {
|
||||
// Logger.info(`[Db] Root user exists, pw: ${rootUser.hasPw}`)
|
||||
// }
|
||||
|
||||
// if (!this.libraries.length) {
|
||||
// await this.insertEntity('library', this.getDefaultLibrary())
|
||||
// }
|
||||
|
||||
if (!this.serverSettings) {
|
||||
this.serverSettings = new ServerSettings()
|
||||
await this.insertEntity('settings', this.serverSettings)
|
||||
@@ -278,6 +267,14 @@ class Db {
|
||||
return this.updateEntity('settings', this.serverSettings)
|
||||
}
|
||||
|
||||
getAllEntities(entityName) {
|
||||
var entityDb = this.getEntityDb(entityName)
|
||||
return entityDb.select(() => true).then((results) => results.data).catch((error) => {
|
||||
Logger.error(`[DB] Failed to get all ${entityName}`, error)
|
||||
return null
|
||||
})
|
||||
}
|
||||
|
||||
insertEntities(entityName, entities) {
|
||||
var entityDb = this.getEntityDb(entityName)
|
||||
return entityDb.insert(entities).then((results) => {
|
||||
@@ -428,6 +425,15 @@ class Db {
|
||||
})
|
||||
}
|
||||
|
||||
getAllSessions() {
|
||||
return this.sessionsDb.select(() => true).then((results) => {
|
||||
return results.data || []
|
||||
}).catch((error) => {
|
||||
Logger.error('[Db] Failed to select sessions', error)
|
||||
return []
|
||||
})
|
||||
}
|
||||
|
||||
selectUserSessions(userId) {
|
||||
return this.sessionsDb.select((session) => session.userId === userId).then((results) => {
|
||||
return results.data || []
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
const date = require('./libs/dateAndTime')
|
||||
const { LogLevel } = require('./utils/constants')
|
||||
|
||||
class Logger {
|
||||
constructor() {
|
||||
this.logLevel = process.env.NODE_ENV === 'production' ? LogLevel.INFO : LogLevel.TRACE
|
||||
// this.logFileLevel = LogLevel.INFO
|
||||
this.socketListeners = []
|
||||
|
||||
this.logManager = null
|
||||
}
|
||||
|
||||
get timestamp() {
|
||||
return (new Date()).toISOString()
|
||||
return date.format(new Date(), 'YYYY-MM-DD HH:mm:ss')
|
||||
}
|
||||
|
||||
get levelString() {
|
||||
|
||||
@@ -2,9 +2,9 @@ const Path = require('path')
|
||||
const express = require('express')
|
||||
const http = require('http')
|
||||
const SocketIO = require('socket.io')
|
||||
const fs = require('fs-extra')
|
||||
const fileUpload = require('express-fileupload')
|
||||
const rateLimit = require('express-rate-limit')
|
||||
const fs = require('./libs/fsExtra')
|
||||
const fileUpload = require('./libs/expressFileupload')
|
||||
const rateLimit = require('./libs/expressRateLimit')
|
||||
|
||||
const { version } = require('../package.json')
|
||||
|
||||
@@ -136,12 +136,19 @@ class Server {
|
||||
await this.db.init()
|
||||
}
|
||||
|
||||
// Create token secret if does not exist (Added v2.1.0)
|
||||
if (!this.db.serverSettings.tokenSecret) {
|
||||
await this.auth.initTokenSecret()
|
||||
}
|
||||
|
||||
await this.checkUserMediaProgress() // Remove invalid user item progress
|
||||
await this.purgeMetadata() // Remove metadata folders without library item
|
||||
await this.cacheManager.ensureCachePaths()
|
||||
await this.abMergeManager.ensureDownloadDirPath()
|
||||
|
||||
await this.backupManager.init()
|
||||
await this.logManager.init()
|
||||
await this.rssFeedManager.init()
|
||||
this.podcastManager.init()
|
||||
|
||||
if (this.db.serverSettings.scannerDisableWatcher) {
|
||||
@@ -194,14 +201,14 @@ class Server {
|
||||
|
||||
// RSS Feed temp route
|
||||
app.get('/feed/:id', (req, res) => {
|
||||
Logger.info(`[Server] requesting rss feed ${req.params.id}`)
|
||||
Logger.info(`[Server] Requesting rss feed ${req.params.id}`)
|
||||
this.rssFeedManager.getFeed(req, res)
|
||||
})
|
||||
app.get('/feed/:id/cover', (req, res) => {
|
||||
this.rssFeedManager.getFeedCover(req, res)
|
||||
})
|
||||
app.get('/feed/:id/item/*', (req, res) => {
|
||||
Logger.info(`[Server] requesting rss feed ${req.params.id}`)
|
||||
app.get('/feed/:id/item/:episodeId/*', (req, res) => {
|
||||
Logger.debug(`[Server] Requesting rss feed episode ${req.params.id}/${req.params.episodeId}`)
|
||||
this.rssFeedManager.getFeedItem(req, res)
|
||||
})
|
||||
|
||||
@@ -209,6 +216,7 @@ class Server {
|
||||
const dyanimicRoutes = [
|
||||
'/item/:id',
|
||||
'/item/:id/manage',
|
||||
'/author/:id',
|
||||
'/audiobook/:id/chapters',
|
||||
'/audiobook/:id/edit',
|
||||
'/library/:library',
|
||||
@@ -275,6 +283,7 @@ class Server {
|
||||
|
||||
// Logs
|
||||
socket.on('set_log_listener', (level) => Logger.addSocketListener(socket, level))
|
||||
socket.on('remove_log_listener', () => Logger.removeSocketListener(socket.id))
|
||||
socket.on('fetch_daily_logs', () => this.logManager.socketRequestDailyLogs(socket))
|
||||
|
||||
socket.on('ping', () => {
|
||||
@@ -284,21 +293,21 @@ class Server {
|
||||
socket.emit('pong')
|
||||
})
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
socket.on('disconnect', (reason) => {
|
||||
Logger.removeSocketListener(socket.id)
|
||||
|
||||
var _client = this.clients[socket.id]
|
||||
if (!_client) {
|
||||
Logger.warn('[Server] Socket disconnect, no client ' + socket.id)
|
||||
Logger.warn(`[Server] Socket ${socket.id} disconnect, no client (Reason: ${reason})`)
|
||||
} else if (!_client.user) {
|
||||
Logger.info('[Server] Unauth socket disconnected ' + socket.id)
|
||||
Logger.info(`[Server] Unauth socket ${socket.id} disconnected (Reason: ${reason})`)
|
||||
delete this.clients[socket.id]
|
||||
} else {
|
||||
Logger.debug('[Server] User Offline ' + _client.user.username)
|
||||
this.io.emit('user_offline', _client.user.toJSONForPublic(this.playbackSessionManager.sessions, this.db.libraryItems))
|
||||
|
||||
const disconnectTime = Date.now() - _client.connected_at
|
||||
Logger.info(`[Server] Socket ${socket.id} disconnected from client "${_client.user.username}" after ${disconnectTime}ms`)
|
||||
Logger.info(`[Server] Socket ${socket.id} disconnected from client "${_client.user.username}" after ${disconnectTime}ms (Reason: ${reason})`)
|
||||
delete this.clients[socket.id]
|
||||
}
|
||||
})
|
||||
@@ -310,7 +319,7 @@ class Server {
|
||||
const newRoot = req.body.newRoot
|
||||
let rootPash = newRoot.password ? await this.auth.hashPass(newRoot.password) : ''
|
||||
if (!rootPash) Logger.warn(`[Server] Creating root user with no password`)
|
||||
let rootToken = await this.auth.generateAccessToken({ userId: 'root' })
|
||||
let rootToken = await this.auth.generateAccessToken({ userId: 'root', username: newRoot.username })
|
||||
await this.db.createRootUser(newRoot.username, rootPash, rootToken)
|
||||
|
||||
res.sendStatus(200)
|
||||
@@ -448,14 +457,13 @@ class Server {
|
||||
} else {
|
||||
Logger.debug(`[Server] User Online ${client.user.username}`)
|
||||
}
|
||||
|
||||
this.io.emit('user_online', client.user.toJSONForPublic(this.playbackSessionManager.sessions, this.db.libraryItems))
|
||||
|
||||
user.lastSeen = Date.now()
|
||||
await this.db.updateEntity('user', user)
|
||||
|
||||
const initialPayload = {
|
||||
// TODO: this is sent with user auth now, update mobile app to use that then remove this
|
||||
serverSettings: this.db.serverSettings.toJSON(),
|
||||
metadataPath: global.MetadataPath,
|
||||
configPath: global.ConfigPath,
|
||||
user: client.user.toJSONForBrowser(),
|
||||
@@ -467,11 +475,6 @@ class Server {
|
||||
initialPayload.usersOnline = this.usersOnline
|
||||
}
|
||||
client.socket.emit('init', initialPayload)
|
||||
|
||||
// Setup log listener for root user
|
||||
if (user.type === 'root') {
|
||||
Logger.addSocketListener(socket, this.db.serverSettings.logLevel || 0)
|
||||
}
|
||||
}
|
||||
|
||||
async stop() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const Logger = require('../Logger')
|
||||
const { reqSupportsWebp } = require('../utils/index')
|
||||
const { createNewSortInstance } = require('fast-sort')
|
||||
const { createNewSortInstance } = require('../libs/fastSort')
|
||||
|
||||
const naturalSort = createNewSortInstance({
|
||||
comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare
|
||||
@@ -63,15 +63,27 @@ class AuthorController {
|
||||
// If updating or removing cover image then clear cache
|
||||
if (payload.imagePath !== undefined && req.author.imagePath && payload.imagePath !== req.author.imagePath) {
|
||||
this.cacheManager.purgeImageCache(req.author.id)
|
||||
|
||||
if (!payload.imagePath) { // If removing image then remove file
|
||||
var currentImagePath = req.author.imagePath
|
||||
await this.coverManager.removeFile(currentImagePath)
|
||||
} else if (payload.imagePath.startsWith('http')) { // Check if image path is a url
|
||||
var imageData = await this.authorFinder.saveAuthorImage(req.author.id, payload.imagePath)
|
||||
if (imageData) {
|
||||
req.author.imagePath = imageData.path
|
||||
req.author.relImagePath = imageData.relPath
|
||||
hasUpdated = hasUpdated || true;
|
||||
} else {
|
||||
req.author.imagePath = null
|
||||
req.author.relImagePath = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var authorNameUpdate = payload.name !== undefined && payload.name !== req.author.name
|
||||
|
||||
var hasUpdated = req.author.update(payload)
|
||||
|
||||
if (hasUpdated) {
|
||||
if (authorNameUpdate) { // Update author name on all books
|
||||
var itemsWithAuthor = this.db.libraryItems.filter(li => li.mediaType === 'book' && li.media.metadata.hasAuthor(req.author.id))
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user