mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-01-06 06:31:19 -05:00
Compare commits
142 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f702c02859 | ||
|
|
ad88de0571 | ||
|
|
b64a651b27 | ||
|
|
06b8d1194c | ||
|
|
377ae7ab19 | ||
|
|
53cf6edd6a | ||
|
|
92bedeac15 | ||
|
|
3cf8b9dca9 | ||
|
|
bcc2f847f9 | ||
|
|
f1421f351b | ||
|
|
ed23feaf3f | ||
|
|
668ebf8550 | ||
|
|
a8c7905f6d | ||
|
|
45cd39ac0c | ||
|
|
21e1f62c65 | ||
|
|
8416f2d6be | ||
|
|
3b4ac3a230 | ||
|
|
6244909332 | ||
|
|
5db949e4a7 | ||
|
|
c453d3e8c7 | ||
|
|
9d7ffdfcd0 | ||
|
|
976427b0b3 | ||
|
|
6cbfd8679b | ||
|
|
217bbb4a8e | ||
|
|
9916a1e8f6 | ||
|
|
372101592c | ||
|
|
18123664ee | ||
|
|
2e6e4f970c | ||
|
|
1c9e56ce2e | ||
|
|
9e7b84f289 | ||
|
|
7b83ab8970 | ||
|
|
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 |
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
|
||||
|
||||
@@ -25,5 +25,5 @@ HEALTHCHECK \
|
||||
--interval=30s \
|
||||
--timeout=3s \
|
||||
--start-period=10s \
|
||||
CMD curl -f http://127.0.0.1/ping || exit 1
|
||||
CMD curl -f http://127.0.0.1/healthcheck || 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;
|
||||
}
|
||||
@@ -3,14 +3,14 @@
|
||||
<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-8 h-8 mr-8 sm:w-12 sm:h-12 sm:mr-4" />
|
||||
<img src="/icon.svg" class="w-10 min-w-10 h-10 mr-2 sm:w-12 sm:min-w-12 sm:h-12 sm:mr-4" />
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link to="/">
|
||||
<h1 class="text-2xl font-book mr-6 hidden lg:block hover:underline">audiobookshelf</h1>
|
||||
</nuxt-link>
|
||||
|
||||
<ui-libraries-dropdown />
|
||||
<ui-libraries-dropdown class="mr-2" />
|
||||
|
||||
<controls-global-search v-if="currentLibrary" class="" />
|
||||
<div class="flex-grow" />
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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() {
|
||||
@@ -66,7 +70,7 @@ export default {
|
||||
},
|
||||
{
|
||||
id: 'config-sessions',
|
||||
title: 'Sessions',
|
||||
title: 'Listening Sessions',
|
||||
path: '/config/sessions'
|
||||
},
|
||||
{
|
||||
@@ -76,7 +80,7 @@ export default {
|
||||
},
|
||||
{
|
||||
id: 'config-log',
|
||||
title: 'Log',
|
||||
title: 'Logs',
|
||||
path: '/config/log'
|
||||
}
|
||||
]
|
||||
@@ -129,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>
|
||||
@@ -75,17 +75,21 @@
|
||||
</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="font-mono text-xs text-center text-gray-300 leading-3 mb-1">v{{ $config.version }}</p>
|
||||
<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() {
|
||||
@@ -150,17 +154,21 @@ export default {
|
||||
hasUpdate() {
|
||||
return !!this.versionData.hasUpdate
|
||||
},
|
||||
latestVersion() {
|
||||
return this.versionData.latestVersion
|
||||
},
|
||||
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,30 +1,30 @@
|
||||
<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">
|
||||
<div id="videoDock" />
|
||||
<nuxt-link v-if="!playerHandler.isVideo" :to="`/item/${streamLibraryItem.id}`" class="absolute left-4 cursor-pointer" :style="{ top: bookCoverPosTop + 'px' }">
|
||||
<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 mb-6 md:mb-0" :class="playerHandler.isVideo ? 'ml-4 pl-96' : 'pl-24'">
|
||||
<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 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>
|
||||
<player-ui
|
||||
ref="audioPlayer"
|
||||
@@ -364,7 +364,11 @@ export default {
|
||||
var episodeId = payload.episodeId || null
|
||||
|
||||
if (this.playerHandler.libraryItemId == libraryItemId && this.playerHandler.episodeId == episodeId) {
|
||||
this.playerHandler.play()
|
||||
if (payload.startTime !== null && !isNaN(payload.startTime)) {
|
||||
this.seek(payload.startTime)
|
||||
} else {
|
||||
this.playerHandler.play()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -377,7 +381,11 @@ export default {
|
||||
libraryItem,
|
||||
episodeId
|
||||
})
|
||||
this.playerHandler.load(libraryItem, episodeId, true, this.initialPlaybackRate)
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.audioPlayer) this.$refs.audioPlayer.checkUpdateChapterTrack()
|
||||
})
|
||||
|
||||
this.playerHandler.load(libraryItem, episodeId, true, this.initialPlaybackRate, payload.startTime)
|
||||
},
|
||||
pauseItem() {
|
||||
this.playerHandler.pause()
|
||||
@@ -389,11 +397,13 @@ export default {
|
||||
},
|
||||
mounted() {
|
||||
this.$eventBus.$on('cast-session-active', this.castSessionActive)
|
||||
this.$eventBus.$on('playback-seek', this.seek)
|
||||
this.$eventBus.$on('play-item', this.playLibraryItem)
|
||||
this.$eventBus.$on('pause-item', this.pauseItem)
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$eventBus.$off('cast-session-active', this.castSessionActive)
|
||||
this.$eventBus.$off('playback-seek', this.seek)
|
||||
this.$eventBus.$off('play-item', this.playLibraryItem)
|
||||
this.$eventBus.$off('pause-item', this.pauseItem)
|
||||
}
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -248,13 +248,15 @@ export default {
|
||||
return this.mediaMetadata.authorNameLF
|
||||
},
|
||||
displayTitle() {
|
||||
if (this.orderBy === 'media.metadata.title' && this.sortingIgnorePrefix) {
|
||||
return this.mediaMetadata.titleIgnorePrefix
|
||||
}
|
||||
return this.title
|
||||
if (this.recentEpisode) return this.recentEpisode.title
|
||||
const ignorePrefix = this.orderBy === 'media.metadata.title' && this.sortingIgnorePrefix
|
||||
if (this.collapsedSeries) return ignorePrefix ? this.collapsedSeries.nameIgnorePrefix : this.collapsedSeries.name
|
||||
return ignorePrefix ? this.mediaMetadata.titleIgnorePrefix : 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 || ''
|
||||
}
|
||||
@@ -262,6 +264,7 @@ export default {
|
||||
return this.author
|
||||
},
|
||||
displaySortLine() {
|
||||
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)
|
||||
@@ -497,7 +500,21 @@ export default {
|
||||
}
|
||||
this.$emit('edit', this.libraryItem)
|
||||
},
|
||||
toggleFinished() {
|
||||
toggleFinished(confirmed = false) {
|
||||
if (!this.itemIsFinished && this.userProgressPercent > 0 && !confirmed) {
|
||||
const payload = {
|
||||
message: `Are you sure you want to mark "${this.displayTitle}" as finished?`,
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.toggleFinished(true)
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.store.commit('globals/setConfirmPrompt', payload)
|
||||
return
|
||||
}
|
||||
|
||||
var updatePayload = {
|
||||
isFinished: !this.itemIsFinished
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div ref="card" :id="`series-card-${index}`" :style="{ width: width + 'px', height: height + 'px' }" class="rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
|
||||
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
|
||||
<div class="w-full h-full bg-primary relative rounded overflow-hidden z-0">
|
||||
<covers-group-cover v-if="series" ref="cover" :id="seriesId" :name="title" :book-items="books" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" :group-to="seriesBooksRoute" />
|
||||
<covers-group-cover v-if="series" ref="cover" :id="seriesId" :name="displayTitle" :book-items="books" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" :group-to="seriesBooksRoute" />
|
||||
</div>
|
||||
|
||||
<div class="absolute z-10 top-1.5 right-1.5 rounded-md leading-3 text-sm p-1 font-semibold text-white flex items-center justify-center" style="background-color: #cd9d49dd">{{ books.length }}</div>
|
||||
@@ -10,16 +10,16 @@
|
||||
<div v-if="isSeriesFinished" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b bg-success w-full" />
|
||||
|
||||
<div v-if="hasValidCovers" class="bg-black bg-opacity-60 absolute top-0 left-0 w-full h-full flex items-center justify-center text-center transition-opacity" :class="isHovering ? '' : 'opacity-0'" :style="{ padding: `${sizeMultiplier}rem` }">
|
||||
<p class="font-book" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">{{ title }}</p>
|
||||
<p class="font-book" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">{{ displayTitle }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(160, width) + 'px' }">
|
||||
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
|
||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
|
||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ displayTitle }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="absolute z-30 left-0 right-0 mx-auto -bottom-8 h-8 py-1 rounded-md text-center">
|
||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
|
||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ displayTitle }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -39,7 +39,8 @@ export default {
|
||||
seriesMount: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
}
|
||||
},
|
||||
sortingIgnorePrefix: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -65,6 +66,13 @@ export default {
|
||||
title() {
|
||||
return this.series ? this.series.name : ''
|
||||
},
|
||||
nameIgnorePrefix() {
|
||||
return this.series ? this.series.nameIgnorePrefix : ''
|
||||
},
|
||||
displayTitle() {
|
||||
if (this.sortingIgnorePrefix) return this.nameIgnorePrefix || this.title
|
||||
return this.title
|
||||
},
|
||||
books() {
|
||||
return this.series ? this.series.books || [] : []
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="sm:w-80 w-full sm:ml-6 relative">
|
||||
<div class="sm:w-80 w-full 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>
|
||||
|
||||
@@ -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,6 +1,11 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" name="bookmarks" :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">
|
||||
<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">Your Bookmarks</p>
|
||||
</div>
|
||||
</template>
|
||||
<div ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
||||
<div v-if="show" class="w-full h-full">
|
||||
<template v-for="bookmark in bookmarks">
|
||||
<modals-bookmarks-bookmark-item :key="bookmark.id" :highlight="currentTime === bookmark.time" :bookmark="bookmark" @click="clickBookmark" @update="submitUpdateBookmark" @delete="deleteBookmark" />
|
||||
@@ -8,8 +13,8 @@
|
||||
<div v-if="!bookmarks.length" class="flex h-32 items-center justify-center">
|
||||
<p class="text-xl">No Bookmarks</p>
|
||||
</div>
|
||||
<div class="w-full h-px bg-white bg-opacity-10" />
|
||||
<form @submit.prevent="submitCreateBookmark">
|
||||
<div v-if="!hideCreate" class="w-full h-px bg-white bg-opacity-10" />
|
||||
<form v-if="!hideCreate" @submit.prevent="submitCreateBookmark">
|
||||
<div v-show="canCreateBookmark" class="flex px-4 py-2 items-center text-center border-b border-white border-opacity-10 text-white text-opacity-80">
|
||||
<div class="w-16 max-w-16 text-center">
|
||||
<p class="text-sm font-mono text-gray-400">
|
||||
@@ -39,7 +44,8 @@ export default {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
libraryItemId: String
|
||||
libraryItemId: String,
|
||||
hideCreate: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
||||
@@ -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>
|
||||
@@ -89,7 +89,6 @@ export default {
|
||||
setTimeout(() => {
|
||||
this.content.style.transform = 'scale(1)'
|
||||
}, 10)
|
||||
document.documentElement.classList.add('modal-open')
|
||||
|
||||
this.$store.commit('setInnerModalOpen', true)
|
||||
this.$eventBus.$on('modal-hotkey', this.hotkey)
|
||||
@@ -97,7 +96,6 @@ export default {
|
||||
setHide() {
|
||||
if (this.content) this.content.style.transform = 'scale(0)'
|
||||
if (this.el) this.el.remove()
|
||||
document.documentElement.classList.remove('modal-open')
|
||||
|
||||
this.$store.commit('setInnerModalOpen', false)
|
||||
this.$eventBus.$off('modal-hotkey', this.hotkey)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
@@ -50,7 +50,8 @@ export default {
|
||||
return {
|
||||
el: null,
|
||||
content: null,
|
||||
preventClickoutside: false
|
||||
preventClickoutside: false,
|
||||
isShowingPrompt: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -93,7 +94,7 @@ export default {
|
||||
this.show = false
|
||||
},
|
||||
clickBg(ev) {
|
||||
if (!this.show) return
|
||||
if (!this.show || this.isShowingPrompt) return
|
||||
if (this.preventClickoutside) {
|
||||
this.preventClickoutside = false
|
||||
return
|
||||
@@ -147,8 +148,16 @@ export default {
|
||||
} else {
|
||||
console.warn('Invalid modal init', this.name)
|
||||
}
|
||||
},
|
||||
showingPrompt(isShowing) {
|
||||
this.isShowingPrompt = isShowing
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
mounted() {
|
||||
this.$eventBus.$on('showing-prompt', this.showingPrompt)
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$eventBus.$off('showing-prompt', this.showingPrompt)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -116,6 +116,9 @@ export default {
|
||||
if (result.updated) {
|
||||
this.$toast.success('Author updated')
|
||||
this.show = false
|
||||
} else if (result.merged) {
|
||||
this.$toast.success('Author merged')
|
||||
this.show = false
|
||||
} else this.$toast.info('No updates were needed')
|
||||
}
|
||||
this.processing = false
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<template>
|
||||
<div class="flex items-center px-4 py-4 justify-start relative hover:bg-bg" :class="wrapperClass" @click="click" @mouseover="mouseover" @mouseleave="mouseleave">
|
||||
<!-- <span class="material-icons" :class="highlight ? 'text-success' : 'text-white text-opacity-80'">{{ highlight ? 'bookmark' : 'bookmark_border' }}</span> -->
|
||||
<div class="flex items-center px-4 py-4 justify-start relative bg-primary hover:bg-opacity-25" :class="wrapperClass" @click.stop="click" @mouseover="mouseover" @mouseleave="mouseleave">
|
||||
<div class="w-16 max-w-16 text-center">
|
||||
<p class="text-sm font-mono text-gray-400">
|
||||
{{ this.$secondsToTimestamp(bookmark.time) }}
|
||||
|
||||
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)
|
||||
@@ -189,7 +190,6 @@ export default {
|
||||
if (prevBook) {
|
||||
this.unregisterListeners()
|
||||
this.libraryItem = prevBook
|
||||
this.selectedTab = 'details'
|
||||
this.$store.commit('setSelectedLibraryItem', prevBook)
|
||||
this.$nextTick(this.registerListeners)
|
||||
} else {
|
||||
@@ -209,7 +209,6 @@ export default {
|
||||
if (nextBook) {
|
||||
this.unregisterListeners()
|
||||
this.libraryItem = nextBook
|
||||
this.selectedTab = 'details'
|
||||
this.$store.commit('setSelectedLibraryItem', nextBook)
|
||||
this.$nextTick(this.registerListeners)
|
||||
} else {
|
||||
@@ -246,15 +245,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>
|
||||
|
||||
@@ -3,21 +3,21 @@
|
||||
<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-3xl">first_page</span>
|
||||
<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-3xl">replay_10</span>
|
||||
<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-3xl">forward_10</span>
|
||||
<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-3xl">last_page</span>
|
||||
<span class="material-icons text-2xl sm:text-3xl">last_page</span>
|
||||
</div>
|
||||
<controls-playback-speed-control v-model="playbackRate" @input="playbackRateUpdated" @change="playbackRateChanged" />
|
||||
<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">
|
||||
@@ -40,7 +40,16 @@ export default {
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {},
|
||||
computed: {
|
||||
playbackRateInput: {
|
||||
get() {
|
||||
return this.playbackRate
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('update:playbackRate', val)
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
playPause() {
|
||||
this.$emit('playPause')
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<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">
|
||||
<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>
|
||||
@@ -34,6 +34,10 @@ export default {
|
||||
chapters: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
currentChapter: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
@@ -46,7 +50,8 @@ export default {
|
||||
trackOffsetLeft: 16, // Track is 16px from edge
|
||||
playedTrackWidth: 0,
|
||||
readyTrackWidth: 0,
|
||||
bufferTrackWidth: 0
|
||||
bufferTrackWidth: 0,
|
||||
useChapterTrack: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -57,14 +62,30 @@ export default {
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {},
|
||||
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
|
||||
var time = perc * this.duration
|
||||
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
|
||||
@@ -76,7 +97,10 @@ export default {
|
||||
this.updateBufferTrack()
|
||||
},
|
||||
updateBufferTrack() {
|
||||
var bufferlen = (this.bufferTime / this.duration) * this.trackWidth
|
||||
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'
|
||||
@@ -97,8 +121,10 @@ export default {
|
||||
this.updatePlayedTrackWidth()
|
||||
},
|
||||
updatePlayedTrackWidth() {
|
||||
var perc = this.currentTime / this.duration
|
||||
var ptWidth = Math.round(perc * this.trackWidth)
|
||||
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
|
||||
}
|
||||
@@ -116,9 +142,11 @@ export default {
|
||||
},
|
||||
mousemoveTrack(e) {
|
||||
var offsetX = e.offsetX
|
||||
var time = (offsetX / this.trackWidth) * this.duration
|
||||
|
||||
console.log('Mousemove track', this.trackWidth, this.duration)
|
||||
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
|
||||
@@ -139,9 +167,9 @@ export default {
|
||||
this.$refs.hoverTimestampArrow.style.left = posLeft + 'px'
|
||||
}
|
||||
if (this.$refs.hoverTimestampText) {
|
||||
var hoverText = this.$secondsToTimestamp(time)
|
||||
var hoverText = this.$secondsToTimestamp(progressTime)
|
||||
|
||||
var chapter = this.chapters.find((chapter) => chapter.start <= time && time < chapter.end)
|
||||
var chapter = this.chapters.find((chapter) => chapter.start <= totalTime && totalTime < chapter.end)
|
||||
if (chapter && chapter.title) {
|
||||
hoverText += ` - ${chapter.title}`
|
||||
}
|
||||
|
||||
@@ -1,40 +1,48 @@
|
||||
<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">
|
||||
<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 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 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 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 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 mx-1 md:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="showChapters">
|
||||
<span class="material-icons text-3xl">format_list_bulleted</span>
|
||||
<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="playbackRate" :paused="paused" :has-next-chapter="hasNextChapter" @prevChapter="prevChapter" @nextChapter="nextChapter" @jumpForward="jumpForward" @jumpBackward="jumpBackward" @setPlaybackRate="setPlaybackRate" @playPause="playPause" />
|
||||
<player-playback-controls :loading="loading" :seek-loading="seekLoading" :playback-rate.sync="playbackRate" :paused="paused" :has-next-chapter="hasNextChapter" @prevChapter="prevChapter" @nextChapter="nextChapter" @jumpForward="jumpForward" @jumpBackward="jumpBackward" @setPlaybackRate="setPlaybackRate" @playPause="playPause" />
|
||||
</div>
|
||||
|
||||
<player-track-bar ref="trackbar" :loading="loading" :chapters="chapters" :duration="duration" @seek="seek" />
|
||||
<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-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>
|
||||
<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-sm text-gray-300 pt-0.5">{{ currentChapterName }}</p>
|
||||
<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-sm text-gray-100 pointer-events-auto">{{ timeRemainingPretty }}</p>
|
||||
<p class="font-mono text-xxs sm:text-sm text-gray-100 pointer-events-auto">{{ timeRemainingPretty }}</p>
|
||||
</div>
|
||||
|
||||
<modals-chapters-modal v-model="showChaptersModal" :current-chapter="currentChapter" :chapters="chapters" @select="selectChapter" />
|
||||
@@ -66,7 +74,8 @@ export default {
|
||||
seekLoading: false,
|
||||
showChaptersModal: false,
|
||||
currentTime: 0,
|
||||
duration: 0
|
||||
duration: 0,
|
||||
useChapterTrack: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -86,6 +95,10 @@ export default {
|
||||
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() {
|
||||
@@ -95,8 +108,11 @@ export default {
|
||||
return '-' + this.$secondsToTimestamp(this.timeRemaining)
|
||||
},
|
||||
progressPercent() {
|
||||
if (!this.duration) return 0
|
||||
return Math.round((100 * this.currentTime) / this.duration)
|
||||
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)
|
||||
@@ -104,6 +120,14 @@ export default {
|
||||
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
|
||||
},
|
||||
@@ -192,6 +216,23 @@ export default {
|
||||
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)
|
||||
},
|
||||
@@ -239,10 +280,10 @@ export default {
|
||||
console.error('No timestamp el')
|
||||
return
|
||||
}
|
||||
var currTimeClean = this.$secondsToTimestamp(this.currentTime)
|
||||
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)
|
||||
},
|
||||
@@ -252,6 +293,11 @@ export default {
|
||||
},
|
||||
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) {
|
||||
|
||||
117
client/components/prompt/Confirm.vue
Normal file
117
client/components/prompt/Confirm.vue
Normal file
@@ -0,0 +1,117 @@
|
||||
<template>
|
||||
<div ref="wrapper" class="modal modal-bg w-full h-full fixed top-0 left-0 bg-primary bg-opacity-75 flex items-center justify-center z-60 opacity-0">
|
||||
<div class="absolute top-0 left-0 right-0 w-full h-36 bg-gradient-to-t from-transparent via-black-500 to-black-700 opacity-90 pointer-events-none" />
|
||||
<div ref="content" class="relative text-white" :style="{ height: modalHeight, width: modalWidth }" v-click-outside="clickedOutside">
|
||||
<div class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300">
|
||||
<p class="text-base mb-8 mt-2 px-1">{{ message }}</p>
|
||||
<div class="flex px-1 items-center">
|
||||
<ui-btn v-if="isYesNo" color="primary" @click="nevermind">Cancel</ui-btn>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn v-if="isYesNo" color="success" @click="confirm">Yes</ui-btn>
|
||||
<ui-btn v-else color="primary" @click="confirm">Ok</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {},
|
||||
data() {
|
||||
return {
|
||||
el: null,
|
||||
content: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
show(newVal) {
|
||||
if (newVal) {
|
||||
this.setShow()
|
||||
} else {
|
||||
this.setHide()
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.$store.state.globals.showConfirmPrompt
|
||||
},
|
||||
set(val) {
|
||||
this.$store.commit('globals/setShowConfirmPrompt', val)
|
||||
}
|
||||
},
|
||||
confirmPromptOptions() {
|
||||
return this.$store.state.globals.confirmPromptOptions || {}
|
||||
},
|
||||
message() {
|
||||
return this.confirmPromptOptions.message || ''
|
||||
},
|
||||
callback() {
|
||||
return this.confirmPromptOptions.callback
|
||||
},
|
||||
type() {
|
||||
return this.confirmPromptOptions.type || 'ok'
|
||||
},
|
||||
persistent() {
|
||||
return !!this.confirmPromptOptions.persistent
|
||||
},
|
||||
isYesNo() {
|
||||
return this.type === 'yesNo'
|
||||
},
|
||||
modalHeight() {
|
||||
return 'unset'
|
||||
},
|
||||
modalWidth() {
|
||||
return '500px'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickedOutside(evt) {
|
||||
if (!this.show) return
|
||||
if (evt) {
|
||||
evt.stopPropagation()
|
||||
evt.preventDefault()
|
||||
}
|
||||
|
||||
if (this.persistent) return
|
||||
if (this.callback) this.callback(false)
|
||||
this.show = false
|
||||
},
|
||||
nevermind() {
|
||||
if (this.callback) this.callback(false)
|
||||
this.show = false
|
||||
},
|
||||
confirm() {
|
||||
if (this.callback) this.callback(true)
|
||||
this.show = false
|
||||
},
|
||||
setShow() {
|
||||
this.$eventBus.$emit('showing-prompt', true)
|
||||
document.body.appendChild(this.el)
|
||||
setTimeout(() => {
|
||||
this.content.style.transform = 'scale(1)'
|
||||
}, 10)
|
||||
},
|
||||
setHide() {
|
||||
this.$eventBus.$emit('showing-prompt', false)
|
||||
this.content.style.transform = 'scale(0)'
|
||||
this.el.remove()
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.el = this.$refs.wrapper
|
||||
this.content = this.$refs.content
|
||||
this.content.style.transform = 'scale(0)'
|
||||
this.content.style.transition = 'transform 0.25s cubic-bezier(0.16, 1, 0.3, 1)'
|
||||
this.el.style.opacity = 1
|
||||
this.el.remove()
|
||||
},
|
||||
beforeDestroy() {
|
||||
if (this.show) {
|
||||
this.$eventBus.$emit('showing-prompt', false)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -65,12 +65,10 @@ export default {
|
||||
setTimeout(() => {
|
||||
this.content.style.transform = 'scale(1)'
|
||||
}, 10)
|
||||
document.documentElement.classList.add('modal-open')
|
||||
},
|
||||
setHide() {
|
||||
this.content.style.transform = 'scale(0)'
|
||||
this.el.remove()
|
||||
document.documentElement.classList.remove('modal-open')
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -24,10 +24,10 @@
|
||||
<td class="font-book">
|
||||
{{ chapter.title }}
|
||||
</td>
|
||||
<td class="font-mono text-center">
|
||||
<td class="font-mono text-center hover:underline cursor-pointer" @click.stop="goToTimestamp(chapter.start)">
|
||||
{{ $secondsToTimestamp(chapter.start) }}
|
||||
</td>
|
||||
<td class="font-mono text-center">
|
||||
<td class="font-mono text-center hover:underline cursor-pointer" @click.stop="goToTimestamp(chapter.start)">
|
||||
{{ $secondsToTimestamp(chapter.end) }}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -57,6 +57,9 @@ export default {
|
||||
media() {
|
||||
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||
},
|
||||
metadata() {
|
||||
return this.media.metadata || {}
|
||||
},
|
||||
chapters() {
|
||||
return this.media.chapters || []
|
||||
},
|
||||
@@ -67,6 +70,30 @@ export default {
|
||||
methods: {
|
||||
clickBar() {
|
||||
this.expanded = !this.expanded
|
||||
},
|
||||
goToTimestamp(time) {
|
||||
if (this.$store.getters['getIsMediaStreaming'](this.libraryItemId)) {
|
||||
this.$eventBus.$emit('play-item', {
|
||||
libraryItemId: this.libraryItemId,
|
||||
episodeId: null,
|
||||
startTime: time
|
||||
})
|
||||
} else {
|
||||
const payload = {
|
||||
message: `Start playback for "${this.metadata.title}" at ${this.$secondsToTimestamp(time)}?`,
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.$eventBus.$emit('play-item', {
|
||||
libraryItemId: this.libraryItemId,
|
||||
episodeId: null,
|
||||
startTime: time
|
||||
})
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
|
||||
@@ -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,12 +59,53 @@ 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`)
|
||||
.$get(`/api/libraries/${this.library.id}/matchall`)
|
||||
.then(() => {
|
||||
console.log('Starting scan for matches')
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -78,7 +78,7 @@ export default {
|
||||
return this.$secondsToTimestamp(this.episode.duration)
|
||||
},
|
||||
isStreaming() {
|
||||
return this.$store.getters['getIsEpisodeStreaming'](this.libraryItemId, this.episode.id)
|
||||
return this.$store.getters['getIsMediaStreaming'](this.libraryItemId, this.episode.id)
|
||||
},
|
||||
streamIsPlaying() {
|
||||
return this.$store.state.streamIsPlaying && this.isStreaming
|
||||
@@ -124,7 +124,21 @@ export default {
|
||||
})
|
||||
}
|
||||
},
|
||||
toggleFinished() {
|
||||
toggleFinished(confirmed = false) {
|
||||
if (!this.userIsFinished && this.itemProgressPercent > 0 && !confirmed) {
|
||||
const payload = {
|
||||
message: `Are you sure you want to mark "${this.title}" as finished?`,
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.toggleFinished(true)
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
return
|
||||
}
|
||||
|
||||
var updatePayload = {
|
||||
isFinished: !this.userIsFinished
|
||||
}
|
||||
|
||||
@@ -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,18 +1,18 @@
|
||||
<template>
|
||||
<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>
|
||||
<div v-if="currentLibrary" class="relative h-8 max-w-52" v-click-outside="clickOutsideObj">
|
||||
<button type="button" :disabled="disabled" class="w-10 sm:w-full 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">
|
||||
<div class="flex items-center justify-center sm:justify-start">
|
||||
<widgets-library-icon :icon="currentLibraryIcon" class="sm:mr-1.5" />
|
||||
<span class="hidden sm:block truncate">{{ currentLibrary.name }}</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<transition name="menu">
|
||||
<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">
|
||||
<ul v-show="showMenu" class="absolute z-10 -mt-px min-w-48 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">
|
||||
<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">
|
||||
<widgets-library-icon :icon="library.icon" class="mr-2" />
|
||||
<div class="flex items-center px-2">
|
||||
<widgets-library-icon :icon="library.icon" class="mr-1.5 text-gray-400" />
|
||||
<span class="font-normal block truncate font-sans text-sm">{{ library.name }}</span>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
212
client/components/ui/TimePicker.vue
Normal file
212
client/components/ui/TimePicker.vue
Normal file
@@ -0,0 +1,212 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<div class="rounded text-gray-200 border w-full px-3 py-2" :class="focusedDigit ? 'bg-primary bg-opacity-50 border-gray-300' : 'bg-primary border-gray-600'" @click="clickInput" v-click-outside="clickOutsideObj">
|
||||
<div class="flex items-center">
|
||||
<template v-for="(digit, index) in digitDisplay">
|
||||
<div v-if="digit == ':'" :key="index" class="px-px" @click.stop="clickMedian(index)">:</div>
|
||||
<div v-else :key="index" class="px-px" :class="{ 'digit-focused': focusedDigit == digit }" @click.stop="focusDigit(digit)">{{ digits[digit] }}</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: [String, Number],
|
||||
showThreeDigitHour: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
clickOutsideObj: {
|
||||
handler: this.clickOutside,
|
||||
events: ['mousedown'],
|
||||
isActive: true
|
||||
},
|
||||
digitDisplay: ['hour1', 'hour0', ':', 'minute1', 'minute0', ':', 'second1', 'second0'],
|
||||
focusedDigit: null,
|
||||
digits: {
|
||||
hour2: 0,
|
||||
hour1: 0,
|
||||
hour0: 0,
|
||||
minute1: 0,
|
||||
minute0: 0,
|
||||
second1: 0,
|
||||
second0: 0
|
||||
},
|
||||
isOver99Hours: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
value: {
|
||||
immediate: true,
|
||||
handler() {
|
||||
this.initDigits()
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {},
|
||||
methods: {
|
||||
initDigits() {
|
||||
var totalSeconds = !this.value || isNaN(this.value) ? 0 : Number(this.value)
|
||||
totalSeconds = Math.round(totalSeconds)
|
||||
|
||||
var minutes = Math.floor(totalSeconds / 60)
|
||||
var seconds = totalSeconds - minutes * 60
|
||||
var hours = Math.floor(minutes / 60)
|
||||
minutes -= hours * 60
|
||||
|
||||
this.digits.second1 = seconds <= 9 ? 0 : Number(String(seconds)[0])
|
||||
this.digits.second0 = seconds <= 9 ? seconds : Number(String(seconds)[1])
|
||||
|
||||
this.digits.minute1 = minutes <= 9 ? 0 : Number(String(minutes)[0])
|
||||
this.digits.minute0 = minutes <= 9 ? minutes : Number(String(minutes)[1])
|
||||
|
||||
if (hours > 99) {
|
||||
this.digits.hour2 = Number(String(hours)[0])
|
||||
this.digits.hour1 = Number(String(hours)[1])
|
||||
this.digits.hour0 = Number(String(hours)[2])
|
||||
this.isOver99Hours = true
|
||||
} else {
|
||||
this.digits.hour1 = hours <= 9 ? 0 : Number(String(hours)[0])
|
||||
this.digits.hour0 = hours <= 9 ? hours : Number(String(hours)[1])
|
||||
this.isOver99Hours = this.showThreeDigitHour
|
||||
}
|
||||
|
||||
if (this.isOver99Hours) {
|
||||
this.digitDisplay = ['hour2', 'hour1', 'hour0', ':', 'minute1', 'minute0', ':', 'second1', 'second0']
|
||||
} else {
|
||||
this.digitDisplay = ['hour1', 'hour0', ':', 'minute1', 'minute0', ':', 'second1', 'second0']
|
||||
}
|
||||
},
|
||||
updateSeconds() {
|
||||
var seconds = this.digits.second0 + this.digits.second1 * 10
|
||||
seconds += this.digits.minute0 * 60 + this.digits.minute1 * 600
|
||||
seconds += this.digits.hour0 * 3600 + this.digits.hour1 * 36000
|
||||
if (this.isOver99Hours) seconds += this.digits.hour2 * 360000
|
||||
|
||||
if (Number(this.value) !== seconds) {
|
||||
this.$emit('input', seconds)
|
||||
this.$emit('change', seconds)
|
||||
}
|
||||
},
|
||||
clickMedian(index) {
|
||||
// Click colon select digit to right
|
||||
if (index >= 5) {
|
||||
this.focusedDigit = 'second1'
|
||||
} else {
|
||||
this.focusedDigit = 'minute1'
|
||||
}
|
||||
},
|
||||
clickOutside() {
|
||||
this.removeFocus()
|
||||
},
|
||||
removeFocus() {
|
||||
this.focusedDigit = null
|
||||
this.removeListeners()
|
||||
},
|
||||
focusDigit(digit) {
|
||||
if (this.focusedDigit == null || isNaN(this.focusedDigit)) this.initListeners()
|
||||
this.focusedDigit = digit
|
||||
},
|
||||
clickInput() {
|
||||
if (this.focusedDigit) return
|
||||
this.focusDigit('second0')
|
||||
},
|
||||
shiftFocusLeft() {
|
||||
if (!this.focusedDigit) return
|
||||
if (this.focusedDigit.endsWith('2')) return
|
||||
|
||||
const isDigit1 = this.focusedDigit.endsWith('1')
|
||||
if (!isDigit1) {
|
||||
const digit1Key = this.focusedDigit.replace('0', '1')
|
||||
this.focusedDigit = digit1Key
|
||||
} else if (this.focusedDigit.startsWith('second')) {
|
||||
this.focusedDigit = 'minute0'
|
||||
} else if (this.focusedDigit.startsWith('minute')) {
|
||||
this.focusedDigit = 'hour0'
|
||||
} else if (this.isOver99Hours && this.focusedDigit.startsWith('hour')) {
|
||||
this.focusedDigit = 'hour2'
|
||||
}
|
||||
},
|
||||
shiftFocusRight() {
|
||||
if (!this.focusedDigit) return
|
||||
if (this.focusedDigit.endsWith('2')) {
|
||||
// Must be hour2
|
||||
this.focusedDigit = 'hour1'
|
||||
return
|
||||
}
|
||||
const isDigit1 = this.focusedDigit.endsWith('1')
|
||||
if (isDigit1) {
|
||||
const digit0Key = this.focusedDigit.replace('1', '0')
|
||||
this.focusedDigit = digit0Key
|
||||
} else if (this.focusedDigit.startsWith('hour')) {
|
||||
this.focusedDigit = 'minute1'
|
||||
} else if (this.focusedDigit.startsWith('minute')) {
|
||||
this.focusedDigit = 'second1'
|
||||
}
|
||||
},
|
||||
increaseFocused() {
|
||||
if (!this.focusedDigit) return
|
||||
const isDigit1 = this.focusedDigit.endsWith('1')
|
||||
const digit = Number(this.digits[this.focusedDigit])
|
||||
if (isDigit1 && !this.focusedDigit.startsWith('hour')) this.digits[this.focusedDigit] = (digit + 1) % 6
|
||||
else this.digits[this.focusedDigit] = (digit + 1) % 10
|
||||
this.updateSeconds()
|
||||
},
|
||||
decreaseFocused() {
|
||||
if (!this.focusedDigit) return
|
||||
const isDigit1 = this.focusedDigit.endsWith('1')
|
||||
const digit = Number(this.digits[this.focusedDigit])
|
||||
if (isDigit1 && !this.focusedDigit.startsWith('hour')) this.digits[this.focusedDigit] = digit - 1 < 0 ? 5 : digit - 1
|
||||
else this.digits[this.focusedDigit] = digit - 1 < 0 ? 9 : digit - 1
|
||||
this.updateSeconds()
|
||||
},
|
||||
keydown(evt) {
|
||||
if (!this.focusedDigit || !evt.key) return
|
||||
|
||||
if (evt.key === 'ArrowLeft') {
|
||||
return this.shiftFocusLeft()
|
||||
} else if (evt.key === 'ArrowRight') {
|
||||
return this.shiftFocusRight()
|
||||
} else if (evt.key === 'ArrowUp') {
|
||||
return this.increaseFocused()
|
||||
} else if (evt.key === 'ArrowDown') {
|
||||
return this.decreaseFocused()
|
||||
} else if (evt.key === 'Enter' || evt.key === 'Escape') {
|
||||
return this.removeFocus()
|
||||
}
|
||||
|
||||
if (isNaN(evt.key)) return
|
||||
|
||||
var digit = Number(evt.key)
|
||||
const isDigit1 = this.focusedDigit.endsWith('1')
|
||||
if (isDigit1 && !this.focusedDigit.startsWith('hour') && digit >= 6) {
|
||||
digit = 5
|
||||
}
|
||||
|
||||
this.digits[this.focusedDigit] = digit
|
||||
|
||||
this.updateSeconds()
|
||||
this.shiftFocusRight()
|
||||
},
|
||||
initListeners() {
|
||||
window.addEventListener('keydown', this.keydown)
|
||||
},
|
||||
removeListeners() {
|
||||
window.removeEventListener('keydown', this.keydown)
|
||||
}
|
||||
},
|
||||
mounted() {},
|
||||
beforeDestroy() {
|
||||
this.removeListeners()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.digit-focused {
|
||||
background-color: #555;
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div :class="`h-${size} w-${size}`">
|
||||
<div :class="`h-${size} w-${size} min-w-${size}`">
|
||||
<component :is="iconComponentName" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="absolute w-36 bg-bg rounded-md border border-black-200 shadow-lg z-50" v-click-outside="clickOutsideObj" style="top: 0; left: 0">
|
||||
<template v-for="(item, index) in items">
|
||||
<div :key="index" class="flex h-7 items-center px-2 hover:bg-white hover:bg-opacity-5 text-white text-xs cursor-pointer" @click="clickAction(item.func)">
|
||||
<div :key="index" class="flex h-7 items-center px-2 hover:bg-white hover:bg-opacity-5 text-white text-xs cursor-pointer" @click.stop="clickAction(item.func)">
|
||||
<p>{{ item.text }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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>
|
||||
@@ -15,6 +15,7 @@
|
||||
<modals-podcast-edit-episode />
|
||||
<modals-podcast-view-episode />
|
||||
<modals-authors-edit-modal />
|
||||
<prompt-confirm />
|
||||
<readers-reader />
|
||||
</div>
|
||||
</template>
|
||||
@@ -40,6 +41,7 @@ export default {
|
||||
if (this.$store.state.selectedLibraryItems) {
|
||||
this.$store.commit('setSelectedLibraryItems', [])
|
||||
}
|
||||
this.updateBodyClass()
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -53,11 +55,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)
|
||||
@@ -237,9 +251,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)
|
||||
@@ -248,7 +262,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) {
|
||||
@@ -257,7 +271,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)
|
||||
@@ -521,6 +535,7 @@ export default {
|
||||
this.initializeSocket()
|
||||
},
|
||||
mounted() {
|
||||
this.updateBodyClass()
|
||||
this.resize()
|
||||
window.addEventListener('resize', this.resize)
|
||||
window.addEventListener('keydown', this.keyDown)
|
||||
|
||||
@@ -52,13 +52,13 @@ export default {
|
||||
width: this.entityWidth,
|
||||
height: this.entityHeight,
|
||||
bookCoverAspectRatio: this.bookCoverAspectRatio,
|
||||
bookshelfView: this.bookshelfView
|
||||
bookshelfView: this.bookshelfView,
|
||||
sortingIgnorePrefix: !!this.sortingIgnorePrefix
|
||||
}
|
||||
|
||||
if (this.entityName === 'books') {
|
||||
props.filterBy = this.filterBy
|
||||
props.orderBy = this.orderBy
|
||||
props.sortingIgnorePrefix = !!this.sortingIgnorePrefix
|
||||
}
|
||||
|
||||
var _this = this
|
||||
|
||||
@@ -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
|
||||
@@ -107,23 +108,33 @@ module.exports = {
|
||||
background_color: '#373838',
|
||||
icons: [
|
||||
{
|
||||
src: '/icon64.png',
|
||||
src: '/icon.svg',
|
||||
sizes: "64x64"
|
||||
},
|
||||
{
|
||||
src: '/icon192.png',
|
||||
src: '/icon.svg',
|
||||
sizes: "192x192"
|
||||
},
|
||||
{
|
||||
src: '/Logo.png',
|
||||
src: '/icon.svg',
|
||||
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.22",
|
||||
"version": "2.1.1",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@
|
||||
<div class="flex items-center">
|
||||
<p class="text-lg mb-4 font-semibold">Audiobook Chapters</p>
|
||||
<div class="flex-grow" />
|
||||
<ui-checkbox v-model="showSecondInputs" checkbox-bg="primary" small label-class="text-sm text-gray-200 pl-1" label="Show seconds" class="mx-2" />
|
||||
<ui-btn color="primary" small class="mx-2" @click="showFindChaptersModal = true">Lookup</ui-btn>
|
||||
<ui-btn color="success" small @click="saveChapters">Save</ui-btn>
|
||||
<div class="w-40" />
|
||||
@@ -32,7 +33,8 @@
|
||||
<div :key="chapter.id" class="flex py-1">
|
||||
<div class="w-12">#{{ chapter.id + 1 }}</div>
|
||||
<div class="w-32 px-1">
|
||||
<ui-text-input v-model="chapter.start" type="number" class="text-xs" @change="checkChapters" />
|
||||
<ui-text-input v-if="showSecondInputs" v-model="chapter.start" type="number" class="text-xs" @change="checkChapters" />
|
||||
<ui-time-picker v-else class="text-xs" v-model="chapter.start" :show-three-digit-hour="mediaDuration >= 360000" @change="checkChapters" />
|
||||
</div>
|
||||
<div class="flex-grow px-1">
|
||||
<ui-text-input v-model="chapter.title" class="text-xs" />
|
||||
@@ -136,7 +138,7 @@
|
||||
|
||||
<script>
|
||||
export default {
|
||||
async asyncData({ store, params, app, redirect, route }) {
|
||||
async asyncData({ store, params, app, redirect, from }) {
|
||||
if (!store.getters['user/getUserCanUpdate']) {
|
||||
return redirect('/?error=unauthorized')
|
||||
}
|
||||
@@ -152,8 +154,12 @@ export default {
|
||||
console.error('Invalid media type')
|
||||
return redirect('/')
|
||||
}
|
||||
|
||||
var previousRoute = from ? from.fullPath : null
|
||||
if (from && from.path === '/login') previousRoute = null
|
||||
return {
|
||||
libraryItem
|
||||
libraryItem,
|
||||
previousRoute
|
||||
}
|
||||
},
|
||||
data() {
|
||||
@@ -168,7 +174,8 @@ export default {
|
||||
asinInput: null,
|
||||
findingChapters: false,
|
||||
showFindChaptersModal: false,
|
||||
chapterData: null
|
||||
chapterData: null,
|
||||
showSecondInputs: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -339,7 +346,6 @@ export default {
|
||||
|
||||
this.saving = true
|
||||
|
||||
console.log('udpated chapters', this.newChapters)
|
||||
const payload = {
|
||||
chapters: this.newChapters
|
||||
}
|
||||
@@ -349,7 +355,11 @@ export default {
|
||||
this.saving = false
|
||||
if (data.updated) {
|
||||
this.$toast.success('Chapters updated')
|
||||
this.$router.push(`/item/${this.libraryItem.id}`)
|
||||
if (this.previousRoute) {
|
||||
this.$router.push(this.previousRoute)
|
||||
} else {
|
||||
this.$router.push(`/item/${this.libraryItem.id}`)
|
||||
}
|
||||
} else {
|
||||
this.$toast.info('No changes needed updating')
|
||||
}
|
||||
|
||||
@@ -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,169 +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">
|
||||
<h1 class="text-xl font-semibold">Settings</h1>
|
||||
<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="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="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="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="newServerSettings.chromecastEnabled" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('chromecastEnabled', val)" />
|
||||
<p class="pl-4">Chromecast support</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center mb-2 mt-8">
|
||||
<h1 class="text-xl font-semibold">Display Settings</h1>
|
||||
</div>
|
||||
<div class="pt-4">
|
||||
<h2 class="font-semibold">Display</h2>
|
||||
</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="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 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="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">
|
||||
<p class="pr-4 text-lg">Date Format</p>
|
||||
<ui-dropdown v-model="newServerSettings.dateFormat" :items="dateFormats" small class="max-w-40" @input="(val) => updateSettingsKey('dateFormat', val)" />
|
||||
</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 mb-2 mt-8">
|
||||
<h1 class="text-xl font-semibold">Scanner Settings</h1>
|
||||
</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.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">
|
||||
<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.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 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.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.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.scannerPreferOverdriveMediaMarker" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferOverdriveMediaMarker', val)" />
|
||||
<ui-tooltip :text="tooltips.scannerPreferOverdriveMediaMarker">
|
||||
<p class="pl-4 text-lg">
|
||||
Scanner prefer Overdrive Media Markers for chapters
|
||||
<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 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.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.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.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 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.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 mb-2 mt-8">
|
||||
<h1 class="text-xl font-semibold">Experimental Feature Settings</h1>
|
||||
</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="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="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>
|
||||
|
||||
@@ -171,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
|
||||
@@ -207,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" />
|
||||
@@ -264,8 +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)',
|
||||
scannerPreferOverdriveMediaMarker: 'MP3 files from Overdrive come with chapter timings embedded as custom metadata. Enabling this will use these tags for chapter timings automatically'
|
||||
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
|
||||
}
|
||||
@@ -297,6 +322,26 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
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) {
|
||||
this.$toast.error('Must have at least 1 prefix')
|
||||
|
||||
@@ -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,20 +1,17 @@
|
||||
<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-36 font-mono text-xs">{{ log.timestamp }}</p>
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -1,61 +1,61 @@
|
||||
<template>
|
||||
<div class="w-full h-full">
|
||||
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-0 sm:p-4 mb-8">
|
||||
<div class="py-2">
|
||||
<div class="flex items-center mb-1">
|
||||
<h1 class="text-lg mb-2 text-white text-opacity-90 px-2 sm:px-0">Listening Sessions</h1>
|
||||
<div class="flex-grow" />
|
||||
|
||||
<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 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 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 hover:underline" @click.stop="clickCurrentTime(session)">
|
||||
<p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p>
|
||||
</td>
|
||||
<td class="text-center hidden sm:table-cell">
|
||||
<ui-tooltip v-if="session.updatedAt" direction="top" :text="$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" />
|
||||
@@ -89,7 +89,8 @@ export default {
|
||||
total: 0,
|
||||
currentPage: 0,
|
||||
userFilter: null,
|
||||
selectedUser: ''
|
||||
selectedUser: '',
|
||||
processingGoToTimestamp: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -110,6 +111,41 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async clickCurrentTime(session) {
|
||||
if (this.processingGoToTimestamp) return
|
||||
this.processingGoToTimestamp = true
|
||||
const libraryItem = await this.$axios.$get(`/api/items/${session.libraryItemId}`).catch((error) => {
|
||||
console.error('Failed to get library item', error)
|
||||
return null
|
||||
})
|
||||
|
||||
if (!libraryItem) {
|
||||
this.$toast.error('Failed to get library item')
|
||||
this.processingGoToTimestamp = false
|
||||
return
|
||||
}
|
||||
if (session.episodeId && !libraryItem.media.episodes.find((ep) => ep.id === session.episodeId)) {
|
||||
this.$toast.error('Failed to get podcast episode')
|
||||
this.processingGoToTimestamp = false
|
||||
return
|
||||
}
|
||||
|
||||
const payload = {
|
||||
message: `Start playback for "${session.displayTitle}" at ${this.$secondsToTimestamp(session.currentTime)}?`,
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.$eventBus.$emit('play-item', {
|
||||
libraryItemId: libraryItem.id,
|
||||
episodeId: session.episodeId || null,
|
||||
startTime: session.currentTime
|
||||
})
|
||||
}
|
||||
this.processingGoToTimestamp = false
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
},
|
||||
updateUserFilter() {
|
||||
this.loadSessions(0)
|
||||
},
|
||||
|
||||
@@ -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,12 +139,15 @@ export default {
|
||||
this.$copyToClipboard(str, this)
|
||||
},
|
||||
async init() {
|
||||
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.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 []
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
<td class="text-center">
|
||||
<p class="text-xs font-mono">{{ $elapsedPretty(session.timeListening) }}</p>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<td class="text-center hover:underline" @click.stop="clickCurrentTime(session)">
|
||||
<p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p>
|
||||
</td>
|
||||
<td class="text-center hidden sm:table-cell">
|
||||
@@ -85,7 +85,8 @@ export default {
|
||||
listeningSessions: [],
|
||||
numPages: 0,
|
||||
total: 0,
|
||||
currentPage: 0
|
||||
currentPage: 0,
|
||||
processingGoToTimestamp: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -97,6 +98,41 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async clickCurrentTime(session) {
|
||||
if (this.processingGoToTimestamp) return
|
||||
this.processingGoToTimestamp = true
|
||||
const libraryItem = await this.$axios.$get(`/api/items/${session.libraryItemId}`).catch((error) => {
|
||||
console.error('Failed to get library item', error)
|
||||
return null
|
||||
})
|
||||
|
||||
if (!libraryItem) {
|
||||
this.$toast.error('Failed to get library item')
|
||||
this.processingGoToTimestamp = false
|
||||
return
|
||||
}
|
||||
if (session.episodeId && !libraryItem.media.episodes.find((ep) => ep.id === session.episodeId)) {
|
||||
this.$toast.error('Failed to get podcast episode')
|
||||
this.processingGoToTimestamp = false
|
||||
return
|
||||
}
|
||||
|
||||
const payload = {
|
||||
message: `Start playback for "${session.displayTitle}" at ${this.$secondsToTimestamp(session.currentTime)}?`,
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.$eventBus.$emit('play-item', {
|
||||
libraryItemId: libraryItem.id,
|
||||
episodeId: session.episodeId || null,
|
||||
startTime: session.currentTime
|
||||
})
|
||||
}
|
||||
this.processingGoToTimestamp = false
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
},
|
||||
prevPage() {
|
||||
this.loadSessions(this.currentPage - 1)
|
||||
},
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
<!-- Item Cover Overlay -->
|
||||
<div class="absolute top-0 left-0 w-full h-full z-10 bg-black bg-opacity-30 opacity-0 hover:opacity-100 transition-opacity" @mousedown.prevent @mouseup.prevent>
|
||||
<div v-show="showPlayButton && !streaming" class="h-full flex items-center justify-center pointer-events-none">
|
||||
<div v-show="showPlayButton && !isStreaming" class="h-full flex items-center justify-center pointer-events-none">
|
||||
<div class="hover:text-white text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto cursor-pointer" @click.stop.prevent="startStream">
|
||||
<span class="material-icons text-4xl">play_circle_filled</span>
|
||||
</div>
|
||||
@@ -129,12 +129,12 @@
|
||||
|
||||
<!-- Icon buttons -->
|
||||
<div class="flex items-center justify-center md:justify-start pt-4">
|
||||
<ui-btn v-if="showPlayButton" :disabled="streaming" color="success" :padding-x="4" small class="flex items-center h-9 mr-2" @click="startStream">
|
||||
<span v-show="!streaming" class="material-icons -ml-2 pr-1 text-white">play_arrow</span>
|
||||
{{ streaming ? 'Playing' : 'Play' }}
|
||||
<ui-btn v-if="showPlayButton" :disabled="isStreaming" color="success" :padding-x="4" small class="flex items-center h-9 mr-2" @click="startStream">
|
||||
<span v-show="!isStreaming" class="material-icons -ml-2 pr-1 text-white">play_arrow</span>
|
||||
{{ isStreaming ? 'Playing' : 'Play' }}
|
||||
</ui-btn>
|
||||
<ui-btn v-else-if="isMissing || isInvalid" color="error" :padding-x="4" small class="flex items-center h-9 mr-2">
|
||||
<span v-show="!streaming" class="material-icons -ml-2 pr-1 text-white">error</span>
|
||||
<span v-show="!isStreaming" class="material-icons -ml-2 pr-1 text-white">error</span>
|
||||
{{ isMissing ? 'Missing' : 'Incomplete' }}
|
||||
</ui-btn>
|
||||
|
||||
@@ -160,7 +160,11 @@
|
||||
<ui-icon-btn icon="search" class="mx-0.5" :loading="fetchingRSSFeed" outlined @click="findEpisodesClick" />
|
||||
</ui-tooltip>
|
||||
|
||||
<!-- Experimental RSS feed open -->
|
||||
<ui-tooltip v-if="bookmarks.length" text="Your Bookmarks" direction="top">
|
||||
<ui-icon-btn :icon="bookmarks.length ? 'bookmarks' : 'bookmark_border'" class="mx-0.5" @click="clickBookmarksBtn" />
|
||||
</ui-tooltip>
|
||||
|
||||
<!-- RSS feed -->
|
||||
<ui-tooltip v-if="showRssFeedBtn" text="Open RSS Feed" direction="top">
|
||||
<ui-icon-btn icon="rss_feed" class="mx-0.5" :bg-color="rssFeedUrl ? 'success' : 'primary'" outlined @click="clickRSSFeed" />
|
||||
</ui-tooltip>
|
||||
@@ -189,6 +193,7 @@
|
||||
|
||||
<modals-podcast-episode-feed v-model="showPodcastEpisodeFeed" :library-item="libraryItem" :episodes="podcastFeedEpisodes" />
|
||||
<modals-rssfeed-view-modal v-model="showRssFeedModal" :library-item="libraryItem" :feed-url="rssFeedUrl" />
|
||||
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :library-item-id="libraryItemId" hide-create @select="selectBookmark" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -222,7 +227,8 @@ export default {
|
||||
podcastFeedEpisodes: [],
|
||||
episodesDownloading: [],
|
||||
episodeDownloadsQueued: [],
|
||||
showRssFeedModal: false
|
||||
showRssFeedModal: false,
|
||||
showBookmarksModal: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -296,6 +302,10 @@ export default {
|
||||
chapters() {
|
||||
return this.media.chapters || []
|
||||
},
|
||||
bookmarks() {
|
||||
if (this.isPodcast) return []
|
||||
return this.$store.getters['user/getUserBookmarksForItem'](this.libraryItemId)
|
||||
},
|
||||
tracks() {
|
||||
return this.media.tracks || []
|
||||
},
|
||||
@@ -389,7 +399,7 @@ export default {
|
||||
streamLibraryItem() {
|
||||
return this.$store.state.streamLibraryItem
|
||||
},
|
||||
streaming() {
|
||||
isStreaming() {
|
||||
return this.streamLibraryItem && this.streamLibraryItem.id === this.libraryItemId
|
||||
},
|
||||
userCanUpdate() {
|
||||
@@ -409,6 +419,31 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickBookmarksBtn() {
|
||||
this.showBookmarksModal = true
|
||||
},
|
||||
selectBookmark(bookmark) {
|
||||
if (!bookmark) return
|
||||
if (this.isStreaming) {
|
||||
this.$eventBus.$emit('playback-seek', bookmark.time)
|
||||
} else if (this.streamLibraryItem) {
|
||||
this.showBookmarksModal = false
|
||||
console.log('Already streaming library item so ask about it')
|
||||
const payload = {
|
||||
message: `Start playback for "${this.title}" at ${this.$secondsToTimestamp(bookmark.time)}?`,
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.startStream(bookmark.time)
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
} else {
|
||||
this.startStream(bookmark.time)
|
||||
}
|
||||
this.showBookmarksModal = false
|
||||
},
|
||||
clearDownloadQueue() {
|
||||
if (confirm('Are you sure you want to clear episode download queue?')) {
|
||||
this.$axios
|
||||
@@ -453,7 +488,21 @@ export default {
|
||||
openEbook() {
|
||||
this.$store.commit('showEReader', this.libraryItem)
|
||||
},
|
||||
toggleFinished() {
|
||||
toggleFinished(confirmed = false) {
|
||||
if (!this.userIsFinished && this.progressPercent > 0 && !confirmed) {
|
||||
const payload = {
|
||||
message: `Are you sure you want to mark "${this.title}" as finished?`,
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.toggleFinished(true)
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
return
|
||||
}
|
||||
|
||||
var updatePayload = {
|
||||
isFinished: !this.userIsFinished
|
||||
}
|
||||
@@ -470,7 +519,7 @@ export default {
|
||||
this.$toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
||||
})
|
||||
},
|
||||
startStream() {
|
||||
startStream(startTime = null) {
|
||||
var episodeId = null
|
||||
if (this.isPodcast) {
|
||||
var episode = this.podcastEpisodes.find((ep) => {
|
||||
@@ -483,7 +532,8 @@ export default {
|
||||
|
||||
this.$eventBus.$emit('play-item', {
|
||||
libraryItemId: this.libraryItem.id,
|
||||
episodeId
|
||||
episodeId,
|
||||
startTime
|
||||
})
|
||||
},
|
||||
editClick() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -18,6 +18,7 @@ export default class PlayerHandler {
|
||||
this.isHlsTranscode = false
|
||||
this.isVideo = false
|
||||
this.currentSessionId = null
|
||||
this.startTimeOverride = undefined // Used for starting playback at a specific time (i.e. clicking bookmark from library item page)
|
||||
this.startTime = 0
|
||||
|
||||
this.failedProgressSyncs = 0
|
||||
@@ -51,12 +52,13 @@ export default class PlayerHandler {
|
||||
return this.libraryItem.media.episodes.find(ep => ep.id === this.episodeId)
|
||||
}
|
||||
|
||||
load(libraryItem, episodeId, playWhenReady, playbackRate) {
|
||||
load(libraryItem, episodeId, playWhenReady, playbackRate, startTimeOverride = undefined) {
|
||||
this.libraryItem = libraryItem
|
||||
this.episodeId = episodeId
|
||||
this.playWhenReady = playWhenReady
|
||||
this.initialPlaybackRate = playbackRate
|
||||
this.isVideo = libraryItem.mediaType === 'video'
|
||||
this.startTimeOverride = (startTimeOverride == null || isNaN(startTimeOverride)) ? undefined : Number(startTimeOverride)
|
||||
|
||||
if (!this.player) this.switchPlayer(playWhenReady)
|
||||
else this.prepare()
|
||||
@@ -142,11 +144,13 @@ export default class PlayerHandler {
|
||||
} else {
|
||||
this.stopPlayInterval()
|
||||
}
|
||||
if (this.playerState === 'LOADED' || this.playerState === 'PLAYING') {
|
||||
this.ctx.setDuration(this.getDuration())
|
||||
}
|
||||
if (this.playerState !== 'LOADING') {
|
||||
this.ctx.setCurrentTime(this.player.getCurrentTime())
|
||||
if (this.player) {
|
||||
if (this.playerState === 'LOADED' || this.playerState === 'PLAYING') {
|
||||
this.ctx.setDuration(this.getDuration())
|
||||
}
|
||||
if (this.playerState !== 'LOADING') {
|
||||
this.ctx.setCurrentTime(this.player.getCurrentTime())
|
||||
}
|
||||
}
|
||||
|
||||
this.ctx.setPlaying(this.playerState === 'PLAYING')
|
||||
@@ -183,13 +187,14 @@ export default class PlayerHandler {
|
||||
this.isVideo = session.libraryItem.mediaType === 'video'
|
||||
this.playWhenReady = false
|
||||
this.initialPlaybackRate = playbackRate
|
||||
this.startTimeOverride = undefined
|
||||
|
||||
this.prepareSession(session)
|
||||
}
|
||||
|
||||
prepareSession(session) {
|
||||
this.failedProgressSyncs = 0
|
||||
this.startTime = session.currentTime
|
||||
this.startTime = this.startTimeOverride !== undefined ? this.startTimeOverride : session.currentTime
|
||||
this.currentSessionId = session.id
|
||||
this.displayTitle = session.displayTitle
|
||||
this.displayAuthor = session.displayAuthor
|
||||
|
||||
@@ -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', 'wav'],
|
||||
audio: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'oga', 'mp4', 'aac', 'wma', 'aiff', 'wav'],
|
||||
ebook: ['epub', 'pdf', 'mobi', 'azw3', 'cbr', 'cbz'],
|
||||
info: ['nfo'],
|
||||
text: ['txt'],
|
||||
|
||||
@@ -204,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)) {
|
||||
|
||||
@@ -47,8 +47,13 @@ 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')
|
||||
@@ -59,6 +64,7 @@ export async function checkForUpdate() {
|
||||
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
|
||||
}
|
||||
}
|
||||
41
client/static/icon.svg
Normal file
41
client/static/icon.svg
Normal file
@@ -0,0 +1,41 @@
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 1235.7 1235.4" style="enable-background:new 0 0 6702.7 1277.4;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#FFFFFF;}
|
||||
.st1{fill:url(#SVGID_1_);}
|
||||
.st2{fill:#C9C9C9;}
|
||||
.st3{font-family:'GentiumBookBasic';}
|
||||
.st4{font-size:800px;}
|
||||
.st5{fill:#474747;}
|
||||
</style>
|
||||
<title>bgAsset 6</title>
|
||||
<g id="Layer_2_1_">
|
||||
<g id="Layer_2-2">
|
||||
<g id="Layer_4">
|
||||
<g id="Layer_5">
|
||||
<circle class="st0" cx="618.6" cy="618.6" r="618.6"/>
|
||||
</g>
|
||||
|
||||
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="617.37" y1="1257.3" x2="617.37" y2="61.4399" gradientTransform="matrix(1 0 0 -1 0 1278)">
|
||||
<stop offset="0.32" style="stop-color:#CD9D49"/>
|
||||
<stop offset="0.99" style="stop-color:#875D27"/>
|
||||
</linearGradient>
|
||||
<circle class="st1" cx="617.4" cy="618.6" r="597.9"/>
|
||||
</g>
|
||||
<path class="st0" d="M1005.6,574.1c-4.8-4-12.4-10-22.6-17v-79.2c0-201.9-163.7-365.6-365.6-365.6l0,0
|
||||
c-201.9,0-365.6,163.7-365.6,365.6v79.2c-10.2,7-17.7,13-22.6,17c-4.1,3.4-6.5,8.5-6.5,13.9v94.9c0,5.4,2.4,10.5,6.5,14
|
||||
c11.3,9.4,37.2,29.1,77.5,49.3v9.2c0,24.9,16,45,35.8,45l0,0c19.8,0,35.8-20.2,35.8-45V527.8c0-24.9-16-45-35.8-45l0,0
|
||||
c-19,0-34.5,18.5-35.8,41.9h-0.1v-46.9c0-171.6,139.1-310.7,310.7-310.7l0,0C789,167.2,928,306.3,928,477.9v46.9H928
|
||||
c-1.3-23.4-16.8-41.9-35.8-41.9l0,0c-19.8,0-35.8,20.2-35.8,45v227.6c0,24.9,16,45,35.8,45l0,0c19.8,0,35.8-20.2,35.8-45v-9.2
|
||||
c40.3-20.2,66.2-39.9,77.5-49.3c4.2-3.5,6.5-8.6,6.5-14V588C1012.1,582.6,1009.7,577.5,1005.6,574.1z"/>
|
||||
<path class="st0" d="M489.9,969.7c23.9,0,43.3-19.4,43.3-43.3V441.6c0-23.9-19.4-43.3-43.3-43.3h-44.7
|
||||
c-23.9,0-43.3,19.4-43.3,43.3v484.8c0,23.9,19.4,43.3,43.3,43.3L489.9,969.7z M418.2,514.6h98.7v10.3h-98.7V514.6z"/>
|
||||
<path class="st0" d="M639.7,969.7c23.9,0,43.3-19.4,43.3-43.3V441.6c0-23.9-19.4-43.3-43.3-43.3H595c-23.9,0-43.3,19.4-43.3,43.3
|
||||
v484.8c0,23.9,19.4,43.3,43.3,43.3H639.7z M568,514.6h98.7v10.3H568V514.6z"/>
|
||||
<path class="st0" d="M789.6,969.7c23.9,0,43.3-19.4,43.3-43.3V441.6c0-23.9-19.4-43.3-43.3-43.3h-44.7
|
||||
c-23.9,0-43.3,19.4-43.3,43.3v484.8c0,23.9,19.4,43.3,43.3,43.3L789.6,969.7z M717.9,514.6h98.7v10.3h-98.7V514.6z"/>
|
||||
<path class="st0" d="M327.1,984.7h580.5c18,0,32.6,14.6,32.6,32.6v0c0,18-14.6,32.6-32.6,32.6H327.1c-18,0-32.6-14.6-32.6-32.6v0
|
||||
C294.5,999.3,309.1,984.7,327.1,984.7z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
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
@@ -6,6 +6,8 @@ export const state = () => ({
|
||||
showEditCollectionModal: false,
|
||||
showEditPodcastEpisode: false,
|
||||
showViewPodcastEpisodeModal: false,
|
||||
showConfirmPrompt: false,
|
||||
confirmPromptOptions: null,
|
||||
showEditAuthorModal: false,
|
||||
selectedEpisode: null,
|
||||
selectedCollection: null,
|
||||
@@ -69,6 +71,13 @@ export const mutations = {
|
||||
setShowViewPodcastEpisodeModal(state, val) {
|
||||
state.showViewPodcastEpisodeModal = val
|
||||
},
|
||||
setShowConfirmPrompt(state, val) {
|
||||
state.showConfirmPrompt = val
|
||||
},
|
||||
setConfirmPrompt(state, options) {
|
||||
state.confirmPromptOptions = options
|
||||
state.showConfirmPrompt = true
|
||||
},
|
||||
setEditCollection(state, collection) {
|
||||
state.selectedCollection = collection
|
||||
state.showEditCollectionModal = true
|
||||
|
||||
@@ -41,8 +41,9 @@ export const getters = {
|
||||
getLibraryItemIdStreaming: state => {
|
||||
return state.streamLibraryItem ? state.streamLibraryItem.id : null
|
||||
},
|
||||
getIsEpisodeStreaming: state => (libraryItemId, episodeId) => {
|
||||
getIsMediaStreaming: state => (libraryItemId, episodeId) => {
|
||||
if (!state.streamLibraryItem) return null
|
||||
if (!episodeId) return state.streamLibraryItem.id == libraryItemId
|
||||
return state.streamLibraryItem.id == libraryItemId && state.streamEpisodeId == episodeId
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,23 @@
|
||||
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',
|
||||
'min-w-5'
|
||||
],
|
||||
},
|
||||
darkMode: false,
|
||||
theme: {
|
||||
extend: {
|
||||
height: {
|
||||
@@ -33,11 +37,14 @@ module.exports = {
|
||||
'32': '8rem',
|
||||
'40': '10rem',
|
||||
'48': '12rem',
|
||||
'52': '13rem',
|
||||
'64': '16rem',
|
||||
'80': '20rem'
|
||||
},
|
||||
minWidth: {
|
||||
'5': '1.25rem',
|
||||
'6': '1.5rem',
|
||||
'10': '2.5rem',
|
||||
'12': '3rem',
|
||||
'16': '4rem',
|
||||
'20': '5rem',
|
||||
@@ -75,15 +82,17 @@ 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
|
||||
'50': 50,
|
||||
'60': 60
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@ services:
|
||||
ports:
|
||||
- 13378:80
|
||||
volumes:
|
||||
- /audiobooks:/audiobooks
|
||||
- /metadata:/metadata
|
||||
- /config:/config
|
||||
- ./audiobooks:/audiobooks
|
||||
- ./metadata:/metadata
|
||||
- ./config:/config
|
||||
restart: unless-stopped
|
||||
|
||||
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
|
||||
|
||||
|
||||
1787
package-lock.json
generated
1787
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
16
package.json
16
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf",
|
||||
"version": "2.0.22",
|
||||
"version": "2.1.1",
|
||||
"description": "Self-hosted audiobook and podcast server",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
@@ -30,22 +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.3.1",
|
||||
"express": "^4.17.1",
|
||||
"express-fileupload": "^1.2.1",
|
||||
"express-rate-limit": "^5.3.0",
|
||||
"fluent-ffmpeg": "^2.1.2",
|
||||
"fs-extra": "^10.0.0",
|
||||
"graceful-fs": "^4.2.10",
|
||||
"htmlparser2": "^8.0.1",
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
"libgen": "^2.1.0",
|
||||
"node-ffprobe": "^3.0.0",
|
||||
"node-stream-zip": "^1.15.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')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
22
server/Db.js
22
server/Db.js
@@ -10,7 +10,6 @@ const Author = require('./objects/entities/Author')
|
||||
const Series = require('./objects/entities/Series')
|
||||
const ServerSettings = require('./objects/settings/ServerSettings')
|
||||
const PlaybackSession = require('./objects/PlaybackSession')
|
||||
const Feed = require('./objects/Feed')
|
||||
|
||||
class Db {
|
||||
constructor() {
|
||||
@@ -414,6 +413,23 @@ class Db {
|
||||
})
|
||||
}
|
||||
|
||||
removeEntities(entityName, selectFunc) {
|
||||
var entityDb = this.getEntityDb(entityName)
|
||||
return entityDb.delete(selectFunc).then((results) => {
|
||||
Logger.debug(`[DB] Deleted entities ${entityName}: ${results.deleted}`)
|
||||
var arrayKey = this.getEntityArrayKey(entityName)
|
||||
if (this[arrayKey]) {
|
||||
this[arrayKey] = this[arrayKey].filter(e => {
|
||||
return !selectFunc(e)
|
||||
})
|
||||
}
|
||||
return results.deleted
|
||||
}).catch((error) => {
|
||||
Logger.error(`[DB] Remove entities ${entityName} Failed: ${error}`)
|
||||
return 0
|
||||
})
|
||||
}
|
||||
|
||||
recreateLibraryItemsDb() {
|
||||
return this.libraryItemsDb.drop().then((results) => {
|
||||
Logger.info(`[DB] Dropped library items db`, results)
|
||||
@@ -426,8 +442,8 @@ class Db {
|
||||
})
|
||||
}
|
||||
|
||||
getAllSessions() {
|
||||
return this.sessionsDb.select(() => true).then((results) => {
|
||||
getAllSessions(selectFunc = () => true) {
|
||||
return this.sessionsDb.select(selectFunc).then((results) => {
|
||||
return results.data || []
|
||||
}).catch((error) => {
|
||||
Logger.error('[Db] Failed to select sessions', error)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const date = require('date-and-time')
|
||||
const date = require('./libs/dateAndTime')
|
||||
const { LogLevel } = require('./utils/constants')
|
||||
|
||||
class Logger {
|
||||
|
||||
@@ -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,8 +136,14 @@ 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.playbackSessionManager.removeInvalidSessions()
|
||||
await this.cacheManager.ensureCachePaths()
|
||||
await this.abMergeManager.ensureDownloadDirPath()
|
||||
|
||||
@@ -171,7 +177,6 @@ class Server {
|
||||
const distPath = Path.join(global.appRoot, '/client/dist')
|
||||
app.use(express.static(distPath))
|
||||
|
||||
|
||||
// Metadata folder static path
|
||||
app.use('/metadata', this.authMiddleware.bind(this), express.static(global.MetadataPath))
|
||||
|
||||
@@ -247,9 +252,10 @@ class Server {
|
||||
res.json(payload)
|
||||
})
|
||||
app.get('/ping', (req, res) => {
|
||||
Logger.info('Recieved ping')
|
||||
Logger.info('Received ping')
|
||||
res.json({ success: true })
|
||||
})
|
||||
app.get('/healthcheck', (req, res) => res.sendStatus(200))
|
||||
|
||||
this.server.listen(this.Port, this.Host, () => {
|
||||
Logger.info(`Listening on http://${this.Host}:${this.Port}`)
|
||||
@@ -278,6 +284,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', () => {
|
||||
@@ -287,21 +294,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]
|
||||
}
|
||||
})
|
||||
@@ -313,7 +320,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)
|
||||
@@ -458,8 +465,6 @@ class Server {
|
||||
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(),
|
||||
@@ -471,11 +476,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() {
|
||||
|
||||
@@ -82,31 +82,59 @@ class AuthorController {
|
||||
|
||||
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))
|
||||
itemsWithAuthor.forEach(libraryItem => {
|
||||
libraryItem.media.metadata.updateAuthor(req.author)
|
||||
})
|
||||
if (itemsWithAuthor.length) {
|
||||
await this.db.updateLibraryItems(itemsWithAuthor)
|
||||
this.emitter('items_updated', itemsWithAuthor.map(li => li.toJSONExpanded()))
|
||||
}
|
||||
// Check if author name matches another author and merge the authors
|
||||
var existingAuthor = authorNameUpdate ? this.db.authors.find(au => au.id !== req.author.id && payload.name === au.name) : false
|
||||
if (existingAuthor) {
|
||||
var itemsWithAuthor = this.db.libraryItems.filter(li => li.mediaType === 'book' && li.media.metadata.hasAuthor(req.author.id))
|
||||
itemsWithAuthor.forEach(libraryItem => { // Replace old author with merging author for each book
|
||||
libraryItem.media.metadata.replaceAuthor(req.author, existingAuthor)
|
||||
})
|
||||
if (itemsWithAuthor.length) {
|
||||
await this.db.updateLibraryItems(itemsWithAuthor)
|
||||
this.emitter('items_updated', itemsWithAuthor.map(li => li.toJSONExpanded()))
|
||||
}
|
||||
|
||||
await this.db.updateEntity('author', req.author)
|
||||
var numBooks = this.db.libraryItems.filter(li => {
|
||||
return li.media.metadata.hasAuthor && li.media.metadata.hasAuthor(req.author.id)
|
||||
}).length
|
||||
this.emitter('author_updated', req.author.toJSONExpanded(numBooks))
|
||||
}
|
||||
// Remove old author
|
||||
await this.db.removeEntity('author', req.author.id)
|
||||
this.emitter('author_removed', req.author.toJSON())
|
||||
|
||||
res.json({
|
||||
author: req.author.toJSON(),
|
||||
updated: hasUpdated
|
||||
})
|
||||
// Send updated num books for merged author
|
||||
var numBooks = this.db.libraryItems.filter(li => {
|
||||
return li.media.metadata.hasAuthor && li.media.metadata.hasAuthor(existingAuthor.id)
|
||||
}).length
|
||||
this.emitter('author_updated', existingAuthor.toJSONExpanded(numBooks))
|
||||
|
||||
res.json({
|
||||
author: existingAuthor.toJSON(),
|
||||
merged: true
|
||||
})
|
||||
} else { // Regular author update
|
||||
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))
|
||||
itemsWithAuthor.forEach(libraryItem => {
|
||||
libraryItem.media.metadata.updateAuthor(req.author)
|
||||
})
|
||||
if (itemsWithAuthor.length) {
|
||||
await this.db.updateLibraryItems(itemsWithAuthor)
|
||||
this.emitter('items_updated', itemsWithAuthor.map(li => li.toJSONExpanded()))
|
||||
}
|
||||
}
|
||||
|
||||
await this.db.updateEntity('author', req.author)
|
||||
var numBooks = this.db.libraryItems.filter(li => {
|
||||
return li.media.metadata.hasAuthor && li.media.metadata.hasAuthor(req.author.id)
|
||||
}).length
|
||||
this.emitter('author_updated', req.author.toJSONExpanded(numBooks))
|
||||
}
|
||||
|
||||
res.json({
|
||||
author: req.author.toJSON(),
|
||||
updated: hasUpdated
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async search(req, res) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const Path = require('path')
|
||||
const fs = require('fs-extra')
|
||||
const fs = require('../libs/fsExtra')
|
||||
const filePerms = require('../utils/filePerms')
|
||||
const Logger = require('../Logger')
|
||||
const Library = require('../objects/Library')
|
||||
@@ -176,7 +176,8 @@ class LibraryController {
|
||||
}
|
||||
|
||||
// Handle server setting sortingIgnorePrefix
|
||||
if (sortKey === 'media.metadata.title' && this.db.serverSettings.sortingIgnorePrefix) {
|
||||
const sortByTitle = sortKey === 'media.metadata.title'
|
||||
if (sortByTitle && this.db.serverSettings.sortingIgnorePrefix) {
|
||||
// BookMetadata.js has titleIgnorePrefix getter
|
||||
sortKey += 'IgnorePrefix'
|
||||
}
|
||||
@@ -186,6 +187,16 @@ class LibraryController {
|
||||
var sortArray = [
|
||||
{
|
||||
[direction]: (li) => {
|
||||
// When collapsing by series and sorting by title use the series name instead of the book title
|
||||
if (payload.mediaType === 'book' && payload.collapseseries && li.media.metadata.seriesName) {
|
||||
if (sortByTitle) {
|
||||
return this.db.serverSettings.sortingIgnorePrefix ? li.media.metadata.seriesNameIgnorePrefix : li.media.metadata.seriesName
|
||||
} else {
|
||||
// When not sorting by title always show the collapsed series at the end
|
||||
return direction === 'desc' ? -1 : 'zzzz'
|
||||
}
|
||||
}
|
||||
|
||||
// Supports dot notation strings i.e. "media.metadata.title"
|
||||
return sortKey.split('.').reduce((a, b) => a[b], li)
|
||||
}
|
||||
@@ -262,7 +273,7 @@ class LibraryController {
|
||||
|
||||
var series = libraryHelpers.getSeriesFromBooks(libraryItems, payload.minified)
|
||||
series = sort(series).asc(s => {
|
||||
return s.name
|
||||
return this.db.serverSettings.sortingIgnorePrefix ? s.nameIgnorePrefix : s.name
|
||||
})
|
||||
payload.total = series.length
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ class MeController {
|
||||
|
||||
// GET: api/me/progress/:id/:episodeId?
|
||||
async getMediaProgress(req, res) {
|
||||
const mediaProgress = req.user.getMediaProgress(req.id, req.episodeId || null)
|
||||
const mediaProgress = req.user.getMediaProgress(req.params.id, req.params.episodeId || null)
|
||||
if (!mediaProgress) {
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
@@ -57,6 +57,7 @@ class MeController {
|
||||
if (!libraryItem) {
|
||||
return res.status(404).send('Item not found')
|
||||
}
|
||||
|
||||
var wasUpdated = req.user.createUpdateMediaProgress(libraryItem, req.body)
|
||||
if (wasUpdated) {
|
||||
await this.db.updateEntity('user', req.user)
|
||||
@@ -189,6 +190,7 @@ class MeController {
|
||||
const updatedLocalMediaProgress = []
|
||||
var numServerProgressUpdates = 0
|
||||
var localMediaProgress = req.body.localMediaProgress || []
|
||||
|
||||
localMediaProgress.forEach(localProgress => {
|
||||
if (!localProgress.libraryItemId) {
|
||||
Logger.error(`[MeController] syncLocalMediaProgress invalid local media progress object`, localProgress)
|
||||
@@ -215,7 +217,8 @@ class MeController {
|
||||
Logger.debug(`[MeController] syncLocalMediaProgress server progress is more recent by ${updateTimeDifference}ms - ${mediaProgress.id}`)
|
||||
|
||||
for (const key in localProgress) {
|
||||
if (mediaProgress[key] != undefined && localProgress[key] !== mediaProgress[key]) {
|
||||
// Local media progress ID uses the local library item id and server media progress uses the library item id
|
||||
if (key !== 'id' && mediaProgress[key] != undefined && localProgress[key] !== mediaProgress[key]) {
|
||||
// Logger.debug(`[MeController] syncLocalMediaProgress key ${key} changed from ${localProgress[key]} to ${mediaProgress[key]} - ${mediaProgress.id}`)
|
||||
localProgress[key] = mediaProgress[key]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const Path = require('path')
|
||||
const fs = require('fs-extra')
|
||||
const fs = require('../libs/fsExtra')
|
||||
const Logger = require('../Logger')
|
||||
const filePerms = require('../utils/filePerms')
|
||||
|
||||
@@ -242,7 +242,7 @@ class MiscController {
|
||||
const userResponse = {
|
||||
user: req.user,
|
||||
userDefaultLibraryId: req.user.getDefaultLibraryId(this.db.libraries),
|
||||
serverSettings: this.db.serverSettings.toJSON(),
|
||||
serverSettings: this.db.serverSettings.toJSONForBrowser(),
|
||||
Source: global.Source
|
||||
}
|
||||
res.json(userResponse)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const axios = require('axios')
|
||||
const fs = require('fs-extra')
|
||||
const fs = require('../libs/fsExtra')
|
||||
const Path = require('path')
|
||||
const Logger = require('../Logger')
|
||||
const { parsePodcastRssFeedXml } = require('../utils/podcastUtils')
|
||||
|
||||
@@ -43,7 +43,7 @@ class UserController {
|
||||
account.id = getId('usr')
|
||||
account.pash = await this.auth.hashPass(account.password)
|
||||
delete account.password
|
||||
account.token = await this.auth.generateAccessToken({ userId: account.id })
|
||||
account.token = await this.auth.generateAccessToken({ userId: account.id, username })
|
||||
account.createdAt = Date.now()
|
||||
var newUser = new User(account)
|
||||
var success = await this.db.insertEntity('user', newUser)
|
||||
@@ -74,12 +74,14 @@ class UserController {
|
||||
}
|
||||
|
||||
var account = req.body
|
||||
var shouldUpdateToken = false
|
||||
|
||||
if (account.username !== undefined && account.username !== user.username) {
|
||||
var usernameExists = this.db.users.find(u => u.username.toLowerCase() === account.username.toLowerCase())
|
||||
if (usernameExists) {
|
||||
return res.status(500).send('Username already taken')
|
||||
}
|
||||
shouldUpdateToken = true
|
||||
}
|
||||
|
||||
// Updating password
|
||||
@@ -90,6 +92,10 @@ class UserController {
|
||||
|
||||
var hasUpdated = user.update(account)
|
||||
if (hasUpdated) {
|
||||
if (shouldUpdateToken) {
|
||||
user.token = await this.auth.generateAccessToken({ userId: user.id, username: user.username })
|
||||
Logger.info(`[UserController] User ${user.username} was generated a new api token`)
|
||||
}
|
||||
await this.db.updateEntity('user', user)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const fs = require('fs-extra')
|
||||
const fs = require('../libs/fsExtra')
|
||||
const Logger = require('../Logger')
|
||||
const Path = require('path')
|
||||
const Audnexus = require('../providers/Audnexus')
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
const OpenLibrary = require('../providers/OpenLibrary')
|
||||
const LibGen = require('../providers/LibGen')
|
||||
const GoogleBooks = require('../providers/GoogleBooks')
|
||||
const Audible = require('../providers/Audible')
|
||||
const iTunes = require('../providers/iTunes')
|
||||
@@ -10,7 +9,6 @@ const { levenshteinDistance } = require('../utils/index')
|
||||
class BookFinder {
|
||||
constructor() {
|
||||
this.openLibrary = new OpenLibrary()
|
||||
this.libGen = new LibGen()
|
||||
this.googleBooks = new GoogleBooks()
|
||||
this.audible = new Audible()
|
||||
this.iTunesApi = new iTunes()
|
||||
@@ -123,20 +121,6 @@ class BookFinder {
|
||||
})
|
||||
}
|
||||
|
||||
async getLibGenResults(title, author, maxTitleDistance, maxAuthorDistance) {
|
||||
var books = await this.libGen.search(title)
|
||||
if (this.verbose) Logger.debug(`LibGen Book Search Results: ${books.length || 0}`)
|
||||
if (books.errorCode) {
|
||||
Logger.error(`LibGen Search Error ${books.errorCode}`)
|
||||
return []
|
||||
}
|
||||
var booksFiltered = this.filterSearchResults(books, title, author, maxTitleDistance, maxAuthorDistance)
|
||||
if (!booksFiltered.length && books.length) {
|
||||
if (this.verbose) Logger.debug(`Search has ${books.length} matches, but no close title matches`)
|
||||
}
|
||||
return booksFiltered
|
||||
}
|
||||
|
||||
async getOpenLibResults(title, author, maxTitleDistance, maxAuthorDistance) {
|
||||
var books = await this.openLibrary.searchTitle(title)
|
||||
if (this.verbose) Logger.debug(`OpenLib Book Search Results: ${books.length || 0}`)
|
||||
@@ -180,32 +164,15 @@ class BookFinder {
|
||||
Logger.debug(`Book Search: title: "${title}", author: "${author}", provider: ${provider}`)
|
||||
|
||||
if (provider === 'google') {
|
||||
books = this.getGoogleBooksResults(title, author)
|
||||
books = await this.getGoogleBooksResults(title, author)
|
||||
} else if (provider === 'audible') {
|
||||
books = this.getAudibleResults(title, author, asin)
|
||||
books = await this.getAudibleResults(title, author, asin)
|
||||
} else if (provider === 'itunes') {
|
||||
books = this.getiTunesAudiobooksResults(title, author)
|
||||
} else if (provider === 'libgen') {
|
||||
books = await this.getLibGenResults(title, author, maxTitleDistance, maxAuthorDistance)
|
||||
books = await this.getiTunesAudiobooksResults(title, author)
|
||||
} else if (provider === 'openlibrary') {
|
||||
books = await this.getOpenLibResults(title, author, maxTitleDistance, maxAuthorDistance)
|
||||
} else if (provider === 'all') {
|
||||
var lbBooks = await this.getLibGenResults(title, author, maxTitleDistance, maxAuthorDistance)
|
||||
var olBooks = await this.getOpenLibResults(title, author, maxTitleDistance, maxAuthorDistance)
|
||||
books = books.concat(lbBooks, olBooks)
|
||||
} else {
|
||||
books = await this.getOpenLibResults(title, author, maxTitleDistance, maxAuthorDistance)
|
||||
var hasCloseMatch = books.find(b => (b.totalDistance < 2 && b.totalPossibleDistance > 6))
|
||||
if (!hasCloseMatch) {
|
||||
Logger.debug(`Book Search, openlib has no super close matches - get libgen results also`)
|
||||
var lbBooks = await this.getLibGenResults(title, author, maxTitleDistance, maxAuthorDistance)
|
||||
books = books.concat(lbBooks)
|
||||
}
|
||||
|
||||
if (!books.length && author && options.fallbackTitleOnly) {
|
||||
Logger.debug(`Book Search, no matches for title and author.. check title only`)
|
||||
return this.search(provider, title, null, options)
|
||||
}
|
||||
books = await this.getGoogleBooksResults(title, author)
|
||||
}
|
||||
|
||||
if (!books.length && !options.currentlyTryingCleaned) {
|
||||
|
||||
22
server/libs/archiver/LICENSE
Normal file
22
server/libs/archiver/LICENSE
Normal file
@@ -0,0 +1,22 @@
|
||||
Copyright (c) 2012-2014 Chris Talkington, contributors.
|
||||
|
||||
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.
|
||||
21
server/libs/archiver/archiverUtils/balancedMatch/LICENSE
Normal file
21
server/libs/archiver/archiverUtils/balancedMatch/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
(MIT)
|
||||
|
||||
Copyright (c) 2013 Julian Gruber <julian@juliangruber.com>
|
||||
|
||||
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.
|
||||
81
server/libs/archiver/archiverUtils/balancedMatch/index.js
Normal file
81
server/libs/archiver/archiverUtils/balancedMatch/index.js
Normal file
@@ -0,0 +1,81 @@
|
||||
'use strict'
|
||||
|
||||
/**
|
||||
* @param {string | RegExp} a
|
||||
* @param {string | RegExp} b
|
||||
* @param {string} str
|
||||
*/
|
||||
function balanced(a, b, str) {
|
||||
if (a instanceof RegExp) a = maybeMatch(a, str)
|
||||
if (b instanceof RegExp) b = maybeMatch(b, str)
|
||||
|
||||
const r = range(a, b, str)
|
||||
|
||||
return (
|
||||
r && {
|
||||
start: r[0],
|
||||
end: r[1],
|
||||
pre: str.slice(0, r[0]),
|
||||
body: str.slice(r[0] + a.length, r[1]),
|
||||
post: str.slice(r[1] + b.length)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {RegExp} reg
|
||||
* @param {string} str
|
||||
*/
|
||||
function maybeMatch(reg, str) {
|
||||
const m = str.match(reg)
|
||||
return m ? m[0] : null
|
||||
}
|
||||
|
||||
balanced.range = range
|
||||
|
||||
/**
|
||||
* @param {string} a
|
||||
* @param {string} b
|
||||
* @param {string} str
|
||||
*/
|
||||
function range(a, b, str) {
|
||||
let begs, beg, left, right, result
|
||||
let ai = str.indexOf(a)
|
||||
let bi = str.indexOf(b, ai + 1)
|
||||
let i = ai
|
||||
|
||||
if (ai >= 0 && bi > 0) {
|
||||
if (a === b) {
|
||||
return [ai, bi]
|
||||
}
|
||||
begs = []
|
||||
left = str.length
|
||||
|
||||
while (i >= 0 && !result) {
|
||||
if (i === ai) {
|
||||
begs.push(i)
|
||||
ai = str.indexOf(a, i + 1)
|
||||
} else if (begs.length === 1) {
|
||||
result = [begs.pop(), bi]
|
||||
} else {
|
||||
beg = begs.pop()
|
||||
if (beg < left) {
|
||||
left = beg
|
||||
right = bi
|
||||
}
|
||||
|
||||
bi = str.indexOf(b, i + 1)
|
||||
}
|
||||
|
||||
i = ai < bi && ai >= 0 ? ai : bi
|
||||
}
|
||||
|
||||
if (begs.length) {
|
||||
result = [left, right]
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
module.exports = balanced
|
||||
21
server/libs/archiver/archiverUtils/braceExpansion/LICENSE
Normal file
21
server/libs/archiver/archiverUtils/braceExpansion/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2013 Julian Gruber <julian@juliangruber.com>
|
||||
|
||||
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.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user