mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-06-05 02:02:44 +02:00
Compare commits
266 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 372101592c | |||
| 18123664ee | |||
| 2e6e4f970c | |||
| 1c9e56ce2e | |||
| 9e7b84f289 | |||
| 86ee4dcff2 | |||
| 277a5fa37c | |||
| 51b87912f8 | |||
| 653019921e | |||
| ccc291067d | |||
| af7e3a03f0 | |||
| 7c40d26857 | |||
| 6c507de501 | |||
| 482a4340f5 | |||
| 21e704e12c | |||
| 2b91bff1af | |||
| d11f9608b4 | |||
| 2b0b691b69 | |||
| 5dfd5c4971 | |||
| 201f1bff3e | |||
| a22ebb257f | |||
| bf6e87d4bc | |||
| b823a93ae2 | |||
| 05afd12682 | |||
| 997e23150e | |||
| 3c5bf376b5 | |||
| bca2cfda13 | |||
| 916b41d587 | |||
| ab08d83c04 | |||
| 415e0a7b5a | |||
| d301c12acd | |||
| 7aa7e662b2 | |||
| 1dbfb5637a | |||
| 4e1aacb44f | |||
| 954cf3e14e | |||
| b61ecefce4 | |||
| 8562b8d1b3 | |||
| 06ec2159f5 | |||
| 68b565505e | |||
| 83ff2752dd | |||
| d0af1c3c9a | |||
| 1ad46d4fb8 | |||
| d3dd13eae5 | |||
| f27982d887 | |||
| 624a44f572 | |||
| e623bf7fde | |||
| 6fc70b8656 | |||
| 354cefb9f4 | |||
| a78aa88dbc | |||
| 9ac2453676 | |||
| bb70800b4e | |||
| 855272a558 | |||
| ebb2c5f791 | |||
| 2e466bb164 | |||
| 95ebe0f087 | |||
| 0a6aa43b07 | |||
| 806a8cf659 | |||
| 1a32fbfeec | |||
| 67396c16dd | |||
| b0684b6f1b | |||
| 661778c02c | |||
| 5c4241aefe | |||
| 3f6bc90824 | |||
| 4ade6e04a8 | |||
| 49d0835236 | |||
| d90bd92bcc | |||
| 41c016b8c7 | |||
| 5b4d3f71f9 | |||
| 256a9322ef | |||
| 793f82e445 | |||
| ab6da3914b | |||
| 0b53f0ebf3 | |||
| 76d668514e | |||
| 3c347bef7d | |||
| e837e5f780 | |||
| 26348ccc74 | |||
| 729a756e21 | |||
| 4dbddcf179 | |||
| f2fff34d4d | |||
| 59c5e2c1d9 | |||
| 067006f406 | |||
| 93d82b973e | |||
| a9a3423b58 | |||
| f4ee215ad8 | |||
| 48431b1c35 | |||
| ce961f90ba | |||
| 916d2f6bb3 | |||
| 01e7098f00 | |||
| e02fbac4cd | |||
| a8fce32e70 | |||
| d0637c1e3d | |||
| f6702d299d | |||
| 033b7ece28 | |||
| 5f5dce6d53 | |||
| 82c5c7518b | |||
| 7a60ffb3c4 | |||
| 2795f657b5 | |||
| 9ef5b5830e | |||
| 879adfa633 | |||
| b12a344776 | |||
| 50b1098797 | |||
| fdfaa7eba4 | |||
| 5525587513 | |||
| 1f20ed7640 | |||
| f741064843 | |||
| d5138e4c0a | |||
| 42a30c33db | |||
| e5d978f8e8 | |||
| ccc82520a9 | |||
| 22acf52a26 | |||
| 2ccd2786f4 | |||
| 0028136935 | |||
| 0edc46b771 | |||
| 2261f3d1c3 | |||
| 5c0e792782 | |||
| 644882e04f | |||
| 67f51c6de9 | |||
| 0c8fd6ab0e | |||
| 5452a57a14 | |||
| 19f020e7a6 | |||
| 825641f2a9 | |||
| 35ab4cb2fe | |||
| fd13607d89 | |||
| f79b4d44b9 | |||
| 91e30a6e84 | |||
| 8ab0f164a6 | |||
| 578bb03404 | |||
| 06582b5371 | |||
| 7f6baf35b7 | |||
| 6227d0baa1 | |||
| e334b585be | |||
| c83b3f19f7 | |||
| d31ec055f9 | |||
| 38c259a45e | |||
| b2ee24de98 | |||
| 9ba0e52bb7 | |||
| edc712e6f6 | |||
| 485888b2d9 | |||
| c2e90d4d83 | |||
| f5d89b8f52 | |||
| 378b40790a | |||
| be3d38392d | |||
| 27fef50983 | |||
| 167df85c1e | |||
| 009e16c9a4 | |||
| b40cc767b2 | |||
| 4f5f2d32be | |||
| 66be3e0281 | |||
| 987f188f00 | |||
| daca2bdf2a | |||
| 8894f52439 | |||
| 863f81e55a | |||
| d03d3735e5 | |||
| 3bb2df6e12 | |||
| 80c9efc618 | |||
| 3279901ab0 | |||
| d43d351721 | |||
| 8210eba439 | |||
| cbd7294b0b | |||
| 6064e8af87 | |||
| 8754f0c25f | |||
| f31700f668 | |||
| 9877b139f6 | |||
| 5643c846ee | |||
| 5a071babe9 | |||
| 3d85d0bce6 | |||
| 68afc2c718 | |||
| b3d9323f66 | |||
| effc63755b | |||
| b90934a72a | |||
| e01748eb2f | |||
| 430fbf5e46 | |||
| 27e6b9ce0d | |||
| fc614b9833 | |||
| a97c102369 | |||
| 745a491f90 | |||
| b2880ab0a9 | |||
| f916454c55 | |||
| 701b8ea12e | |||
| 2079942ccd | |||
| 140b718592 | |||
| 2de8c72131 | |||
| 089d4b5cee | |||
| e06a015d6e | |||
| b7e546f2f5 | |||
| 26ef275ab4 | |||
| 416db7c981 | |||
| 78079b2e60 | |||
| 03bffb725a | |||
| 46fc89e247 | |||
| fbbceaa642 | |||
| f5aae25cc8 | |||
| 8d03943acb | |||
| 853513b926 | |||
| c606a41314 | |||
| 35f29ca22b | |||
| ac00f3ebe7 | |||
| 6846de98f8 | |||
| 881baa818d | |||
| b671145e73 | |||
| 8809c7b900 | |||
| ae8f3aa918 | |||
| 5d4047c171 | |||
| 6f80591afd | |||
| 9b6fa8fe8c | |||
| d6c02ebb2c | |||
| 788d867ec3 | |||
| 3bc3914fd9 | |||
| 3d821dacb7 | |||
| e0546c6164 | |||
| be7ccfb209 | |||
| 938a8c6f80 | |||
| 5cd343cb01 | |||
| ab0094a53b | |||
| 2d5e4ebcf0 | |||
| 3171ce5aba | |||
| 0e1692d26b | |||
| e8cd18eac2 | |||
| bf928692d5 | |||
| 792490b629 | |||
| 0d1ff35c5e | |||
| 67e02fddbd | |||
| 09beb6a2ae | |||
| 1bd657f07d | |||
| 2dba17a7ae | |||
| 4900649908 | |||
| c3b33ea37a | |||
| 36bd6e649a | |||
| 4621c78573 | |||
| c88bbf1ce4 | |||
| d37b25a6f6 | |||
| 792268f5ee | |||
| 5f2d6f4d5e | |||
| 1350a91fba | |||
| acf22ca4fa | |||
| 705aac40d7 | |||
| 7456052620 | |||
| 6cd4ec7fce | |||
| 93b8e11378 | |||
| 6161daeef0 | |||
| cfcd351570 | |||
| 514893646a | |||
| e5469cc0f8 | |||
| ec6e70725c | |||
| 160dac109d | |||
| 6be741045f | |||
| f41d6d5c77 | |||
| a5dacd7821 | |||
| 8b12508b0c | |||
| a394f38fe9 | |||
| c4bfa266b0 | |||
| 96232676cb | |||
| b2aab06e01 | |||
| f002532c1e | |||
| 54663f0f01 | |||
| d8df9a9dff | |||
| 68efd30a54 | |||
| 27407d49dd | |||
| 97d4330cda | |||
| 3153bdc5bb | |||
| 31fd75a895 | |||
| b22173a631 | |||
| aeb87c81a1 | |||
| ce88ebb55b | |||
| c7e3f08d39 | |||
| d15264832d |
@@ -9,6 +9,12 @@ body:
|
|||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: "### Mobile app issues report [here](https://github.com/advplyr/audiobookshelf-app/issues/new/choose)."
|
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
|
- type: textarea
|
||||||
id: what-happened
|
id: what-happened
|
||||||
attributes:
|
attributes:
|
||||||
@@ -27,6 +33,7 @@ body:
|
|||||||
id: version
|
id: version
|
||||||
attributes:
|
attributes:
|
||||||
label: Audiobookshelf version
|
label: Audiobookshelf version
|
||||||
|
description: Do not put 'Latest version', please put the actual version here
|
||||||
placeholder: "e.g. v1.6.60"
|
placeholder: "e.g. v1.6.60"
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|||||||
@@ -3,8 +3,15 @@
|
|||||||
name: Build and Push Docker Image
|
name: Build and Push Docker Image
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
# Allows you to run workflow manually from Actions tab
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tags:
|
||||||
|
description: 'Docker Tag'
|
||||||
|
required: true
|
||||||
|
default: 'latest'
|
||||||
push:
|
push:
|
||||||
branches: [master]
|
branches: [main,master]
|
||||||
tags:
|
tags:
|
||||||
- 'v*.*.*'
|
- 'v*.*.*'
|
||||||
# Only build when files in these directories have been changed
|
# Only build when files in these directories have been changed
|
||||||
@@ -13,8 +20,6 @@ on:
|
|||||||
- server/**
|
- server/**
|
||||||
- index.js
|
- index.js
|
||||||
- package.json
|
- package.json
|
||||||
# Allows you to run workflow manually from Actions tab
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
@@ -23,24 +28,25 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Check out
|
- name: Check out
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: Docker meta
|
- name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v3
|
uses: docker/metadata-action@v4
|
||||||
with:
|
with:
|
||||||
images: advplyr/audiobookshelf,ghcr.io/${{ github.repository_owner }}/audiobookshelf
|
images: advplyr/audiobookshelf,ghcr.io/${{ github.repository_owner }}/audiobookshelf
|
||||||
tags: |
|
tags: |
|
||||||
type=edge,branch=master
|
type=edge,branch=master
|
||||||
type=semver,pattern={{version}}
|
type=semver,pattern={{version}}
|
||||||
|
|
||||||
- name: Setup QEMU
|
- name: Setup QEMU
|
||||||
uses: docker/setup-qemu-action@v1
|
uses: docker/setup-qemu-action@v2
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v1
|
uses: docker/setup-buildx-action@v2
|
||||||
|
|
||||||
- name: Cache Docker layers
|
- name: Cache Docker layers
|
||||||
uses: actions/cache@v2
|
uses: actions/cache@v3
|
||||||
with:
|
with:
|
||||||
path: /tmp/.buildx-cache
|
path: /tmp/.buildx-cache
|
||||||
key: ${{ runner.os }}-buildx-${{ github.sha }}
|
key: ${{ runner.os }}-buildx-${{ github.sha }}
|
||||||
@@ -48,22 +54,22 @@ jobs:
|
|||||||
${{ runner.os }}-buildx-
|
${{ runner.os }}-buildx-
|
||||||
|
|
||||||
- name: Login to Dockerhub
|
- name: Login to Dockerhub
|
||||||
uses: docker/login-action@v1
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||||
|
|
||||||
- name: Login to ghcr
|
- name: Login to ghcr
|
||||||
uses: docker/login-action@v1
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
password: ${{ secrets.GHCR_PASSWORD }}
|
password: ${{ secrets.GHCR_PASSWORD }}
|
||||||
|
|
||||||
- name: Build image
|
- name: Build image
|
||||||
uses: docker/build-push-action@v2
|
uses: docker/build-push-action@v3
|
||||||
with:
|
with:
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ github.event.inputs.tags || steps.meta.outputs.tags }}
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||||
push: true
|
push: true
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ test/
|
|||||||
/client/.nuxt/
|
/client/.nuxt/
|
||||||
/client/dist/
|
/client/dist/
|
||||||
/dist/
|
/dist/
|
||||||
|
library/
|
||||||
|
|
||||||
sw.*
|
sw.*
|
||||||
.DS_STORE
|
.DS_STORE
|
||||||
+15
-5
@@ -2,18 +2,28 @@
|
|||||||
FROM node:16-alpine AS build
|
FROM node:16-alpine AS build
|
||||||
WORKDIR /client
|
WORKDIR /client
|
||||||
COPY /client /client
|
COPY /client /client
|
||||||
RUN npm install
|
RUN npm ci && npm cache clean --force
|
||||||
RUN npm run generate
|
RUN npm run generate
|
||||||
|
|
||||||
### STAGE 1: Build server ###
|
### STAGE 1: Build server ###
|
||||||
FROM node:16-alpine
|
FROM node:16-alpine
|
||||||
RUN apk update && apk add --no-cache --update ffmpeg
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
RUN apk update && \
|
||||||
|
apk add --no-cache --update \
|
||||||
|
curl \
|
||||||
|
tzdata \
|
||||||
|
ffmpeg
|
||||||
|
|
||||||
COPY --from=build /client/dist /client/dist
|
COPY --from=build /client/dist /client/dist
|
||||||
COPY index.js index.js
|
COPY index.js package* /
|
||||||
COPY package-lock.json package-lock.json
|
|
||||||
COPY package.json package.json
|
|
||||||
COPY server server
|
COPY server server
|
||||||
|
|
||||||
RUN npm ci --only=production
|
RUN npm ci --only=production
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
HEALTHCHECK \
|
||||||
|
--interval=30s \
|
||||||
|
--timeout=3s \
|
||||||
|
--start-period=10s \
|
||||||
|
CMD curl -f http://127.0.0.1/ping || exit 1
|
||||||
CMD ["npm", "start"]
|
CMD ["npm", "start"]
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
@import './fonts.css';
|
@import './fonts.css';
|
||||||
@import './transitions.css';
|
@import './transitions.css';
|
||||||
@import './draggable.css';
|
@import './draggable.css';
|
||||||
|
@import './defaultStyles.css';
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--bookshelf-texture-img: url(/textures/wood_default.jpg);
|
--bookshelf-texture-img: url(/textures/wood_default.jpg);
|
||||||
@@ -225,3 +226,17 @@ Bookshelf Label
|
|||||||
/* number of lines to show */
|
/* number of lines to show */
|
||||||
-webkit-box-orient: vertical;
|
-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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/*
|
||||||
|
|
||||||
|
This is for setting regular html styles for places where embedding HTML will be
|
||||||
|
like podcast episode descriptions. Otherwise TailwindCSS will have stripped all default markup.
|
||||||
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
.default-style p {
|
||||||
|
display: block;
|
||||||
|
margin-block-start: 1em;
|
||||||
|
margin-block-end: 1em;
|
||||||
|
margin-inline-start: 0px;
|
||||||
|
margin-inline-end: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.default-style a {
|
||||||
|
text-decoration: none;
|
||||||
|
color: #5985ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.default-style ul {
|
||||||
|
display: block;
|
||||||
|
list-style: circle;
|
||||||
|
list-style-type: disc;
|
||||||
|
margin-block-start: 1em;
|
||||||
|
margin-block-end: 1em;
|
||||||
|
margin-inline-start: 0px;
|
||||||
|
margin-inline-end: 0px;
|
||||||
|
padding-inline-start: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.default-style ol {
|
||||||
|
display: block;
|
||||||
|
list-style: decimal;
|
||||||
|
list-style-type: decimal;
|
||||||
|
margin-block-start: 1em;
|
||||||
|
margin-block-end: 1em;
|
||||||
|
margin-inline-start: 0px;
|
||||||
|
margin-inline-end: 0px;
|
||||||
|
padding-inline-start: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.default-style li {
|
||||||
|
display: list-item;
|
||||||
|
text-align: -webkit-match-parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.default-style li::marker {
|
||||||
|
unicode-bidi: isolate;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
text-transform: none;
|
||||||
|
text-indent: 0px !important;
|
||||||
|
text-align: start !important;
|
||||||
|
text-align-last: start !important;
|
||||||
|
}
|
||||||
@@ -1,34 +1,40 @@
|
|||||||
.flip-list-move {
|
.flip-list-move {
|
||||||
transition: transform 0.5s;
|
transition: transform 0.5s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.no-move {
|
.no-move {
|
||||||
transition: transform 0s;
|
transition: transform 0s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ghost {
|
.ghost {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
background-color: rgba(255, 255, 255, 0.25);
|
background-color: rgba(255, 255, 255, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-group {
|
.list-group {
|
||||||
min-height: 30px;
|
min-height: 30px;
|
||||||
}
|
}
|
||||||
#librariesTable .item {
|
|
||||||
cursor: n-resize;
|
|
||||||
}
|
|
||||||
.drag-handle {
|
.drag-handle {
|
||||||
cursor: n-resize;
|
cursor: n-resize;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-group-item:not(.exclude) {
|
.list-group-item:not(.exclude) {
|
||||||
cursor: n-resize;
|
cursor: n-resize;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-group-item.exclude {
|
.list-group-item.exclude {
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-group-item:not(.ghost):not(.exclude):hover {
|
.list-group-item:not(.ghost):not(.exclude):hover {
|
||||||
background-color: rgba(0, 0, 0, 0.1);
|
background-color: rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-group-item:nth-child(even):not(.ghost):not(.exclude) {
|
.list-group-item:nth-child(even):not(.ghost):not(.exclude) {
|
||||||
background-color: rgba(0, 0, 0, 0.25);
|
background-color: rgba(0, 0, 0, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-group-item:nth-child(even):not(.ghost):not(.exclude):hover {
|
.list-group-item:nth-child(even):not(.ghost):not(.exclude):hover {
|
||||||
background-color: rgba(0, 0, 0, 0.1);
|
background-color: rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
@@ -36,6 +42,7 @@
|
|||||||
.list-group-item.exclude:not(.ghost) {
|
.list-group-item.exclude:not(.ghost) {
|
||||||
background-color: rgba(255, 0, 0, 0.25);
|
background-color: rgba(255, 0, 0, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-group-item.exclude:not(.ghost):hover {
|
.list-group-item.exclude:not(.ghost):hover {
|
||||||
background-color: rgba(223, 0, 0, 0.25);
|
background-color: rgba(223, 0, 0, 0.25);
|
||||||
}
|
}
|
||||||
+26
-26
@@ -74,7 +74,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
font-display: swap;
|
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;
|
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-style: normal;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
font-display: swap;
|
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;
|
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,7 +94,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
font-display: swap;
|
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;
|
unicode-range: U+1F00-1FFF;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,7 +104,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
font-display: swap;
|
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;
|
unicode-range: U+0370-03FF;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +114,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
font-display: swap;
|
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;
|
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-style: normal;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
font-display: swap;
|
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;
|
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-style: normal;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
font-display: swap;
|
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;
|
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-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: swap;
|
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;
|
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,7 +164,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: swap;
|
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;
|
unicode-range: U+1F00-1FFF;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,7 +174,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: swap;
|
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;
|
unicode-range: U+0370-03FF;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,7 +184,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: swap;
|
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;
|
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-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: swap;
|
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;
|
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-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: swap;
|
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;
|
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-style: normal;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-display: swap;
|
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;
|
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-style: normal;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-display: swap;
|
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;
|
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,7 +234,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-display: swap;
|
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;
|
unicode-range: U+1F00-1FFF;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,7 +244,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-display: swap;
|
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;
|
unicode-range: U+0370-03FF;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,7 +254,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-display: swap;
|
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;
|
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-style: normal;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-display: swap;
|
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;
|
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-style: normal;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-display: swap;
|
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;
|
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-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: swap;
|
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;
|
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-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: swap;
|
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;
|
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,7 +304,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: swap;
|
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;
|
unicode-range: U+1F00-1FFF;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,7 +314,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: swap;
|
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;
|
unicode-range: U+0370-03FF;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,7 +324,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: swap;
|
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;
|
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-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: swap;
|
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;
|
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;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,563 @@
|
|||||||
|
@charset "UTF-8";
|
||||||
|
|
||||||
|
/*
|
||||||
|
Trix 1.3.1
|
||||||
|
Copyright © 2020 Basecamp, LLC
|
||||||
|
http://trix-editor.org/*/
|
||||||
|
trix-editor {
|
||||||
|
border: 1px solid rgb(75, 85, 99);
|
||||||
|
border-radius: 3px;
|
||||||
|
background: rgb(35, 35, 35);
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.4em 0.6em;
|
||||||
|
min-height: 5em;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar * {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button-group {
|
||||||
|
display: flex;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
border: 1px solid rgb(75, 85, 99);
|
||||||
|
border-top-color: rgb(75, 85, 99);
|
||||||
|
border-bottom-color: rgb(75, 85, 99);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button-group:not(:first-child) {
|
||||||
|
margin-left: 1.5vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-device-width: 768px) {
|
||||||
|
trix-toolbar .trix-button-group:not(:first-child) {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button-group-spacer {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-device-width: 768px) {
|
||||||
|
trix-toolbar .trix-button-group-spacer {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button {
|
||||||
|
position: relative;
|
||||||
|
float: left;
|
||||||
|
color: rgba(0, 0, 0, 0.6);
|
||||||
|
font-size: 0.75em;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 0 0.5em;
|
||||||
|
margin: 0;
|
||||||
|
outline: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button:not(:first-child) {
|
||||||
|
border-left: 1px solid rgb(75, 85, 99);
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button.trix-active {
|
||||||
|
background: #bbb;
|
||||||
|
color: black;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button:not(:disabled) {
|
||||||
|
cursor: pointer;
|
||||||
|
background: rgb(35, 35, 35);
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button:disabled {
|
||||||
|
color: rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-device-width: 768px) {
|
||||||
|
trix-toolbar .trix-button {
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
padding: 0 0.3em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button--icon {
|
||||||
|
font-size: inherit;
|
||||||
|
width: 2.6em;
|
||||||
|
height: 1.6em;
|
||||||
|
max-width: calc(0.8em + 4vw);
|
||||||
|
text-indent: -9999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-device-width: 768px) {
|
||||||
|
trix-toolbar .trix-button--icon {
|
||||||
|
height: 2em;
|
||||||
|
max-width: calc(0.8em + 3.5vw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button--icon::before {
|
||||||
|
display: inline-block;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
opacity: 0.6;
|
||||||
|
content: "";
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: contain;
|
||||||
|
filter: invert(100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-device-width: 768px) {
|
||||||
|
trix-toolbar .trix-button--icon::before {
|
||||||
|
right: 6%;
|
||||||
|
left: 6%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button--icon.trix-active::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button--icon:disabled::before {
|
||||||
|
opacity: 0.125;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button--icon-attach::before {
|
||||||
|
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M16.5%206v11.5a4%204%200%201%201-8%200V5a2.5%202.5%200%200%201%205%200v10.5a1%201%200%201%201-2%200V6H10v9.5a2.5%202.5%200%200%200%205%200V5a4%204%200%201%200-8%200v12.5a5.5%205.5%200%200%200%2011%200V6h-1.5z%22%2F%3E%3C%2Fsvg%3E);
|
||||||
|
top: 8%;
|
||||||
|
bottom: 4%;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button--icon-bold::before {
|
||||||
|
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M15.6%2011.8c1-.7%201.6-1.8%201.6-2.8a4%204%200%200%200-4-4H7v14h7c2.1%200%203.7-1.7%203.7-3.8%200-1.5-.8-2.8-2.1-3.4zM10%207.5h3a1.5%201.5%200%201%201%200%203h-3v-3zm3.5%209H10v-3h3.5a1.5%201.5%200%201%201%200%203z%22%2F%3E%3C%2Fsvg%3E);
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button--icon-italic::before {
|
||||||
|
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M10%205v3h2.2l-3.4%208H6v3h8v-3h-2.2l3.4-8H18V5h-8z%22%2F%3E%3C%2Fsvg%3E);
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button--icon-link::before {
|
||||||
|
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M9.88%2013.7a4.3%204.3%200%200%201%200-6.07l3.37-3.37a4.26%204.26%200%200%201%206.07%200%204.3%204.3%200%200%201%200%206.06l-1.96%201.72a.91.91%200%201%201-1.3-1.3l1.97-1.71a2.46%202.46%200%200%200-3.48-3.48l-3.38%203.37a2.46%202.46%200%200%200%200%203.48.91.91%200%201%201-1.3%201.3z%22%2F%3E%3Cpath%20d%3D%22M4.25%2019.46a4.3%204.3%200%200%201%200-6.07l1.93-1.9a.91.91%200%201%201%201.3%201.3l-1.93%201.9a2.46%202.46%200%200%200%203.48%203.48l3.37-3.38c.96-.96.96-2.52%200-3.48a.91.91%200%201%201%201.3-1.3%204.3%204.3%200%200%201%200%206.07l-3.38%203.38a4.26%204.26%200%200%201-6.07%200z%22%2F%3E%3C%2Fsvg%3E);
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button--icon-strike::before {
|
||||||
|
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M12.73%2014l.28.14c.26.15.45.3.57.44.12.14.18.3.18.5%200%20.3-.15.56-.44.75-.3.2-.76.3-1.39.3A13.52%2013.52%200%200%201%207%2014.95v3.37a10.64%2010.64%200%200%200%204.84.88c1.26%200%202.35-.19%203.28-.56.93-.37%201.64-.9%202.14-1.57s.74-1.45.74-2.32c0-.26-.02-.51-.06-.75h-5.21zm-5.5-4c-.08-.34-.12-.7-.12-1.1%200-1.29.52-2.3%201.58-3.02%201.05-.72%202.5-1.08%204.34-1.08%201.62%200%203.28.34%204.97%201l-1.3%202.93c-1.47-.6-2.73-.9-3.8-.9-.55%200-.96.08-1.2.26-.26.17-.38.38-.38.64%200%20.27.16.52.48.74.17.12.53.3%201.05.53H7.23zM3%2013h18v-2H3v2z%22%2F%3E%3C%2Fsvg%3E);
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button--icon-quote::before {
|
||||||
|
background-image: url(data:image/svg+xml,%3Csvg%20version%3D%221%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M6%2017h3l2-4V7H5v6h3zm8%200h3l2-4V7h-6v6h3z%22%2F%3E%3C%2Fsvg%3E);
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button--icon-heading-1::before {
|
||||||
|
background-image: url(data:image/svg+xml,%3Csvg%20version%3D%221%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M12%209v3H9v7H6v-7H3V9h9zM8%204h14v3h-6v12h-3V7H8V4z%22%2F%3E%3C%2Fsvg%3E);
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button--icon-code::before {
|
||||||
|
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M18.2%2012L15%2015.2l1.4%201.4L21%2012l-4.6-4.6L15%208.8l3.2%203.2zM5.8%2012L9%208.8%207.6%207.4%203%2012l4.6%204.6L9%2015.2%205.8%2012z%22%2F%3E%3C%2Fsvg%3E);
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button--icon-bullet-list::before {
|
||||||
|
background-image: url(data:image/svg+xml,%3Csvg%20version%3D%221%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M4%204a2%202%200%201%200%200%204%202%202%200%200%200%200-4zm0%206a2%202%200%201%200%200%204%202%202%200%200%200%200-4zm0%206a2%202%200%201%200%200%204%202%202%200%200%200%200-4zm4%203h14v-2H8v2zm0-6h14v-2H8v2zm0-8v2h14V5H8z%22%2F%3E%3C%2Fsvg%3E);
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button--icon-number-list::before {
|
||||||
|
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M2%2017h2v.5H3v1h1v.5H2v1h3v-4H2v1zm1-9h1V4H2v1h1v3zm-1%203h1.8L2%2013.1v.9h3v-1H3.2L5%2010.9V10H2v1zm5-6v2h14V5H7zm0%2014h14v-2H7v2zm0-6h14v-2H7v2z%22%2F%3E%3C%2Fsvg%3E);
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button--icon-undo::before {
|
||||||
|
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M12.5%208c-2.6%200-5%201-6.9%202.6L2%207v9h9l-3.6-3.6A8%208%200%200%201%2020%2016l2.4-.8a10.5%2010.5%200%200%200-10-7.2z%22%2F%3E%3C%2Fsvg%3E);
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button--icon-redo::before {
|
||||||
|
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M18.4%2010.6a10.5%2010.5%200%200%200-16.9%204.6L4%2016a8%208%200%200%201%2012.7-3.6L13%2016h9V7l-3.6%203.6z%22%2F%3E%3C%2Fsvg%3E);
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button--icon-decrease-nesting-level::before {
|
||||||
|
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M3%2019h19v-2H3v2zm7-6h12v-2H10v2zm-8.3-.3l2.8%202.9L6%2014.2%204%2012l2-2-1.4-1.5L1%2012l.7.7zM3%205v2h19V5H3z%22%2F%3E%3C%2Fsvg%3E);
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button--icon-increase-nesting-level::before {
|
||||||
|
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M3%2019h19v-2H3v2zm7-6h12v-2H10v2zm-6.9-1L1%2014.2l1.4%201.4L6%2012l-.7-.7-2.8-2.8L1%209.9%203.1%2012zM3%205v2h19V5H3z%22%2F%3E%3C%2Fsvg%3E);
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-dialogs {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-dialog {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
font-size: 0.75em;
|
||||||
|
padding: 15px 10px;
|
||||||
|
background: rgb(48, 48, 48);
|
||||||
|
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||||
|
border: 1px solid rgb(112, 112, 112);
|
||||||
|
border-radius: 5px;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-input--dialog {
|
||||||
|
font-size: inherit;
|
||||||
|
font-weight: normal;
|
||||||
|
padding: 0.5em 0.8em;
|
||||||
|
margin: 0 10px 0 0;
|
||||||
|
border-radius: 3px;
|
||||||
|
border: 1px solid #bbb;
|
||||||
|
background-color: rgb(95, 95, 95);
|
||||||
|
box-shadow: none;
|
||||||
|
outline: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
-moz-appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-input--dialog.validate:invalid {
|
||||||
|
box-shadow: #F00 0px 0px 1.5px 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-button--dialog {
|
||||||
|
font-size: inherit;
|
||||||
|
padding: 0.5em;
|
||||||
|
border-bottom: none;
|
||||||
|
color: #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-dialog--link {
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-dialog__link-fields {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-dialog__link-fields .trix-input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-toolbar .trix-dialog__link-fields .trix-button-group {
|
||||||
|
flex: 0 0 content;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor [data-trix-mutable]:not(.attachment__caption-editor) {
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor [data-trix-mutable]::-moz-selection,
|
||||||
|
trix-editor [data-trix-cursor-target]::-moz-selection,
|
||||||
|
trix-editor [data-trix-mutable] ::-moz-selection {
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor [data-trix-mutable]::selection,
|
||||||
|
trix-editor [data-trix-cursor-target]::selection,
|
||||||
|
trix-editor [data-trix-mutable] ::selection {
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor [data-trix-mutable].attachment__caption-editor:focus::-moz-selection {
|
||||||
|
background: highlight;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor [data-trix-mutable].attachment__caption-editor:focus::selection {
|
||||||
|
background: highlight;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor [data-trix-mutable].attachment.attachment--file {
|
||||||
|
box-shadow: 0 0 0 2px highlight;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor [data-trix-mutable].attachment img {
|
||||||
|
box-shadow: 0 0 0 2px highlight;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor .attachment {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor .attachment:hover {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor .attachment--preview .attachment__caption:hover {
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor .attachment__progress {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
|
height: 20px;
|
||||||
|
top: calc(50% - 10px);
|
||||||
|
left: 5%;
|
||||||
|
width: 90%;
|
||||||
|
opacity: 0.9;
|
||||||
|
transition: opacity 200ms ease-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor .attachment__progress[value="100"] {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor .attachment__caption-editor {
|
||||||
|
display: inline-block;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-size: inherit;
|
||||||
|
font-family: inherit;
|
||||||
|
line-height: inherit;
|
||||||
|
color: inherit;
|
||||||
|
text-align: center;
|
||||||
|
vertical-align: top;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
-moz-appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor .attachment__toolbar {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
|
top: -0.9em;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor .trix-button-group {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor .trix-button {
|
||||||
|
position: relative;
|
||||||
|
float: left;
|
||||||
|
color: #666;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 80%;
|
||||||
|
padding: 0 0.8em;
|
||||||
|
margin: 0;
|
||||||
|
outline: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor .trix-button:not(:first-child) {
|
||||||
|
border-left: 1px solid #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor .trix-button.trix-active {
|
||||||
|
background: #cbeefa;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor .trix-button:not(:disabled) {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor .trix-button--remove {
|
||||||
|
text-indent: -9999px;
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0;
|
||||||
|
outline: none;
|
||||||
|
width: 1.8em;
|
||||||
|
height: 1.8em;
|
||||||
|
line-height: 1.8em;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #fff;
|
||||||
|
border: 2px solid highlight;
|
||||||
|
box-shadow: 1px 1px 6px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor .trix-button--remove::before {
|
||||||
|
display: inline-block;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
opacity: 0.7;
|
||||||
|
content: "";
|
||||||
|
background-image: url(data:image/svg+xml,%3Csvg%20height%3D%2224%22%20width%3D%2224%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20d%3D%22M19%206.4L17.6%205%2012%2010.6%206.4%205%205%206.4l5.6%205.6L5%2017.6%206.4%2019l5.6-5.6%205.6%205.6%201.4-1.4-5.6-5.6z%22%2F%3E%3Cpath%20d%3D%22M0%200h24v24H0z%22%20fill%3D%22none%22%2F%3E%3C%2Fsvg%3E);
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: 90%;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor .trix-button--remove:hover {
|
||||||
|
border-color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor .trix-button--remove:hover::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor .attachment__metadata-container {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor .attachment__metadata {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 2em;
|
||||||
|
transform: translate(-50%, 0);
|
||||||
|
max-width: 90%;
|
||||||
|
padding: 0.1em 0.6em;
|
||||||
|
font-size: 0.8em;
|
||||||
|
color: #fff;
|
||||||
|
background-color: rgba(0, 0, 0, 0.7);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor .attachment__metadata .attachment__name {
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 100%;
|
||||||
|
vertical-align: bottom;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
trix-editor .attachment__metadata .attachment__size {
|
||||||
|
margin-left: 0.2em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trix-content {
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trix-content * {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trix-content h1 {
|
||||||
|
font-size: 1.2em;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trix-content blockquote {
|
||||||
|
border: 0 solid #ccc;
|
||||||
|
border-left-width: 0.3em;
|
||||||
|
margin-left: 0.3em;
|
||||||
|
padding-left: 0.6em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trix-content [dir=rtl] blockquote,
|
||||||
|
.trix-content blockquote[dir=rtl] {
|
||||||
|
border-width: 0;
|
||||||
|
border-right-width: 0.3em;
|
||||||
|
margin-right: 0.3em;
|
||||||
|
padding-right: 0.6em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trix-content li {
|
||||||
|
margin-left: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trix-content [dir=rtl] li {
|
||||||
|
margin-right: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trix-content pre {
|
||||||
|
display: inline-block;
|
||||||
|
width: 100%;
|
||||||
|
vertical-align: top;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
padding: 0.5em;
|
||||||
|
white-space: pre;
|
||||||
|
background-color: #eee;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trix-content img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trix-content .attachment {
|
||||||
|
display: inline-block;
|
||||||
|
position: relative;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trix-content .attachment a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trix-content .attachment a:hover,
|
||||||
|
.trix-content .attachment a:visited:hover {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trix-content .attachment__caption {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trix-content .attachment__caption .attachment__name+.attachment__size::before {
|
||||||
|
content: ' · ';
|
||||||
|
}
|
||||||
|
|
||||||
|
.trix-content .attachment--preview {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trix-content .attachment--preview .attachment__caption {
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.9em;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trix-content .attachment--file {
|
||||||
|
color: #333;
|
||||||
|
line-height: 1;
|
||||||
|
margin: 0 2px 2px 2px;
|
||||||
|
padding: 0.4em 1em;
|
||||||
|
border: 1px solid #bbb;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trix-content .attachment-gallery {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trix-content .attachment-gallery .attachment {
|
||||||
|
flex: 1 0 33%;
|
||||||
|
padding: 0 0.5em;
|
||||||
|
max-width: 33%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trix-content .attachment-gallery.attachment-gallery--2 .attachment,
|
||||||
|
.trix-content .attachment-gallery.attachment-gallery--4 .attachment {
|
||||||
|
flex-basis: 50%;
|
||||||
|
max-width: 50%;
|
||||||
|
}
|
||||||
@@ -1,415 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="w-full -mt-6">
|
|
||||||
<div class="w-full relative mb-1">
|
|
||||||
<div class="absolute -top-10 md:top-0 right-0 md:right-2 flex items-center h-full">
|
|
||||||
<controls-volume-control ref="volumeControl" v-model="volume" @input="setVolume" class="mx-2 hidden md:block" />
|
|
||||||
|
|
||||||
<div class="cursor-pointer text-gray-300 mx-1 md:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showSleepTimer')">
|
|
||||||
<span v-if="!sleepTimerSet" class="material-icons" style="font-size: 1.7rem">snooze</span>
|
|
||||||
<div v-else class="flex items-center">
|
|
||||||
<span class="material-icons text-lg text-warning">snooze</span>
|
|
||||||
<p class="text-xl text-warning font-mono font-semibold text-center px-0.5 pb-0.5" style="min-width: 30px">{{ sleepTimerRemainingString }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="!isPodcast" class="cursor-pointer text-gray-300 mx-1 md:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showBookmarks')">
|
|
||||||
<span class="material-icons" style="font-size: 1.7rem">{{ bookmarks.length ? 'bookmarks' : 'bookmark_border' }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="chapters.length" class="cursor-pointer text-gray-300 mx-1 md:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="showChapters">
|
|
||||||
<span class="material-icons text-3xl">format_list_bulleted</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex pt-4 pb-2 md:pt-0 md:pb-2">
|
|
||||||
<div class="flex-grow" />
|
|
||||||
<template v-if="!loading">
|
|
||||||
<div class="cursor-pointer flex items-center justify-center text-gray-300 mr-8" @mousedown.prevent @mouseup.prevent @click.stop="restart">
|
|
||||||
<span class="material-icons text-3xl">first_page</span>
|
|
||||||
</div>
|
|
||||||
<div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpBackward">
|
|
||||||
<span class="material-icons text-3xl">replay_10</span>
|
|
||||||
</div>
|
|
||||||
<div class="cursor-pointer p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-8" :class="seekLoading ? 'animate-spin' : ''" @mousedown.prevent @mouseup.prevent @click.stop="playPause">
|
|
||||||
<span class="material-icons">{{ seekLoading ? 'autorenew' : paused ? 'play_arrow' : 'pause' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpForward">
|
|
||||||
<span class="material-icons text-3xl">forward_10</span>
|
|
||||||
</div>
|
|
||||||
<controls-playback-speed-control v-model="playbackRate" @input="playbackRateUpdated" @change="playbackRateChanged" />
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<div class="cursor-pointer p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-8 animate-spin">
|
|
||||||
<span class="material-icons">autorenew</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<div class="flex-grow" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="relative">
|
|
||||||
<!-- Track -->
|
|
||||||
<div ref="track" class="w-full h-2 bg-gray-700 relative cursor-pointer transform duration-100 hover:scale-y-125 overflow-hidden" @mousemove="mousemoveTrack" @mouseleave="mouseleaveTrack" @click.stop="clickTrack">
|
|
||||||
<div ref="readyTrack" class="h-full bg-gray-600 absolute top-0 left-0 pointer-events-none" />
|
|
||||||
<div ref="bufferTrack" class="h-full bg-gray-400 absolute top-0 left-0 pointer-events-none" />
|
|
||||||
<div ref="playedTrack" class="h-full bg-gray-200 absolute top-0 left-0 pointer-events-none" />
|
|
||||||
<div ref="trackCursor" class="h-full w-0.5 bg-gray-50 absolute top-0 left-0 opacity-0 pointer-events-none" />
|
|
||||||
<div v-if="loading" class="h-full w-1/4 absolute left-0 top-0 loadingTrack pointer-events-none bg-white bg-opacity-25" />
|
|
||||||
</div>
|
|
||||||
<div ref="track" class="w-full h-2 relative overflow-hidden">
|
|
||||||
<template v-for="(tick, index) in chapterTicks">
|
|
||||||
<div :key="index" :style="{ left: tick.left + 'px' }" class="absolute top-0 w-px bg-white bg-opacity-30 h-1 pointer-events-none" />
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Hover timestamp -->
|
|
||||||
<div ref="hoverTimestamp" class="absolute -top-8 left-0 bg-white text-black rounded-full opacity-0 pointer-events-none">
|
|
||||||
<p ref="hoverTimestampText" class="text-xs font-mono text-center px-2 py-0.5 truncate whitespace-nowrap">00:00</p>
|
|
||||||
</div>
|
|
||||||
<div ref="hoverTimestampArrow" class="absolute -top-3 left-0 bg-white text-black rounded-full opacity-0 pointer-events-none">
|
|
||||||
<div class="absolute -bottom-1.5 left-0 right-0 w-full flex justify-center">
|
|
||||||
<div class="arrow-down" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex">
|
|
||||||
<p ref="currentTimestamp" class="font-mono text-sm text-gray-100 pointer-events-auto">00:00:00</p>
|
|
||||||
<p class="font-mono text-sm text-gray-100 pointer-events-auto"> / {{ progressPercent }}%</p>
|
|
||||||
<div class="flex-grow" />
|
|
||||||
<p class="text-sm text-gray-300 pt-0.5">{{ currentChapterName }}</p>
|
|
||||||
<div class="flex-grow" />
|
|
||||||
<p class="font-mono text-sm text-gray-100 pointer-events-auto">{{ timeRemainingPretty }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<modals-chapters-modal v-model="showChaptersModal" :current-chapter="currentChapter" :chapters="chapters" @select="selectChapter" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
loading: Boolean,
|
|
||||||
paused: Boolean,
|
|
||||||
chapters: {
|
|
||||||
type: Array,
|
|
||||||
default: () => []
|
|
||||||
},
|
|
||||||
bookmarks: {
|
|
||||||
type: Array,
|
|
||||||
default: () => []
|
|
||||||
},
|
|
||||||
sleepTimerSet: Boolean,
|
|
||||||
sleepTimerRemaining: Number,
|
|
||||||
isPodcast: Boolean
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
volume: 1,
|
|
||||||
playbackRate: 1,
|
|
||||||
trackWidth: 0,
|
|
||||||
playedTrackWidth: 0,
|
|
||||||
bufferTrackWidth: 0,
|
|
||||||
readyTrackWidth: 0,
|
|
||||||
audioEl: null,
|
|
||||||
seekLoading: false,
|
|
||||||
showChaptersModal: false,
|
|
||||||
currentTime: 0,
|
|
||||||
trackOffsetLeft: 16, // Track is 16px from edge
|
|
||||||
duration: 0,
|
|
||||||
chapterTicks: []
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
sleepTimerRemainingString() {
|
|
||||||
var rounded = Math.round(this.sleepTimerRemaining)
|
|
||||||
if (rounded < 90) {
|
|
||||||
return `${rounded}s`
|
|
||||||
}
|
|
||||||
var minutesRounded = Math.round(rounded / 60)
|
|
||||||
if (minutesRounded < 90) {
|
|
||||||
return `${minutesRounded}m`
|
|
||||||
}
|
|
||||||
var hoursRounded = Math.round(minutesRounded / 60)
|
|
||||||
return `${hoursRounded}h`
|
|
||||||
},
|
|
||||||
token() {
|
|
||||||
return this.$store.getters['user/getToken']
|
|
||||||
},
|
|
||||||
timeRemaining() {
|
|
||||||
return (this.duration - this.currentTime) / this.playbackRate
|
|
||||||
},
|
|
||||||
timeRemainingPretty() {
|
|
||||||
if (this.timeRemaining < 0) {
|
|
||||||
return this.$secondsToTimestamp(this.timeRemaining * -1)
|
|
||||||
}
|
|
||||||
return '-' + this.$secondsToTimestamp(this.timeRemaining)
|
|
||||||
},
|
|
||||||
progressPercent() {
|
|
||||||
if (!this.duration) return 0
|
|
||||||
return Math.round((100 * this.currentTime) / this.duration)
|
|
||||||
},
|
|
||||||
currentChapter() {
|
|
||||||
return this.chapters.find((chapter) => chapter.start <= this.currentTime && this.currentTime < chapter.end)
|
|
||||||
},
|
|
||||||
currentChapterName() {
|
|
||||||
return this.currentChapter ? this.currentChapter.title : ''
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
setDuration(duration) {
|
|
||||||
this.duration = duration
|
|
||||||
|
|
||||||
this.chapterTicks = this.chapters.map((chap) => {
|
|
||||||
var perc = chap.start / this.duration
|
|
||||||
return {
|
|
||||||
title: chap.title,
|
|
||||||
left: perc * this.trackWidth
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
setCurrentTime(time) {
|
|
||||||
this.currentTime = time
|
|
||||||
this.updateTimestamp()
|
|
||||||
this.updatePlayedTrack()
|
|
||||||
},
|
|
||||||
playPause() {
|
|
||||||
this.$emit('playPause')
|
|
||||||
},
|
|
||||||
jumpBackward() {
|
|
||||||
this.$emit('jumpBackward')
|
|
||||||
},
|
|
||||||
jumpForward() {
|
|
||||||
this.$emit('jumpForward')
|
|
||||||
},
|
|
||||||
increaseVolume() {
|
|
||||||
if (this.volume >= 1) return
|
|
||||||
this.volume = Math.min(1, this.volume + 0.1)
|
|
||||||
this.setVolume(this.volume)
|
|
||||||
},
|
|
||||||
decreaseVolume() {
|
|
||||||
if (this.volume <= 0) return
|
|
||||||
this.volume = Math.max(0, this.volume - 0.1)
|
|
||||||
this.setVolume(this.volume)
|
|
||||||
},
|
|
||||||
setVolume(volume) {
|
|
||||||
this.$emit('setVolume', volume)
|
|
||||||
},
|
|
||||||
toggleMute() {
|
|
||||||
if (this.$refs.volumeControl && this.$refs.volumeControl.toggleMute) {
|
|
||||||
this.$refs.volumeControl.toggleMute()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
increasePlaybackRate() {
|
|
||||||
var rates = [0.25, 0.5, 0.8, 1, 1.3, 1.5, 2, 2.5, 3]
|
|
||||||
var currentRateIndex = rates.findIndex((r) => r === this.playbackRate)
|
|
||||||
if (currentRateIndex >= rates.length - 1) return
|
|
||||||
this.playbackRate = rates[currentRateIndex + 1] || 1
|
|
||||||
this.playbackRateChanged(this.playbackRate)
|
|
||||||
},
|
|
||||||
decreasePlaybackRate() {
|
|
||||||
var rates = [0.25, 0.5, 0.8, 1, 1.3, 1.5, 2, 2.5, 3]
|
|
||||||
var currentRateIndex = rates.findIndex((r) => r === this.playbackRate)
|
|
||||||
if (currentRateIndex <= 0) return
|
|
||||||
this.playbackRate = rates[currentRateIndex - 1] || 1
|
|
||||||
this.playbackRateChanged(this.playbackRate)
|
|
||||||
},
|
|
||||||
setPlaybackRate(playbackRate) {
|
|
||||||
this.$emit('setPlaybackRate', playbackRate)
|
|
||||||
},
|
|
||||||
selectChapter(chapter) {
|
|
||||||
this.seek(chapter.start)
|
|
||||||
this.showChaptersModal = false
|
|
||||||
},
|
|
||||||
seek(time) {
|
|
||||||
this.$emit('seek', time)
|
|
||||||
},
|
|
||||||
playbackRateUpdated(playbackRate) {
|
|
||||||
this.setPlaybackRate(playbackRate)
|
|
||||||
},
|
|
||||||
playbackRateChanged(playbackRate) {
|
|
||||||
this.setPlaybackRate(playbackRate)
|
|
||||||
this.$store.dispatch('user/updateUserSettings', { playbackRate }).catch((err) => {
|
|
||||||
console.error('Failed to update settings', err)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
mousemoveTrack(e) {
|
|
||||||
var offsetX = e.offsetX
|
|
||||||
var time = (offsetX / this.trackWidth) * this.duration
|
|
||||||
if (this.$refs.hoverTimestamp) {
|
|
||||||
var width = this.$refs.hoverTimestamp.clientWidth
|
|
||||||
this.$refs.hoverTimestamp.style.opacity = 1
|
|
||||||
var posLeft = offsetX - width / 2
|
|
||||||
if (posLeft + width + this.trackOffsetLeft > window.innerWidth) {
|
|
||||||
posLeft = window.innerWidth - width - this.trackOffsetLeft
|
|
||||||
} else if (posLeft < -this.trackOffsetLeft) {
|
|
||||||
posLeft = -this.trackOffsetLeft
|
|
||||||
}
|
|
||||||
this.$refs.hoverTimestamp.style.left = posLeft + 'px'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.$refs.hoverTimestampArrow) {
|
|
||||||
var width = this.$refs.hoverTimestampArrow.clientWidth
|
|
||||||
var posLeft = offsetX - width / 2
|
|
||||||
this.$refs.hoverTimestampArrow.style.opacity = 1
|
|
||||||
this.$refs.hoverTimestampArrow.style.left = posLeft + 'px'
|
|
||||||
}
|
|
||||||
if (this.$refs.hoverTimestampText) {
|
|
||||||
var hoverText = this.$secondsToTimestamp(time)
|
|
||||||
|
|
||||||
var chapter = this.chapters.find((chapter) => chapter.start <= time && time < chapter.end)
|
|
||||||
if (chapter && chapter.title) {
|
|
||||||
hoverText += ` - ${chapter.title}`
|
|
||||||
}
|
|
||||||
this.$refs.hoverTimestampText.innerText = hoverText
|
|
||||||
}
|
|
||||||
if (this.$refs.trackCursor) {
|
|
||||||
this.$refs.trackCursor.style.opacity = 1
|
|
||||||
this.$refs.trackCursor.style.left = offsetX - 1 + 'px'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mouseleaveTrack() {
|
|
||||||
if (this.$refs.hoverTimestamp) {
|
|
||||||
this.$refs.hoverTimestamp.style.opacity = 0
|
|
||||||
}
|
|
||||||
if (this.$refs.hoverTimestampArrow) {
|
|
||||||
this.$refs.hoverTimestampArrow.style.opacity = 0
|
|
||||||
}
|
|
||||||
if (this.$refs.trackCursor) {
|
|
||||||
this.$refs.trackCursor.style.opacity = 0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
restart() {
|
|
||||||
this.seek(0)
|
|
||||||
},
|
|
||||||
setStreamReady() {
|
|
||||||
this.readyTrackWidth = this.trackWidth
|
|
||||||
this.$refs.readyTrack.style.width = this.trackWidth + 'px'
|
|
||||||
},
|
|
||||||
setChunksReady(chunks, numSegments) {
|
|
||||||
var largestSeg = 0
|
|
||||||
for (let i = 0; i < chunks.length; i++) {
|
|
||||||
var chunk = chunks[i]
|
|
||||||
if (typeof chunk === 'string') {
|
|
||||||
var chunkRange = chunk.split('-').map((c) => Number(c))
|
|
||||||
if (chunkRange.length < 2) continue
|
|
||||||
if (chunkRange[1] > largestSeg) largestSeg = chunkRange[1]
|
|
||||||
} else if (chunk > largestSeg) {
|
|
||||||
largestSeg = chunk
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var percentageReady = largestSeg / numSegments
|
|
||||||
var widthReady = Math.round(this.trackWidth * percentageReady)
|
|
||||||
if (this.readyTrackWidth === widthReady) return
|
|
||||||
this.readyTrackWidth = widthReady
|
|
||||||
this.$refs.readyTrack.style.width = widthReady + 'px'
|
|
||||||
},
|
|
||||||
updateTimestamp() {
|
|
||||||
var ts = this.$refs.currentTimestamp
|
|
||||||
if (!ts) {
|
|
||||||
console.error('No timestamp el')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var currTimeClean = this.$secondsToTimestamp(this.currentTime)
|
|
||||||
ts.innerText = currTimeClean
|
|
||||||
},
|
|
||||||
updatePlayedTrack() {
|
|
||||||
var perc = this.currentTime / this.duration
|
|
||||||
var ptWidth = Math.round(perc * this.trackWidth)
|
|
||||||
if (this.playedTrackWidth === ptWidth) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
this.$refs.playedTrack.style.width = ptWidth + 'px'
|
|
||||||
this.playedTrackWidth = ptWidth
|
|
||||||
},
|
|
||||||
clickTrack(e) {
|
|
||||||
if (this.loading) return
|
|
||||||
|
|
||||||
var offsetX = e.offsetX
|
|
||||||
var perc = offsetX / this.trackWidth
|
|
||||||
var time = perc * this.duration
|
|
||||||
if (isNaN(time) || time === null) {
|
|
||||||
console.error('Invalid time', perc, time)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
this.seek(time)
|
|
||||||
},
|
|
||||||
setBufferTime(bufferTime) {
|
|
||||||
if (!this.audioEl) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var bufferlen = (bufferTime / this.duration) * this.trackWidth
|
|
||||||
bufferlen = Math.round(bufferlen)
|
|
||||||
if (this.bufferTrackWidth === bufferlen || !this.$refs.bufferTrack) return
|
|
||||||
this.$refs.bufferTrack.style.width = bufferlen + 'px'
|
|
||||||
this.bufferTrackWidth = bufferlen
|
|
||||||
},
|
|
||||||
showChapters() {
|
|
||||||
if (!this.chapters.length) return
|
|
||||||
this.showChaptersModal = !this.showChaptersModal
|
|
||||||
},
|
|
||||||
init() {
|
|
||||||
this.playbackRate = this.$store.getters['user/getUserSetting']('playbackRate') || 1
|
|
||||||
this.$emit('setPlaybackRate', this.playbackRate)
|
|
||||||
this.setTrackWidth()
|
|
||||||
},
|
|
||||||
setTrackWidth() {
|
|
||||||
if (this.$refs.track) {
|
|
||||||
this.trackWidth = this.$refs.track.clientWidth
|
|
||||||
} else {
|
|
||||||
console.error('Track not loaded', this.$refs)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
settingsUpdated(settings) {
|
|
||||||
if (settings.playbackRate && this.playbackRate !== settings.playbackRate) {
|
|
||||||
this.setPlaybackRate(settings.playbackRate)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
closePlayer() {
|
|
||||||
if (this.loading) return
|
|
||||||
this.$emit('close')
|
|
||||||
},
|
|
||||||
hotkey(action) {
|
|
||||||
if (action === this.$hotkeys.AudioPlayer.PLAY_PAUSE) this.playPause()
|
|
||||||
else if (action === this.$hotkeys.AudioPlayer.JUMP_FORWARD) this.jumpForward()
|
|
||||||
else if (action === this.$hotkeys.AudioPlayer.JUMP_BACKWARD) this.jumpBackward()
|
|
||||||
else if (action === this.$hotkeys.AudioPlayer.VOLUME_UP) this.increaseVolume()
|
|
||||||
else if (action === this.$hotkeys.AudioPlayer.VOLUME_DOWN) this.decreaseVolume()
|
|
||||||
else if (action === this.$hotkeys.AudioPlayer.MUTE_UNMUTE) this.toggleMute()
|
|
||||||
else if (action === this.$hotkeys.AudioPlayer.SHOW_CHAPTERS) this.showChapters()
|
|
||||||
else if (action === this.$hotkeys.AudioPlayer.INCREASE_PLAYBACK_RATE) this.increasePlaybackRate()
|
|
||||||
else if (action === this.$hotkeys.AudioPlayer.DECREASE_PLAYBACK_RATE) this.decreasePlaybackRate()
|
|
||||||
else if (action === this.$hotkeys.AudioPlayer.CLOSE) this.closePlayer()
|
|
||||||
},
|
|
||||||
windowResize() {
|
|
||||||
this.setTrackWidth()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
window.addEventListener('resize', this.windowResize)
|
|
||||||
this.$store.commit('user/addSettingsListener', { id: 'audioplayer', meth: this.settingsUpdated })
|
|
||||||
this.init()
|
|
||||||
this.$eventBus.$on('player-hotkey', this.hotkey)
|
|
||||||
},
|
|
||||||
beforeDestroy() {
|
|
||||||
window.removeEventListener('resize', this.windowResize)
|
|
||||||
this.$store.commit('user/removeSettingsListener', 'audioplayer')
|
|
||||||
this.$eventBus.$off('player-hotkey', this.hotkey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.loadingTrack {
|
|
||||||
animation-name: loadingTrack;
|
|
||||||
animation-duration: 1s;
|
|
||||||
animation-iteration-count: infinite;
|
|
||||||
}
|
|
||||||
@keyframes loadingTrack {
|
|
||||||
0% {
|
|
||||||
left: -25%;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
left: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
<div id="appbar" class="absolute top-0 bottom-0 left-0 w-full h-full px-2 md:px-6 py-1 z-50">
|
<div 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">
|
<div class="flex h-full items-center">
|
||||||
<nuxt-link to="/">
|
<nuxt-link to="/">
|
||||||
<img src="/icon48.png" class="w-10 h-10 md:w-12 md:h-12 mr-4" />
|
<img src="/icon48.png" class="w-8 h-8 mr-8 sm:w-12 sm:h-12 sm:mr-4" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
<nuxt-link to="/">
|
<nuxt-link to="/">
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
<ui-libraries-dropdown />
|
<ui-libraries-dropdown />
|
||||||
|
|
||||||
<controls-global-search v-if="currentLibrary" class="hidden md:block" />
|
<controls-global-search v-if="currentLibrary" class="" />
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
|
|
||||||
<span v-if="showExperimentalFeatures" class="material-icons text-4xl text-warning pr-0 sm:pr-2 md:pr-4">logo_dev</span>
|
<span v-if="showExperimentalFeatures" class="material-icons text-4xl text-warning pr-0 sm:pr-2 md:pr-4">logo_dev</span>
|
||||||
@@ -20,11 +20,11 @@
|
|||||||
<ui-tooltip v-if="isChromecastInitialized && !isHttps" direction="bottom" text="Casting requires a secure connection" class="flex items-center">
|
<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>
|
<span class="material-icons-outlined text-warning text-opacity-50"> cast </span>
|
||||||
</ui-tooltip>
|
</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>
|
<google-cast-launcher></google-cast-launcher>
|
||||||
</div>
|
</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>
|
<span class="material-icons" aria-label="User Stats" role="button">equalizer</span>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
@@ -147,9 +147,6 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
toggleBookshelfTexture() {
|
|
||||||
this.$store.dispatch('setBookshelfTexture', 'wood2.png')
|
|
||||||
},
|
|
||||||
cancelSelectionMode() {
|
cancelSelectionMode() {
|
||||||
if (this.processingBatchDelete) return
|
if (this.processingBatchDelete) return
|
||||||
this.$store.commit('setSelectedLibraryItems', [])
|
this.$store.commit('setSelectedLibraryItems', [])
|
||||||
|
|||||||
@@ -2,10 +2,6 @@
|
|||||||
<div id="bookshelf" ref="wrapper" class="w-full max-w-full h-full overflow-y-scroll relative">
|
<div id="bookshelf" ref="wrapper" class="w-full max-w-full h-full overflow-y-scroll relative">
|
||||||
<!-- Cover size widget -->
|
<!-- Cover size widget -->
|
||||||
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-30" />
|
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-30" />
|
||||||
<!-- Experimental Bookshelf Texture -->
|
|
||||||
<div v-show="showExperimentalFeatures && !isAlternativeBookshelfView" class="fixed bottom-4 right-28 z-40">
|
|
||||||
<div class="rounded-full py-1 bg-primary hover:bg-bg cursor-pointer px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent @click="showBookshelfTextureModal"><p class="text-sm py-0.5">Texture</p></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="loaded && !shelves.length && !search" class="w-full flex flex-col items-center justify-center py-12">
|
<div v-if="loaded && !shelves.length && !search" class="w-full flex flex-col items-center justify-center py-12">
|
||||||
<p class="text-center text-2xl font-book mb-4 py-4">{{ libraryName }} Library is empty!</p>
|
<p class="text-center text-2xl font-book mb-4 py-4">{{ libraryName }} Library is empty!</p>
|
||||||
@@ -100,9 +96,6 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
showBookshelfTextureModal() {
|
|
||||||
this.$store.commit('globals/setShowBookshelfTextureModal', true)
|
|
||||||
},
|
|
||||||
async init() {
|
async init() {
|
||||||
this.wrapperClientWidth = this.$refs.wrapper ? this.$refs.wrapper.clientWidth : 0
|
this.wrapperClientWidth = this.$refs.wrapper ? this.$refs.wrapper.clientWidth : 0
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border">
|
||||||
<p class="transform text-sm">{{ shelf.label }}</p>
|
<p class="transform text-sm">{{ shelf.label }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,13 +2,19 @@
|
|||||||
<div class="w-full h-20 md:h-10 relative">
|
<div class="w-full h-20 md:h-10 relative">
|
||||||
<div class="flex md:hidden h-10 items-center">
|
<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'">
|
<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>
|
||||||
<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'">
|
<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>
|
||||||
<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'">
|
<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>Series</p>
|
<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>
|
</nuxt-link>
|
||||||
</div>
|
</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">
|
<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">
|
||||||
@@ -61,6 +67,10 @@
|
|||||||
<p>Search results for "{{ searchQuery }}"</p>
|
<p>Search results for "{{ searchQuery }}"</p>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
</template>
|
</template>
|
||||||
|
<template v-else-if="page === 'authors'">
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<ui-btn v-if="userCanUpdate && authors && authors.length" :loading="processingAuthors" color="primary" small @click="matchAllAuthors">Match All Authors</ui-btn>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -75,7 +85,11 @@ export default {
|
|||||||
default: () => null
|
default: () => null
|
||||||
},
|
},
|
||||||
searchQuery: String,
|
searchQuery: String,
|
||||||
viewMode: String
|
viewMode: String,
|
||||||
|
authors: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -85,13 +99,20 @@ export default {
|
|||||||
keywordFilter: null,
|
keywordFilter: null,
|
||||||
keywordTimeout: null,
|
keywordTimeout: null,
|
||||||
processingSeries: false,
|
processingSeries: false,
|
||||||
processingIssues: false
|
processingIssues: false,
|
||||||
|
processingAuthors: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
userIsAdminOrUp() {
|
||||||
|
return this.$store.getters['user/getIsAdminOrUp']
|
||||||
|
},
|
||||||
userCanDelete() {
|
userCanDelete() {
|
||||||
return this.$store.getters['user/getUserCanDelete']
|
return this.$store.getters['user/getUserCanDelete']
|
||||||
},
|
},
|
||||||
|
userCanUpdate() {
|
||||||
|
return this.$store.getters['user/getUserCanUpdate']
|
||||||
|
},
|
||||||
isPodcast() {
|
isPodcast() {
|
||||||
return this.$store.getters['libraries/getCurrentLibraryMediaType'] == 'podcast'
|
return this.$store.getters['libraries/getCurrentLibraryMediaType'] == 'podcast'
|
||||||
},
|
},
|
||||||
@@ -117,6 +138,12 @@ export default {
|
|||||||
currentLibraryId() {
|
currentLibraryId() {
|
||||||
return this.$store.state.libraries.currentLibraryId
|
return this.$store.state.libraries.currentLibraryId
|
||||||
},
|
},
|
||||||
|
currentLibraryMediaType() {
|
||||||
|
return this.$store.getters['libraries/getCurrentLibraryMediaType']
|
||||||
|
},
|
||||||
|
isPodcastLibrary() {
|
||||||
|
return this.currentLibraryMediaType === 'podcast'
|
||||||
|
},
|
||||||
homePage() {
|
homePage() {
|
||||||
return this.$route.name === 'library-library'
|
return this.$route.name === 'library-library'
|
||||||
},
|
},
|
||||||
@@ -144,9 +171,41 @@ export default {
|
|||||||
},
|
},
|
||||||
isIssuesFilter() {
|
isIssuesFilter() {
|
||||||
return this.filterBy === 'issues' && this.$route.query.filter === 'issues'
|
return this.filterBy === 'issues' && this.$route.query.filter === 'issues'
|
||||||
|
},
|
||||||
|
isPodcastSearchPage() {
|
||||||
|
return this.$route.name === 'library-library-podcast-search'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
async matchAllAuthors() {
|
||||||
|
this.processingAuthors = true
|
||||||
|
|
||||||
|
for (const author of this.authors) {
|
||||||
|
const payload = {}
|
||||||
|
if (author.asin) payload.asin = author.asin
|
||||||
|
else payload.q = author.name
|
||||||
|
console.log('Payload', payload, 'author', author)
|
||||||
|
|
||||||
|
this.$eventBus.$emit(`searching-author-${author.id}`, true)
|
||||||
|
|
||||||
|
var response = await this.$axios.$post(`/api/authors/${author.id}/match`, payload).catch((error) => {
|
||||||
|
console.error('Failed', error)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
if (!response) {
|
||||||
|
console.error(`Author ${author.name} not found`)
|
||||||
|
this.$toast.error(`Author ${author.name} not found`)
|
||||||
|
} else if (response.updated) {
|
||||||
|
if (response.author.imagePath) console.log(`Author ${response.author.name} was updated`)
|
||||||
|
else console.log(`Author ${response.author.name} was updated (no image found)`)
|
||||||
|
} else {
|
||||||
|
console.log(`No updates were made for Author ${response.author.name}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$eventBus.$emit(`searching-author-${author.id}`, false)
|
||||||
|
}
|
||||||
|
this.processingAuthors = false
|
||||||
|
},
|
||||||
removeAllIssues() {
|
removeAllIssues() {
|
||||||
if (confirm(`Are you sure you want to remove all library items with issues?\n\nNote: This will not delete any files`)) {
|
if (confirm(`Are you sure you want to remove all library items with issues?\n\nNote: This will not delete any files`)) {
|
||||||
this.processingIssues = true
|
this.processingIssues = true
|
||||||
|
|||||||
@@ -4,19 +4,21 @@
|
|||||||
<span class="material-icons text-2xl">arrow_back</span>
|
<span class="material-icons text-2xl">arrow_back</span>
|
||||||
</div>
|
</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>
|
<p>{{ route.title }}</p>
|
||||||
<div v-show="routeName === route.iod" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
<div v-show="routeName === route.iod" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||||
</nuxt-link>
|
</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="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">
|
<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>
|
<p class="font-mono text-xs text-gray-300 italic">{{ Source }}</p>
|
||||||
</div>
|
</div>
|
||||||
<a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-xs">Latest: {{ latestVersion }}</a>
|
<a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-xs">Latest: {{ latestVersion }}</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<modals-changelog-view-modal v-model="showChangelogModal" :changelog="currentVersionChangelog" :currentVersion="$config.version"/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -26,7 +28,9 @@ export default {
|
|||||||
isOpen: Boolean
|
isOpen: Boolean
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {}
|
return {
|
||||||
|
showChangelogModal: false
|
||||||
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
Source() {
|
Source() {
|
||||||
@@ -64,6 +68,11 @@ export default {
|
|||||||
title: 'Users',
|
title: 'Users',
|
||||||
path: '/config/users'
|
path: '/config/users'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'config-sessions',
|
||||||
|
title: 'Listening Sessions',
|
||||||
|
path: '/config/sessions'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'config-backups',
|
id: 'config-backups',
|
||||||
title: 'Backups',
|
title: 'Backups',
|
||||||
@@ -71,7 +80,7 @@ export default {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'config-log',
|
id: 'config-log',
|
||||||
title: 'Log',
|
title: 'Logs',
|
||||||
path: '/config/log'
|
path: '/config/log'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -124,18 +133,24 @@ export default {
|
|||||||
githubTagUrl() {
|
githubTagUrl() {
|
||||||
return this.versionData.githubTagUrl
|
return this.versionData.githubTagUrl
|
||||||
},
|
},
|
||||||
|
currentVersionChangelog() {
|
||||||
|
return this.versionData.currentVersionChangelog || 'No Changelog Available'
|
||||||
|
},
|
||||||
streamLibraryItem() {
|
streamLibraryItem() {
|
||||||
return this.$store.state.streamLibraryItem
|
return this.$store.state.streamLibraryItem
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
clickChangelog(){
|
||||||
|
this.showChangelogModal = true
|
||||||
|
},
|
||||||
clickOutside() {
|
clickOutside() {
|
||||||
if (!this.isOpen) return
|
if (!this.isOpen) return
|
||||||
this.closeDrawer()
|
this.closeDrawer()
|
||||||
},
|
},
|
||||||
closeDrawer() {
|
closeDrawer() {
|
||||||
this.$emit('update:isOpen', false)
|
this.$emit('update:isOpen', false)
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -22,13 +22,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-30" />
|
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-30" />
|
||||||
|
|
||||||
<!-- Experimental Bookshelf Texture -->
|
|
||||||
<div v-show="showExperimentalFeatures && !isAlternativeBookshelfView" class="fixed bottom-4 right-28 z-40">
|
|
||||||
<div class="rounded-full py-1 bg-primary hover:bg-bg cursor-pointer px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent @click="showBookshelfTextureModal">
|
|
||||||
<p class="text-sm py-0.5">Texture</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -206,9 +199,6 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
showBookshelfTextureModal() {
|
|
||||||
this.$store.commit('globals/setShowBookshelfTextureModal', true)
|
|
||||||
},
|
|
||||||
clearFilter() {
|
clearFilter() {
|
||||||
this.$store.dispatch('user/updateUserSettings', { filterBy: 'all' })
|
this.$store.dispatch('user/updateUserSettings', { filterBy: 'all' })
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -73,15 +73,31 @@
|
|||||||
<p class="text-xs font-mono pb-0.5">{{ numIssues }}</p>
|
<p class="text-xs font-mono pb-0.5">{{ numIssues }}</p>
|
||||||
</div>
|
</div>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
|
<div class="w-full h-12 px-1 py-2 border-t border-black border-opacity-20 absolute left-0" :style="{ bottom: streamLibraryItem ? '240px' : '65px' }">
|
||||||
|
<p class="underline font-mono text-xs text-center text-gray-300 leading-3 mb-1" @click="clickChangelog">v{{ $config.version }}</p>
|
||||||
|
<a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-xxs text-center block leading-3">Update</a>
|
||||||
|
<p v-else class="text-xxs text-gray-400 leading-3 text-center italic">{{ Source }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<modals-changelog-view-modal v-model="showChangelogModal" :changelog="currentVersionChangelog" :currentVersion="$config.version"/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
data() {
|
data() {
|
||||||
return {}
|
return {
|
||||||
|
showChangelogModal: false
|
||||||
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
Source() {
|
||||||
|
return this.$store.state.Source
|
||||||
|
},
|
||||||
|
isMobileLandscape() {
|
||||||
|
return this.$store.state.globals.isMobileLandscape
|
||||||
|
},
|
||||||
isShowingBookshelfToolbar() {
|
isShowingBookshelfToolbar() {
|
||||||
if (!this.$route.name) return false
|
if (!this.$route.name) return false
|
||||||
return this.$route.name.startsWith('library')
|
return this.$route.name.startsWith('library')
|
||||||
@@ -131,9 +147,28 @@ export default {
|
|||||||
},
|
},
|
||||||
numIssues() {
|
numIssues() {
|
||||||
return this.$store.state.libraries.issues || 0
|
return this.$store.state.libraries.issues || 0
|
||||||
|
},
|
||||||
|
versionData() {
|
||||||
|
return this.$store.state.versionData || {}
|
||||||
|
},
|
||||||
|
hasUpdate() {
|
||||||
|
return !!this.versionData.hasUpdate
|
||||||
|
},
|
||||||
|
githubTagUrl() {
|
||||||
|
return this.versionData.githubTagUrl
|
||||||
|
},
|
||||||
|
currentVersionChangelog() {
|
||||||
|
return this.versionData.currentVersionChangelog || 'No Changelog Available'
|
||||||
|
},
|
||||||
|
streamLibraryItem() {
|
||||||
|
return this.$store.state.streamLibraryItem
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
clickChangelog(){
|
||||||
|
this.showChangelogModal = true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {},
|
|
||||||
mounted() {}
|
mounted() {}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -1,31 +1,32 @@
|
|||||||
<template>
|
<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 v-if="streamLibraryItem" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-48 sm:h-44 md:h-40 z-40 bg-primary px-4 pb-1 md:pb-4 pt-2">
|
||||||
<nuxt-link :to="`/item/${streamLibraryItem.id}`" class="absolute left-4 cursor-pointer" :style="{ top: bookCoverPosTop + 'px' }">
|
<div id="videoDock" />
|
||||||
|
<nuxt-link v-if="!playerHandler.isVideo" :to="`/item/${streamLibraryItem.id}`" class="absolute left-1 sm:left-4 cursor-pointer" :style="{ top: bookCoverPosTop + 'px' }">
|
||||||
<covers-book-cover :library-item="streamLibraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<covers-book-cover :library-item="streamLibraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
<div class="flex items-start pl-24 mb-6 md:mb-0">
|
<div class="flex items-start mb-6 md:mb-0" :class="playerHandler.isVideo ? 'ml-4 pl-96' : 'pl-20 sm:pl-24'">
|
||||||
<div>
|
<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 }}
|
{{ title }}
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
<div class="text-gray-400 flex items-center">
|
<div v-if="!playerHandler.isVideo" class="text-gray-400 flex items-center">
|
||||||
<span class="material-icons text-sm">person</span>
|
<span class="material-icons text-sm">person</span>
|
||||||
<p v-if="podcastAuthor">{{ podcastAuthor }}</p>
|
<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.5 text-sm sm:text-base">
|
<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>
|
<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>
|
||||||
<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>
|
||||||
|
|
||||||
<div class="text-gray-400 flex items-center">
|
<div class="text-gray-400 flex items-center">
|
||||||
<span class="material-icons text-xs">schedule</span>
|
<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>
|
</div>
|
||||||
<div class="flex-grow" />
|
<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>
|
</div>
|
||||||
<audio-player
|
<player-ui
|
||||||
ref="audioPlayer"
|
ref="audioPlayer"
|
||||||
:chapters="chapters"
|
:chapters="chapters"
|
||||||
:paused="!isPlaying"
|
:paused="!isPlaying"
|
||||||
@@ -70,7 +71,8 @@ export default {
|
|||||||
sleepTimerRemaining: 0,
|
sleepTimerRemaining: 0,
|
||||||
sleepTimer: null,
|
sleepTimer: null,
|
||||||
displayTitle: null,
|
displayTitle: null,
|
||||||
initialPlaybackRate: 1
|
initialPlaybackRate: 1,
|
||||||
|
syncFailedToast: null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -375,10 +377,18 @@ export default {
|
|||||||
libraryItem,
|
libraryItem,
|
||||||
episodeId
|
episodeId
|
||||||
})
|
})
|
||||||
|
this.$nextTick(() => {
|
||||||
|
if (this.$refs.audioPlayer) this.$refs.audioPlayer.checkUpdateChapterTrack()
|
||||||
|
})
|
||||||
|
|
||||||
this.playerHandler.load(libraryItem, episodeId, true, this.initialPlaybackRate)
|
this.playerHandler.load(libraryItem, episodeId, true, this.initialPlaybackRate)
|
||||||
},
|
},
|
||||||
pauseItem() {
|
pauseItem() {
|
||||||
this.playerHandler.pause()
|
this.playerHandler.pause()
|
||||||
|
},
|
||||||
|
showFailedProgressSyncs() {
|
||||||
|
if (!isNaN(this.syncFailedToast)) this.$toast.dismiss(this.syncFailedToast)
|
||||||
|
this.syncFailedToast = this.$toast('Progress is not being synced. Restart playback', { timeout: false, type: 'error' })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
|||||||
@@ -101,8 +101,16 @@ export default {
|
|||||||
this.$toast.info('No updates were made for Author')
|
this.$toast.info('No updates were made for Author')
|
||||||
}
|
}
|
||||||
this.searching = false
|
this.searching = false
|
||||||
|
},
|
||||||
|
setSearching(isSearching) {
|
||||||
|
this.searching = isSearching
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {}
|
mounted() {
|
||||||
|
this.$eventBus.$on(`searching-author-${this.authorId}`, this.setSearching)
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
this.$eventBus.$off(`searching-author-${this.authorId}`, this.setSearching)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -6,11 +6,18 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-if="!isPodcast" class="px-4 flex-grow">
|
<div v-if="!isPodcast" class="px-4 flex-grow">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<h1>{{ book.title }}</h1>
|
<h1 class="text-base">{{ book.title }}</h1>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<p>{{ book.publishedYear }}</p>
|
<p>{{ book.publishedYear }}</p>
|
||||||
</div>
|
</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">
|
<div class="w-full max-h-12 overflow-hidden">
|
||||||
<p class="text-gray-500 text-xs">{{ book.description }}</p>
|
<p class="text-gray-500 text-xs">{{ book.description }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -62,21 +62,10 @@ export default {
|
|||||||
matchHtml() {
|
matchHtml() {
|
||||||
if (!this.matchText || !this.search) return ''
|
if (!this.matchText || !this.search) return ''
|
||||||
if (this.matchKey === 'subtitle') return ''
|
if (this.matchKey === 'subtitle') return ''
|
||||||
var matchSplit = this.matchText.toLowerCase().split(this.search.toLowerCase().trim())
|
|
||||||
if (matchSplit.length < 2) return ''
|
|
||||||
|
|
||||||
var html = ''
|
// This used to highlight the part of the search found
|
||||||
var totalLenSoFar = 0
|
// but with removing commas periods etc this is no longer plausible
|
||||||
for (let i = 0; i < matchSplit.length - 1; i++) {
|
const html = this.matchText
|
||||||
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
|
|
||||||
|
|
||||||
if (this.matchKey === 'tags') return `<p class="truncate">Tags: ${html}</p>`
|
if (this.matchKey === 'tags') return `<p class="truncate">Tags: ${html}</p>`
|
||||||
if (this.matchKey === 'authors') return `by ${html}`
|
if (this.matchKey === 'authors') return `by ${html}`
|
||||||
|
|||||||
@@ -144,6 +144,9 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
dateFormat() {
|
||||||
|
return this.store.state.serverSettings.dateFormat
|
||||||
|
},
|
||||||
showExperimentalFeatures() {
|
showExperimentalFeatures() {
|
||||||
return this.store.state.showExperimentalFeatures
|
return this.store.state.showExperimentalFeatures
|
||||||
},
|
},
|
||||||
@@ -245,13 +248,17 @@ export default {
|
|||||||
return this.mediaMetadata.authorNameLF
|
return this.mediaMetadata.authorNameLF
|
||||||
},
|
},
|
||||||
displayTitle() {
|
displayTitle() {
|
||||||
|
if (this.recentEpisode) return this.recentEpisode.title
|
||||||
|
if (this.collapsedSeries) return this.collapsedSeries.name
|
||||||
if (this.orderBy === 'media.metadata.title' && this.sortingIgnorePrefix) {
|
if (this.orderBy === 'media.metadata.title' && this.sortingIgnorePrefix) {
|
||||||
return this.mediaMetadata.titleIgnorePrefix
|
return this.mediaMetadata.titleIgnorePrefix
|
||||||
}
|
}
|
||||||
return this.title
|
return this.title
|
||||||
},
|
},
|
||||||
displayLineTwo() {
|
displayLineTwo() {
|
||||||
|
if (this.recentEpisode) return this.title
|
||||||
if (this.isPodcast) return this.author
|
if (this.isPodcast) return this.author
|
||||||
|
if (this.collapsedSeries) return ''
|
||||||
if (this.isAuthorBookshelfView) {
|
if (this.isAuthorBookshelfView) {
|
||||||
return this.mediaMetadata.publishedYear || ''
|
return this.mediaMetadata.publishedYear || ''
|
||||||
}
|
}
|
||||||
@@ -259,9 +266,10 @@ export default {
|
|||||||
return this.author
|
return this.author
|
||||||
},
|
},
|
||||||
displaySortLine() {
|
displaySortLine() {
|
||||||
if (this.orderBy === 'mtimeMs') return 'Modified ' + this.$formatDate(this._libraryItem.mtimeMs)
|
if (this.collapsedSeries) return null
|
||||||
if (this.orderBy === 'birthtimeMs') return 'Born ' + this.$formatDate(this._libraryItem.birthtimeMs)
|
if (this.orderBy === 'mtimeMs') return 'Modified ' + this.$formatDate(this._libraryItem.mtimeMs, this.dateFormat)
|
||||||
if (this.orderBy === 'addedAt') return 'Added ' + this.$formatDate(this._libraryItem.addedAt)
|
if (this.orderBy === 'birthtimeMs') return 'Born ' + this.$formatDate(this._libraryItem.birthtimeMs, this.dateFormat)
|
||||||
|
if (this.orderBy === 'addedAt') return 'Added ' + this.$formatDate(this._libraryItem.addedAt, this.dateFormat)
|
||||||
if (this.orderBy === 'media.duration') return 'Duration: ' + this.$elapsedPrettyExtended(this.media.duration, false)
|
if (this.orderBy === 'media.duration') return 'Duration: ' + this.$elapsedPrettyExtended(this.media.duration, false)
|
||||||
if (this.orderBy === 'size') return 'Size: ' + this.$bytesPretty(this._libraryItem.size)
|
if (this.orderBy === 'size') return 'Size: ' + this.$bytesPretty(this._libraryItem.size)
|
||||||
if (this.orderBy === 'media.numTracks') return `${this.numEpisodes} Episodes`
|
if (this.orderBy === 'media.numTracks') return `${this.numEpisodes} Episodes`
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<template>
|
||||||
|
<div ref="wrapper" class="w-full p-2 border border-white border-opacity-10 rounded">
|
||||||
|
<div class="flex">
|
||||||
|
<div class="w-16 min-w-16">
|
||||||
|
<div class="w-full h-16 bg-primary">
|
||||||
|
<img v-if="image" :src="image" class="w-full h-full object-cover" />
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-400 text-xxs pt-1 text-center">{{ numEpisodes }} Episodes</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow pl-2" :style="{ maxWidth: detailsWidth + 'px' }">
|
||||||
|
<p class="mb-1">{{ title }}</p>
|
||||||
|
<p class="text-xs mb-1 text-gray-300">{{ author }}</p>
|
||||||
|
<p class="text-xs mb-2 text-gray-200">{{ description }}</p>
|
||||||
|
<p class="text-xs truncate text-blue-200">
|
||||||
|
Folder: <span class="font-mono">{{ folderPath }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
feed: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
},
|
||||||
|
libraryFolderPath: String
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
width: 900
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
title() {
|
||||||
|
return this.metadata.title || 'No Title'
|
||||||
|
},
|
||||||
|
image() {
|
||||||
|
return this.metadata.imageUrl
|
||||||
|
},
|
||||||
|
description() {
|
||||||
|
return this.metadata.description || ''
|
||||||
|
},
|
||||||
|
author() {
|
||||||
|
return this.metadata.author || ''
|
||||||
|
},
|
||||||
|
metadata() {
|
||||||
|
return this.feed || {}
|
||||||
|
},
|
||||||
|
numEpisodes() {
|
||||||
|
return this.feed.numEpisodes || 0
|
||||||
|
},
|
||||||
|
folderPath() {
|
||||||
|
if (!this.libraryFolderPath) return ''
|
||||||
|
return `${this.libraryFolderPath}\\${this.$sanitizeFilename(this.title)}`
|
||||||
|
},
|
||||||
|
detailsWidth() {
|
||||||
|
return this.width - 85
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {},
|
||||||
|
updated() {
|
||||||
|
this.width = this.$refs.wrapper.clientWidth
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.width = this.$refs.wrapper.clientWidth
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -214,7 +214,7 @@ export default {
|
|||||||
return this.filterData.languages || []
|
return this.filterData.languages || []
|
||||||
},
|
},
|
||||||
progress() {
|
progress() {
|
||||||
return ['Finished', 'In Progress', 'Not Started']
|
return ['Finished', 'In Progress', 'Not Started', 'Not Finished']
|
||||||
},
|
},
|
||||||
missing() {
|
missing() {
|
||||||
return ['ASIN', 'ISBN', 'Subtitle', 'Author', 'Publish Year', 'Series', 'Description', 'Genres', 'Tags', 'Narrator', 'Publisher', 'Language']
|
return ['ASIN', 'ISBN', 'Subtitle', 'Author', 'Publish Year', 'Series', 'Description', 'Genres', 'Tags', 'Narrator', 'Publisher', 'Language']
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-80 ml-6 relative">
|
<div class="sm:w-80 w-full sm:ml-6 relative">
|
||||||
<form @submit.prevent="submitSearch">
|
<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" />
|
<ui-text-input ref="input" v-model="search" placeholder="Search.." @input="inputUpdate" @focus="focussed" @blur="blurred" class="w-full h-8 text-sm" />
|
||||||
</form>
|
</form>
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
<span v-if="!search" class="material-icons" style="font-size: 1.2rem">search</span>
|
<span v-if="!search" class="material-icons" style="font-size: 1.2rem">search</span>
|
||||||
<span v-else class="material-icons" style="font-size: 1.2rem">close</span>
|
<span v-else class="material-icons" style="font-size: 1.2rem">close</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-show="showMenu && (lastSearch || isTyping)" class="absolute z-40 -mt-px w-full bg-bg border border-black-200 shadow-lg rounded-md py-1 px-2 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm globalSearchMenu">
|
<div v-show="showMenu && (lastSearch || isTyping)" class="absolute z-40 -mt-px w-40 sm:w-full bg-bg border border-black-200 shadow-lg rounded-md py-1 px-2 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm globalSearchMenu">
|
||||||
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||||
<li v-if="isTyping" class="py-2 px-2">
|
<li v-if="isTyping" class="py-2 px-2">
|
||||||
<p>Thinking...</p>
|
<p>Thinking...</p>
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
<template v-else>
|
<template v-else>
|
||||||
<p v-if="bookResults.length" class="uppercase text-xs text-gray-400 my-1 px-1 font-semibold">Books</p>
|
<p v-if="bookResults.length" class="uppercase text-xs text-gray-400 my-1 px-1 font-semibold">Books</p>
|
||||||
<template v-for="item in bookResults">
|
<template v-for="item in bookResults">
|
||||||
<li :key="item.libraryItem.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
|
<li :key="item.libraryItem.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
|
||||||
<nuxt-link :to="`/item/${item.libraryItem.id}`">
|
<nuxt-link :to="`/item/${item.libraryItem.id}`">
|
||||||
<cards-item-search-card :library-item="item.libraryItem" :match-key="item.matchKey" :match-text="item.matchText" :search="lastSearch" />
|
<cards-item-search-card :library-item="item.libraryItem" :match-key="item.matchKey" :match-text="item.matchText" :search="lastSearch" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
|
|
||||||
<p v-if="podcastResults.length" class="uppercase text-xs text-gray-400 my-1 px-1 font-semibold">Podcasts</p>
|
<p v-if="podcastResults.length" class="uppercase text-xs text-gray-400 my-1 px-1 font-semibold">Podcasts</p>
|
||||||
<template v-for="item in podcastResults">
|
<template v-for="item in podcastResults">
|
||||||
<li :key="item.libraryItem.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
|
<li :key="item.libraryItem.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
|
||||||
<nuxt-link :to="`/item/${item.libraryItem.id}`">
|
<nuxt-link :to="`/item/${item.libraryItem.id}`">
|
||||||
<cards-item-search-card :library-item="item.libraryItem" :match-key="item.matchKey" :match-text="item.matchText" :search="lastSearch" />
|
<cards-item-search-card :library-item="item.libraryItem" :match-key="item.matchKey" :match-text="item.matchText" :search="lastSearch" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
|
|
||||||
<p v-if="authorResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">Authors</p>
|
<p v-if="authorResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">Authors</p>
|
||||||
<template v-for="item in authorResults">
|
<template v-for="item in authorResults">
|
||||||
<li :key="item.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
|
<li :key="item.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
|
||||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=authors.${$encode(item.id)}`">
|
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=authors.${$encode(item.id)}`">
|
||||||
<cards-author-search-card :author="item" />
|
<cards-author-search-card :author="item" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
|
|
||||||
<p v-if="seriesResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">Series</p>
|
<p v-if="seriesResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">Series</p>
|
||||||
<template v-for="item in seriesResults">
|
<template v-for="item in seriesResults">
|
||||||
<li :key="item.series.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
|
<li :key="item.series.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
|
||||||
<nuxt-link :to="`/library/${currentLibraryId}/series/${item.series.id}`">
|
<nuxt-link :to="`/library/${currentLibraryId}/series/${item.series.id}`">
|
||||||
<cards-series-search-card :series="item.series" :book-items="item.books" />
|
<cards-series-search-card :series="item.series" :book-items="item.books" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
|
|
||||||
<p v-if="tagResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">Tags</p>
|
<p v-if="tagResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">Tags</p>
|
||||||
<template v-for="item in tagResults">
|
<template v-for="item in tagResults">
|
||||||
<li :key="item.name" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
|
<li :key="item.name" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
|
||||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=tags.${$encode(item.name)}`">
|
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=tags.${$encode(item.name)}`">
|
||||||
<cards-tag-search-card :tag="item.name" />
|
<cards-tag-search-card :tag="item.name" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
@@ -97,6 +97,9 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
clickOption() {
|
||||||
|
this.clearResults()
|
||||||
|
},
|
||||||
submitSearch() {
|
submitSearch() {
|
||||||
if (!this.search) return
|
if (!this.search) return
|
||||||
var search = this.search
|
var search = this.search
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<template>
|
<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)">
|
<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>
|
||||||
<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 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-2 left-0 right-0 w-full flex justify-center">
|
<div class="absolute -bottom-1.5 right-0 w-full flex justify-center" :style="{ left: arrowLeft + 'px' }">
|
||||||
<div class="arrow-down" />
|
<div class="arrow-down" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center h-9 relative overflow-hidden rounded-lg" style="width: 220px">
|
<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="w-full py-1 px-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<ui-icon-btn :disabled="!canDecrement" icon="remove" @click="decrement" />
|
<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" />
|
<ui-icon-btn :disabled="!canIncrement" icon="add" @click="increment" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -40,7 +40,9 @@ export default {
|
|||||||
showMenu: false,
|
showMenu: false,
|
||||||
currentPlaybackRate: 0,
|
currentPlaybackRate: 0,
|
||||||
MIN_SPEED: 0.5,
|
MIN_SPEED: 0.5,
|
||||||
MAX_SPEED: 3
|
MAX_SPEED: 3,
|
||||||
|
menuLeft: -92,
|
||||||
|
arrowLeft: 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -80,8 +82,22 @@ export default {
|
|||||||
var newPlaybackRate = this.playbackRate - 0.1
|
var newPlaybackRate = this.playbackRate - 0.1
|
||||||
this.playbackRate = Number(newPlaybackRate.toFixed(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) {
|
setShowMenu(val) {
|
||||||
if (val) {
|
if (val) {
|
||||||
|
this.updateMenuPositions()
|
||||||
this.currentPlaybackRate = this.playbackRate
|
this.currentPlaybackRate = this.playbackRate
|
||||||
} else if (this.currentPlaybackRate !== this.playbackRate) {
|
} else if (this.currentPlaybackRate !== this.playbackRate) {
|
||||||
this.$emit('change', this.playbackRate)
|
this.$emit('change', this.playbackRate)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="relative" v-click-outside="clickOutside" @mouseover="mouseover" @mouseleave="mouseleave">
|
<div class="relative" v-click-outside="clickOutside" @mouseover="mouseover" @mouseleave="mouseleave">
|
||||||
<div class="cursor-pointer" @mousedown.prevent @mouseup.prevent @click="clickVolumeIcon">
|
<div class="cursor-pointer text-gray-300 hover:text-white" @mousedown.prevent @mouseup.prevent @click="clickVolumeIcon">
|
||||||
<span class="material-icons text-3xl">{{ volumeIcon }}</span>
|
<span class="material-icons text-2xl sm:text-3xl">{{ volumeIcon }}</span>
|
||||||
</div>
|
</div>
|
||||||
<transition name="menux">
|
<transition name="menux">
|
||||||
<div v-show="isOpen" class="volumeMenu h-6 absolute bottom-2 w-28 px-2 bg-bg shadow-sm rounded-lg" style="left: -116px">
|
<div v-show="isOpen" class="volumeMenu h-6 absolute bottom-2 w-28 px-2 bg-bg shadow-sm rounded-lg" style="left: -116px">
|
||||||
|
|||||||
@@ -65,6 +65,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center my-2 max-w-md">
|
||||||
|
<div class="w-1/2">
|
||||||
|
<p>Can Access Explicit Content</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-1/2">
|
||||||
|
<ui-toggle-switch v-model="newUser.permissions.accessExplicitContent" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center my-2 max-w-md">
|
<div class="flex items-center my-2 max-w-md">
|
||||||
<div class="w-1/2">
|
<div class="w-1/2">
|
||||||
<p>Can Access All Libraries</p>
|
<p>Can Access All Libraries</p>
|
||||||
@@ -137,7 +146,6 @@ export default {
|
|||||||
watch: {
|
watch: {
|
||||||
show: {
|
show: {
|
||||||
handler(newVal) {
|
handler(newVal) {
|
||||||
console.log('accoutn modal show change', newVal)
|
|
||||||
if (newVal) {
|
if (newVal) {
|
||||||
this.init()
|
this.init()
|
||||||
}
|
}
|
||||||
@@ -153,6 +161,9 @@ export default {
|
|||||||
this.$emit('input', val)
|
this.$emit('input', val)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
user() {
|
||||||
|
return this.$store.state.user.user
|
||||||
|
},
|
||||||
title() {
|
title() {
|
||||||
return this.isNew ? 'Add New Account' : `Update ${(this.account || {}).username}`
|
return this.isNew ? 'Add New Account' : `Update ${(this.account || {}).username}`
|
||||||
},
|
},
|
||||||
@@ -241,6 +252,12 @@ export default {
|
|||||||
this.$toast.error(`Failed to update account: ${data.error}`)
|
this.$toast.error(`Failed to update account: ${data.error}`)
|
||||||
} else {
|
} else {
|
||||||
console.log('Account updated', data.user)
|
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.$toast.success('Account updated')
|
||||||
this.show = false
|
this.show = false
|
||||||
}
|
}
|
||||||
@@ -296,7 +313,6 @@ export default {
|
|||||||
|
|
||||||
this.isNew = !this.account
|
this.isNew = !this.account
|
||||||
if (this.account) {
|
if (this.account) {
|
||||||
console.log(this.account)
|
|
||||||
this.newUser = {
|
this.newUser = {
|
||||||
username: this.account.username,
|
username: this.account.username,
|
||||||
password: this.account.password,
|
password: this.account.password,
|
||||||
|
|||||||
@@ -1,68 +0,0 @@
|
|||||||
<template>
|
|
||||||
<modals-modal v-model="show" name="textures" :width="'40vw'" :height="'unset'" :bg-opacity="10" :processing="processing">
|
|
||||||
<template #outer>
|
|
||||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
|
||||||
<p class="font-book text-3xl text-white truncate">Bookshelf Texture</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<div class="px-4 w-full max-w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300" @mousedown.prevent @mouseup.prevent @mousemove.prevent>
|
|
||||||
<h1 class="text-2xl mb-2">Select a bookshelf texture (For testing only)</h1>
|
|
||||||
<div class="overflow-y-hidden overflow-x-auto">
|
|
||||||
<div class="flex -mx-1">
|
|
||||||
<template v-for="texture in textures">
|
|
||||||
<div :key="texture" class="relative mx-1" style="height: 180px; width: 180px; min-width: 180px" @mousedown.prevent @mouseup.prevent>
|
|
||||||
<img :src="texture" class="h-full object-cover cursor-pointer" @click="setTexture(texture)" />
|
|
||||||
<div v-if="texture === selectedBookshelfTexture" class="absolute top-0 left-0 flex items-center justify-center w-full h-full bg-black bg-opacity-10">
|
|
||||||
<span class="material-icons text-4xl text-success">check</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- <div class="flex pt-4">
|
|
||||||
<div class="flex-grow" />
|
|
||||||
<ui-btn color="success" type="submit">Submit</ui-btn>
|
|
||||||
</div> -->
|
|
||||||
</div>
|
|
||||||
</modals-modal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
textures: ['/textures/wood_default.jpg', '/textures/wood1.png', '/textures/wood2.png', '/textures/wood3.png', '/textures/wood4.png', '/textures/leather1.jpg'],
|
|
||||||
processing: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
show: {
|
|
||||||
handler(newVal) {
|
|
||||||
if (newVal) {
|
|
||||||
this.init()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
show: {
|
|
||||||
get() {
|
|
||||||
return this.$store.state.globals.showBookshelfTextureModal
|
|
||||||
},
|
|
||||||
set(val) {
|
|
||||||
this.$store.commit('globals/setShowBookshelfTextureModal', val)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
selectedBookshelfTexture() {
|
|
||||||
return this.$store.state.selectedBookshelfTexture
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
init() {},
|
|
||||||
setTexture(img) {
|
|
||||||
this.$store.dispatch('setBookshelfTexture', img)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mounted() {}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<modals-modal v-model="show" name="chapters" :width="500" :height="'unset'">
|
<modals-modal v-model="show" name="chapters" :width="600" :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">
|
<div id="chapter-modal-wrapper" ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
||||||
<template v-for="chap in chapters">
|
<template v-for="chap in chapters">
|
||||||
<div :key="chap.id" :id="`chapter-row-${chap.id}`" class="flex items-center px-6 py-3 justify-start cursor-pointer hover:bg-bg relative" :class="chap.id === currentChapterId ? 'bg-yellow-400 bg-opacity-10' : chap.end <= currentChapterStart ? 'bg-success bg-opacity-5' : 'bg-opacity-20'" @click="clickChapter(chap)">
|
<div :key="chap.id" :id="`chapter-row-${chap.id}`" class="flex items-center px-6 py-3 justify-start cursor-pointer hover:bg-bg relative" :class="chap.id === currentChapterId ? 'bg-yellow-400 bg-opacity-10' : chap.end <= 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="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 v-show="chap.id === currentChapterId" class="w-0.5 h-full absolute top-0 left-0 bg-yellow-400" />
|
||||||
</div>
|
</div>
|
||||||
@@ -71,3 +74,14 @@ 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>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
<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-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 px-2 py-6 sm:p-6 md:p-8" @click.stop>
|
||||||
|
<div class="flex">
|
||||||
|
<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-24 sm:w-28 md:w-40 p-1">
|
||||||
|
<ui-text-input-with-label v-model="selectedSeries.sequence" label="Sequence" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end mt-2 p-1">
|
||||||
|
<ui-btn type="submit">Save</ui-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: Boolean,
|
||||||
|
selectedSeries: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
},
|
||||||
|
existingSeriesNames: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
el: null,
|
||||||
|
content: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
show(newVal) {
|
||||||
|
if (newVal) {
|
||||||
|
this.$nextTick(this.setShow)
|
||||||
|
} else {
|
||||||
|
this.setHide()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
show: {
|
||||||
|
get() {
|
||||||
|
return this.value
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('input', val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
submitSeriesForm() {
|
||||||
|
if (this.$refs.newSeriesSelect) {
|
||||||
|
this.$refs.newSeriesSelect.blur()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$emit('submit')
|
||||||
|
},
|
||||||
|
clickClose() {
|
||||||
|
this.show = false
|
||||||
|
},
|
||||||
|
hotkey(action) {
|
||||||
|
if (action === this.$hotkeys.Modal.CLOSE) {
|
||||||
|
this.show = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setShow() {
|
||||||
|
if (!this.el || !this.content) {
|
||||||
|
this.init()
|
||||||
|
}
|
||||||
|
if (!this.el || !this.content) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.appendChild(this.el)
|
||||||
|
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)
|
||||||
|
},
|
||||||
|
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)
|
||||||
|
},
|
||||||
|
init() {
|
||||||
|
this.el = this.$refs.wrapper
|
||||||
|
this.content = this.$refs.content
|
||||||
|
if (this.content && this.el) {
|
||||||
|
this.el.classList.remove('hidden')
|
||||||
|
this.el.classList.add('flex')
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
<template>
|
||||||
|
<modals-modal v-model="show" name="listening-session-modal" :width="700" :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">Session {{ _session.id }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-full h-px bg-white bg-opacity-10 my-4" />
|
||||||
|
|
||||||
|
<div class="flex flex-wrap mb-4">
|
||||||
|
<div class="w-full md:w-2/3">
|
||||||
|
<p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mb-2">Details</p>
|
||||||
|
<div class="flex items-center -mx-1 mb-1">
|
||||||
|
<div class="w-40 px-1 text-gray-200">Started At</div>
|
||||||
|
<div class="px-1">
|
||||||
|
{{ $formatDate(_session.startedAt, 'MMMM do, yyyy HH:mm') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center -mx-1 mb-1">
|
||||||
|
<div class="w-40 px-1 text-gray-200">Updated At</div>
|
||||||
|
<div class="px-1">
|
||||||
|
{{ $formatDate(_session.updatedAt, 'MMMM do, yyyy HH:mm') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center -mx-1 mb-1">
|
||||||
|
<div class="w-40 px-1 text-gray-200">Listened for</div>
|
||||||
|
<div class="px-1">
|
||||||
|
{{ $elapsedPrettyExtended(_session.timeListening) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center -mx-1 mb-1">
|
||||||
|
<div class="w-40 px-1 text-gray-200">Start Time</div>
|
||||||
|
<div class="px-1">
|
||||||
|
{{ $secondsToTimestamp(_session.startTime) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center -mx-1 mb-1">
|
||||||
|
<div class="w-40 px-1 text-gray-200">Last Time</div>
|
||||||
|
<div class="px-1">
|
||||||
|
{{ $secondsToTimestamp(_session.currentTime) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mt-6 mb-2">Item</p>
|
||||||
|
<div v-if="_session.libraryId" class="flex items-center -mx-1 mb-1">
|
||||||
|
<div class="w-40 px-1 text-gray-200">Library Id</div>
|
||||||
|
<div class="px-1">
|
||||||
|
{{ _session.libraryId }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center -mx-1 mb-1">
|
||||||
|
<div class="w-40 px-1 text-gray-200">Library Item Id</div>
|
||||||
|
<div class="px-1">
|
||||||
|
{{ _session.libraryItemId }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="_session.episodeId" class="flex items-center -mx-1 mb-1">
|
||||||
|
<div class="w-40 px-1 text-gray-200">Episode Id</div>
|
||||||
|
<div class="px-1">
|
||||||
|
{{ _session.episodeId }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center -mx-1 mb-1">
|
||||||
|
<div class="w-40 px-1 text-gray-200">Media Type</div>
|
||||||
|
<div class="px-1">
|
||||||
|
{{ _session.mediaType }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center -mx-1 mb-1">
|
||||||
|
<div class="w-40 px-1 text-gray-200">Duration</div>
|
||||||
|
<div class="px-1">
|
||||||
|
{{ $elapsedPretty(_session.duration) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="w-full md:w-1/3">
|
||||||
|
<p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mb-2 mt-6 md:mt-0">User</p>
|
||||||
|
<p class="mb-1">{{ _session.userId }}</p>
|
||||||
|
|
||||||
|
<p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mt-6 mb-2">Media Player</p>
|
||||||
|
<p class="mb-1">{{ playMethodName }}</p>
|
||||||
|
<p class="mb-1">{{ _session.mediaPlayer }}</p>
|
||||||
|
|
||||||
|
<p v-if="hasDeviceInfo" class="font-semibold uppercase text-xs text-gray-400 tracking-wide mt-6 mb-2">Device</p>
|
||||||
|
<p v-if="deviceInfo.ipAddress" class="mb-1">{{ deviceInfo.ipAddress }}</p>
|
||||||
|
<p v-if="osDisplayName" class="mb-1">{{ osDisplayName }}</p>
|
||||||
|
<p v-if="deviceInfo.browserName" class="mb-1">{{ deviceInfo.browserName }}</p>
|
||||||
|
<p v-if="clientDisplayName" class="mb-1">{{ clientDisplayName }}</p>
|
||||||
|
<p v-if="deviceInfo.sdkVersion" class="mb-1">SDK Version: {{ deviceInfo.sdkVersion }}</p>
|
||||||
|
<p v-if="deviceInfo.deviceType" class="mb-1">Type: {{ deviceInfo.deviceType }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</modals-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: Boolean,
|
||||||
|
session: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
show: {
|
||||||
|
get() {
|
||||||
|
return this.value
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('input', val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_session() {
|
||||||
|
return this.session || {}
|
||||||
|
},
|
||||||
|
deviceInfo() {
|
||||||
|
return this._session.deviceInfo || {}
|
||||||
|
},
|
||||||
|
hasDeviceInfo() {
|
||||||
|
return Object.keys(this.deviceInfo).length
|
||||||
|
},
|
||||||
|
osDisplayName() {
|
||||||
|
if (!this.deviceInfo.osName) return null
|
||||||
|
return `${this.deviceInfo.osName} ${this.deviceInfo.osVersion}`
|
||||||
|
},
|
||||||
|
clientDisplayName() {
|
||||||
|
if (!this.deviceInfo.manufacturer || !this.deviceInfo.model) return null
|
||||||
|
return `${this.deviceInfo.manufacturer} ${this.deviceInfo.model}`
|
||||||
|
},
|
||||||
|
playMethodName() {
|
||||||
|
const playMethod = this._session.playMethod
|
||||||
|
if (playMethod === this.$constants.PlayMethod.DIRECTPLAY) return 'Direct Play'
|
||||||
|
else if (playMethod === this.$constants.PlayMethod.TRANSCODE) return 'Transcode'
|
||||||
|
else if (playMethod === this.$constants.PlayMethod.DIRECTSTREAM) return 'Direct Stream'
|
||||||
|
else if (playMethod === this.$constants.PlayMethod.LOCAL) return 'Local'
|
||||||
|
return 'Unknown'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -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 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-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">
|
<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-4xl">close</span>
|
<span class="material-icons text-2xl landscape:text-2xl md:portrait:text-4xl lg:text-4xl">close</span>
|
||||||
</div>
|
</div>
|
||||||
<slot name="outer" />
|
<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 />
|
<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">
|
<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 />
|
<ui-loading-indicator />
|
||||||
@@ -104,6 +104,7 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
hotkey(action) {
|
hotkey(action) {
|
||||||
|
if (this.$store.state.innerModalOpen) return
|
||||||
if (action === this.$hotkeys.Modal.CLOSE) {
|
if (action === this.$hotkeys.Modal.CLOSE) {
|
||||||
this.show = false
|
this.show = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,9 @@
|
|||||||
<ui-text-input-with-label v-model="authorCopy.asin" :disabled="processing" label="ASIN" />
|
<ui-text-input-with-label v-model="authorCopy.asin" :disabled="processing" label="ASIN" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="p-2">
|
||||||
|
<ui-text-input-with-label v-model="authorCopy.imagePath" :disabled="processing" label="Photo Path/URL" />
|
||||||
|
</div>
|
||||||
<div class="p-2">
|
<div class="p-2">
|
||||||
<ui-textarea-with-label v-model="authorCopy.description" :disabled="processing" label="Description" :rows="8" />
|
<ui-textarea-with-label v-model="authorCopy.description" :disabled="processing" label="Description" :rows="8" />
|
||||||
</div>
|
</div>
|
||||||
@@ -43,19 +46,13 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
// props: {
|
|
||||||
// value: Boolean,
|
|
||||||
// author: {
|
|
||||||
// type: Object,
|
|
||||||
// default: () => {}
|
|
||||||
// }
|
|
||||||
// },
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
authorCopy: {
|
authorCopy: {
|
||||||
name: '',
|
name: '',
|
||||||
asin: '',
|
asin: '',
|
||||||
description: ''
|
description: '',
|
||||||
|
imagePath: ''
|
||||||
},
|
},
|
||||||
processing: false
|
processing: false
|
||||||
}
|
}
|
||||||
@@ -95,9 +92,10 @@ export default {
|
|||||||
this.authorCopy.name = this.author.name
|
this.authorCopy.name = this.author.name
|
||||||
this.authorCopy.asin = this.author.asin
|
this.authorCopy.asin = this.author.asin
|
||||||
this.authorCopy.description = this.author.description
|
this.authorCopy.description = this.author.description
|
||||||
|
this.authorCopy.imagePath = this.author.imagePath
|
||||||
},
|
},
|
||||||
async submitForm() {
|
async submitForm() {
|
||||||
var keysToCheck = ['name', 'asin', 'description']
|
var keysToCheck = ['name', 'asin', 'description', 'imagePath']
|
||||||
var updatePayload = {}
|
var updatePayload = {}
|
||||||
keysToCheck.forEach((key) => {
|
keysToCheck.forEach((key) => {
|
||||||
if (this.authorCopy[key] !== this.author[key]) {
|
if (this.authorCopy[key] !== this.author[key]) {
|
||||||
|
|||||||
@@ -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>
|
<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>
|
<template #outer>
|
||||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden pointer-events-none">
|
<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-3xl text-white truncate pointer-events-none">{{ title }}</p>
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div class="absolute -top-10 left-0 z-10 w-full flex">
|
<div class="absolute -top-10 left-0 z-10 w-full flex">
|
||||||
<template v-for="tab in availableTabs">
|
<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>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -30,6 +30,8 @@ export default {
|
|||||||
return {
|
return {
|
||||||
processing: false,
|
processing: false,
|
||||||
libraryItem: null,
|
libraryItem: null,
|
||||||
|
availableHeight: 0,
|
||||||
|
marginTop: 0,
|
||||||
tabs: [
|
tabs: [
|
||||||
{
|
{
|
||||||
id: 'details',
|
id: 'details',
|
||||||
@@ -133,8 +135,7 @@ export default {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
height() {
|
height() {
|
||||||
var maxHeightAllowed = window.innerHeight - 150
|
return Math.min(this.availableHeight, 650)
|
||||||
return Math.min(maxHeightAllowed, 650)
|
|
||||||
},
|
},
|
||||||
tabName() {
|
tabName() {
|
||||||
var _tab = this.tabs.find((t) => t.id === this.selectedTab)
|
var _tab = this.tabs.find((t) => t.id === this.selectedTab)
|
||||||
@@ -246,15 +247,29 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
registerListeners() {
|
registerListeners() {
|
||||||
|
window.addEventListener('orientationchange', this.orientationChange)
|
||||||
this.$eventBus.$on('modal-hotkey', this.hotkey)
|
this.$eventBus.$on('modal-hotkey', this.hotkey)
|
||||||
this.$eventBus.$on(`${this.selectedLibraryItemId}_updated`, this.libraryItemUpdated)
|
this.$eventBus.$on(`${this.selectedLibraryItemId}_updated`, this.libraryItemUpdated)
|
||||||
},
|
},
|
||||||
unregisterListeners() {
|
unregisterListeners() {
|
||||||
|
window.removeEventListener('orientationchange', this.orientationChange)
|
||||||
this.$eventBus.$off('modal-hotkey', this.hotkey)
|
this.$eventBus.$off('modal-hotkey', this.hotkey)
|
||||||
this.$eventBus.$off(`${this.selectedLibraryItemId}_updated`, this.libraryItemUpdated)
|
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() {
|
beforeDestroy() {
|
||||||
this.unregisterListeners()
|
this.unregisterListeners()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6 relative">
|
<div class="w-full h-full overflow-hidden overflow-y-auto px-2 sm:px-4 py-6 relative">
|
||||||
<div class="flex">
|
<div class="flex flex-wrap">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<covers-book-cover :library-item="libraryItem" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<covers-book-cover :library-item="libraryItem" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
<!-- book cover overlay -->
|
<!-- book cover overlay -->
|
||||||
@@ -11,14 +11,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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 class="flex items-center">
|
||||||
<div v-if="userCanUpload" class="w-40 pr-2 pt-4" style="min-width: 160px">
|
<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">Upload Cover</ui-file-input>
|
<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>
|
</div>
|
||||||
<form @submit.prevent="submitForm" class="flex flex-grow">
|
<form @submit.prevent="submitForm" class="flex flex-grow">
|
||||||
<ui-text-input-with-label v-model="imageUrl" label="Cover Image URL" />
|
<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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -5,23 +5,24 @@
|
|||||||
<widgets-podcast-details-edit v-else ref="itemDetailsEdit" :library-item="libraryItem" @submit="saveAndClose" />
|
<widgets-podcast-details-edit v-else ref="itemDetailsEdit" :library-item="libraryItem" @submit="saveAndClose" />
|
||||||
</div>
|
</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">
|
<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" />
|
<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-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>
|
||||||
|
|
||||||
<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-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-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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,13 +2,13 @@
|
|||||||
<div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6">
|
<div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6">
|
||||||
<!-- Merge to m4b -->
|
<!-- Merge to m4b -->
|
||||||
<div v-if="showM4bDownload" class="w-full border border-black-200 p-4 my-8">
|
<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>
|
<div>
|
||||||
<p class="text-lg">Make M4B Audiobook File <span class="text-error">*</span></p>
|
<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>
|
<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>
|
||||||
<div class="flex-grow" />
|
<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.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.READY" class="text-success mb-2">Download Ready!</p>
|
||||||
<p v-if="abmergeStatus === $constants.DownloadStatus.EXPIRED" class="text-error mb-2">Download Expired</p>
|
<p v-if="abmergeStatus === $constants.DownloadStatus.EXPIRED" class="text-error mb-2">Download Expired</p>
|
||||||
@@ -69,7 +69,7 @@
|
|||||||
|
|
||||||
<p v-if="showM4bDownload" class="text-left text-base mb-4 py-4">
|
<p v-if="showM4bDownload" class="text-left text-base mb-4 py-4">
|
||||||
<span class="text-error">* <strong>Experimental</strong></span
|
<span class="text-error">* <strong>Experimental</strong></span
|
||||||
> - M4b merge can take several minutes and will be stored in <span class="bg-primary bg-opacity-75 font-mono p-1 text-base">/metadata/downloads</span>. After the download is ready, it will remain available for 60 minutes, then be deleted. Download will timeout after 20 minutes.
|
> - M4b merge can take several minutes and will be stored in <span class="bg-primary bg-opacity-75 font-mono p-1 text-base">/metadata/downloads</span>. After the download is ready, it will remain available for 60 minutes, then be deleted. Download will timeout after 30 minutes.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- <p v-if="isSingleM4b" class="text-lg text-center my-8">Audiobook is already a single m4b!</p> -->
|
<!-- <p v-if="isSingleM4b" class="text-lg text-center my-8">Audiobook is already a single m4b!</p> -->
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full h-full overflow-hidden px-4 py-6 relative">
|
<div id="match-wrapper" class="w-full h-full overflow-hidden px-4 py-6 relative">
|
||||||
<form @submit.prevent="submitSearch">
|
<form @submit.prevent="submitSearch">
|
||||||
<div class="flex items-center justify-start -mx-1 h-20">
|
<div class="flex items-center justify-start -mx-1 h-20">
|
||||||
<div class="w-40 px-1">
|
<div class="w-40 px-1">
|
||||||
@@ -87,7 +87,7 @@
|
|||||||
<div v-if="selectedMatch.series" class="flex items-center py-2">
|
<div v-if="selectedMatch.series" class="flex items-center py-2">
|
||||||
<ui-checkbox v-model="selectedMatchUsage.series" />
|
<ui-checkbox v-model="selectedMatchUsage.series" />
|
||||||
<div class="flex-grow ml-4">
|
<div class="flex-grow ml-4">
|
||||||
<ui-text-input-with-label v-model="selectedMatch.series" :disabled="!selectedMatchUsage.series" label="Series" />
|
<widgets-series-input-widget v-model="selectedMatch.series" />
|
||||||
<p v-if="mediaMetadata.seriesName" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.seriesName || '' }}</p>
|
<p v-if="mediaMetadata.seriesName" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.seriesName || '' }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -95,6 +95,27 @@
|
|||||||
<ui-checkbox v-model="selectedMatchUsage.volumeNumber" />
|
<ui-checkbox v-model="selectedMatchUsage.volumeNumber" />
|
||||||
<ui-text-input-with-label v-model="selectedMatch.volumeNumber" :disabled="!selectedMatchUsage.volumeNumber" label="Volume Number" class="flex-grow ml-4" />
|
<ui-text-input-with-label v-model="selectedMatch.volumeNumber" :disabled="!selectedMatchUsage.volumeNumber" label="Volume Number" class="flex-grow ml-4" />
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="selectedMatch.genres" class="flex items-center py-2">
|
||||||
|
<ui-checkbox v-model="selectedMatchUsage.genres" />
|
||||||
|
<div class="flex-grow ml-4">
|
||||||
|
<ui-text-input-with-label v-model="selectedMatch.genres" :disabled="!selectedMatchUsage.genres" label="Genres" />
|
||||||
|
<p v-if="mediaMetadata.genresList" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.genresList || '' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedMatch.tags" class="flex items-center py-2">
|
||||||
|
<ui-checkbox v-model="selectedMatchUsage.tags" />
|
||||||
|
<div class="flex-grow ml-4">
|
||||||
|
<ui-text-input-with-label v-model="selectedMatch.tags" :disabled="!selectedMatchUsage.tags" label="Tags" />
|
||||||
|
<p v-if="mediaMetadata.tagsList" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.tagsList || '' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedMatch.language" class="flex items-center py-2">
|
||||||
|
<ui-checkbox v-model="selectedMatchUsage.language" />
|
||||||
|
<div class="flex-grow ml-4">
|
||||||
|
<ui-text-input-with-label v-model="selectedMatch.language" :disabled="!selectedMatchUsage.language" label="Language" />
|
||||||
|
<p v-if="mediaMetadata.language" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.language || '' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div v-if="selectedMatch.isbn" class="flex items-center py-2">
|
<div v-if="selectedMatch.isbn" class="flex items-center py-2">
|
||||||
<ui-checkbox v-model="selectedMatchUsage.isbn" />
|
<ui-checkbox v-model="selectedMatchUsage.isbn" />
|
||||||
<div class="flex-grow ml-4">
|
<div class="flex-grow ml-4">
|
||||||
@@ -177,6 +198,10 @@ export default {
|
|||||||
publishedYear: true,
|
publishedYear: true,
|
||||||
series: true,
|
series: true,
|
||||||
volumeNumber: true,
|
volumeNumber: true,
|
||||||
|
genres: true,
|
||||||
|
tags: true,
|
||||||
|
language: true,
|
||||||
|
explicit: true,
|
||||||
asin: true,
|
asin: true,
|
||||||
isbn: true,
|
isbn: true,
|
||||||
// Podcast specific
|
// Podcast specific
|
||||||
@@ -204,6 +229,22 @@ export default {
|
|||||||
this.$emit('update:processing', val)
|
this.$emit('update:processing', val)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
seriesItems: {
|
||||||
|
get() {
|
||||||
|
return this.selectedMatch.series.map((se) => {
|
||||||
|
return {
|
||||||
|
id: `new-${Math.floor(Math.random() * 10000)}`,
|
||||||
|
displayName: se.volumeNumber ? `${se.series} #${se.volumeNumber}` : se.series,
|
||||||
|
name: se.series,
|
||||||
|
sequence: se.volumeNumber || ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
console.log('set series items', val)
|
||||||
|
this.selectedMatch.series = val
|
||||||
|
}
|
||||||
|
},
|
||||||
bookCoverAspectRatio() {
|
bookCoverAspectRatio() {
|
||||||
return this.$store.getters['getBookCoverAspectRatio']
|
return this.$store.getters['getBookCoverAspectRatio']
|
||||||
},
|
},
|
||||||
@@ -294,6 +335,10 @@ export default {
|
|||||||
publishedYear: true,
|
publishedYear: true,
|
||||||
series: true,
|
series: true,
|
||||||
volumeNumber: true,
|
volumeNumber: true,
|
||||||
|
genres: true,
|
||||||
|
tags: true,
|
||||||
|
language: true,
|
||||||
|
explicit: true,
|
||||||
asin: true,
|
asin: true,
|
||||||
isbn: true,
|
isbn: true,
|
||||||
// Podcast specific
|
// Podcast specific
|
||||||
@@ -320,36 +365,82 @@ export default {
|
|||||||
else this.provider = localStorage.getItem('book-provider') || 'google'
|
else this.provider = localStorage.getItem('book-provider') || 'google'
|
||||||
},
|
},
|
||||||
selectMatch(match) {
|
selectMatch(match) {
|
||||||
|
if (match) {
|
||||||
|
if (match.series) {
|
||||||
|
if (!match.series.length) {
|
||||||
|
delete match.series
|
||||||
|
} else {
|
||||||
|
match.series = match.series.map((se) => {
|
||||||
|
return {
|
||||||
|
id: `new-${Math.floor(Math.random() * 10000)}`,
|
||||||
|
displayName: se.volumeNumber ? `${se.series} #${se.volumeNumber}` : se.series,
|
||||||
|
name: se.series,
|
||||||
|
sequence: se.volumeNumber || ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (match.genres && Array.isArray(match.genres)) {
|
||||||
|
match.genres = match.genres.join(',')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Select Match', match)
|
||||||
this.selectedMatch = match
|
this.selectedMatch = match
|
||||||
},
|
},
|
||||||
buildMatchUpdatePayload() {
|
buildMatchUpdatePayload() {
|
||||||
var updatePayload = {}
|
var updatePayload = {}
|
||||||
|
updatePayload.metadata = {}
|
||||||
|
|
||||||
var volumeNumber = this.selectedMatchUsage.volumeNumber ? this.selectedMatch.volumeNumber || null : null
|
var volumeNumber = this.selectedMatchUsage.volumeNumber ? this.selectedMatch.volumeNumber || null : null
|
||||||
for (const key in this.selectedMatchUsage) {
|
for (const key in this.selectedMatchUsage) {
|
||||||
if (this.selectedMatchUsage[key] && this.selectedMatch[key]) {
|
if (this.selectedMatchUsage[key] && this.selectedMatch[key]) {
|
||||||
if (key === 'series') {
|
if (key === 'series') {
|
||||||
var seriesItem = {
|
var seriesPayload = []
|
||||||
id: `new-${Math.floor(Math.random() * 10000)}`,
|
if (!Array.isArray(this.selectedMatch[key])) {
|
||||||
name: this.selectedMatch[key],
|
seriesPayload.push({
|
||||||
sequence: volumeNumber
|
id: `new-${Math.floor(Math.random() * 10000)}`,
|
||||||
|
name: this.selectedMatch[key],
|
||||||
|
sequence: volumeNumber
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
this.selectedMatch[key].forEach((seriesItem) =>
|
||||||
|
seriesPayload.push({
|
||||||
|
id: seriesItem.id,
|
||||||
|
name: seriesItem.name,
|
||||||
|
sequence: seriesItem.sequence
|
||||||
|
})
|
||||||
|
)
|
||||||
}
|
}
|
||||||
updatePayload.series = [seriesItem]
|
|
||||||
|
updatePayload.metadata.series = seriesPayload
|
||||||
} else if (key === 'author' && !this.isPodcast) {
|
} else if (key === 'author' && !this.isPodcast) {
|
||||||
var authorItem = {
|
var authors = this.selectedMatch[key]
|
||||||
id: `new-${Math.floor(Math.random() * 10000)}`,
|
if (!Array.isArray(authors)) {
|
||||||
name: this.selectedMatch[key]
|
authors = authors.split(',').map((au) => au.trim())
|
||||||
}
|
}
|
||||||
updatePayload.authors = [authorItem]
|
var authorPayload = []
|
||||||
|
authors.forEach((authorName) =>
|
||||||
|
authorPayload.push({
|
||||||
|
id: `new-${Math.floor(Math.random() * 10000)}`,
|
||||||
|
name: authorName
|
||||||
|
})
|
||||||
|
)
|
||||||
|
updatePayload.metadata.authors = authorPayload
|
||||||
} else if (key === 'narrator') {
|
} else if (key === 'narrator') {
|
||||||
updatePayload.narrators = [this.selectedMatch[key]]
|
updatePayload.metadata.narrators = this.selectedMatch[key].split(',').map((v) => v.trim())
|
||||||
|
} else if (key === 'genres') {
|
||||||
|
updatePayload.metadata.genres = this.selectedMatch[key].split(',').map((v) => v.trim())
|
||||||
|
} else if (key === 'tags') {
|
||||||
|
updatePayload.tags = this.selectedMatch[key].split(',').map((v) => v.trim())
|
||||||
} else if (key === 'itunesId') {
|
} else if (key === 'itunesId') {
|
||||||
updatePayload.itunesId = Number(this.selectedMatch[key])
|
updatePayload.metadata.itunesId = Number(this.selectedMatch[key])
|
||||||
} else if (key !== 'volumeNumber') {
|
} else {
|
||||||
updatePayload[key] = this.selectedMatch[key]
|
updatePayload.metadata[key] = this.selectedMatch[key]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return updatePayload
|
return updatePayload
|
||||||
},
|
},
|
||||||
async submitMatchUpdate() {
|
async submitMatchUpdate() {
|
||||||
@@ -357,11 +448,13 @@ export default {
|
|||||||
if (!Object.keys(updatePayload).length) {
|
if (!Object.keys(updatePayload).length) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('Match payload', updatePayload)
|
||||||
this.isProcessing = true
|
this.isProcessing = true
|
||||||
|
|
||||||
if (updatePayload.cover) {
|
if (updatePayload.metadata.cover) {
|
||||||
var coverPayload = {
|
var coverPayload = {
|
||||||
url: updatePayload.cover
|
url: updatePayload.metadata.cover
|
||||||
}
|
}
|
||||||
var success = await this.$axios.$post(`/api/items/${this.libraryItemId}/cover`, coverPayload).catch((error) => {
|
var success = await this.$axios.$post(`/api/items/${this.libraryItemId}/cover`, coverPayload).catch((error) => {
|
||||||
console.error('Failed to update', error)
|
console.error('Failed to update', error)
|
||||||
@@ -373,13 +466,11 @@ export default {
|
|||||||
this.$toast.error('Item Cover Failed to Update')
|
this.$toast.error('Item Cover Failed to Update')
|
||||||
}
|
}
|
||||||
console.log('Updated cover')
|
console.log('Updated cover')
|
||||||
delete updatePayload.cover
|
delete updatePayload.metadata.cover
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Object.keys(updatePayload).length) {
|
if (Object.keys(updatePayload).length) {
|
||||||
var mediaUpdatePayload = {
|
var mediaUpdatePayload = updatePayload
|
||||||
metadata: updatePayload
|
|
||||||
}
|
|
||||||
var updateResult = await this.$axios.$patch(`/api/items/${this.libraryItemId}/media`, mediaUpdatePayload).catch((error) => {
|
var updateResult = await this.$axios.$patch(`/api/items/${this.libraryItemId}/media`, mediaUpdatePayload).catch((error) => {
|
||||||
console.error('Failed to update', error)
|
console.error('Failed to update', error)
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
|
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div ref="wrapper" class="p-4 w-full text-sm py-2 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden">
|
<div ref="wrapper" class="p-4 w-full text-sm py-2 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-y-auto" style="max-height: 80vh">
|
||||||
<div class="flex flex-wrap">
|
<div class="flex flex-wrap">
|
||||||
<div class="w-1/5 p-1">
|
<div class="w-1/5 p-1">
|
||||||
<ui-text-input-with-label v-model="newEpisode.season" label="Season" />
|
<ui-text-input-with-label v-model="newEpisode.season" label="Season" />
|
||||||
@@ -25,8 +25,8 @@
|
|||||||
<div class="w-full p-1">
|
<div class="w-full p-1">
|
||||||
<ui-textarea-with-label v-model="newEpisode.subtitle" label="Subtitle" :rows="3" />
|
<ui-textarea-with-label v-model="newEpisode.subtitle" label="Subtitle" :rows="3" />
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full p-1">
|
<div class="w-full p-1 default-style">
|
||||||
<ui-textarea-with-label v-model="newEpisode.description" label="Description" :rows="8" />
|
<ui-rich-text-editor v-if="show" label="Description" v-model="newEpisode.description" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end pt-4">
|
<div class="flex justify-end pt-4">
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
<template>
|
||||||
|
<modals-modal v-model="show" name="opml-feeds-modal" :width="1000" :height="'unset'" :processing="processing">
|
||||||
|
<template #outer>
|
||||||
|
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||||
|
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div ref="wrapper" class="p-4 w-full text-sm py-2 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden">
|
||||||
|
<div class="w-full p-4">
|
||||||
|
<div class="flex items-center -mx-2 mb-2">
|
||||||
|
<div class="w-full md:w-2/3 p-2">
|
||||||
|
<ui-dropdown v-model="selectedFolderId" :items="folderItems" :disabled="processing" label="Folder" />
|
||||||
|
</div>
|
||||||
|
<div class="w-full md:w-1/3 p-2 pt-6">
|
||||||
|
<ui-checkbox v-model="autoDownloadEpisodes" label="Auto Download New Episodes" checkbox-bg="primary" border-color="gray-600" label-class="text-sm font-semibold pl-2" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-lg font-semibold mb-2">Podcasts to Add</p>
|
||||||
|
|
||||||
|
<div class="w-full overflow-y-auto" style="max-height: 50vh">
|
||||||
|
<template v-for="(feed, index) in feedMetadata">
|
||||||
|
<cards-podcast-feed-summary-card :key="index" :feed="feed" :library-folder-path="selectedFolderPath" class="my-1" />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center py-4">
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<ui-btn color="success" @click="submit">Add Podcasts</ui-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</modals-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: Boolean,
|
||||||
|
feeds: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
processing: false,
|
||||||
|
selectedFolderId: null,
|
||||||
|
fullPath: null,
|
||||||
|
autoDownloadEpisodes: false,
|
||||||
|
feedMetadata: []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
show: {
|
||||||
|
immediate: true,
|
||||||
|
handler(newVal) {
|
||||||
|
if (newVal) {
|
||||||
|
this.init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
show: {
|
||||||
|
get() {
|
||||||
|
return this.value
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('input', val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title() {
|
||||||
|
return 'OPML Feeds'
|
||||||
|
},
|
||||||
|
currentLibrary() {
|
||||||
|
return this.$store.getters['libraries/getCurrentLibrary']
|
||||||
|
},
|
||||||
|
folders() {
|
||||||
|
if (!this.currentLibrary) return []
|
||||||
|
return this.currentLibrary.folders || []
|
||||||
|
},
|
||||||
|
folderItems() {
|
||||||
|
return this.folders.map((fold) => {
|
||||||
|
return {
|
||||||
|
value: fold.id,
|
||||||
|
text: fold.fullPath
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
selectedFolder() {
|
||||||
|
return this.folders.find((f) => f.id === this.selectedFolderId)
|
||||||
|
},
|
||||||
|
selectedFolderPath() {
|
||||||
|
if (!this.selectedFolder) return ''
|
||||||
|
return this.selectedFolder.fullPath
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
toFeedMetadata(feed) {
|
||||||
|
var metadata = feed.metadata
|
||||||
|
return {
|
||||||
|
title: metadata.title,
|
||||||
|
author: metadata.author,
|
||||||
|
description: metadata.description,
|
||||||
|
releaseDate: '',
|
||||||
|
genres: [...metadata.categories],
|
||||||
|
feedUrl: metadata.feedUrl,
|
||||||
|
imageUrl: metadata.image,
|
||||||
|
itunesPageUrl: '',
|
||||||
|
itunesId: '',
|
||||||
|
itunesArtistId: '',
|
||||||
|
language: '',
|
||||||
|
numEpisodes: feed.numEpisodes
|
||||||
|
}
|
||||||
|
},
|
||||||
|
init() {
|
||||||
|
this.feedMetadata = this.feeds.map(this.toFeedMetadata)
|
||||||
|
|
||||||
|
if (this.folderItems[0]) {
|
||||||
|
this.selectedFolderId = this.folderItems[0].value
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async submit() {
|
||||||
|
this.processing = true
|
||||||
|
var newFeedPayloads = this.feedMetadata.map((metadata) => {
|
||||||
|
return {
|
||||||
|
path: `${this.selectedFolderPath}\\${this.$sanitizeFilename(metadata.title)}`,
|
||||||
|
folderId: this.selectedFolderId,
|
||||||
|
libraryId: this.currentLibrary.id,
|
||||||
|
media: {
|
||||||
|
metadata: {
|
||||||
|
...metadata
|
||||||
|
},
|
||||||
|
autoDownloadEpisodes: this.autoDownloadEpisodes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
console.log('New feed payloads', newFeedPayloads)
|
||||||
|
|
||||||
|
for (const podcastPayload of newFeedPayloads) {
|
||||||
|
await this.$axios
|
||||||
|
.$post('/api/podcasts', podcastPayload)
|
||||||
|
.then(() => {
|
||||||
|
this.$toast.success(`${podcastPayload.media.metadata.title}: Podcast created successfully`)
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed to create podcast'
|
||||||
|
console.error('Failed to create podcast', podcastPayload, error)
|
||||||
|
this.$toast.error(`${podcastPayload.media.metadata.title}: ${errorMsg}`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
this.processing = false
|
||||||
|
this.show = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
#podcast-wrapper {
|
||||||
|
min-height: 400px;
|
||||||
|
max-height: 80vh;
|
||||||
|
}
|
||||||
|
#episodes-scroll {
|
||||||
|
max-height: calc(80vh - 200px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<template>
|
||||||
|
<modals-modal v-model="show" name="podcast-episode-view-modal" :width="800" :height="'unset'" :processing="processing">
|
||||||
|
<template #outer>
|
||||||
|
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||||
|
<p class="font-book text-3xl text-white truncate">Episode</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div ref="wrapper" class="p-4 w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-y-auto" style="max-height: 80vh">
|
||||||
|
<div class="flex mb-4">
|
||||||
|
<div class="w-12 h-12">
|
||||||
|
<covers-book-cover :library-item="libraryItem" :width="48" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow px-2">
|
||||||
|
<p class="text-base mb-1">{{ podcastTitle }}</p>
|
||||||
|
<p class="text-xs text-gray-300">{{ podcastAuthor }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-lg font-semibold mb-6">{{ title }}</p>
|
||||||
|
<div v-if="description" class="default-style" v-html="description" />
|
||||||
|
<p v-else class="mb-2">No description</p>
|
||||||
|
</div>
|
||||||
|
</modals-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
processing: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
show: {
|
||||||
|
get() {
|
||||||
|
return this.$store.state.globals.showViewPodcastEpisodeModal
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$store.commit('globals/setShowViewPodcastEpisodeModal', val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
libraryItem() {
|
||||||
|
return this.$store.state.selectedLibraryItem
|
||||||
|
},
|
||||||
|
episode() {
|
||||||
|
return this.$store.state.globals.selectedEpisode || {}
|
||||||
|
},
|
||||||
|
episodeId() {
|
||||||
|
return this.episode.id
|
||||||
|
},
|
||||||
|
title() {
|
||||||
|
return this.episode.title || 'No Episode Title'
|
||||||
|
},
|
||||||
|
description() {
|
||||||
|
return this.episode.description || ''
|
||||||
|
},
|
||||||
|
media() {
|
||||||
|
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||||
|
},
|
||||||
|
mediaMetadata() {
|
||||||
|
return this.media.metadata || {}
|
||||||
|
},
|
||||||
|
podcastTitle() {
|
||||||
|
return this.mediaMetadata.title
|
||||||
|
},
|
||||||
|
podcastAuthor() {
|
||||||
|
return this.mediaMetadata.author
|
||||||
|
},
|
||||||
|
bookCoverAspectRatio() {
|
||||||
|
return this.$store.getters['getBookCoverAspectRatio']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -27,7 +27,6 @@
|
|||||||
<p v-if="hasEpisodesWithoutPubDate" class="w-full pt-2 text-warning text-xs">Warning: 1 or more of your episodes do not have a Pub Date. Some podcast apps require this.</p>
|
<p v-if="hasEpisodesWithoutPubDate" class="w-full pt-2 text-warning text-xs">Warning: 1 or more of your episodes do not have a Pub Date. Some podcast apps require this.</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-show="userIsAdminOrUp" class="flex items-center pt-6">
|
<div v-show="userIsAdminOrUp" class="flex items-center pt-6">
|
||||||
<p class="text-xs text-gray-300">Note: RSS feed URLs are not authenticated</p>
|
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<ui-btn v-if="currentFeedUrl" color="error" small @click="closeFeed">Close RSS Feed</ui-btn>
|
<ui-btn v-if="currentFeedUrl" color="error" small @click="closeFeed">Close RSS Feed</ui-btn>
|
||||||
<ui-btn v-else color="success" small @click="openFeed">Open RSS Feed</ui-btn>
|
<ui-btn v-else color="success" small @click="openFeed">Open RSS Feed</ui-btn>
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex pt-4 pb-2 md:pt-0 md:pb-2">
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<template v-if="!loading">
|
||||||
|
<div class="cursor-pointer flex items-center justify-center text-gray-300 mr-4 md:mr-8" @mousedown.prevent @mouseup.prevent @click.stop="prevChapter">
|
||||||
|
<span class="material-icons text-2xl sm:text-3xl">first_page</span>
|
||||||
|
</div>
|
||||||
|
<div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpBackward">
|
||||||
|
<span class="material-icons text-2xl sm:text-3xl">replay_10</span>
|
||||||
|
</div>
|
||||||
|
<div class="cursor-pointer p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-4 md:mx-8" :class="seekLoading ? 'animate-spin' : ''" @mousedown.prevent @mouseup.prevent @click.stop="playPause">
|
||||||
|
<span class="material-icons">{{ seekLoading ? 'autorenew' : paused ? 'play_arrow' : 'pause' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpForward">
|
||||||
|
<span class="material-icons text-2xl sm:text-3xl">forward_10</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-center ml-4 md:ml-8" :class="hasNextChapter ? 'text-gray-300 cursor-pointer' : 'text-gray-500'" @mousedown.prevent @mouseup.prevent @click.stop="nextChapter">
|
||||||
|
<span class="material-icons text-2xl sm:text-3xl">last_page</span>
|
||||||
|
</div>
|
||||||
|
<controls-playback-speed-control v-model="playbackRateInput" @input="playbackRateUpdated" @change="playbackRateChanged" />
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="cursor-pointer p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-8 animate-spin">
|
||||||
|
<span class="material-icons">autorenew</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="flex-grow" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
loading: Boolean,
|
||||||
|
seekLoading: Boolean,
|
||||||
|
playbackRate: Number,
|
||||||
|
paused: Boolean,
|
||||||
|
hasNextChapter: Boolean
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
playbackRateInput: {
|
||||||
|
get() {
|
||||||
|
return this.playbackRate
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('update:playbackRate', val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
playPause() {
|
||||||
|
this.$emit('playPause')
|
||||||
|
},
|
||||||
|
prevChapter() {
|
||||||
|
this.$emit('prevChapter')
|
||||||
|
},
|
||||||
|
nextChapter() {
|
||||||
|
if (!this.hasNextChapter) return
|
||||||
|
this.$emit('nextChapter')
|
||||||
|
},
|
||||||
|
jumpBackward() {
|
||||||
|
this.$emit('jumpBackward')
|
||||||
|
},
|
||||||
|
jumpForward() {
|
||||||
|
this.$emit('jumpForward')
|
||||||
|
},
|
||||||
|
playbackRateUpdated(playbackRate) {
|
||||||
|
this.$emit('setPlaybackRate', playbackRate)
|
||||||
|
},
|
||||||
|
playbackRateChanged(playbackRate) {
|
||||||
|
this.$emit('setPlaybackRate', playbackRate)
|
||||||
|
this.$store.dispatch('user/updateUserSettings', { playbackRate }).catch((err) => {
|
||||||
|
console.error('Failed to update settings', err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
<template>
|
||||||
|
<div class="relative">
|
||||||
|
<!-- Track -->
|
||||||
|
<div ref="track" class="w-full h-2 bg-gray-700 relative cursor-pointer transform duration-100 hover:scale-y-125 overflow-hidden" @mousemove="mousemoveTrack" @mouseleave="mouseleaveTrack" @click.stop="clickTrack">
|
||||||
|
<div ref="readyTrack" class="h-full bg-gray-600 absolute top-0 left-0 pointer-events-none" />
|
||||||
|
<div ref="bufferTrack" class="h-full bg-gray-500 absolute top-0 left-0 pointer-events-none" />
|
||||||
|
<div ref="playedTrack" class="h-full bg-gray-200 absolute top-0 left-0 pointer-events-none" />
|
||||||
|
<div ref="trackCursor" class="h-full w-0.5 bg-gray-50 absolute top-0 left-0 opacity-0 pointer-events-none" />
|
||||||
|
<div v-if="loading" class="h-full w-1/4 absolute left-0 top-0 loadingTrack pointer-events-none bg-white bg-opacity-25" />
|
||||||
|
</div>
|
||||||
|
<div class="w-full h-2 relative overflow-hidden" :class="useChapterTrack ? 'opacity-0' : ''">
|
||||||
|
<template v-for="(tick, index) in chapterTicks">
|
||||||
|
<div :key="index" :style="{ left: tick.left + 'px' }" class="absolute top-0 w-px bg-white bg-opacity-30 h-1 pointer-events-none" />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hover timestamp -->
|
||||||
|
<div ref="hoverTimestamp" class="absolute -top-8 left-0 bg-white text-black rounded-full opacity-0 pointer-events-none">
|
||||||
|
<p ref="hoverTimestampText" class="text-xs font-mono text-center px-2 py-0.5 truncate whitespace-nowrap">00:00</p>
|
||||||
|
</div>
|
||||||
|
<div ref="hoverTimestampArrow" class="absolute -top-3 left-0 bg-white text-black rounded-full opacity-0 pointer-events-none">
|
||||||
|
<div class="absolute -bottom-1.5 left-0 right-0 w-full flex justify-center">
|
||||||
|
<div class="arrow-down" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
loading: Boolean,
|
||||||
|
duration: Number,
|
||||||
|
chapters: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
currentChapter: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
trackWidth: 0,
|
||||||
|
currentTime: 0,
|
||||||
|
percentReady: 0,
|
||||||
|
bufferTime: 0,
|
||||||
|
chapterTicks: [],
|
||||||
|
trackOffsetLeft: 16, // Track is 16px from edge
|
||||||
|
playedTrackWidth: 0,
|
||||||
|
readyTrackWidth: 0,
|
||||||
|
bufferTrackWidth: 0,
|
||||||
|
useChapterTrack: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
duration: {
|
||||||
|
immediate: true,
|
||||||
|
handler() {
|
||||||
|
this.setChapterTicks()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
currentChapterDuration() {
|
||||||
|
if (!this.currentChapter) return 0
|
||||||
|
return this.currentChapter.end - this.currentChapter.start
|
||||||
|
},
|
||||||
|
currentChapterStart() {
|
||||||
|
if (!this.currentChapter) return 0
|
||||||
|
return this.currentChapter.start
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
setUseChapterTrack(useChapterTrack) {
|
||||||
|
this.useChapterTrack = useChapterTrack
|
||||||
|
this.updateBufferTrack()
|
||||||
|
this.updatePlayedTrackWidth()
|
||||||
|
},
|
||||||
|
clickTrack(e) {
|
||||||
|
if (this.loading) return
|
||||||
|
|
||||||
|
var offsetX = e.offsetX
|
||||||
|
var perc = offsetX / this.trackWidth
|
||||||
|
const baseTime = this.useChapterTrack ? this.currentChapterStart : 0;
|
||||||
|
const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration;
|
||||||
|
const time = baseTime + (perc * duration);
|
||||||
|
if (isNaN(time) || time === null) {
|
||||||
|
console.error('Invalid time', perc, time)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.$emit('seek', time)
|
||||||
|
},
|
||||||
|
setBufferTime(time) {
|
||||||
|
this.bufferTime = time
|
||||||
|
this.updateBufferTrack()
|
||||||
|
},
|
||||||
|
updateBufferTrack() {
|
||||||
|
const time = this.useChapterTrack ? Math.max(0, this.bufferTime - this.currentChapterStart) : this.bufferTime
|
||||||
|
const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration
|
||||||
|
|
||||||
|
var bufferlen = (time / duration) * this.trackWidth
|
||||||
|
bufferlen = Math.round(bufferlen)
|
||||||
|
if (this.bufferTrackWidth === bufferlen || !this.$refs.bufferTrack) return
|
||||||
|
if (this.$refs.bufferTrack) this.$refs.bufferTrack.style.width = bufferlen + 'px'
|
||||||
|
this.bufferTrackWidth = bufferlen
|
||||||
|
},
|
||||||
|
setPercentageReady(percent) {
|
||||||
|
this.percentReady = percent
|
||||||
|
this.updateReadyTrack()
|
||||||
|
},
|
||||||
|
updateReadyTrack() {
|
||||||
|
var widthReady = Math.round(this.trackWidth * this.percentReady)
|
||||||
|
if (this.readyTrackWidth === widthReady) return
|
||||||
|
this.readyTrackWidth = widthReady
|
||||||
|
if (this.$refs.readyTrack) this.$refs.readyTrack.style.width = widthReady + 'px'
|
||||||
|
},
|
||||||
|
setCurrentTime(time) {
|
||||||
|
this.currentTime = time
|
||||||
|
this.updatePlayedTrackWidth()
|
||||||
|
},
|
||||||
|
updatePlayedTrackWidth() {
|
||||||
|
const time = this.useChapterTrack ? Math.max(0, this.currentTime - this.currentChapterStart) : this.currentTime
|
||||||
|
const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration
|
||||||
|
|
||||||
|
var ptWidth = Math.round((time / duration) * this.trackWidth)
|
||||||
|
if (this.playedTrackWidth === ptWidth) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (this.$refs.playedTrack) this.$refs.playedTrack.style.width = ptWidth + 'px'
|
||||||
|
this.playedTrackWidth = ptWidth
|
||||||
|
},
|
||||||
|
setChapterTicks() {
|
||||||
|
this.chapterTicks = this.chapters.map((chap) => {
|
||||||
|
var perc = chap.start / this.duration
|
||||||
|
return {
|
||||||
|
title: chap.title,
|
||||||
|
left: perc * this.trackWidth
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
mousemoveTrack(e) {
|
||||||
|
var offsetX = e.offsetX
|
||||||
|
|
||||||
|
const baseTime = this.useChapterTrack ? this.currentChapterStart : 0;
|
||||||
|
const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration;
|
||||||
|
const progressTime = (offsetX / this.trackWidth) * duration;
|
||||||
|
const totalTime = baseTime + progressTime;
|
||||||
|
|
||||||
|
if (this.$refs.hoverTimestamp) {
|
||||||
|
var width = this.$refs.hoverTimestamp.clientWidth
|
||||||
|
this.$refs.hoverTimestamp.style.opacity = 1
|
||||||
|
var posLeft = offsetX - width / 2
|
||||||
|
if (posLeft + width + this.trackOffsetLeft > window.innerWidth) {
|
||||||
|
posLeft = window.innerWidth - width - this.trackOffsetLeft
|
||||||
|
} else if (posLeft < -this.trackOffsetLeft) {
|
||||||
|
posLeft = -this.trackOffsetLeft
|
||||||
|
}
|
||||||
|
this.$refs.hoverTimestamp.style.left = posLeft + 'px'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.$refs.hoverTimestampArrow) {
|
||||||
|
var width = this.$refs.hoverTimestampArrow.clientWidth
|
||||||
|
var posLeft = offsetX - width / 2
|
||||||
|
this.$refs.hoverTimestampArrow.style.opacity = 1
|
||||||
|
this.$refs.hoverTimestampArrow.style.left = posLeft + 'px'
|
||||||
|
}
|
||||||
|
if (this.$refs.hoverTimestampText) {
|
||||||
|
var hoverText = this.$secondsToTimestamp(progressTime)
|
||||||
|
|
||||||
|
var chapter = this.chapters.find((chapter) => chapter.start <= totalTime && totalTime < chapter.end)
|
||||||
|
if (chapter && chapter.title) {
|
||||||
|
hoverText += ` - ${chapter.title}`
|
||||||
|
}
|
||||||
|
this.$refs.hoverTimestampText.innerText = hoverText
|
||||||
|
}
|
||||||
|
if (this.$refs.trackCursor) {
|
||||||
|
this.$refs.trackCursor.style.opacity = 1
|
||||||
|
this.$refs.trackCursor.style.left = offsetX - 1 + 'px'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mouseleaveTrack() {
|
||||||
|
if (this.$refs.hoverTimestamp) {
|
||||||
|
this.$refs.hoverTimestamp.style.opacity = 0
|
||||||
|
}
|
||||||
|
if (this.$refs.hoverTimestampArrow) {
|
||||||
|
this.$refs.hoverTimestampArrow.style.opacity = 0
|
||||||
|
}
|
||||||
|
if (this.$refs.trackCursor) {
|
||||||
|
this.$refs.trackCursor.style.opacity = 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setTrackWidth() {
|
||||||
|
if (this.$refs.track) {
|
||||||
|
this.trackWidth = this.$refs.track.clientWidth
|
||||||
|
} else {
|
||||||
|
console.error('Track not loaded', this.$refs)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
windowResize() {
|
||||||
|
this.setTrackWidth()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.setTrackWidth()
|
||||||
|
window.addEventListener('resize', this.windowResize)
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
window.removeEventListener('resize', this.windowResize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,356 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full -mt-6">
|
||||||
|
<div class="w-full relative mb-1">
|
||||||
|
<div class="absolute -top-10 md:top-0 right-0 lg:right-2 flex items-center h-full">
|
||||||
|
<!-- <span class="material-icons text-2xl cursor-pointer" @click="toggleFullscreen(true)">expand_less</span> -->
|
||||||
|
|
||||||
|
<controls-volume-control ref="volumeControl" v-model="volume" @input="setVolume" class="mx-2 hidden md:block" />
|
||||||
|
|
||||||
|
<div class="cursor-pointer text-gray-300 hover:text-white mx-1 lg:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showSleepTimer')">
|
||||||
|
<span v-if="!sleepTimerSet" class="material-icons text-2xl sm:text-2.5xl">snooze</span>
|
||||||
|
<div v-else class="flex items-center">
|
||||||
|
<span class="material-icons text-lg text-warning">snooze</span>
|
||||||
|
<p class="text-xl text-warning font-mono font-semibold text-center px-0.5 pb-0.5" style="min-width: 30px">{{ sleepTimerRemainingString }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!isPodcast" class="cursor-pointer text-gray-300 hover:text-white mx-1 lg:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showBookmarks')">
|
||||||
|
<span class="material-icons text-2xl sm:text-2.5xl">{{ bookmarks.length ? 'bookmarks' : 'bookmark_border' }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="chapters.length" class="cursor-pointer text-gray-300 hover:text-white mx-1 lg:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="showChapters">
|
||||||
|
<span class="material-icons text-2xl sm:text-3xl">format_list_bulleted</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ui-tooltip v-if="chapters.length" direction="top" :text="useChapterTrack ? 'Use full track' : 'Use chapter track'">
|
||||||
|
<div class="cursor-pointer text-gray-300 mx-1 lg:mx-2 hover:text-white" @mousedown.prevent @mouseup.prevent @click.stop="setUseChapterTrack">
|
||||||
|
<span class="material-icons text-2xl sm:text-3xl transform transition-transform" :class="useChapterTrack ? 'rotate-180' : ''">timelapse</span>
|
||||||
|
</div>
|
||||||
|
</ui-tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<player-playback-controls :loading="loading" :seek-loading="seekLoading" :playback-rate.sync="playbackRate" :paused="paused" :has-next-chapter="hasNextChapter" @prevChapter="prevChapter" @nextChapter="nextChapter" @jumpForward="jumpForward" @jumpBackward="jumpBackward" @setPlaybackRate="setPlaybackRate" @playPause="playPause" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<player-track-bar ref="trackbar" :loading="loading" :chapters="chapters" :duration="duration" :current-chapter="currentChapter" @seek="seek" />
|
||||||
|
|
||||||
|
<div class="flex">
|
||||||
|
<p ref="currentTimestamp" class="font-mono text-xxs sm:text-sm text-gray-100 pointer-events-auto">00:00:00</p>
|
||||||
|
<p class="font-mono text-sm hidden sm:block text-gray-100 pointer-events-auto"> / {{ progressPercent }}%</p>
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<p class="text-xs sm:text-sm text-gray-300 pt-0.5">
|
||||||
|
{{ currentChapterName }} <span v-if="useChapterTrack" class="text-xs text-gray-400"> ({{ currentChapterIndex + 1 }} of {{ chapters.length }})</span>
|
||||||
|
</p>
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<p class="font-mono text-xxs sm:text-sm text-gray-100 pointer-events-auto">{{ timeRemainingPretty }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<modals-chapters-modal v-model="showChaptersModal" :current-chapter="currentChapter" :chapters="chapters" @select="selectChapter" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
loading: Boolean,
|
||||||
|
paused: Boolean,
|
||||||
|
chapters: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
bookmarks: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
sleepTimerSet: Boolean,
|
||||||
|
sleepTimerRemaining: Number,
|
||||||
|
isPodcast: Boolean
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
volume: 1,
|
||||||
|
playbackRate: 1,
|
||||||
|
audioEl: null,
|
||||||
|
seekLoading: false,
|
||||||
|
showChaptersModal: false,
|
||||||
|
currentTime: 0,
|
||||||
|
duration: 0,
|
||||||
|
useChapterTrack: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
sleepTimerRemainingString() {
|
||||||
|
var rounded = Math.round(this.sleepTimerRemaining)
|
||||||
|
if (rounded < 90) {
|
||||||
|
return `${rounded}s`
|
||||||
|
}
|
||||||
|
var minutesRounded = Math.round(rounded / 60)
|
||||||
|
if (minutesRounded < 90) {
|
||||||
|
return `${minutesRounded}m`
|
||||||
|
}
|
||||||
|
var hoursRounded = Math.round(minutesRounded / 60)
|
||||||
|
return `${hoursRounded}h`
|
||||||
|
},
|
||||||
|
token() {
|
||||||
|
return this.$store.getters['user/getToken']
|
||||||
|
},
|
||||||
|
timeRemaining() {
|
||||||
|
if (this.useChapterTrack && this.currentChapter) {
|
||||||
|
var currChapTime = this.currentTime - this.currentChapter.start
|
||||||
|
return (this.currentChapterDuration - currChapTime) / this.playbackRate
|
||||||
|
}
|
||||||
|
return (this.duration - this.currentTime) / this.playbackRate
|
||||||
|
},
|
||||||
|
timeRemainingPretty() {
|
||||||
|
if (this.timeRemaining < 0) {
|
||||||
|
return this.$secondsToTimestamp(this.timeRemaining * -1)
|
||||||
|
}
|
||||||
|
return '-' + this.$secondsToTimestamp(this.timeRemaining)
|
||||||
|
},
|
||||||
|
progressPercent() {
|
||||||
|
const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration
|
||||||
|
const time = this.useChapterTrack ? Math.max(this.currentTime - this.currentChapterStart) : this.currentTime
|
||||||
|
|
||||||
|
if (!duration) return 0
|
||||||
|
return Math.round((100 * time) / duration)
|
||||||
|
},
|
||||||
|
currentChapter() {
|
||||||
|
return this.chapters.find((chapter) => chapter.start <= this.currentTime && this.currentTime < chapter.end)
|
||||||
|
},
|
||||||
|
currentChapterName() {
|
||||||
|
return this.currentChapter ? this.currentChapter.title : ''
|
||||||
|
},
|
||||||
|
currentChapterDuration() {
|
||||||
|
if (!this.currentChapter) return 0
|
||||||
|
return this.currentChapter.end - this.currentChapter.start
|
||||||
|
},
|
||||||
|
currentChapterStart() {
|
||||||
|
if (!this.currentChapter) return 0
|
||||||
|
return this.currentChapter.start
|
||||||
|
},
|
||||||
|
isFullscreen() {
|
||||||
|
return this.$store.state.playerIsFullscreen
|
||||||
|
},
|
||||||
|
currentChapterIndex() {
|
||||||
|
if (!this.currentChapter) return 0
|
||||||
|
return this.chapters.findIndex((ch) => ch.id === this.currentChapter.id)
|
||||||
|
},
|
||||||
|
hasNextChapter() {
|
||||||
|
if (!this.chapters.length) return false
|
||||||
|
return this.currentChapterIndex < this.chapters.length - 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
toggleFullscreen(isFullscreen) {
|
||||||
|
this.$store.commit('setPlayerIsFullscreen', isFullscreen)
|
||||||
|
|
||||||
|
var videoPlayerEl = document.getElementById('video-player')
|
||||||
|
if (videoPlayerEl) {
|
||||||
|
if (isFullscreen) {
|
||||||
|
videoPlayerEl.style.width = '100vw'
|
||||||
|
videoPlayerEl.style.height = '100vh'
|
||||||
|
videoPlayerEl.style.top = '0px'
|
||||||
|
videoPlayerEl.style.left = '0px'
|
||||||
|
} else {
|
||||||
|
videoPlayerEl.style.width = '384px'
|
||||||
|
videoPlayerEl.style.height = '216px'
|
||||||
|
videoPlayerEl.style.top = 'unset'
|
||||||
|
videoPlayerEl.style.bottom = '80px'
|
||||||
|
videoPlayerEl.style.left = '16px'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setDuration(duration) {
|
||||||
|
this.duration = duration
|
||||||
|
},
|
||||||
|
setCurrentTime(time) {
|
||||||
|
this.currentTime = time
|
||||||
|
this.updateTimestamp()
|
||||||
|
if (this.$refs.trackbar) this.$refs.trackbar.setCurrentTime(time)
|
||||||
|
},
|
||||||
|
playPause() {
|
||||||
|
this.$emit('playPause')
|
||||||
|
},
|
||||||
|
jumpBackward() {
|
||||||
|
this.$emit('jumpBackward')
|
||||||
|
},
|
||||||
|
jumpForward() {
|
||||||
|
this.$emit('jumpForward')
|
||||||
|
},
|
||||||
|
increaseVolume() {
|
||||||
|
if (this.volume >= 1) return
|
||||||
|
this.volume = Math.min(1, this.volume + 0.1)
|
||||||
|
this.setVolume(this.volume)
|
||||||
|
},
|
||||||
|
decreaseVolume() {
|
||||||
|
if (this.volume <= 0) return
|
||||||
|
this.volume = Math.max(0, this.volume - 0.1)
|
||||||
|
this.setVolume(this.volume)
|
||||||
|
},
|
||||||
|
setVolume(volume) {
|
||||||
|
this.$emit('setVolume', volume)
|
||||||
|
},
|
||||||
|
toggleMute() {
|
||||||
|
if (this.$refs.volumeControl && this.$refs.volumeControl.toggleMute) {
|
||||||
|
this.$refs.volumeControl.toggleMute()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
increasePlaybackRate() {
|
||||||
|
var rates = [0.25, 0.5, 0.8, 1, 1.3, 1.5, 2, 2.5, 3]
|
||||||
|
var currentRateIndex = rates.findIndex((r) => r === this.playbackRate)
|
||||||
|
if (currentRateIndex >= rates.length - 1) return
|
||||||
|
this.playbackRate = rates[currentRateIndex + 1] || 1
|
||||||
|
this.playbackRateChanged(this.playbackRate)
|
||||||
|
},
|
||||||
|
decreasePlaybackRate() {
|
||||||
|
var rates = [0.25, 0.5, 0.8, 1, 1.3, 1.5, 2, 2.5, 3]
|
||||||
|
var currentRateIndex = rates.findIndex((r) => r === this.playbackRate)
|
||||||
|
if (currentRateIndex <= 0) return
|
||||||
|
this.playbackRate = rates[currentRateIndex - 1] || 1
|
||||||
|
this.playbackRateChanged(this.playbackRate)
|
||||||
|
},
|
||||||
|
setPlaybackRate(playbackRate) {
|
||||||
|
this.$emit('setPlaybackRate', playbackRate)
|
||||||
|
},
|
||||||
|
selectChapter(chapter) {
|
||||||
|
this.seek(chapter.start)
|
||||||
|
this.showChaptersModal = false
|
||||||
|
},
|
||||||
|
setUseChapterTrack() {
|
||||||
|
var useChapterTrack = !this.useChapterTrack
|
||||||
|
this.useChapterTrack = useChapterTrack
|
||||||
|
if (this.$refs.trackbar) this.$refs.trackbar.setUseChapterTrack(useChapterTrack)
|
||||||
|
|
||||||
|
this.$store.dispatch('user/updateUserSettings', { useChapterTrack }).catch((err) => {
|
||||||
|
console.error('Failed to update settings', err)
|
||||||
|
})
|
||||||
|
this.updateTimestamp()
|
||||||
|
},
|
||||||
|
checkUpdateChapterTrack() {
|
||||||
|
// Changing media in player may not have chapters
|
||||||
|
if (!this.chapters.length && this.useChapterTrack) {
|
||||||
|
this.useChapterTrack = false
|
||||||
|
if (this.$refs.trackbar) this.$refs.trackbar.setUseChapterTrack(this.useChapterTrack)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
seek(time) {
|
||||||
|
this.$emit('seek', time)
|
||||||
|
},
|
||||||
|
restart() {
|
||||||
|
this.seek(0)
|
||||||
|
},
|
||||||
|
prevChapter() {
|
||||||
|
if (!this.currentChapter || this.currentChapterIndex === 0) {
|
||||||
|
return this.restart()
|
||||||
|
}
|
||||||
|
var timeInCurrentChapter = this.currentTime - this.currentChapter.start
|
||||||
|
if (timeInCurrentChapter <= 3 && this.chapters[this.currentChapterIndex - 1]) {
|
||||||
|
var prevChapter = this.chapters[this.currentChapterIndex - 1]
|
||||||
|
this.seek(prevChapter.start)
|
||||||
|
} else {
|
||||||
|
this.seek(this.currentChapter.start)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
nextChapter() {
|
||||||
|
if (!this.currentChapter || !this.hasNextChapter) return
|
||||||
|
var nextChapter = this.chapters[this.currentChapterIndex + 1]
|
||||||
|
this.seek(nextChapter.start)
|
||||||
|
},
|
||||||
|
setStreamReady() {
|
||||||
|
if (this.$refs.trackbar) this.$refs.trackbar.setPercentageReady(1)
|
||||||
|
},
|
||||||
|
setChunksReady(chunks, numSegments) {
|
||||||
|
var largestSeg = 0
|
||||||
|
for (let i = 0; i < chunks.length; i++) {
|
||||||
|
var chunk = chunks[i]
|
||||||
|
if (typeof chunk === 'string') {
|
||||||
|
var chunkRange = chunk.split('-').map((c) => Number(c))
|
||||||
|
if (chunkRange.length < 2) continue
|
||||||
|
if (chunkRange[1] > largestSeg) largestSeg = chunkRange[1]
|
||||||
|
} else if (chunk > largestSeg) {
|
||||||
|
largestSeg = chunk
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var percentageReady = largestSeg / numSegments
|
||||||
|
if (this.$refs.trackbar) this.$refs.trackbar.setPercentageReady(percentageReady)
|
||||||
|
},
|
||||||
|
updateTimestamp() {
|
||||||
|
var ts = this.$refs.currentTimestamp
|
||||||
|
if (!ts) {
|
||||||
|
console.error('No timestamp el')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const time = this.useChapterTrack ? Math.max(0, this.currentTime - this.currentChapterStart) : this.currentTime
|
||||||
|
var currTimeClean = this.$secondsToTimestamp(time)
|
||||||
|
ts.innerText = currTimeClean
|
||||||
|
},
|
||||||
|
setBufferTime(bufferTime) {
|
||||||
|
if (this.$refs.trackbar) this.$refs.trackbar.setBufferTime(bufferTime)
|
||||||
|
},
|
||||||
|
showChapters() {
|
||||||
|
if (!this.chapters.length) return
|
||||||
|
this.showChaptersModal = !this.showChaptersModal
|
||||||
|
},
|
||||||
|
init() {
|
||||||
|
this.playbackRate = this.$store.getters['user/getUserSetting']('playbackRate') || 1
|
||||||
|
|
||||||
|
var _useChapterTrack = this.$store.getters['user/getUserSetting']('useChapterTrack') || false
|
||||||
|
this.useChapterTrack = this.chapters.length ? _useChapterTrack : false
|
||||||
|
|
||||||
|
if (this.$refs.trackbar) this.$refs.trackbar.setUseChapterTrack(this.useChapterTrack)
|
||||||
|
this.$emit('setPlaybackRate', this.playbackRate)
|
||||||
|
},
|
||||||
|
settingsUpdated(settings) {
|
||||||
|
if (settings.playbackRate && this.playbackRate !== settings.playbackRate) {
|
||||||
|
this.setPlaybackRate(settings.playbackRate)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
closePlayer() {
|
||||||
|
if (this.isFullscreen) {
|
||||||
|
this.toggleFullscreen(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.loading) return
|
||||||
|
this.$emit('close')
|
||||||
|
},
|
||||||
|
hotkey(action) {
|
||||||
|
if (action === this.$hotkeys.AudioPlayer.PLAY_PAUSE) this.playPause()
|
||||||
|
else if (action === this.$hotkeys.AudioPlayer.JUMP_FORWARD) this.jumpForward()
|
||||||
|
else if (action === this.$hotkeys.AudioPlayer.JUMP_BACKWARD) this.jumpBackward()
|
||||||
|
else if (action === this.$hotkeys.AudioPlayer.VOLUME_UP) this.increaseVolume()
|
||||||
|
else if (action === this.$hotkeys.AudioPlayer.VOLUME_DOWN) this.decreaseVolume()
|
||||||
|
else if (action === this.$hotkeys.AudioPlayer.MUTE_UNMUTE) this.toggleMute()
|
||||||
|
else if (action === this.$hotkeys.AudioPlayer.SHOW_CHAPTERS) this.showChapters()
|
||||||
|
else if (action === this.$hotkeys.AudioPlayer.INCREASE_PLAYBACK_RATE) this.increasePlaybackRate()
|
||||||
|
else if (action === this.$hotkeys.AudioPlayer.DECREASE_PLAYBACK_RATE) this.decreasePlaybackRate()
|
||||||
|
else if (action === this.$hotkeys.AudioPlayer.CLOSE) this.closePlayer()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.$store.commit('user/addSettingsListener', { id: 'audioplayer', meth: this.settingsUpdated })
|
||||||
|
this.init()
|
||||||
|
this.$eventBus.$on('player-hotkey', this.hotkey)
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
this.$store.commit('user/removeSettingsListener', 'audioplayer')
|
||||||
|
this.$eventBus.$off('player-hotkey', this.hotkey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.loadingTrack {
|
||||||
|
animation-name: loadingTrack;
|
||||||
|
animation-duration: 1s;
|
||||||
|
animation-iteration-count: infinite;
|
||||||
|
}
|
||||||
|
@keyframes loadingTrack {
|
||||||
|
0% {
|
||||||
|
left: -25%;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
left: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
<div id="heatmap" class="w-full">
|
<div 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)">
|
<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>
|
<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 :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>
|
<div v-for="dayLabel in dayLabels" :key="dayLabel.label" :style="dayLabel.style" class="absolute top-0 left-0 text-gray-300">{{ dayLabel.label }}</div>
|
||||||
|
|
||||||
|
|||||||
@@ -192,7 +192,11 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#accounts tr:nth-child(even) {
|
#accounts tr:nth-child(even) {
|
||||||
background-color: #3a3a3a;
|
background-color: #373838;
|
||||||
|
}
|
||||||
|
|
||||||
|
#accounts tr:nth-child(odd) {
|
||||||
|
background-color: #2f2f2f;
|
||||||
}
|
}
|
||||||
|
|
||||||
#accounts tr:hover {
|
#accounts tr:hover {
|
||||||
@@ -204,6 +208,6 @@ export default {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding-top: 5px;
|
padding-top: 5px;
|
||||||
padding-bottom: 5px;
|
padding-bottom: 5px;
|
||||||
background-color: #333;
|
background-color: #272727
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -6,10 +6,10 @@
|
|||||||
<span class="material-icons" style="font-size: 1.4rem">add</span>
|
<span class="material-icons" style="font-size: 1.4rem">add</span>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<template v-for="library in libraryCopies">
|
||||||
<div :key="library.id" class="item">
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</draggable>
|
</draggable>
|
||||||
|
|||||||
@@ -7,18 +7,26 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<p class="text-xl font-book pl-4 hover:underline cursor-pointer" @click.stop="$emit('click', library)">{{ library.name }}</p>
|
<p class="text-xl font-book pl-4 hover:underline cursor-pointer" @click.stop="$emit('click', library)">{{ library.name }}</p>
|
||||||
<div class="flex-grow" />
|
<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" 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" @click.stop="forceScan">Force Re-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-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-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="!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">
|
<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">
|
<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" />
|
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -30,22 +38,19 @@ export default {
|
|||||||
default: () => {}
|
default: () => {}
|
||||||
},
|
},
|
||||||
selected: Boolean,
|
selected: Boolean,
|
||||||
showEdit: Boolean,
|
|
||||||
dragging: Boolean
|
dragging: Boolean
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
mouseover: false,
|
mouseover: false,
|
||||||
isDeleting: false
|
isDeleting: false,
|
||||||
|
showMobileMenu: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
isHovering() {
|
isHovering() {
|
||||||
return this.mouseover && !this.dragging
|
return this.mouseover && !this.dragging
|
||||||
},
|
},
|
||||||
isMain() {
|
|
||||||
return this.library.id === 'main'
|
|
||||||
},
|
|
||||||
libraryScan() {
|
libraryScan() {
|
||||||
return this.$store.getters['scanners/getLibraryScan'](this.library.id)
|
return this.$store.getters['scanners/getLibraryScan'](this.library.id)
|
||||||
},
|
},
|
||||||
@@ -54,9 +59,50 @@ export default {
|
|||||||
},
|
},
|
||||||
isBookLibrary() {
|
isBookLibrary() {
|
||||||
return this.mediaType === 'book'
|
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: {
|
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() {
|
matchAll() {
|
||||||
this.$axios
|
this.$axios
|
||||||
.$post(`/api/libraries/${this.library.id}/matchall`)
|
.$post(`/api/libraries/${this.library.id}/matchall`)
|
||||||
@@ -97,7 +143,6 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
deleteClick() {
|
deleteClick() {
|
||||||
if (this.isMain) return
|
|
||||||
if (confirm(`Are you sure you want to permanently delete library "${this.library.name}"?`)) {
|
if (confirm(`Are you sure you want to permanently delete library "${this.library.name}"?`)) {
|
||||||
this.isDeleting = true
|
this.isDeleting = true
|
||||||
this.$axios
|
this.$axios
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full px-2 py-3 overflow-hidden relative border-b border-white border-opacity-10" @mouseover="mouseover" @mouseleave="mouseleave">
|
<div class="w-full px-2 py-3 overflow-hidden relative border-b border-white border-opacity-10" @mouseover="mouseover" @mouseleave="mouseleave">
|
||||||
<div v-if="episode" class="flex items-center h-24">
|
<div v-if="episode" class="flex items-center h-24 cursor-pointer" @click="$emit('view', episode)">
|
||||||
<div class="flex-grow px-2">
|
<div class="flex-grow px-2">
|
||||||
<p class="text-sm font-semibold">
|
<p class="text-sm font-semibold">
|
||||||
{{ title }}
|
{{ title }}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-sm text-gray-200 episode-subtitle mt-1.5 mb-0.5">{{ description }}</p>
|
|
||||||
|
<p class="text-sm text-gray-200 episode-subtitle mt-1.5 mb-0.5">{{ subtitle }}</p>
|
||||||
|
|
||||||
<div class="flex items-center pt-2">
|
<div class="flex items-center pt-2">
|
||||||
<div class="h-8 px-4 border border-white border-opacity-20 hover:bg-white hover:bg-opacity-10 rounded-full flex items-center justify-center cursor-pointer" :class="userIsFinished ? 'text-white text-opacity-40' : ''" @click="playClick">
|
<button class="h-8 px-4 border border-white border-opacity-20 hover:bg-white hover:bg-opacity-10 rounded-full flex items-center justify-center cursor-pointer focus:outline-none" :class="userIsFinished ? 'text-white text-opacity-40' : ''" @click.stop="playClick">
|
||||||
<span class="material-icons" :class="streamIsPlaying ? '' : 'text-success'">{{ streamIsPlaying ? 'pause' : 'play_arrow' }}</span>
|
<span class="material-icons" :class="streamIsPlaying ? '' : 'text-success'">{{ streamIsPlaying ? 'pause' : 'play_arrow' }}</span>
|
||||||
<p class="pl-2 pr-1 text-sm font-semibold">{{ timeRemaining }}</p>
|
<p class="pl-2 pr-1 text-sm font-semibold">{{ timeRemaining }}</p>
|
||||||
</div>
|
</button>
|
||||||
|
|
||||||
<ui-tooltip :text="userIsFinished ? 'Mark as Not Finished' : 'Mark as Finished'" direction="top">
|
<ui-tooltip :text="userIsFinished ? 'Mark as Not Finished' : 'Mark as Finished'" direction="top">
|
||||||
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" borderless class="mx-1 mt-0.5" @click="toggleFinished" />
|
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" borderless class="mx-1 mt-0.5" @click="toggleFinished" />
|
||||||
@@ -66,10 +68,11 @@ export default {
|
|||||||
title() {
|
title() {
|
||||||
return this.episode.title || ''
|
return this.episode.title || ''
|
||||||
},
|
},
|
||||||
|
subtitle() {
|
||||||
|
return this.episode.subtitle || ''
|
||||||
|
},
|
||||||
description() {
|
description() {
|
||||||
if (this.episode.subtitle) return this.episode.subtitle
|
return this.episode.description || ''
|
||||||
var desc = this.episode.description || ''
|
|
||||||
return desc
|
|
||||||
},
|
},
|
||||||
duration() {
|
duration() {
|
||||||
return this.$secondsToTimestamp(this.episode.duration)
|
return this.$secondsToTimestamp(this.episode.duration)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<p v-if="!episodes.length" class="py-4 text-center text-lg">No Episodes</p>
|
<p v-if="!episodes.length" class="py-4 text-center text-lg">No Episodes</p>
|
||||||
<template v-for="episode in episodesSorted">
|
<template v-for="episode in episodesSorted">
|
||||||
<tables-podcast-episode-table-row :key="episode.id" :episode="episode" :library-item-id="libraryItem.id" class="item" @remove="removeEpisode" @edit="editEpisode" />
|
<tables-podcast-episode-table-row :key="episode.id" :episode="episode" :library-item-id="libraryItem.id" class="item" @remove="removeEpisode" @edit="editEpisode" @view="viewEpisode" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<modals-podcast-remove-episode v-model="showPodcastRemoveModal" :library-item="libraryItem" :episode="selectedEpisode" />
|
<modals-podcast-remove-episode v-model="showPodcastRemoveModal" :library-item="libraryItem" :episode="selectedEpisode" />
|
||||||
@@ -68,6 +68,11 @@ export default {
|
|||||||
this.$store.commit('globals/setSelectedEpisode', episode)
|
this.$store.commit('globals/setSelectedEpisode', episode)
|
||||||
this.$store.commit('globals/setShowEditPodcastEpisodeModal', true)
|
this.$store.commit('globals/setShowEditPodcastEpisodeModal', true)
|
||||||
},
|
},
|
||||||
|
viewEpisode(episode) {
|
||||||
|
this.$store.commit('setSelectedLibraryItem', this.libraryItem)
|
||||||
|
this.$store.commit('globals/setSelectedEpisode', episode)
|
||||||
|
this.$store.commit('globals/setShowViewPodcastEpisodeModal', true)
|
||||||
|
},
|
||||||
init() {
|
init() {
|
||||||
this.episodesCopy = this.episodes.map((ep) => ({ ...ep }))
|
this.episodesCopy = this.episodes.map((ep) => ({ ...ep }))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export default {
|
|||||||
default: ''
|
default: ''
|
||||||
},
|
},
|
||||||
paddingX: Number,
|
paddingX: Number,
|
||||||
|
paddingY: Number,
|
||||||
small: Boolean,
|
small: Boolean,
|
||||||
loading: Boolean,
|
loading: Boolean,
|
||||||
disabled: Boolean
|
disabled: Boolean
|
||||||
@@ -48,14 +49,17 @@ export default {
|
|||||||
if (this.small) {
|
if (this.small) {
|
||||||
list.push('text-sm')
|
list.push('text-sm')
|
||||||
if (this.paddingX === undefined) list.push('px-4')
|
if (this.paddingX === undefined) list.push('px-4')
|
||||||
list.push('py-1')
|
if (this.paddingY === undefined) list.push('py-1')
|
||||||
} else {
|
} else {
|
||||||
if (this.paddingX === undefined) list.push('px-8')
|
if (this.paddingX === undefined) list.push('px-8')
|
||||||
list.push('py-2')
|
if (this.paddingY === undefined) list.push('py-2')
|
||||||
}
|
}
|
||||||
if (this.paddingX !== undefined) {
|
if (this.paddingX !== undefined) {
|
||||||
list.push(`px-${this.paddingX}`)
|
list.push(`px-${this.paddingX}`)
|
||||||
}
|
}
|
||||||
|
if (this.paddingY !== undefined) {
|
||||||
|
list.push(`py-${this.paddingY}`)
|
||||||
|
}
|
||||||
if (this.disabled) {
|
if (this.disabled) {
|
||||||
list.push('cursor-not-allowed')
|
list.push('cursor-not-allowed')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<input ref="fileInput" id="hidden-input" type="file" :accept="accept" class="hidden" @change="inputChanged" />
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<button class="icon-btn rounded-md flex items-center justify-center h-9 w-9 relative" @mousedown.prevent :disabled="disabled || loading" :class="className" @click="clickBtn">
|
<button class="icon-btn rounded-md flex items-center justify-center relative" @mousedown.prevent :disabled="disabled || loading" :class="className" @click="clickBtn">
|
||||||
<div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100">
|
<div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100">
|
||||||
<svg class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24">
|
<svg class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24">
|
||||||
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
|
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
|
||||||
@@ -20,20 +20,29 @@ export default {
|
|||||||
},
|
},
|
||||||
outlined: Boolean,
|
outlined: Boolean,
|
||||||
borderless: Boolean,
|
borderless: Boolean,
|
||||||
loading: Boolean
|
loading: Boolean,
|
||||||
|
iconFontSize: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
type: Number,
|
||||||
|
default: 9
|
||||||
|
}
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {}
|
return {}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
className() {
|
className() {
|
||||||
var classes = []
|
var classes = [`h-${this.size} w-${this.size}`]
|
||||||
if (!this.borderless) {
|
if (!this.borderless) {
|
||||||
classes.push(`bg-${this.bgColor} border border-gray-600`)
|
classes.push(`bg-${this.bgColor} border border-gray-600`)
|
||||||
}
|
}
|
||||||
return classes.join(' ')
|
return classes.join(' ')
|
||||||
},
|
},
|
||||||
fontSize() {
|
fontSize() {
|
||||||
|
if (this.iconFontSize) return this.iconFontSize
|
||||||
if (this.icon === 'edit') return '1.25rem'
|
if (this.icon === 'edit') return '1.25rem'
|
||||||
return '1.4rem'
|
return '1.4rem'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,8 @@ export default {
|
|||||||
editable: {
|
editable: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: true
|
default: true
|
||||||
}
|
},
|
||||||
|
showAllWhenEmpty: Boolean
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -72,6 +73,7 @@ export default {
|
|||||||
itemsToShow() {
|
itemsToShow() {
|
||||||
if (!this.editable) return this.items
|
if (!this.editable) return this.items
|
||||||
if (!this.textInput || this.textInput === this.input) {
|
if (!this.textInput || this.textInput === this.input) {
|
||||||
|
if (this.showAllWhenEmpty) return this.items
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
return this.items.filter((i) => {
|
return this.items.filter((i) => {
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="currentLibrary" class="relative w-36 h-8" v-click-outside="clickOutsideObj">
|
<div v-if="currentLibrary" class="relative sm:w-36 h-8 px-1.5" v-click-outside="clickOutsideObj">
|
||||||
<button type="button" :disabled="disabled" class="relative h-full w-full border border-white border-opacity-10 hover:border-opacity-20 rounded shadow-sm pl-3 pr-3 text-left focus:outline-none cursor-pointer bg-black bg-opacity-20 text-gray-400 hover:text-gray-200" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
|
<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">
|
<span class="flex items-center justify-center sm:justify-start">
|
||||||
<widgets-library-icon :icon="currentLibraryIcon" class="mr-2" />
|
<widgets-library-icon :icon="currentLibraryIcon" class="sm:mr-2" />
|
||||||
<span class="block truncate text-sm">{{ currentLibrary.name }}</span>
|
<span class="hidden sm:block">{{ currentLibrary.name }}</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<transition name="menu">
|
<transition name="menu">
|
||||||
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full bg-primary border border-black-200 shadow-lg max-h-56 rounded-b-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox">
|
<ul v-show="showMenu" class="absolute z-10 -mt-px w-36 bg-primary border border-black-200 shadow-lg max-h-56 rounded-b-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox">
|
||||||
<template v-for="library in librariesFiltered">
|
<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)">
|
<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">
|
<div class="flex items-center px-3">
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<div ref="wrapper" class="relative">
|
<div ref="wrapper" class="relative">
|
||||||
<form @submit.prevent="submitForm">
|
<form @submit.prevent="submitForm">
|
||||||
<div ref="inputWrapper" style="min-height: 36px" class="flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-1" :class="wrapperClass" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent>
|
<div ref="inputWrapper" style="min-height: 36px" class="flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-1" :class="wrapperClass" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent>
|
||||||
<div v-for="item in selected" :key="item" class="rounded-full px-2 py-1 mx-0.5 text-xs bg-bg flex flex-nowrap whitespace-nowrap items-center relative">
|
<div v-for="item in selected" :key="item" class="rounded-full px-2 py-1 mx-0.5 my-0.5 text-xs bg-bg flex flex-nowrap break-all items-center relative">
|
||||||
<div class="w-full h-full rounded-full absolute top-0 left-0 opacity-0 hover:opacity-100 px-1 bg-bg bg-opacity-75 flex items-center justify-end cursor-pointer">
|
<div class="w-full h-full rounded-full absolute top-0 left-0 opacity-0 hover:opacity-100 px-1 bg-bg bg-opacity-75 flex items-center justify-end cursor-pointer">
|
||||||
<span v-if="showEdit" class="material-icons text-white hover:text-warning" style="font-size: 1.1rem" @click.stop="editItem(item)">edit</span>
|
<span v-if="showEdit" class="material-icons text-white hover:text-warning" style="font-size: 1.1rem" @click.stop="editItem(item)">edit</span>
|
||||||
<span class="material-icons text-white hover:text-error" style="font-size: 1.1rem" @click.stop="removeItem(item)">close</span>
|
<span class="material-icons text-white hover:text-error" style="font-size: 1.1rem" @click.stop="removeItem(item)">close</span>
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<p v-if="label" class="px-1 text-sm font-semibold" :class="{ 'text-gray-400': disabled }">
|
||||||
|
{{ label }}
|
||||||
|
</p>
|
||||||
|
<ui-vue-trix v-model="content" :config="config" :disabled-editor="disabled" @trix-file-accept="trixFileAccept" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: String,
|
||||||
|
label: String,
|
||||||
|
disabled: Boolean
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
content: {
|
||||||
|
get() {
|
||||||
|
return this.value
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('input', val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
config() {
|
||||||
|
return {
|
||||||
|
toolbar: {
|
||||||
|
getDefaultHTML: () => ` <div class="trix-button-row">
|
||||||
|
<span class="trix-button-group trix-button-group--text-tools" data-trix-button-group="text-tools">
|
||||||
|
<button type="button" class="trix-button trix-button--icon trix-button--icon-bold" data-trix-attribute="bold" data-trix-key="b" title="#{lang.bold}" tabindex="-1">#{lang.bold}</button>
|
||||||
|
<button type="button" class="trix-button trix-button--icon trix-button--icon-italic" data-trix-attribute="italic" data-trix-key="i" title="#{lang.italic}" tabindex="-1">#{lang.italic}</button>
|
||||||
|
<button type="button" class="trix-button trix-button--icon trix-button--icon-strike" data-trix-attribute="strike" title="#{lang.strike}" tabindex="-1">#{lang.strike}</button>
|
||||||
|
<button type="button" class="trix-button trix-button--icon trix-button--icon-link" data-trix-attribute="href" data-trix-action="link" data-trix-key="k" title="#{lang.link}" tabindex="-1">#{lang.link}</button>
|
||||||
|
</span>
|
||||||
|
<span class="trix-button-group trix-button-group--block-tools" data-trix-button-group="block-tools">
|
||||||
|
<button type="button" class="trix-button trix-button--icon trix-button--icon-bullet-list" data-trix-attribute="bullet" title="#{lang.bullets}" tabindex="-1">#{lang.bullets}</button>
|
||||||
|
<button type="button" class="trix-button trix-button--icon trix-button--icon-number-list" data-trix-attribute="number" title="#{lang.numbers}" tabindex="-1">#{lang.numbers}</button>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="trix-button-group-spacer"></span>
|
||||||
|
<span class="trix-button-group trix-button-group--history-tools" data-trix-button-group="history-tools">
|
||||||
|
<button type="button" class="trix-button trix-button--icon trix-button--icon-undo" data-trix-action="undo" data-trix-key="z" title="#{lang.undo}" tabindex="-1">#{lang.undo}</button>
|
||||||
|
<button type="button" class="trix-button trix-button--icon trix-button--icon-redo" data-trix-action="redo" data-trix-key="shift+z" title="#{lang.redo}" tabindex="-1">#{lang.redo}</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="trix-dialogs" data-trix-dialogs>
|
||||||
|
<div class="trix-dialog trix-dialog--link" data-trix-dialog="href" data-trix-dialog-attribute="href">
|
||||||
|
<div class="trix-dialog__link-fields">
|
||||||
|
<input type="url" name="href" class="trix-input trix-input--dialog" placeholder="#{lang.urlPlaceholder}" aria-label="#{lang.url}" required data-trix-input>
|
||||||
|
<div class="trix-button-group">
|
||||||
|
<input type="button" class="trix-button trix-button--dialog" value="#{lang.link}" data-trix-method="setAttribute">
|
||||||
|
<input type="button" class="trix-button trix-button--dialog" value="#{lang.unlink}" data-trix-method="removeAttribute">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
trixFileAccept(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {},
|
||||||
|
beforeDestroy() {
|
||||||
|
console.log('Before destroy')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -53,7 +53,7 @@ export default {
|
|||||||
var tooltip = document.createElement('div')
|
var tooltip = document.createElement('div')
|
||||||
this.tooltipId = String(Math.floor(Math.random() * 10000))
|
this.tooltipId = String(Math.floor(Math.random() * 10000))
|
||||||
tooltip.id = this.tooltipId
|
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.zIndex = 100
|
||||||
tooltip.style.backgroundColor = 'rgba(0,0,0,0.85)'
|
tooltip.style.backgroundColor = 'rgba(0,0,0,0.85)'
|
||||||
tooltip.innerHTML = this.text
|
tooltip.innerHTML = this.text
|
||||||
|
|||||||
@@ -0,0 +1,284 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<trix-editor :contenteditable="!disabledEditor" :class="['trix-content']" ref="trix" :input="computedId" :placeholder="placeholder" @trix-change="handleContentChange" @trix-initialize="handleInitialize" @trix-focus="processTrixFocus" @trix-blur="processTrixBlur" />
|
||||||
|
<input type="hidden" :name="inputName" :id="computedId" :value="editorContent" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
/*
|
||||||
|
ORIGINAL SOURCE: https://github.com/hanhdt/vue-trix
|
||||||
|
|
||||||
|
modified for audiobookshelf
|
||||||
|
*/
|
||||||
|
import Trix from 'trix'
|
||||||
|
import '@/assets/trix.css'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'vue-trix',
|
||||||
|
model: {
|
||||||
|
prop: 'srcContent',
|
||||||
|
event: 'update'
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
/**
|
||||||
|
* This prop will put the editor in read-only mode
|
||||||
|
*/
|
||||||
|
disabledEditor: {
|
||||||
|
type: Boolean,
|
||||||
|
required: false,
|
||||||
|
default() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* This is referenced `id` of the hidden input field defined.
|
||||||
|
* It is optional and will be a random string by default.
|
||||||
|
*/
|
||||||
|
inputId: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
default() {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* This is referenced `name` of the hidden input field defined,
|
||||||
|
* default value is `content`.
|
||||||
|
*/
|
||||||
|
inputName: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
default() {
|
||||||
|
return 'content'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* The placeholder attribute specifies a short hint
|
||||||
|
* that describes the expected value of a editor.
|
||||||
|
*/
|
||||||
|
placeholder: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
default() {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* The source content is associcated to v-model directive.
|
||||||
|
*/
|
||||||
|
srcContent: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
default() {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* The boolean attribute allows saving editor state into browser's localStorage
|
||||||
|
* (optional, default is `false`).
|
||||||
|
*/
|
||||||
|
localStorage: {
|
||||||
|
type: Boolean,
|
||||||
|
required: false,
|
||||||
|
default() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Focuses cursor in the editor when attached to the DOM
|
||||||
|
* (optional, default is `false`).
|
||||||
|
*/
|
||||||
|
autofocus: {
|
||||||
|
type: Boolean,
|
||||||
|
required: false,
|
||||||
|
default() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Object to override default editor configuration
|
||||||
|
*/
|
||||||
|
config: {
|
||||||
|
type: Object,
|
||||||
|
required: false,
|
||||||
|
default() {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
editorContent: this.srcContent,
|
||||||
|
isActived: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
editorContent: {
|
||||||
|
handler: 'emitEditorState'
|
||||||
|
},
|
||||||
|
initialContent: {
|
||||||
|
handler: 'handleInitialContentChange'
|
||||||
|
},
|
||||||
|
isDisabled: {
|
||||||
|
handler: 'decorateDisabledEditor'
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
handler: 'overrideConfig',
|
||||||
|
immediate: true,
|
||||||
|
deep: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
/**
|
||||||
|
* Compute a random id of hidden input
|
||||||
|
* when it haven't been specified.
|
||||||
|
*/
|
||||||
|
generateId() {
|
||||||
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||||
|
var r = (Math.random() * 16) | 0
|
||||||
|
var v = c === 'x' ? r : (r & 0x3) | 0x8
|
||||||
|
return v.toString(16)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
computedId() {
|
||||||
|
return this.inputId || this.generateId
|
||||||
|
},
|
||||||
|
initialContent() {
|
||||||
|
return this.srcContent
|
||||||
|
},
|
||||||
|
isDisabled() {
|
||||||
|
return this.disabledEditor
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
processTrixFocus(event) {
|
||||||
|
if (this.$refs.trix) {
|
||||||
|
this.isActived = true
|
||||||
|
this.$emit('trix-focus', this.$refs.trix.editor, event)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
processTrixBlur(event) {
|
||||||
|
if (this.$refs.trix) {
|
||||||
|
this.isActived = false
|
||||||
|
this.$emit('trix-blur', this.$refs.trix.editor, event)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
handleContentChange(event) {
|
||||||
|
this.editorContent = event.srcElement ? event.srcElement.value : event.target.value
|
||||||
|
this.$emit('input', this.editorContent)
|
||||||
|
},
|
||||||
|
handleInitialize(event) {
|
||||||
|
/**
|
||||||
|
* If autofocus is true, manually set focus to
|
||||||
|
* beginning of content (consistent with Trix behavior)
|
||||||
|
*/
|
||||||
|
if (this.autofocus) {
|
||||||
|
this.$refs.trix.editor.setSelectedRange(0)
|
||||||
|
}
|
||||||
|
this.$emit('trix-initialize', this.emitInitialize)
|
||||||
|
},
|
||||||
|
handleInitialContentChange(newContent, oldContent) {
|
||||||
|
newContent = newContent === undefined ? '' : newContent
|
||||||
|
if (this.$refs.trix.editor && this.$refs.trix.editor.innerHTML !== newContent) {
|
||||||
|
/* Update editor's content when initial content changed */
|
||||||
|
this.editorContent = newContent
|
||||||
|
/**
|
||||||
|
* If user are typing, then don't reload the editor,
|
||||||
|
* hence keep cursor's position after typing.
|
||||||
|
*/
|
||||||
|
if (!this.isActived) {
|
||||||
|
this.reloadEditorContent(this.editorContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
emitEditorState(value) {
|
||||||
|
/**
|
||||||
|
* If localStorage is enabled,
|
||||||
|
* then save editor's content into storage
|
||||||
|
*/
|
||||||
|
if (this.localStorage) {
|
||||||
|
localStorage.setItem(this.storageId('VueTrix'), JSON.stringify(this.$refs.trix.editor))
|
||||||
|
}
|
||||||
|
this.$emit('update', this.editorContent)
|
||||||
|
},
|
||||||
|
storageId(component) {
|
||||||
|
if (this.inputId) {
|
||||||
|
return `${component}.${this.inputId}.content`
|
||||||
|
} else {
|
||||||
|
return `${component}.content`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
reloadEditorContent(newContent) {
|
||||||
|
// Reload HTML content
|
||||||
|
this.$refs.trix.editor.loadHTML(newContent)
|
||||||
|
// Move cursor to end of new content updated
|
||||||
|
this.$refs.trix.editor.setSelectedRange(this.getContentEndPosition())
|
||||||
|
},
|
||||||
|
getContentEndPosition() {
|
||||||
|
return this.$refs.trix.editor.getDocument().toString().length - 1
|
||||||
|
},
|
||||||
|
decorateDisabledEditor(editorState) {
|
||||||
|
/** Disable toolbar and editor by pointer events styling */
|
||||||
|
if (editorState) {
|
||||||
|
this.$refs.trix.toolbarElement.style['pointer-events'] = 'none'
|
||||||
|
this.$refs.trix.contentEditable = false
|
||||||
|
this.$refs.trix.style['background'] = '#e9ecef'
|
||||||
|
} else {
|
||||||
|
this.$refs.trix.toolbarElement.style['pointer-events'] = 'unset'
|
||||||
|
this.$refs.trix.style['pointer-events'] = 'unset'
|
||||||
|
this.$refs.trix.style['background'] = 'transparent'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
overrideConfig(config) {
|
||||||
|
Trix.config = this.deepMerge(Trix.config, config)
|
||||||
|
},
|
||||||
|
deepMerge(target, override) {
|
||||||
|
// deep merge the object into the target object
|
||||||
|
for (let prop in override) {
|
||||||
|
if (override.hasOwnProperty(prop)) {
|
||||||
|
if (Object.prototype.toString.call(override[prop]) === '[object Object]') {
|
||||||
|
// if the property is a nested object
|
||||||
|
target[prop] = this.deepMerge(target[prop], override[prop])
|
||||||
|
} else {
|
||||||
|
// for regular property
|
||||||
|
target[prop] = override[prop]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
/** Override editor configuration */
|
||||||
|
this.overrideConfig(this.config)
|
||||||
|
/** Check if editor read-only mode is required */
|
||||||
|
this.decorateDisabledEditor(this.disabledEditor)
|
||||||
|
this.$nextTick(() => {
|
||||||
|
/**
|
||||||
|
* If localStorage is enabled,
|
||||||
|
* then load editor's content from the beginning.
|
||||||
|
*/
|
||||||
|
if (this.localStorage) {
|
||||||
|
const savedValue = localStorage.getItem(this.storageId('VueTrix'))
|
||||||
|
if (savedValue && !this.srcContent) {
|
||||||
|
this.$refs.trix.editor.loadJSON(JSON.parse(savedValue))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="css" module>
|
||||||
|
.trix_container {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
.trix_container .trix-button-group {
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
.trix_container .trix-content {
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,89 +1,68 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full h-full relative">
|
<div class="w-full h-full relative">
|
||||||
<form class="w-full h-full px-4 py-6" @submit.prevent="submitForm">
|
<form class="w-full h-full px-2 md:px-4 py-6" @submit.prevent="submitForm">
|
||||||
<div class="flex -mx-1">
|
<div class="flex flex-wrap -mx-1">
|
||||||
<div class="w-1/2 px-1">
|
<div class="w-full md:w-1/2 px-1">
|
||||||
<ui-text-input-with-label ref="titleInput" v-model="details.title" label="Title" />
|
<ui-text-input-with-label ref="titleInput" v-model="details.title" label="Title" />
|
||||||
</div>
|
</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" />
|
<ui-text-input-with-label ref="subtitleInput" v-model="details.subtitle" label="Subtitle" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex mt-2 -mx-1">
|
<div class="flex flex-wrap mt-2 -mx-1">
|
||||||
<div class="w-3/4 px-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 -->
|
<!-- 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" />
|
<ui-multi-select-query-input ref="authorsSelect" v-model="details.authors" label="Authors" endpoint="authors/search" />
|
||||||
</div>
|
</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" />
|
<ui-text-input-with-label ref="publishYearInput" v-model="details.publishedYear" type="number" label="Publish Year" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex mt-2 -mx-1">
|
<div class="flex mt-2 -mx-1">
|
||||||
<div class="flex-grow px-1">
|
<div class="flex-grow px-1">
|
||||||
<ui-multi-select-query-input ref="seriesSelect" v-model="seriesItems" text-key="displayName" label="Series" readonly show-edit @edit="editSeriesItem" @add="addNewSeries" />
|
<widgets-series-input-widget v-model="details.series" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ui-textarea-with-label ref="descriptionInput" v-model="details.description" :rows="3" label="Description" class="mt-2" />
|
<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="flex flex-wrap mt-2 -mx-1">
|
||||||
<div class="w-1/2 px-1">
|
<div class="w-full md:w-1/2 px-1">
|
||||||
<ui-multi-select ref="genresSelect" v-model="details.genres" label="Genres" :items="genres" />
|
<ui-multi-select ref="genresSelect" v-model="details.genres" label="Genres" :items="genres" />
|
||||||
</div>
|
</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" />
|
<ui-multi-select ref="tagsSelect" v-model="newTags" label="Tags" :items="tags" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex mt-2 -mx-1">
|
<div class="flex flex-wrap mt-2 -mx-1">
|
||||||
<div class="w-1/2 px-1">
|
<div class="w-full md:w-1/2 px-1">
|
||||||
<ui-multi-select ref="narratorsSelect" v-model="details.narrators" label="Narrators" :items="narrators" />
|
<ui-multi-select ref="narratorsSelect" v-model="details.narrators" label="Narrators" :items="narrators" />
|
||||||
</div>
|
</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" />
|
<ui-text-input-with-label ref="isbnInput" v-model="details.isbn" label="ISBN" />
|
||||||
</div>
|
</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" />
|
<ui-text-input-with-label ref="asinInput" v-model="details.asin" label="ASIN" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex mt-2 -mx-1">
|
<div class="flex flex-wrap mt-2 -mx-1">
|
||||||
<div class="w-1/2 px-1">
|
<div class="w-full md:w-1/2 px-1">
|
||||||
<ui-text-input-with-label ref="publisherInput" v-model="details.publisher" label="Publisher" />
|
<ui-text-input-with-label ref="publisherInput" v-model="details.publisher" label="Publisher" />
|
||||||
</div>
|
</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" />
|
<ui-text-input-with-label ref="languageInput" v-model="details.language" label="Language" />
|
||||||
</div>
|
</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">
|
<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" />
|
<ui-checkbox v-model="details.explicit" label="Explicit" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div v-if="showSeriesForm" class="absolute top-0 left-0 z-20 w-full h-full bg-black bg-opacity-50 rounded-lg flex items-center justify-center" @click="cancelSeriesForm">
|
|
||||||
<div class="absolute top-0 right-0 p-4">
|
|
||||||
<span class="material-icons text-gray-200 hover:text-white text-4xl cursor-pointer">close</span>
|
|
||||||
</div>
|
|
||||||
<form @submit.prevent="submitSeriesForm">
|
|
||||||
<div class="bg-bg rounded-lg p-8" @click.stop>
|
|
||||||
<div class="flex">
|
|
||||||
<div class="flex-grow p-1 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">
|
|
||||||
<ui-text-input-with-label v-model="selectedSeries.sequence" label="Sequence" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-end mt-2 p-1">
|
|
||||||
<ui-btn type="submit">Save</ui-btn>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -97,8 +76,6 @@ export default {
|
|||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
selectedSeries: {},
|
|
||||||
showSeriesForm: false,
|
|
||||||
details: {
|
details: {
|
||||||
title: null,
|
title: null,
|
||||||
subtitle: null,
|
subtitle: null,
|
||||||
@@ -146,24 +123,6 @@ export default {
|
|||||||
},
|
},
|
||||||
filterData() {
|
filterData() {
|
||||||
return this.$store.state.libraries.filterData || {}
|
return this.$store.state.libraries.filterData || {}
|
||||||
},
|
|
||||||
existingSeriesNames() {
|
|
||||||
// Only show series names not already selected
|
|
||||||
var alreadySelectedSeriesIds = this.details.series.map((se) => se.id)
|
|
||||||
return this.series.filter((se) => !alreadySelectedSeriesIds.includes(se.id)).map((se) => se.name)
|
|
||||||
},
|
|
||||||
seriesItems: {
|
|
||||||
get() {
|
|
||||||
return this.details.series.map((se) => {
|
|
||||||
return {
|
|
||||||
displayName: se.sequence ? `${se.name} #${se.sequence}` : se.name,
|
|
||||||
...se
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
set(val) {
|
|
||||||
this.details.series = val
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -214,50 +173,6 @@ export default {
|
|||||||
this.$refs.tagsSelect.forceBlur()
|
this.$refs.tagsSelect.forceBlur()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
cancelSeriesForm() {
|
|
||||||
this.showSeriesForm = false
|
|
||||||
},
|
|
||||||
editSeriesItem(series) {
|
|
||||||
var _series = this.details.series.find((se) => se.id === series.id)
|
|
||||||
if (!_series) return
|
|
||||||
this.selectedSeries = {
|
|
||||||
..._series
|
|
||||||
}
|
|
||||||
this.showSeriesForm = true
|
|
||||||
},
|
|
||||||
addNewSeries() {
|
|
||||||
this.selectedSeries = {
|
|
||||||
id: `new-${Date.now()}`,
|
|
||||||
name: '',
|
|
||||||
sequence: ''
|
|
||||||
}
|
|
||||||
this.showSeriesForm = true
|
|
||||||
},
|
|
||||||
submitSeriesForm() {
|
|
||||||
if (!this.selectedSeries.name) {
|
|
||||||
this.$toast.error('Must enter a series')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (this.$refs.newSeriesSelect) {
|
|
||||||
this.$refs.newSeriesSelect.blur()
|
|
||||||
}
|
|
||||||
var existingSeriesIndex = this.details.series.findIndex((se) => se.id === this.selectedSeries.id)
|
|
||||||
|
|
||||||
var seriesSameName = this.series.find((se) => se.name.toLowerCase() === this.selectedSeries.name.toLowerCase())
|
|
||||||
if (existingSeriesIndex < 0 && seriesSameName) {
|
|
||||||
this.selectedSeries.id = seriesSameName.id
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existingSeriesIndex >= 0) {
|
|
||||||
this.details.series.splice(existingSeriesIndex, 1, { ...this.selectedSeries })
|
|
||||||
} else {
|
|
||||||
this.details.series.push({
|
|
||||||
...this.selectedSeries
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
this.showSeriesForm = false
|
|
||||||
},
|
|
||||||
stringArrayEqual(array1, array2) {
|
stringArrayEqual(array1, array2) {
|
||||||
// return false if different
|
// return false if different
|
||||||
if (array1.length !== array2.length) return false
|
if (array1.length !== array2.length) return false
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<ui-multi-select-query-input v-model="seriesItems" text-key="displayName" label="Series" readonly show-edit @edit="editSeriesItem" @add="addNewSeries" />
|
||||||
|
|
||||||
|
<modals-edit-series-input-inner-modal v-model="showSeriesForm" :selected-series="selectedSeries" :existing-series-names="existingSeriesNames" @submit="submitSeriesForm" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
selectedSeries: null,
|
||||||
|
showSeriesForm: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
seriesItems: {
|
||||||
|
get() {
|
||||||
|
return (this.value || []).map((se) => {
|
||||||
|
return {
|
||||||
|
displayName: se.sequence ? `${se.name} #${se.sequence}` : se.name,
|
||||||
|
...se
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('input', val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
series() {
|
||||||
|
return this.filterData.series || []
|
||||||
|
},
|
||||||
|
filterData() {
|
||||||
|
return this.$store.state.libraries.filterData || {}
|
||||||
|
},
|
||||||
|
existingSeriesNames() {
|
||||||
|
// Only show series names not already selected
|
||||||
|
var alreadySelectedSeriesIds = (this.value || []).map((se) => se.id)
|
||||||
|
return this.series.filter((se) => !alreadySelectedSeriesIds.includes(se.id)).map((se) => se.name)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
cancelSeriesForm() {
|
||||||
|
this.showSeriesForm = false
|
||||||
|
},
|
||||||
|
editSeriesItem(series) {
|
||||||
|
var _series = this.seriesItems.find((se) => se.id === series.id)
|
||||||
|
if (!_series) return
|
||||||
|
|
||||||
|
this.selectedSeries = {
|
||||||
|
..._series
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Selected series', this.selectedSeries)
|
||||||
|
this.showSeriesForm = true
|
||||||
|
},
|
||||||
|
addNewSeries() {
|
||||||
|
this.selectedSeries = {
|
||||||
|
id: `new-${Date.now()}`,
|
||||||
|
name: '',
|
||||||
|
sequence: ''
|
||||||
|
}
|
||||||
|
|
||||||
|
this.showSeriesForm = true
|
||||||
|
},
|
||||||
|
submitSeriesForm() {
|
||||||
|
console.log('submit series form', this.value, this.selectedSeries)
|
||||||
|
|
||||||
|
if (!this.selectedSeries.name) {
|
||||||
|
this.$toast.error('Must enter a series')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var existingSeriesIndex = this.seriesItems.findIndex((se) => se.id === this.selectedSeries.id)
|
||||||
|
|
||||||
|
var existingSeriesSameName = this.seriesItems.findIndex((se) => se.name.toLowerCase() === this.selectedSeries.name.toLowerCase())
|
||||||
|
if (existingSeriesSameName >= 0 && existingSeriesIndex < 0) {
|
||||||
|
console.error('Attempt to add duplicate series')
|
||||||
|
this.$toast.error('Cannot add two of the same series')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var seriesSameName = this.series.find((se) => se.name.toLowerCase() === this.selectedSeries.name.toLowerCase())
|
||||||
|
if (existingSeriesIndex < 0 && seriesSameName) {
|
||||||
|
this.selectedSeries.id = seriesSameName.id
|
||||||
|
}
|
||||||
|
|
||||||
|
var selectedSeriesCopy = { ...this.selectedSeries }
|
||||||
|
selectedSeriesCopy.displayName = selectedSeriesCopy.sequence ? `${selectedSeriesCopy.name} #${selectedSeriesCopy.sequence}` : selectedSeriesCopy.name
|
||||||
|
|
||||||
|
var seriesCopy = this.seriesItems.map((v) => ({ ...v }))
|
||||||
|
if (existingSeriesIndex >= 0) {
|
||||||
|
seriesCopy.splice(existingSeriesIndex, 1, selectedSeriesCopy)
|
||||||
|
this.seriesItems = seriesCopy
|
||||||
|
} else {
|
||||||
|
seriesCopy.push(selectedSeriesCopy)
|
||||||
|
this.seriesItems = seriesCopy
|
||||||
|
}
|
||||||
|
|
||||||
|
this.showSeriesForm = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -5,5 +5,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
export default {}
|
export default {
|
||||||
|
mounted() {
|
||||||
|
document.body.classList.remove('app-bar', 'app-bar-and-toolbar')
|
||||||
|
document.body.classList.add('no-bars')
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
+31
-29
@@ -12,8 +12,8 @@
|
|||||||
<modals-item-edit-modal />
|
<modals-item-edit-modal />
|
||||||
<modals-user-collections-modal />
|
<modals-user-collections-modal />
|
||||||
<modals-edit-collection-modal />
|
<modals-edit-collection-modal />
|
||||||
<modals-bookshelf-texture-modal />
|
|
||||||
<modals-podcast-edit-episode />
|
<modals-podcast-edit-episode />
|
||||||
|
<modals-podcast-view-episode />
|
||||||
<modals-authors-edit-modal />
|
<modals-authors-edit-modal />
|
||||||
<readers-reader />
|
<readers-reader />
|
||||||
</div>
|
</div>
|
||||||
@@ -40,6 +40,7 @@ export default {
|
|||||||
if (this.$store.state.selectedLibraryItems) {
|
if (this.$store.state.selectedLibraryItems) {
|
||||||
this.$store.commit('setSelectedLibraryItems', [])
|
this.$store.commit('setSelectedLibraryItems', [])
|
||||||
}
|
}
|
||||||
|
this.updateBodyClass()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -53,11 +54,23 @@ export default {
|
|||||||
if (!this.$route.name) return false
|
if (!this.$route.name) return false
|
||||||
return !this.$route.name.startsWith('config') && this.$store.state.libraries.currentLibraryId
|
return !this.$route.name.startsWith('config') && this.$store.state.libraries.currentLibraryId
|
||||||
},
|
},
|
||||||
|
isShowingToolbar() {
|
||||||
|
return this.isShowingSideRail && this.$route.name !== 'upload' && this.$route.name !== 'account'
|
||||||
|
},
|
||||||
appContentMarginLeft() {
|
appContentMarginLeft() {
|
||||||
return this.isShowingSideRail ? 80 : 0
|
return this.isShowingSideRail ? 80 : 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
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) {
|
updateSocketConnectionToast(content, type, timeout) {
|
||||||
if (this.socketConnectionToastId !== null && this.socketConnectionToastId !== undefined) {
|
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)
|
this.$toast.update(this.socketConnectionToastId, { content: content, options: { timeout: timeout, type: type, closeButton: false, position: 'bottom-center', onClose: () => null, closeOnClick: timeout !== null } }, false)
|
||||||
@@ -110,13 +123,7 @@ export default {
|
|||||||
}
|
}
|
||||||
console.log('Init Payload', payload)
|
console.log('Init Payload', payload)
|
||||||
if (payload.session) {
|
if (payload.session) {
|
||||||
if (this.$refs.streamContainer) {
|
this.$refs.streamContainer.sessionOpen(payload.session)
|
||||||
this.$refs.streamContainer.sessionOpen(payload.session)
|
|
||||||
} else {
|
|
||||||
console.warn('Stream Container not mounted')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (payload.serverSettings) {
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start scans currently running
|
// Start scans currently running
|
||||||
@@ -243,9 +250,9 @@ export default {
|
|||||||
|
|
||||||
var existingScan = this.$store.getters['scanners/getLibraryScan'](data.id)
|
var existingScan = this.$store.getters['scanners/getLibraryScan'](data.id)
|
||||||
if (existingScan && !isNaN(existingScan.toastId)) {
|
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 {
|
} else {
|
||||||
this.$toast.success(message, { timeout: 5000, position: 'bottom-center' })
|
this.$toast.success(message, { timeout: 5000 })
|
||||||
}
|
}
|
||||||
|
|
||||||
this.$store.commit('scanners/remove', data)
|
this.$store.commit('scanners/remove', data)
|
||||||
@@ -254,7 +261,7 @@ export default {
|
|||||||
this.$root.socket.emit('cancel_scan', id)
|
this.$root.socket.emit('cancel_scan', id)
|
||||||
},
|
},
|
||||||
scanStart(data) {
|
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)
|
this.$store.commit('scanners/addUpdate', data)
|
||||||
},
|
},
|
||||||
scanProgress(data) {
|
scanProgress(data) {
|
||||||
@@ -263,7 +270,7 @@ export default {
|
|||||||
data.toastId = existingScan.toastId
|
data.toastId = existingScan.toastId
|
||||||
this.$toast.update(existingScan.toastId, { content: `Scanning "${existingScan.name}"... ${data.progress.progress || 0}%`, options: { timeout: false } }, true)
|
this.$toast.update(existingScan.toastId, { content: `Scanning "${existingScan.name}"... ${data.progress.progress || 0}%`, options: { timeout: false } }, true)
|
||||||
} else {
|
} 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)
|
this.$store.commit('scanners/addUpdate', data)
|
||||||
@@ -515,29 +522,19 @@ export default {
|
|||||||
this.$store.commit('globals/updateWindowSize', { width: window.innerWidth, height: window.innerHeight })
|
this.$store.commit('globals/updateWindowSize', { width: window.innerWidth, height: window.innerHeight })
|
||||||
},
|
},
|
||||||
checkVersionUpdate() {
|
checkVersionUpdate() {
|
||||||
// Version check is only run if time since last check was 5 minutes
|
this.$store
|
||||||
const VERSION_CHECK_BUFF = 1000 * 60 * 5 // 5 minutes
|
.dispatch('checkForUpdate')
|
||||||
var lastVerCheck = localStorage.getItem('lastVerCheck') || 0
|
.then((res) => {
|
||||||
if (Date.now() - Number(lastVerCheck) > VERSION_CHECK_BUFF) {
|
if (res && res.hasUpdate) this.showUpdateToast(res)
|
||||||
this.$store
|
})
|
||||||
.dispatch('checkForUpdate')
|
.catch((err) => console.error(err))
|
||||||
.then((res) => {
|
|
||||||
localStorage.setItem('lastVerCheck', Date.now())
|
|
||||||
if (res && res.hasUpdate) this.showUpdateToast(res)
|
|
||||||
})
|
|
||||||
.catch((err) => console.error(err))
|
|
||||||
|
|
||||||
if (this.$route.query.error) {
|
|
||||||
this.$toast.error(this.$route.query.error)
|
|
||||||
this.$router.replace(this.$route.path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
beforeMount() {
|
beforeMount() {
|
||||||
this.initializeSocket()
|
this.initializeSocket()
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
this.updateBodyClass()
|
||||||
this.resize()
|
this.resize()
|
||||||
window.addEventListener('resize', this.resize)
|
window.addEventListener('resize', this.resize)
|
||||||
window.addEventListener('keydown', this.keyDown)
|
window.addEventListener('keydown', this.keyDown)
|
||||||
@@ -551,6 +548,11 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.checkVersionUpdate()
|
this.checkVersionUpdate()
|
||||||
|
|
||||||
|
if (this.$route.query.error) {
|
||||||
|
this.$toast.error(this.$route.query.error)
|
||||||
|
this.$router.replace(this.$route.path)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
window.removeEventListener('resize', this.resize)
|
window.removeEventListener('resize', this.resize)
|
||||||
|
|||||||
@@ -28,11 +28,11 @@ export default {
|
|||||||
var validOtherFiles = []
|
var validOtherFiles = []
|
||||||
var ignoredFiles = []
|
var ignoredFiles = []
|
||||||
files.forEach((file) => {
|
files.forEach((file) => {
|
||||||
var filetype = this.checkFileType(file.name)
|
// var filetype = this.checkFileType(file.name)
|
||||||
if (!filetype) ignoredFiles.push(file)
|
if (!file.filetype) ignoredFiles.push(file)
|
||||||
else {
|
else {
|
||||||
file.filetype = filetype
|
// file.filetype = filetype
|
||||||
if (filetype === 'audio' || (filetype === 'ebook' && mediaType === 'book')) validItemFiles.push(file)
|
if (file.filetype === 'audio' || (file.filetype === 'ebook' && mediaType === 'book')) validItemFiles.push(file)
|
||||||
else validOtherFiles.push(file)
|
else validOtherFiles.push(file)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -82,12 +82,18 @@ export default {
|
|||||||
items: itemResults,
|
items: itemResults,
|
||||||
ignoredFiles: ignoredFilesInRoot
|
ignoredFiles: ignoredFilesInRoot
|
||||||
}
|
}
|
||||||
} else {
|
} else if (filetree.some((f) => f.filetype !== 'audio') || mediaType !== 'book') {
|
||||||
// Single Book drop
|
// Single Book drop
|
||||||
return {
|
return {
|
||||||
items: this.itemFromTreeItems(filetree, mediaType),
|
items: this.itemFromTreeItems(filetree, mediaType),
|
||||||
ignoredFiles: []
|
ignoredFiles: []
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Only audio files dropped so treat each one as an audiobook
|
||||||
|
return {
|
||||||
|
items: filetree.map((audioFile) => ({ itemFiles: [audioFile], otherFiles: [], ignoredFiles: [] })),
|
||||||
|
ignoredFiles: []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
getFilesDropped(dataTransferItems) {
|
getFilesDropped(dataTransferItems) {
|
||||||
@@ -95,11 +101,12 @@ export default {
|
|||||||
path: '/',
|
path: '/',
|
||||||
items: []
|
items: []
|
||||||
}
|
}
|
||||||
function traverseFileTreePromise(item, currtreemap) {
|
function traverseFileTreePromise(item, currtreemap, checkFileType) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
if (item.isFile) {
|
if (item.isFile) {
|
||||||
item.file((file) => {
|
item.file((file) => {
|
||||||
file.filepath = currtreemap.path + file.name //save full path
|
file.filepath = currtreemap.path + file.name //save full path
|
||||||
|
file.filetype = checkFileType(file.name)
|
||||||
currtreemap.items.push(file)
|
currtreemap.items.push(file)
|
||||||
resolve(file)
|
resolve(file)
|
||||||
})
|
})
|
||||||
@@ -119,7 +126,7 @@ export default {
|
|||||||
dirReader.readEntries((entries) => {
|
dirReader.readEntries((entries) => {
|
||||||
if (entries.length > 0) {
|
if (entries.length > 0) {
|
||||||
for (let entr of entries) {
|
for (let entr of entries) {
|
||||||
entriesPromises.push(traverseFileTreePromise(entr, newtreemap))
|
entriesPromises.push(traverseFileTreePromise(entr, newtreemap, checkFileType))
|
||||||
}
|
}
|
||||||
readEntries()
|
readEntries()
|
||||||
} else {
|
} else {
|
||||||
@@ -135,7 +142,7 @@ export default {
|
|||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let entriesPromises = []
|
let entriesPromises = []
|
||||||
for (let it of dataTransferItems) {
|
for (let it of dataTransferItems) {
|
||||||
var filetree = traverseFileTreePromise(it.webkitGetAsEntry(), treemap)
|
var filetree = traverseFileTreePromise(it.webkitGetAsEntry(), treemap, this.checkFileType)
|
||||||
entriesPromises.push(filetree)
|
entriesPromises.push(filetree)
|
||||||
}
|
}
|
||||||
Promise.all(entriesPromises).then(() => {
|
Promise.all(entriesPromises).then(() => {
|
||||||
@@ -152,7 +159,9 @@ export default {
|
|||||||
...book
|
...book
|
||||||
}
|
}
|
||||||
var firstBookFile = book.itemFiles[0]
|
var firstBookFile = book.itemFiles[0]
|
||||||
if (!firstBookFile.filepath) return audiobook // No path
|
if (!firstBookFile.filepath) {
|
||||||
|
return audiobook // No path
|
||||||
|
}
|
||||||
|
|
||||||
var firstBookPath = Path.dirname(firstBookFile.filepath)
|
var firstBookPath = Path.dirname(firstBookFile.filepath)
|
||||||
|
|
||||||
@@ -165,6 +174,9 @@ export default {
|
|||||||
if (dirs.length) {
|
if (dirs.length) {
|
||||||
audiobook.author = dirs.pop()
|
audiobook.author = dirs.pop()
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Use file basename as title
|
||||||
|
audiobook.title = Path.basename(firstBookFile.name, Path.extname(firstBookFile.name))
|
||||||
}
|
}
|
||||||
return audiobook
|
return audiobook
|
||||||
},
|
},
|
||||||
@@ -178,7 +190,12 @@ export default {
|
|||||||
if (!firstAudioFile.filepath) return podcast // No path
|
if (!firstAudioFile.filepath) return podcast // No path
|
||||||
var firstPath = Path.dirname(firstAudioFile.filepath)
|
var firstPath = Path.dirname(firstAudioFile.filepath)
|
||||||
var dirs = firstPath.split('/').filter(d => !!d && d !== '.')
|
var dirs = firstPath.split('/').filter(d => !!d && d !== '.')
|
||||||
podcast.title = dirs.length > 1 ? dirs[1] : dirs[0]
|
if (dirs.length) {
|
||||||
|
podcast.title = dirs.length > 1 ? dirs[1] : dirs[0]
|
||||||
|
} else {
|
||||||
|
podcast.title = Path.basename(firstAudioFile.name, Path.extname(firstAudioFile.name))
|
||||||
|
}
|
||||||
|
|
||||||
return podcast
|
return podcast
|
||||||
},
|
},
|
||||||
cleanItem(item, mediaType, index) {
|
cleanItem(item, mediaType, index) {
|
||||||
@@ -188,6 +205,7 @@ export default {
|
|||||||
async getItemsFromDataTransferItems(dataTransferItems, mediaType) {
|
async getItemsFromDataTransferItems(dataTransferItems, mediaType) {
|
||||||
var files = await this.getFilesDropped(dataTransferItems)
|
var files = await this.getFilesDropped(dataTransferItems)
|
||||||
if (!files || !files.length) return { error: 'No files found ' }
|
if (!files || !files.length) return { error: 'No files found ' }
|
||||||
|
|
||||||
var itemData = this.fileTreeToItems(files, mediaType)
|
var itemData = this.fileTreeToItems(files, mediaType)
|
||||||
if (!itemData.items.length && !itemData.ignoredFiles.length) {
|
if (!itemData.items.length && !itemData.ignoredFiles.length) {
|
||||||
return { error: 'Invalid file drop' }
|
return { error: 'Invalid file drop' }
|
||||||
@@ -218,9 +236,12 @@ export default {
|
|||||||
else {
|
else {
|
||||||
file.filetype = filetype
|
file.filetype = filetype
|
||||||
if (file.webkitRelativePath) file.filepath = file.webkitRelativePath
|
if (file.webkitRelativePath) file.filepath = file.webkitRelativePath
|
||||||
|
else file.filepath = file.name
|
||||||
|
|
||||||
if (filetype === 'audio' || (filetype === 'ebook' && mediaType === 'book')) {
|
if (filetype === 'audio' || (filetype === 'ebook' && mediaType === 'book')) {
|
||||||
var dir = file.filepath ? Path.dirname(file.filepath) : ''
|
var dir = file.filepath ? Path.dirname(file.filepath) : ''
|
||||||
|
if (dir === '.') dir = ''
|
||||||
|
|
||||||
if (!itemMap[dir]) {
|
if (!itemMap[dir]) {
|
||||||
itemMap[dir] = {
|
itemMap[dir] = {
|
||||||
path: dir,
|
path: dir,
|
||||||
@@ -246,8 +267,17 @@ export default {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
var items = []
|
||||||
var index = 1
|
var index = 1
|
||||||
var items = Object.values(itemMap).map(i => this.cleanItem(i, mediaType, index++))
|
// If book media type and all files are audio files then treat each one as an audiobook
|
||||||
|
if (itemMap[''] && !otherFiles.length && mediaType === 'book' && !itemMap[''].itemFiles.some(f => f.filetype !== 'audio')) {
|
||||||
|
items = itemMap[''].itemFiles.map((audioFile) => {
|
||||||
|
return this.cleanItem({ itemFiles: [audioFile], otherFiles: [], ignoredFiles: [] }, mediaType, index++)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
items = Object.values(itemMap).map(i => this.cleanItem(i, mediaType, index++))
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items,
|
items,
|
||||||
ignoredFiles: ignoredFiles
|
ignoredFiles: ignoredFiles
|
||||||
|
|||||||
+22
-3
@@ -58,7 +58,8 @@ module.exports = {
|
|||||||
buildModules: [
|
buildModules: [
|
||||||
// https://go.nuxtjs.dev/tailwindcss
|
// https://go.nuxtjs.dev/tailwindcss
|
||||||
'@nuxtjs/tailwindcss',
|
'@nuxtjs/tailwindcss',
|
||||||
'@nuxtjs/pwa'
|
'@nuxtjs/pwa',
|
||||||
|
'@nuxt/postcss8'
|
||||||
],
|
],
|
||||||
|
|
||||||
// Modules: https://go.nuxtjs.dev/config-modules
|
// Modules: https://go.nuxtjs.dev/config-modules
|
||||||
@@ -119,11 +120,21 @@ module.exports = {
|
|||||||
sizes: "512x512"
|
sizes: "512x512"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
workbox: {
|
||||||
|
enabled: false,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Build Configuration: https://go.nuxtjs.dev/config-build
|
// Build Configuration: https://go.nuxtjs.dev/config-build
|
||||||
build: {},
|
build: {
|
||||||
|
postcss: {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
watchers: {
|
watchers: {
|
||||||
webpack: {
|
webpack: {
|
||||||
aggregateTimeout: 300,
|
aggregateTimeout: 300,
|
||||||
@@ -133,5 +144,13 @@ module.exports = {
|
|||||||
server: {
|
server: {
|
||||||
port: process.env.NODE_ENV === 'production' ? 80 : 3000,
|
port: process.env.NODE_ENV === 'production' ? 80 : 3000,
|
||||||
host: '0.0.0.0'
|
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: [],
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+577
-397
File diff suppressed because it is too large
Load Diff
+6
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf-client",
|
"name": "audiobookshelf-client",
|
||||||
"version": "2.0.16",
|
"version": "2.1.0",
|
||||||
"description": "Self-hosted audiobook and podcast client",
|
"description": "Self-hosted audiobook and podcast client",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -22,14 +22,18 @@
|
|||||||
"libarchive.js": "^1.3.0",
|
"libarchive.js": "^1.3.0",
|
||||||
"nuxt": "^2.15.8",
|
"nuxt": "^2.15.8",
|
||||||
"nuxt-socket-io": "^1.1.18",
|
"nuxt-socket-io": "^1.1.18",
|
||||||
|
"trix": "^1.3.1",
|
||||||
"v-click-outside": "^3.1.2",
|
"v-click-outside": "^3.1.2",
|
||||||
"vue-pdf": "^4.3.0",
|
"vue-pdf": "^4.3.0",
|
||||||
"vue-toastification": "^1.7.11",
|
"vue-toastification": "^1.7.11",
|
||||||
"vuedraggable": "^2.24.3"
|
"vuedraggable": "^2.24.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@nuxt/postcss8": "^1.1.3",
|
||||||
"@nuxtjs/pwa": "^3.3.5",
|
"@nuxtjs/pwa": "^3.3.5",
|
||||||
"@nuxtjs/tailwindcss": "^4.2.1",
|
"@nuxtjs/tailwindcss": "^4.2.1",
|
||||||
"postcss": "^8.3.6"
|
"autoprefixer": "^10.4.7",
|
||||||
|
"postcss": "^8.3.6",
|
||||||
|
"tailwindcss": "^3.1.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<div id="page-wrapper" class="bg-bg page overflow-y-auto p-8" :class="streamLibraryItem ? 'streaming' : ''">
|
<div id="page-wrapper" class="bg-bg page overflow-y-auto p-4 md:p-8" :class="streamLibraryItem ? 'streaming' : ''">
|
||||||
<div class="max-w-6xl mx-auto">
|
<div class="max-w-6xl mx-auto">
|
||||||
<div class="flex mb-6">
|
<div class="flex flex-wrap sm:flex-nowrap justify-center mb-6">
|
||||||
<div class="w-48 min-w-48">
|
<div class="w-48 min-w-48">
|
||||||
<div class="w-full h-52">
|
<div class="w-full h-52">
|
||||||
<covers-author-image :author="author" rounded="0" />
|
<covers-author-image :author="author" rounded="0" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow px-8">
|
<div class="flex-grow py-4 sm:py-0 px-4 md:px-8">
|
||||||
<div class="flex items-center mb-8">
|
<div class="flex items-center mb-8">
|
||||||
<h1 class="text-2xl">{{ author.name }}</h1>
|
<h1 class="text-2xl">{{ author.name }}</h1>
|
||||||
|
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export default {
|
|||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
dailyBackupsTooltip() {
|
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() {
|
maxBackupSizeTooltip() {
|
||||||
return 'As a safeguard against misconfiguration, backups will fail if they exceed the configured size.'
|
return 'As a safeguard against misconfiguration, backups will fail if they exceed the configured size.'
|
||||||
@@ -74,7 +74,7 @@ export default {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
var updatePayload = {
|
var updatePayload = {
|
||||||
backupSchedule: this.dailyBackups ? '0 1 * * *' : false,
|
backupSchedule: this.dailyBackups ? '30 1 * * *' : false,
|
||||||
backupsToKeep: Number(this.backupsToKeep),
|
backupsToKeep: Number(this.backupsToKeep),
|
||||||
maxBackupSize: Number(this.maxBackupSize)
|
maxBackupSize: Number(this.maxBackupSize)
|
||||||
}
|
}
|
||||||
|
|||||||
+222
-144
@@ -1,140 +1,210 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<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-2">
|
||||||
|
<div class="mb-2">
|
||||||
<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">Settings</h1>
|
<h1 class="text-xl">Settings</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center py-2">
|
<div class="lg:flex">
|
||||||
<ui-toggle-switch v-model="newServerSettings.storeCoverWithItem" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('storeCoverWithItem', val)" />
|
<div class="flex-1">
|
||||||
<ui-tooltip :text="tooltips.storeCoverWithItem">
|
<div class="pt-4">
|
||||||
<p class="pl-4 text-lg">
|
<h2 class="font-semibold">General</h2>
|
||||||
Store covers with item
|
</div>
|
||||||
<span class="material-icons icon-text">info_outlined</span>
|
<div class="flex items-end py-2">
|
||||||
</p>
|
<ui-toggle-switch v-model="newServerSettings.storeCoverWithItem" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('storeCoverWithItem', val)" />
|
||||||
</ui-tooltip>
|
<ui-tooltip :text="tooltips.storeCoverWithItem">
|
||||||
</div>
|
<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">
|
<div class="flex items-center py-2">
|
||||||
<ui-toggle-switch v-model="newServerSettings.storeMetadataWithItem" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('storeMetadataWithItem', val)" />
|
<ui-toggle-switch v-model="newServerSettings.storeMetadataWithItem" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('storeMetadataWithItem', val)" />
|
||||||
<ui-tooltip :text="tooltips.storeMetadataWithItem">
|
<ui-tooltip :text="tooltips.storeMetadataWithItem">
|
||||||
<p class="pl-4 text-lg">
|
<p class="pl-4">
|
||||||
Store metadata with item
|
Store metadata with item
|
||||||
<span class="material-icons icon-text">info_outlined</span>
|
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||||
</p>
|
</p>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center py-2">
|
<div class="flex items-center py-2">
|
||||||
<ui-toggle-switch v-model="useSquareBookCovers" :disabled="updatingServerSettings" @input="updateBookCoverAspectRatio" />
|
<ui-toggle-switch v-model="newServerSettings.sortingIgnorePrefix" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('sortingIgnorePrefix', val)" />
|
||||||
<ui-tooltip :text="tooltips.coverAspectRatio">
|
<ui-tooltip :text="tooltips.sortingIgnorePrefix">
|
||||||
<p class="pl-4 text-lg">
|
<p class="pl-4">
|
||||||
Use square book covers
|
Ignore prefixes when sorting
|
||||||
<span class="material-icons icon-text">info_outlined</span>
|
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||||
</p>
|
</p>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
</div>
|
</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">
|
<div class="flex items-center py-2">
|
||||||
<ui-toggle-switch v-model="useAlternativeBookshelfView" :disabled="updatingServerSettings" @input="updateAlternativeBookshelfView" />
|
<ui-toggle-switch v-model="newServerSettings.chromecastEnabled" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('chromecastEnabled', val)" />
|
||||||
<ui-tooltip :text="tooltips.bookshelfView">
|
<p class="pl-4">Chromecast support</p>
|
||||||
<p class="pl-4 text-lg">
|
</div>
|
||||||
Use alternative bookshelf view
|
|
||||||
<span class="material-icons icon-text">info_outlined</span>
|
|
||||||
</p>
|
|
||||||
</ui-tooltip>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center py-2">
|
<div class="pt-4">
|
||||||
<ui-toggle-switch v-model="newServerSettings.sortingIgnorePrefix" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('sortingIgnorePrefix', val)" />
|
<h2 class="font-semibold">Display</h2>
|
||||||
<ui-tooltip :text="tooltips.sortingIgnorePrefix">
|
</div>
|
||||||
<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">
|
<div class="flex items-center py-2">
|
||||||
<ui-toggle-switch v-model="newServerSettings.chromecastEnabled" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('chromecastEnabled', val)" />
|
<ui-toggle-switch v-model="useSquareBookCovers" :disabled="updatingServerSettings" @input="updateBookCoverAspectRatio" />
|
||||||
<p class="pl-4 text-lg">Enable Chromecast</p>
|
<ui-tooltip :text="tooltips.coverAspectRatio">
|
||||||
</div>
|
<p class="pl-4">
|
||||||
|
Square book covers
|
||||||
|
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||||
|
</p>
|
||||||
|
</ui-tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center mb-2 mt-8">
|
<div class="flex items-center py-2">
|
||||||
<h1 class="text-xl">Scanner Settings</h1>
|
<ui-toggle-switch v-model="useAlternativeBookshelfView" :disabled="updatingServerSettings" @input="updateAlternativeBookshelfView" />
|
||||||
</div>
|
<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">
|
<div class="flex items-center py-2">
|
||||||
<ui-toggle-switch v-model="newServerSettings.scannerParseSubtitle" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerParseSubtitle', val)" />
|
<p class="pr-4">Date Format</p>
|
||||||
<ui-tooltip :text="tooltips.scannerParseSubtitle">
|
<ui-dropdown v-model="newServerSettings.dateFormat" :items="dateFormats" small class="max-w-40" @input="(val) => updateSettingsKey('dateFormat', val)" />
|
||||||
<p class="pl-4 text-lg">
|
</div>
|
||||||
Scanner parse subtitles
|
</div>
|
||||||
<span class="material-icons icon-text">info_outlined</span>
|
|
||||||
</p>
|
|
||||||
</ui-tooltip>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center py-2">
|
<div class="flex-1">
|
||||||
<ui-toggle-switch v-model="newServerSettings.scannerFindCovers" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerFindCovers', val)" />
|
<div class="pt-4">
|
||||||
<ui-tooltip :text="tooltips.scannerFindCovers">
|
<h2 class="font-semibold">Scanner</h2>
|
||||||
<p class="pl-4 text-lg">
|
</div>
|
||||||
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">
|
<div class="flex items-center py-2">
|
||||||
<ui-toggle-switch v-model="newServerSettings.scannerPreferAudioMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferAudioMetadata', val)" />
|
<ui-toggle-switch v-model="newServerSettings.scannerParseSubtitle" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerParseSubtitle', val)" />
|
||||||
<ui-tooltip :text="tooltips.scannerPreferAudioMetadata">
|
<ui-tooltip :text="tooltips.scannerParseSubtitle">
|
||||||
<p class="pl-4 text-lg">
|
<p class="pl-4">
|
||||||
Scanner prefer audio metadata
|
Parse subtitles
|
||||||
<span class="material-icons icon-text">info_outlined</span>
|
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||||
</p>
|
</p>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center py-2">
|
<div class="flex items-center py-2">
|
||||||
<ui-toggle-switch v-model="newServerSettings.scannerPreferOpfMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferOpfMetadata', val)" />
|
<ui-toggle-switch v-model="newServerSettings.scannerFindCovers" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerFindCovers', val)" />
|
||||||
<ui-tooltip :text="tooltips.scannerPreferOpfMetadata">
|
<ui-tooltip :text="tooltips.scannerFindCovers">
|
||||||
<p class="pl-4 text-lg">
|
<p class="pl-4">
|
||||||
Scanner prefer OPF metadata
|
Find covers
|
||||||
<span class="material-icons icon-text">info_outlined</span>
|
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||||
</p>
|
</p>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
</div>
|
<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">
|
<div class="flex items-center py-2">
|
||||||
<ui-toggle-switch v-model="newServerSettings.scannerDisableWatcher" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerDisableWatcher', val)" />
|
<ui-toggle-switch v-model="newServerSettings.scannerPreferOverdriveMediaMarker" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferOverdriveMediaMarker', val)" />
|
||||||
<ui-tooltip :text="tooltips.scannerDisableWatcher">
|
<ui-tooltip :text="tooltips.scannerPreferOverdriveMediaMarker">
|
||||||
<p class="pl-4 text-lg">
|
<p class="pl-4">
|
||||||
Disable Watcher
|
Use Overdrive Media Markers for chapters
|
||||||
<span class="material-icons icon-text">info_outlined</span>
|
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||||
</p>
|
</p>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center mb-2 mt-8">
|
<div class="flex items-center py-2">
|
||||||
<h1 class="text-xl">Experimental Feature Settings</h1>
|
<ui-toggle-switch v-model="newServerSettings.scannerPreferAudioMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferAudioMetadata', val)" />
|
||||||
</div>
|
<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">
|
<div class="flex items-center py-2">
|
||||||
<ui-toggle-switch v-model="newServerSettings.enableEReader" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('enableEReader', val)" />
|
<ui-toggle-switch v-model="newServerSettings.scannerPreferOpfMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferOpfMetadata', val)" />
|
||||||
<ui-tooltip :text="tooltips.enableEReader">
|
<ui-tooltip :text="tooltips.scannerPreferOpfMetadata">
|
||||||
<p class="pl-4 text-lg">
|
<p class="pl-4">
|
||||||
Enable e-reader for all users
|
Prefer OPF metadata
|
||||||
<span class="material-icons icon-text">info_outlined</span>
|
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||||
</p>
|
</p>
|
||||||
</ui-tooltip>
|
</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">
|
||||||
|
Disable Watcher
|
||||||
|
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||||
|
</p>
|
||||||
|
</ui-tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- <div class="flex items-center py-2">
|
||||||
|
<ui-text-input type="number" v-model="newServerSettings.scannerMaxThreads" no-spinner :disabled="updatingServerSettings" :padding-x="1" text-center class="w-10" @change="updateScannerMaxThreads" />
|
||||||
|
<ui-tooltip :text="tooltips.scannerMaxThreads">
|
||||||
|
<p class="pl-4">
|
||||||
|
Max # of threads to use
|
||||||
|
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||||
|
</p>
|
||||||
|
</ui-tooltip>
|
||||||
|
</div> -->
|
||||||
|
|
||||||
|
<div class="pt-4">
|
||||||
|
<h2 class="font-semibold">Experimental Features</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center py-2">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<ui-toggle-switch v-model="showExperimentalFeatures" />
|
||||||
|
<ui-tooltip :text="tooltips.experimentalFeatures">
|
||||||
|
<p class="pl-4">
|
||||||
|
Experimental Features
|
||||||
|
<a href="https://github.com/advplyr/audiobookshelf/discussions/75" target="_blank">
|
||||||
|
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</ui-tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center py-2">
|
||||||
|
<ui-toggle-switch v-model="newServerSettings.enableEReader" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('enableEReader', val)" />
|
||||||
|
<ui-tooltip :text="tooltips.enableEReader">
|
||||||
|
<p class="pl-4">
|
||||||
|
Enable e-reader for all users
|
||||||
|
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||||
|
</p>
|
||||||
|
</ui-tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- <div class="flex items-center py-2">
|
||||||
|
<ui-toggle-switch v-model="newServerSettings.scannerUseSingleThreadedProber" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerUseSingleThreadedProber', val)" />
|
||||||
|
<ui-tooltip :text="tooltips.scannerUseSingleThreadedProber">
|
||||||
|
<p class="pl-4">
|
||||||
|
Scanner use old single threaded audio prober
|
||||||
|
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||||
|
</p>
|
||||||
|
</ui-tooltip>
|
||||||
|
</div> -->
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -142,7 +212,7 @@
|
|||||||
|
|
||||||
<div class="flex items-center py-4">
|
<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 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" />
|
<div class="flex-grow" />
|
||||||
<p class="pr-2 text-sm font-book text-yellow-400">
|
<p class="pr-2 text-sm font-book text-yellow-400">
|
||||||
Report bugs, request features, and contribute on
|
Report bugs, request features, and contribute on
|
||||||
@@ -178,30 +248,12 @@
|
|||||||
|
|
||||||
<div class="h-0.5 bg-primary bg-opacity-30 w-full" />
|
<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">
|
<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">
|
<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-error 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="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">
|
<div class="flex px-1 items-center">
|
||||||
<ui-btn color="primary" @click="showConfirmPurgeCache = false">Nevermind</ui-btn>
|
<ui-btn color="primary" @click="showConfirmPurgeCache = false">Nevermind</ui-btn>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
@@ -226,6 +278,7 @@ export default {
|
|||||||
experimentalFeatures: 'Features in development that could use your feedback and help testing. Click to open github discussion.',
|
experimentalFeatures: 'Features in development that could use your feedback and help testing. Click to open github discussion.',
|
||||||
scannerDisableWatcher: 'Disables the automatic adding/updating of items when file changes are detected. *Requires server restart',
|
scannerDisableWatcher: 'Disables the automatic adding/updating of items when file changes are detected. *Requires server restart',
|
||||||
scannerPreferOpfMetadata: 'OPF file metadata will be used for book details over folder names',
|
scannerPreferOpfMetadata: 'OPF file metadata will be used for book details over folder names',
|
||||||
|
scannerPreferMatchedMetadata: 'Matched data will overide book details when using Quick Match',
|
||||||
scannerPreferAudioMetadata: 'Audio file ID3 meta tags will be used for book details over folder names',
|
scannerPreferAudioMetadata: 'Audio file ID3 meta tags will be used for book details over folder names',
|
||||||
scannerParseSubtitle: 'Extract subtitles from audiobook folder names.<br>Subtitle must be seperated by " - "<br>i.e. "Book Title - A Subtitle Here" has the subtitle "A Subtitle Here"',
|
scannerParseSubtitle: 'Extract subtitles from audiobook folder names.<br>Subtitle must be seperated by " - "<br>i.e. "Book Title - A Subtitle Here" has the subtitle "A Subtitle Here"',
|
||||||
sortingIgnorePrefix: 'i.e. for prefix "the" book title "The Book Title" would sort as "Book Title, The"',
|
sortingIgnorePrefix: 'i.e. for prefix "the" book title "The Book Title" would sort as "Book Title, The"',
|
||||||
@@ -234,7 +287,10 @@ export default {
|
|||||||
storeCoverWithItem: 'By default covers are stored in /metadata/items, enabling this setting will store covers in your library item folder. Only one file named "cover" will be kept',
|
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',
|
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',
|
coverAspectRatio: 'Prefer to use square covers over standard 1.6:1 book covers',
|
||||||
enableEReader: 'E-reader is still a work in progress, but use this setting to open it up to all your users (or use the "Experimental Features" toggle below just for you)'
|
enableEReader: 'E-reader is still a work in progress, but use this setting to open it up to all your users (or use the "Experimental Features" toggle just for use by you)',
|
||||||
|
scannerPreferOverdriveMediaMarker: 'MP3 files from Overdrive come with chapter timings embedded as custom metadata. Enabling this will use these tags for chapter timings automatically',
|
||||||
|
scannerUseSingleThreadedProber: 'The old scanner used a single thread. Leaving it in to use as a comparison for now.',
|
||||||
|
scannerMaxThreads: 'Number of concurrent media files to scan at a time. Value of 1 will be a slower scan but less CPU usage. <br><br>Value of 0 defaults to # of CPU cores for this server times 2 (i.e. 4-core CPU will be 8)'
|
||||||
},
|
},
|
||||||
showConfirmPurgeCache: false
|
showConfirmPurgeCache: false
|
||||||
}
|
}
|
||||||
@@ -260,11 +316,31 @@ export default {
|
|||||||
set(val) {
|
set(val) {
|
||||||
this.$store.commit('setExperimentalFeatures', val)
|
this.$store.commit('setExperimentalFeatures', val)
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
dateFormats() {
|
||||||
|
return this.$store.state.globals.dateFormats
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
updateEnableChromecast(val) {
|
updateScannerMaxThreads(val) {
|
||||||
this.updateServerSettings({ enableChromecast: 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) {
|
updateSortingPrefixes(val) {
|
||||||
if (!val || !val.length) {
|
if (!val || !val.length) {
|
||||||
@@ -303,10 +379,12 @@ export default {
|
|||||||
.then((success) => {
|
.then((success) => {
|
||||||
console.log('Updated Server Settings', success)
|
console.log('Updated Server Settings', success)
|
||||||
this.updatingServerSettings = false
|
this.updatingServerSettings = false
|
||||||
|
this.$toast.success('Server settings updated')
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to update server settings', error)
|
console.error('Failed to update server settings', error)
|
||||||
this.updatingServerSettings = false
|
this.updatingServerSettings = false
|
||||||
|
this.$toast.error('Failed to update server settings')
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
initServerSettings() {
|
initServerSettings() {
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8">
|
||||||
<p class="text-xl">Stats for library {{ currentLibraryName }}</p>
|
<h1 class="text-xl">Stats for library {{ currentLibraryName }}</h1>
|
||||||
|
|
||||||
<stats-preview-icons v-if="totalItems" :library-stats="libraryStats" />
|
<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">
|
<div class="w-80 my-6 mx-auto">
|
||||||
<h1 class="text-2xl mb-4 font-book">Top 5 Genres</h1>
|
<h1 class="text-2xl mb-4 font-book">Top 5 Genres</h1>
|
||||||
<p v-if="!top5Genres.length">No Genres</p>
|
<p v-if="!top5Genres.length">No Genres</p>
|
||||||
|
|||||||
+12
-14
@@ -1,24 +1,21 @@
|
|||||||
<template>
|
<template>
|
||||||
<div id="page-wrapper" class="page overflow-y-auto" :class="streamLibraryItem ? 'streaming' : ''">
|
<div class="w-full h-full">
|
||||||
<div class="w-full max-w-4xl mx-auto">
|
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8">
|
||||||
<div class="mb-4 flex flex-col sm:flex-row items-start sm:items-end">
|
<div class="flex items-center mb-2">
|
||||||
<p class="text-2xl mr-4 mb-2 sm:mb-0">Logger</p>
|
<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" />
|
<ui-dropdown v-model="newServerSettings.logLevel" label="Server Log Level" :items="logLevelItems" @input="logLevelUpdated" class="w-full sm:w-44" />
|
||||||
|
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="relative">
|
<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">
|
<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]}`">
|
<div :key="index" class="flex flex-nowrap px-2 py-1 items-start text-sm bg-opacity-10" :class="`bg-${logColors[log.level]}`">
|
||||||
<p class="text-gray-400 w-40 font-mono">{{ log.timestamp.split('.')[0].split('T').join(' ') }}</p>
|
<p class="text-gray-400 w-36 font-mono text-xs">{{ log.timestamp }}</p>
|
||||||
<p class="font-semibold w-12 text-right" :class="`text-${logColors[log.level]}`">{{ log.levelName }}</p>
|
<p class="font-semibold w-12 text-right text-sm" :class="`text-${logColors[log.level]}`">{{ log.levelName }}</p>
|
||||||
<p class="px-4 logmessage">{{ log.message }}</p>
|
<p class="px-4 logmessage">{{ log.message }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -164,6 +161,7 @@ export default {
|
|||||||
},
|
},
|
||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
if (!this.$root.socket) return
|
if (!this.$root.socket) return
|
||||||
|
this.$root.socket.emit('remove_log_listener')
|
||||||
this.$root.socket.off('daily_logs', this.dailyLogsLoaded)
|
this.$root.socket.off('daily_logs', this.dailyLogsLoaded)
|
||||||
this.$root.socket.off('log', this.logEvtReceived)
|
this.$root.socket.off('log', this.logEvtReceived)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full h-full">
|
||||||
|
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8">
|
||||||
|
<div class="flex items-center mb-2">
|
||||||
|
<h1 class="text-xl">Listening Sessions</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end mb-2">
|
||||||
|
<ui-dropdown v-model="selectedUser" :items="userItems" label="Filter by User" small class="max-w-48" @input="updateUserFilter" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="listeningSessions.length" class="block max-w-full">
|
||||||
|
<table class="userSessionsTable">
|
||||||
|
<tr class="bg-primary bg-opacity-40">
|
||||||
|
<th class="w-48 min-w-48 text-left">Item</th>
|
||||||
|
<th class="w-20 min-w-20 text-left hidden md:table-cell">User</th>
|
||||||
|
<th class="w-32 min-w-32 text-left hidden md:table-cell">Play Method</th>
|
||||||
|
<th class="w-32 min-w-32 text-left hidden sm:table-cell">Device Info</th>
|
||||||
|
<th class="w-32 min-w-32">Listened</th>
|
||||||
|
<th class="w-16 min-w-16">Last Time</th>
|
||||||
|
<th class="flex-grow hidden sm:table-cell">Last Update</th>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr v-for="session in listeningSessions" :key="session.id" class="cursor-pointer" @click="showSession(session)">
|
||||||
|
<td class="py-1 max-w-48">
|
||||||
|
<p class="text-xs text-gray-200 truncate">{{ session.displayTitle }}</p>
|
||||||
|
<p class="text-xs text-gray-400 truncate">{{ session.displayAuthor }}</p>
|
||||||
|
</td>
|
||||||
|
<td class="hidden md:table-cell">
|
||||||
|
<p v-if="filteredUserUsername" class="text-xs">{{ filteredUserUsername }}</p>
|
||||||
|
<p v-else class="text-xs">{{ session.user ? session.user.username : 'N/A' }}</p>
|
||||||
|
</td>
|
||||||
|
<td class="hidden md:table-cell">
|
||||||
|
<p class="text-xs">{{ getPlayMethodName(session.playMethod) }}</p>
|
||||||
|
</td>
|
||||||
|
<td class="hidden sm:table-cell">
|
||||||
|
<p class="text-xs" v-html="getDeviceInfoString(session.deviceInfo)" />
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<p class="text-xs font-mono">{{ $elapsedPretty(session.timeListening) }}</p>
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p>
|
||||||
|
</td>
|
||||||
|
<td class="text-center hidden sm:table-cell">
|
||||||
|
<ui-tooltip v-if="session.updatedAt" direction="top" :text="$formatDate(session.updatedAt, 'MMMM do, yyyy HH:mm')">
|
||||||
|
<p class="text-xs text-gray-200">{{ $dateDistanceFromNow(session.updatedAt) }}</p>
|
||||||
|
</ui-tooltip>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<div class="flex items-center justify-end my-2">
|
||||||
|
<ui-icon-btn icon="arrow_back_ios_new" :size="7" icon-font-size="1rem" class="mx-1" :disabled="currentPage === 0" @click="prevPage" />
|
||||||
|
<p class="text-sm mx-1">Page {{ currentPage + 1 }} of {{ numPages }}</p>
|
||||||
|
<ui-icon-btn icon="arrow_forward_ios" :size="7" icon-font-size="1rem" class="mx-1" :disabled="currentPage >= numPages - 1" @click="nextPage" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-else class="text-white text-opacity-50">No sessions yet...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<modals-listening-session-modal v-model="showSessionModal" :session="selectedSession" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
async asyncData({ params, redirect, app }) {
|
||||||
|
var users = await app.$axios
|
||||||
|
.$get('/api/users')
|
||||||
|
.then((users) => {
|
||||||
|
return users.sort((a, b) => {
|
||||||
|
return a.createdAt - b.createdAt
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed', error)
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
users
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
showSessionModal: false,
|
||||||
|
selectedSession: null,
|
||||||
|
listeningSessions: [],
|
||||||
|
numPages: 0,
|
||||||
|
total: 0,
|
||||||
|
currentPage: 0,
|
||||||
|
userFilter: null,
|
||||||
|
selectedUser: ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
username() {
|
||||||
|
return this.user.username
|
||||||
|
},
|
||||||
|
userOnline() {
|
||||||
|
return this.$store.getters['users/getIsUserOnline'](this.user.id)
|
||||||
|
},
|
||||||
|
userItems() {
|
||||||
|
var userItems = [{ value: '', text: 'All Users' }]
|
||||||
|
return userItems.concat(this.users.map((u) => ({ value: u.id, text: u.username })))
|
||||||
|
},
|
||||||
|
filteredUserUsername() {
|
||||||
|
if (!this.userFilter) return null
|
||||||
|
var user = this.users.find((u) => u.id === this.userFilter)
|
||||||
|
return user ? user.username : null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
updateUserFilter() {
|
||||||
|
this.loadSessions(0)
|
||||||
|
},
|
||||||
|
prevPage() {
|
||||||
|
this.loadSessions(this.currentPage - 1)
|
||||||
|
},
|
||||||
|
nextPage() {
|
||||||
|
this.loadSessions(this.currentPage + 1)
|
||||||
|
},
|
||||||
|
showSession(session) {
|
||||||
|
this.selectedSession = session
|
||||||
|
this.showSessionModal = true
|
||||||
|
},
|
||||||
|
getDeviceInfoString(deviceInfo) {
|
||||||
|
if (!deviceInfo) return ''
|
||||||
|
var lines = []
|
||||||
|
if (deviceInfo.osName) lines.push(`${deviceInfo.osName} ${deviceInfo.osVersion}`)
|
||||||
|
if (deviceInfo.browserName) lines.push(deviceInfo.browserName)
|
||||||
|
|
||||||
|
if (deviceInfo.manufacturer && deviceInfo.model) lines.push(`${deviceInfo.manufacturer} ${deviceInfo.model}`)
|
||||||
|
if (deviceInfo.sdkVersion) lines.push(`SDK Version: ${deviceInfo.sdkVersion}`)
|
||||||
|
return lines.join('<br>')
|
||||||
|
},
|
||||||
|
getPlayMethodName(playMethod) {
|
||||||
|
if (playMethod === this.$constants.PlayMethod.DIRECTPLAY) return 'Direct Play'
|
||||||
|
else if (playMethod === this.$constants.PlayMethod.TRANSCODE) return 'Transcode'
|
||||||
|
else if (playMethod === this.$constants.PlayMethod.DIRECTSTREAM) return 'Direct Stream'
|
||||||
|
else if (playMethod === this.$constants.PlayMethod.LOCAL) return 'Local'
|
||||||
|
return 'Unknown'
|
||||||
|
},
|
||||||
|
async loadSessions(page) {
|
||||||
|
var userFilterQuery = this.selectedUser ? `&user=${this.selectedUser}` : ''
|
||||||
|
const data = await this.$axios.$get(`/api/sessions?page=${page}&itemsPerPage=10${userFilterQuery}`).catch((err) => {
|
||||||
|
console.error('Failed to load listening sesions', err)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
if (!data) {
|
||||||
|
this.$toast.error('Failed to load listening sessions')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.numPages = data.numPages
|
||||||
|
this.total = data.total
|
||||||
|
this.currentPage = data.page
|
||||||
|
this.listeningSessions = data.sessions
|
||||||
|
this.userFilter = data.userFilter
|
||||||
|
},
|
||||||
|
init() {
|
||||||
|
this.loadSessions(0)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.userSessionsTable {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
border: 1px solid #474747;
|
||||||
|
}
|
||||||
|
.userSessionsTable tr:first-child {
|
||||||
|
background-color: #272727;
|
||||||
|
}
|
||||||
|
.userSessionsTable tr:not(:first-child) {
|
||||||
|
background-color: #373838;
|
||||||
|
}
|
||||||
|
.userSessionsTable tr:not(:first-child):nth-child(odd) {
|
||||||
|
background-color: #2f2f2f;
|
||||||
|
}
|
||||||
|
.userSessionsTable tr:hover:not(:first-child) {
|
||||||
|
background-color: #474747;
|
||||||
|
}
|
||||||
|
.userSessionsTable td {
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
.userSessionsTable th {
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
<template>
|
<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 justify-center">
|
||||||
<div class="flex p-2">
|
<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">
|
<svg class="hidden sm:block h-14 w-14 lg:h-18 lg:w-18" viewBox="0 0 24 24">
|
||||||
@@ -37,12 +39,16 @@
|
|||||||
<div class="flex flex-col md:flex-row overflow-hidden max-w-full">
|
<div class="flex flex-col md:flex-row overflow-hidden max-w-full">
|
||||||
<stats-daily-listening-chart :listening-stats="listeningStats" class="origin-top-left transform scale-75 lg:scale-100" />
|
<stats-daily-listening-chart :listening-stats="listeningStats" class="origin-top-left transform scale-75 lg:scale-100" />
|
||||||
<div class="w-80 my-6 mx-auto">
|
<div class="w-80 my-6 mx-auto">
|
||||||
<h1 class="text-2xl mb-4 font-book">Recent Listening Sessions</h1>
|
<div class="flex mb-4 items-center">
|
||||||
|
<h1 class="text-2xl font-book">Recent Sessions</h1>
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<ui-btn :to="`/config/users/${user.id}/sessions`" class="text-xs" :padding-x="1.5" :padding-y="1">View All</ui-btn>
|
||||||
|
</div>
|
||||||
<p v-if="!mostRecentListeningSessions.length">No Listening Sessions</p>
|
<p v-if="!mostRecentListeningSessions.length">No Listening Sessions</p>
|
||||||
<template v-for="(item, index) in mostRecentListeningSessions">
|
<template v-for="(item, index) in mostRecentListeningSessions">
|
||||||
<div :key="item.id" class="w-full py-0.5">
|
<div :key="item.id" class="w-full py-0.5">
|
||||||
<div class="flex items-center mb-1">
|
<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">
|
<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-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>
|
<p class="text-xs text-white text-opacity-50">{{ $dateDistanceFromNow(item.updatedAt) }}</p>
|
||||||
@@ -80,6 +86,9 @@ export default {
|
|||||||
user() {
|
user() {
|
||||||
return this.$store.state.user.user
|
return this.$store.state.user.user
|
||||||
},
|
},
|
||||||
|
username() {
|
||||||
|
return this.user.username
|
||||||
|
},
|
||||||
currentLibraryId() {
|
currentLibraryId() {
|
||||||
return this.$store.state.libraries.currentLibraryId
|
return this.$store.state.libraries.currentLibraryId
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,15 +13,20 @@
|
|||||||
<widgets-online-indicator :value="!!userOnline" />
|
<widgets-online-indicator :value="!!userOnline" />
|
||||||
<h1 class="text-xl pl-2">{{ username }}</h1>
|
<h1 class="text-xl pl-2">{{ username }}</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="cursor-pointer text-gray-400 hover:text-white" @click="copyToClipboard(userToken)">
|
<div v-if="userToken" class="flex text-xs mt-4">
|
||||||
<p v-if="userToken" class="py-2 text-xs">
|
<ui-text-input-with-label label="API Token" :value="userToken" readonly />
|
||||||
<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>
|
<div class="px-1 mt-8 cursor-pointer" @click="copyToClipboard(userToken)">
|
||||||
</p>
|
<span class="material-icons pl-2 text-base">content_copy</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full h-px bg-white bg-opacity-10 my-2" />
|
<div class="w-full h-px bg-white bg-opacity-10 my-2" />
|
||||||
<div class="py-2">
|
<div class="py-2">
|
||||||
<h1 class="text-lg mb-2 text-white text-opacity-90 px-2 sm:px-0">Listening Stats</h1>
|
<h1 class="text-lg mb-2 text-white text-opacity-90 px-2 sm:px-0">Listening Stats</h1>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<p class="text-sm text-gray-300">{{ listeningSessions.length }} Listening Sessions</p>
|
||||||
|
<ui-btn :to="`/config/users/${user.id}/sessions`" class="text-xs mx-2" :padding-x="1.5" :padding-y="1">View All</ui-btn>
|
||||||
|
</div>
|
||||||
<p class="text-sm text-gray-300">
|
<p class="text-sm text-gray-300">
|
||||||
Total Time Listened:
|
Total Time Listened:
|
||||||
<span class="font-mono text-base">{{ listeningTimePretty }}</span>
|
<span class="font-mono text-base">{{ listeningTimePretty }}</span>
|
||||||
@@ -33,12 +38,14 @@
|
|||||||
|
|
||||||
<div v-if="latestSession" class="mt-4">
|
<div v-if="latestSession" class="mt-4">
|
||||||
<h1 class="text-lg mb-2 text-white text-opacity-90 px-2 sm:px-0">Last Listening Session</h1>
|
<h1 class="text-lg mb-2 text-white text-opacity-90 px-2 sm:px-0">Last Listening Session</h1>
|
||||||
<p class="text-sm text-gray-300">{{ latestSession.audiobookTitle }} {{ $dateDistanceFromNow(latestSession.lastUpdate) }} for {{ $elapsedPrettyExtended(this.latestSession.timeListening) }}</p>
|
<p class="text-sm text-gray-300">
|
||||||
|
<strong>{{ latestSession.displayTitle }}</strong> {{ $dateDistanceFromNow(latestSession.updatedAt) }} for <span class="font-mono text-base">{{ $elapsedPrettyExtended(this.latestSession.timeListening) }}</span>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full h-px bg-white bg-opacity-10 my-2" />
|
<div class="w-full h-px bg-white bg-opacity-10 my-2" />
|
||||||
<div class="py-2">
|
<div class="py-2">
|
||||||
<h1 class="text-lg mb-2 text-white text-opacity-90 px-2 sm:px-0">Item Progress</h1>
|
<h1 class="text-lg mb-2 text-white text-opacity-90 px-2 sm:px-0">Saved Media Progress</h1>
|
||||||
<table v-if="mediaProgress.length" class="userAudiobooksTable">
|
<table v-if="mediaProgress.length" class="userAudiobooksTable">
|
||||||
<tr class="bg-primary bg-opacity-40">
|
<tr class="bg-primary bg-opacity-40">
|
||||||
<th class="w-16 text-left">Item</th>
|
<th class="w-16 text-left">Item</th>
|
||||||
@@ -70,7 +77,7 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
<p v-else class="text-white text-opacity-50">Nothing read yet...</p>
|
<p v-else class="text-white text-opacity-50">Nothing listened to yet...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -132,10 +139,15 @@ export default {
|
|||||||
this.$copyToClipboard(str, this)
|
this.$copyToClipboard(str, this)
|
||||||
},
|
},
|
||||||
async init() {
|
async init() {
|
||||||
this.listeningSessions = await this.$axios.$get(`/api/users/${this.user.id}/listening-sessions`).catch((err) => {
|
this.listeningSessions = await this.$axios
|
||||||
console.error('Failed to load listening sesions', err)
|
.$get(`/api/users/${this.user.id}/listening-sessions?page=0&itemsPerPage=10`)
|
||||||
return []
|
.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) => {
|
this.listeningStats = await this.$axios.$get(`/api/users/${this.user.id}/listening-stats`).catch((err) => {
|
||||||
console.error('Failed to load listening sesions', err)
|
console.error('Failed to load listening sesions', err)
|
||||||
return []
|
return []
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
<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">
|
||||||
|
<nuxt-link :to="`/config/users/${user.id}`" class="text-white text-opacity-70 hover:text-opacity-100 hover:bg-white hover:bg-opacity-5 cursor-pointer rounded-full px-2 sm:px-0">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="h-10 w-10 flex items-center justify-center">
|
||||||
|
<span class="material-icons text-2xl">arrow_back</span>
|
||||||
|
</div>
|
||||||
|
<p class="pl-1">Back to User</p>
|
||||||
|
</div>
|
||||||
|
</nuxt-link>
|
||||||
|
<div class="flex items-center mb-2 mt-4 px-2 sm:px-0">
|
||||||
|
<widgets-online-indicator :value="!!userOnline" />
|
||||||
|
<h1 class="text-xl pl-2">{{ username }}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-full h-px bg-white bg-opacity-10 my-2" />
|
||||||
|
|
||||||
|
<div class="py-2">
|
||||||
|
<h1 class="text-lg mb-2 text-white text-opacity-90 px-2 sm:px-0">Listening Sessions</h1>
|
||||||
|
<div v-if="listeningSessions.length">
|
||||||
|
<table class="userSessionsTable">
|
||||||
|
<tr class="bg-primary bg-opacity-40">
|
||||||
|
<th class="w-48 min-w-48 text-left">Item</th>
|
||||||
|
<th class="w-32 min-w-32 text-left hidden md:table-cell">Play Method</th>
|
||||||
|
<th class="w-32 min-w-32 text-left hidden sm:table-cell">Device Info</th>
|
||||||
|
<th class="w-32 min-w-32">Listened</th>
|
||||||
|
<th class="w-16 min-w-16">Last Time</th>
|
||||||
|
<th class="flex-grow hidden sm:table-cell">Last Update</th>
|
||||||
|
</tr>
|
||||||
|
<tr v-for="session in listeningSessions" :key="session.id" class="cursor-pointer" @click="showSession(session)">
|
||||||
|
<td class="py-1 max-w-48">
|
||||||
|
<p class="text-xs text-gray-200 truncate">{{ session.displayTitle }}</p>
|
||||||
|
<p class="text-xs text-gray-400 truncate">{{ session.displayAuthor }}</p>
|
||||||
|
</td>
|
||||||
|
<td class="hidden md:table-cell">
|
||||||
|
<p class="text-xs">{{ getPlayMethodName(session.playMethod) }}</p>
|
||||||
|
</td>
|
||||||
|
<td class="hidden sm:table-cell">
|
||||||
|
<p class="text-xs" v-html="getDeviceInfoString(session.deviceInfo)" />
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<p class="text-xs font-mono">{{ $elapsedPretty(session.timeListening) }}</p>
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p>
|
||||||
|
</td>
|
||||||
|
<td class="text-center hidden sm:table-cell">
|
||||||
|
<ui-tooltip v-if="session.updatedAt" direction="top" :text="$formatDate(session.updatedAt, 'MMMM do, yyyy HH:mm')">
|
||||||
|
<p class="text-xs text-gray-200">{{ $dateDistanceFromNow(session.updatedAt) }}</p>
|
||||||
|
</ui-tooltip>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<div class="flex items-center justify-end py-1">
|
||||||
|
<ui-icon-btn icon="arrow_back_ios_new" :size="7" icon-font-size="1rem" class="mx-1" :disabled="currentPage === 0" @click="prevPage" />
|
||||||
|
<p class="text-sm mx-1">Page {{ currentPage + 1 }} of {{ numPages }}</p>
|
||||||
|
<ui-icon-btn icon="arrow_forward_ios" :size="7" icon-font-size="1rem" class="mx-1" :disabled="currentPage >= numPages - 1" @click="nextPage" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-else class="text-white text-opacity-50">No sessions yet...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<modals-listening-session-modal v-model="showSessionModal" :session="selectedSession" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
async asyncData({ params, redirect, app }) {
|
||||||
|
var user = await app.$axios.$get(`/api/users/${params.id}`).catch((error) => {
|
||||||
|
console.error('Failed to get user', error)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
if (!user) return redirect('/config/users')
|
||||||
|
return {
|
||||||
|
user
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
showSessionModal: false,
|
||||||
|
selectedSession: null,
|
||||||
|
listeningSessions: [],
|
||||||
|
numPages: 0,
|
||||||
|
total: 0,
|
||||||
|
currentPage: 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
username() {
|
||||||
|
return this.user.username
|
||||||
|
},
|
||||||
|
userOnline() {
|
||||||
|
return this.$store.getters['users/getIsUserOnline'](this.user.id)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
prevPage() {
|
||||||
|
this.loadSessions(this.currentPage - 1)
|
||||||
|
},
|
||||||
|
nextPage() {
|
||||||
|
this.loadSessions(this.currentPage + 1)
|
||||||
|
},
|
||||||
|
showSession(session) {
|
||||||
|
this.selectedSession = session
|
||||||
|
this.showSessionModal = true
|
||||||
|
},
|
||||||
|
getDeviceInfoString(deviceInfo) {
|
||||||
|
if (!deviceInfo) return ''
|
||||||
|
var lines = []
|
||||||
|
if (deviceInfo.osName) lines.push(`${deviceInfo.osName} ${deviceInfo.osVersion}`)
|
||||||
|
if (deviceInfo.browserName) lines.push(deviceInfo.browserName)
|
||||||
|
|
||||||
|
if (deviceInfo.manufacturer && deviceInfo.model) lines.push(`${deviceInfo.manufacturer} ${deviceInfo.model}`)
|
||||||
|
if (deviceInfo.sdkVersion) lines.push(`SDK Version: ${deviceInfo.sdkVersion}`)
|
||||||
|
return lines.join('<br>')
|
||||||
|
},
|
||||||
|
getPlayMethodName(playMethod) {
|
||||||
|
if (playMethod === this.$constants.PlayMethod.DIRECTPLAY) return 'Direct Play'
|
||||||
|
else if (playMethod === this.$constants.PlayMethod.TRANSCODE) return 'Transcode'
|
||||||
|
else if (playMethod === this.$constants.PlayMethod.DIRECTSTREAM) return 'Direct Stream'
|
||||||
|
else if (playMethod === this.$constants.PlayMethod.LOCAL) return 'Local'
|
||||||
|
return 'Unknown'
|
||||||
|
},
|
||||||
|
async loadSessions(page) {
|
||||||
|
const data = await this.$axios.$get(`/api/users/${this.user.id}/listening-sessions?page=${page}&itemsPerPage=10`).catch((err) => {
|
||||||
|
console.error('Failed to load listening sesions', err)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
if (!data) {
|
||||||
|
this.$toast.error('Failed to load listening sessions')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.numPages = data.numPages
|
||||||
|
this.total = data.total
|
||||||
|
this.currentPage = data.page
|
||||||
|
this.listeningSessions = data.sessions
|
||||||
|
},
|
||||||
|
init() {
|
||||||
|
this.loadSessions(0)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.userSessionsTable {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
border: 1px solid #474747;
|
||||||
|
}
|
||||||
|
.userSessionsTable tr:first-child {
|
||||||
|
background-color: #272727;
|
||||||
|
}
|
||||||
|
.userSessionsTable tr:not(:first-child) {
|
||||||
|
background-color: #373838;
|
||||||
|
}
|
||||||
|
.userSessionsTable tr:not(:first-child):nth-child(odd) {
|
||||||
|
background-color: #2f2f2f;
|
||||||
|
}
|
||||||
|
.userSessionsTable tr:hover:not(:first-child) {
|
||||||
|
background-color: #474747;
|
||||||
|
}
|
||||||
|
.userSessionsTable td {
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
.userSessionsTable th {
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -31,11 +31,13 @@
|
|||||||
<p v-if="bookSubtitle" class="sm:ml-4 text-gray-400 text-xl md:text-2xl">{{ bookSubtitle }}</p>
|
<p v-if="bookSubtitle" class="sm:ml-4 text-gray-400 text-xl md:text-2xl">{{ bookSubtitle }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="isPodcast" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl">by {{ podcastAuthor || 'Unknown' }}</p>
|
<template v-if="!isVideo">
|
||||||
<p v-else-if="authors.length" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl">
|
<p v-if="isPodcast" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl">by {{ podcastAuthor || 'Unknown' }}</p>
|
||||||
by <nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">, </span></nuxt-link>
|
<p v-else-if="authors.length" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl">
|
||||||
</p>
|
by <nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">, </span></nuxt-link>
|
||||||
<p v-else class="mb-2 mt-0.5 text-gray-200 text-xl">by Unknown</p>
|
</p>
|
||||||
|
<p v-else class="mb-2 mt-0.5 text-gray-200 text-xl">by Unknown</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
<nuxt-link v-for="_series in seriesList" :key="_series.id" :to="`/library/${libraryId}/series/${_series.id}`" class="hover:underline font-sans text-gray-300 text-lg leading-7"> {{ _series.text }}</nuxt-link>
|
<nuxt-link v-for="_series in seriesList" :key="_series.id" :to="`/library/${libraryId}/series/${_series.id}`" class="hover:underline font-sans text-gray-300 text-lg leading-7"> {{ _series.text }}</nuxt-link>
|
||||||
|
|
||||||
@@ -116,9 +118,9 @@
|
|||||||
<!-- Progress -->
|
<!-- Progress -->
|
||||||
<div v-if="!isPodcast && progressPercent > 0" class="px-4 py-2 mt-4 bg-primary text-sm font-semibold rounded-md text-gray-100 relative max-w-max mx-auto md:mx-0" :class="resettingProgress ? 'opacity-25' : ''">
|
<div v-if="!isPodcast && progressPercent > 0" class="px-4 py-2 mt-4 bg-primary text-sm font-semibold rounded-md text-gray-100 relative max-w-max mx-auto md:mx-0" :class="resettingProgress ? 'opacity-25' : ''">
|
||||||
<p v-if="progressPercent < 1" class="leading-6">Your Progress: {{ Math.round(progressPercent * 100) }}%</p>
|
<p v-if="progressPercent < 1" class="leading-6">Your Progress: {{ Math.round(progressPercent * 100) }}%</p>
|
||||||
<p v-else class="text-xs">Finished {{ $formatDate(userProgressFinishedAt, 'MM/dd/yyyy') }}</p>
|
<p v-else class="text-xs">Finished {{ $formatDate(userProgressFinishedAt, dateFormat) }}</p>
|
||||||
<p v-if="progressPercent < 1" class="text-gray-200 text-xs">{{ $elapsedPretty(userTimeRemaining) }} remaining</p>
|
<p v-if="progressPercent < 1" class="text-gray-200 text-xs">{{ $elapsedPretty(userTimeRemaining) }} remaining</p>
|
||||||
<p class="text-gray-400 text-xs pt-1">Started {{ $formatDate(userProgressStartedAt, 'MM/dd/yyyy') }}</p>
|
<p class="text-gray-400 text-xs pt-1">Started {{ $formatDate(userProgressStartedAt, dateFormat) }}</p>
|
||||||
|
|
||||||
<div v-if="!resettingProgress" class="absolute -top-1.5 -right-1.5 p-1 w-5 h-5 rounded-full bg-bg hover:bg-error border border-primary flex items-center justify-center cursor-pointer" @click.stop="clearProgressClick">
|
<div v-if="!resettingProgress" class="absolute -top-1.5 -right-1.5 p-1 w-5 h-5 rounded-full bg-bg hover:bg-error border border-primary flex items-center justify-center cursor-pointer" @click.stop="clearProgressClick">
|
||||||
<span class="material-icons text-sm">close</span>
|
<span class="material-icons text-sm">close</span>
|
||||||
@@ -224,6 +226,9 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
dateFormat() {
|
||||||
|
return this.$store.state.serverSettings.dateFormat
|
||||||
|
},
|
||||||
showExperimentalFeatures() {
|
showExperimentalFeatures() {
|
||||||
return this.$store.state.showExperimentalFeatures
|
return this.$store.state.showExperimentalFeatures
|
||||||
},
|
},
|
||||||
@@ -251,6 +256,9 @@ export default {
|
|||||||
isPodcast() {
|
isPodcast() {
|
||||||
return this.libraryItem.mediaType === 'podcast'
|
return this.libraryItem.mediaType === 'podcast'
|
||||||
},
|
},
|
||||||
|
isVideo() {
|
||||||
|
return this.libraryItem.mediaType === 'video'
|
||||||
|
},
|
||||||
isMissing() {
|
isMissing() {
|
||||||
return this.libraryItem.isMissing
|
return this.libraryItem.isMissing
|
||||||
},
|
},
|
||||||
@@ -258,11 +266,12 @@ export default {
|
|||||||
return this.libraryItem.isInvalid
|
return this.libraryItem.isInvalid
|
||||||
},
|
},
|
||||||
invalidAudioFiles() {
|
invalidAudioFiles() {
|
||||||
if (this.isPodcast) return []
|
if (this.isPodcast || this.isVideo) return []
|
||||||
return this.libraryItem.media.audioFiles.filter((af) => af.invalid)
|
return this.libraryItem.media.audioFiles.filter((af) => af.invalid)
|
||||||
},
|
},
|
||||||
showPlayButton() {
|
showPlayButton() {
|
||||||
if (this.isMissing || this.isInvalid) return false
|
if (this.isMissing || this.isInvalid) return false
|
||||||
|
if (this.isVideo) return !!this.videoFile
|
||||||
if (this.isPodcast) return this.podcastEpisodes.length
|
if (this.isPodcast) return this.podcastEpisodes.length
|
||||||
return this.tracks.length
|
return this.tracks.length
|
||||||
},
|
},
|
||||||
@@ -348,6 +357,9 @@ export default {
|
|||||||
ebookFile() {
|
ebookFile() {
|
||||||
return this.media.ebookFile
|
return this.media.ebookFile
|
||||||
},
|
},
|
||||||
|
videoFile() {
|
||||||
|
return this.media.videoFile
|
||||||
|
},
|
||||||
showExperimentalReadAlert() {
|
showExperimentalReadAlert() {
|
||||||
return !this.tracks.length && this.ebookFile && !this.showExperimentalFeatures && !this.enableEReader
|
return !this.tracks.length && this.ebookFile && !this.showExperimentalFeatures && !this.enableEReader
|
||||||
},
|
},
|
||||||
@@ -525,13 +537,13 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
rssFeedOpen(data) {
|
rssFeedOpen(data) {
|
||||||
if (data.libraryItemId === this.libraryItemId) {
|
if (data.entityId === this.libraryItemId) {
|
||||||
console.log('RSS Feed Opened', data)
|
console.log('RSS Feed Opened', data)
|
||||||
this.rssFeedUrl = data.feedUrl
|
this.rssFeedUrl = data.feedUrl
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
rssFeedClosed(data) {
|
rssFeedClosed(data) {
|
||||||
if (data.libraryItemId === this.libraryItemId) {
|
if (data.entityId === this.libraryItemId) {
|
||||||
console.log('RSS Feed Closed', data)
|
console.log('RSS Feed Closed', data)
|
||||||
this.rssFeedUrl = null
|
this.rssFeedUrl = null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page" :class="streamLibraryItem ? 'streaming' : ''">
|
<div class="page" :class="streamLibraryItem ? 'streaming' : ''">
|
||||||
<app-book-shelf-toolbar is-home />
|
<app-book-shelf-toolbar page="authors" is-home :authors="authors" />
|
||||||
<div id="bookshelf" class="w-full h-full p-8 overflow-y-auto">
|
<div id="bookshelf" class="w-full h-full p-8 overflow-y-auto">
|
||||||
<div class="flex flex-wrap justify-center">
|
<div class="flex flex-wrap justify-center">
|
||||||
<template v-for="author in authors">
|
<template v-for="author in authors">
|
||||||
|
|||||||
@@ -1,24 +1,27 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page" :class="streamLibraryItem ? 'streaming' : ''">
|
<div class="page" :class="streamLibraryItem ? 'streaming' : ''">
|
||||||
<app-book-shelf-toolbar page="podcast-search" />
|
<app-book-shelf-toolbar page="podcast-search" />
|
||||||
<div class="w-full h-full overflow-y-auto p-12 relative">
|
|
||||||
<div class="w-full max-w-3xl mx-auto">
|
<div class="w-full h-full overflow-y-auto px-2 py-6 sm:px-4 md:p-12 relative">
|
||||||
<form @submit.prevent="submit" class="flex">
|
<div class="w-full max-w-4xl mx-auto flex">
|
||||||
<ui-text-input v-model="searchInput" :disabled="processing" placeholder="Enter search term or RSS feed URL" class="flex-grow mr-2" />
|
<form @submit.prevent="submit" class="flex flex-grow">
|
||||||
<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>
|
</form>
|
||||||
|
<ui-file-input ref="fileInput" :accept="'.opml, .txt'" class="ml-2" @change="opmlFileUpload"> Upload OPML File </ui-file-input>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="w-full max-w-3xl mx-auto py-4">
|
<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>
|
<p v-if="termSearched && !results.length && !processing" class="text-center text-xl">No podcasts found</p>
|
||||||
<template v-for="podcast in results">
|
<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 :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" />
|
<img v-if="podcast.cover" :src="podcast.cover" class="h-full w-full" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow pl-4 max-w-2xl">
|
<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>
|
<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-base text-gray-300 whitespace-nowrap truncate">by {{ podcast.artistName }}</p>
|
<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.genres.join(', ') }}</p>
|
||||||
<p class="text-xs text-gray-400 leading-5">{{ podcast.trackCount }} Episodes</p>
|
<p class="text-xs text-gray-400 leading-5">{{ podcast.trackCount }} Episodes</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -32,6 +35,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<modals-podcast-new-modal v-model="showNewPodcastModal" :podcast-data="selectedPodcast" :podcast-feed-data="selectedPodcastFeed" />
|
<modals-podcast-new-modal v-model="showNewPodcastModal" :podcast-data="selectedPodcast" :podcast-feed-data="selectedPodcastFeed" />
|
||||||
|
<modals-podcast-opml-feeds-modal v-model="showOPMLFeedsModal" :feeds="opmlFeeds" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -62,7 +66,9 @@ export default {
|
|||||||
processing: false,
|
processing: false,
|
||||||
showNewPodcastModal: false,
|
showNewPodcastModal: false,
|
||||||
selectedPodcast: null,
|
selectedPodcast: null,
|
||||||
selectedPodcastFeed: null
|
selectedPodcastFeed: null,
|
||||||
|
showOPMLFeedsModal: false,
|
||||||
|
opmlFeeds: []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -71,6 +77,40 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
async opmlFileUpload(file) {
|
||||||
|
this.processing = true
|
||||||
|
var txt = await new Promise((resolve) => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => {
|
||||||
|
resolve(reader.result)
|
||||||
|
}
|
||||||
|
reader.readAsText(file)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (this.$refs.fileInput) {
|
||||||
|
this.$refs.fileInput.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!txt || !txt.includes('<opml') || !txt.includes('<outline ')) {
|
||||||
|
// Quick lazy check for valid OPML
|
||||||
|
this.$toast.error('Invalid OPML file <opml> tag not found OR an <outline> tag was not found')
|
||||||
|
this.processing = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.$axios
|
||||||
|
.$post(`/api/podcasts/opml`, { opmlText: txt })
|
||||||
|
.then((data) => {
|
||||||
|
console.log(data)
|
||||||
|
this.opmlFeeds = data.feeds || []
|
||||||
|
this.showOPMLFeedsModal = true
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed', error)
|
||||||
|
this.$toast.error('Failed to parse OPML file')
|
||||||
|
})
|
||||||
|
this.processing = false
|
||||||
|
},
|
||||||
submit() {
|
submit() {
|
||||||
if (!this.searchInput) return
|
if (!this.searchInput) return
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="pt-8 text-center">
|
<div class="pt-8 text-center">
|
||||||
<p class="text-xs text-white text-opacity-50 font-mono"><strong>Supported File Types: </strong>{{ inputAccept.join(', ') }}</p>
|
<p class="text-xs text-white text-opacity-50 font-mono mb-4"><strong>Supported File Types: </strong>{{ inputAccept.join(', ') }}</p>
|
||||||
|
|
||||||
|
<p class="text-sm text-white text-opacity-70">Folders with media files will be treated as separate library items. <span v-if="selectedLibraryMediaType === 'book'">If uploading only audio files then each audio file will be treated as a separate audiobook.</span></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Item list header -->
|
<!-- Item list header -->
|
||||||
@@ -64,8 +66,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input ref="fileInput" id="hidden-input" type="file" multiple :accept="inputAccept" class="hidden" @change="inputChanged" />
|
<input ref="fileInput" type="file" multiple :accept="inputAccept" class="hidden" @change="inputChanged" />
|
||||||
<input ref="fileFolderInput" id="hidden-input" type="file" webkitdirectory multiple :accept="inputAccept" class="hidden" @change="inputChanged" />
|
<input ref="fileFolderInput" type="file" webkitdirectory multiple :accept="inputAccept" class="hidden" @change="inputChanged" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -52,12 +52,12 @@ export default class CastPlayer extends EventEmitter {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// var currentItemId = media.currentItemId
|
|
||||||
var currentItemId = media.media.itemId
|
var currentItemId = media.media.itemId
|
||||||
if (currentItemId && this.currentTrackIndex !== currentItemId - 1) {
|
if (currentItemId && this.currentTrackIndex !== currentItemId - 1) {
|
||||||
this.currentTrackIndex = currentItemId - 1
|
this.currentTrackIndex = currentItemId - 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: Emit finished event
|
||||||
if (media.playerState !== this.castPlayerState) {
|
if (media.playerState !== this.castPlayerState) {
|
||||||
this.emit('stateChange', media.playerState)
|
this.emit('stateChange', media.playerState)
|
||||||
this.castPlayerState = media.playerState
|
this.castPlayerState = media.playerState
|
||||||
@@ -124,6 +124,8 @@ export default class CastPlayer extends EventEmitter {
|
|||||||
|
|
||||||
async seek(time, playWhenReady) {
|
async seek(time, playWhenReady) {
|
||||||
if (!this.player) return
|
if (!this.player) return
|
||||||
|
|
||||||
|
this.playWhenReady = playWhenReady
|
||||||
if (time < this.currentTrack.startOffset || time > this.currentTrack.startOffset + this.currentTrack.duration) {
|
if (time < this.currentTrack.startOffset || time > this.currentTrack.startOffset + this.currentTrack.duration) {
|
||||||
// Change Track
|
// Change Track
|
||||||
var request = buildCastLoadRequest(this.libraryItem, this.coverUrl, this.audioTracks, time, playWhenReady, this.defaultPlaybackRate)
|
var request = buildCastLoadRequest(this.libraryItem, this.coverUrl, this.audioTracks, time, playWhenReady, this.defaultPlaybackRate)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import Hls from 'hls.js'
|
import Hls from 'hls.js'
|
||||||
import EventEmitter from 'events'
|
import EventEmitter from 'events'
|
||||||
|
|
||||||
export default class LocalPlayer extends EventEmitter {
|
export default class LocalAudioPlayer extends EventEmitter {
|
||||||
constructor(ctx) {
|
constructor(ctx) {
|
||||||
super()
|
super()
|
||||||
|
|
||||||
@@ -71,11 +71,11 @@ export default class LocalPlayer extends EventEmitter {
|
|||||||
console.log(`[LocalPlayer] Track ended - loading next track ${this.currentTrackIndex + 1}`)
|
console.log(`[LocalPlayer] Track ended - loading next track ${this.currentTrackIndex + 1}`)
|
||||||
// Has next track
|
// Has next track
|
||||||
this.currentTrackIndex++
|
this.currentTrackIndex++
|
||||||
this.playWhenReady = true
|
|
||||||
this.startTime = this.currentTrack.startOffset
|
this.startTime = this.currentTrack.startOffset
|
||||||
this.loadCurrentTrack()
|
this.loadCurrentTrack()
|
||||||
} else {
|
} else {
|
||||||
console.log(`[LocalPlayer] Ended`)
|
console.log(`[LocalPlayer] Ended`)
|
||||||
|
this.emit('finished')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
evtError(error) {
|
evtError(error) {
|
||||||
@@ -88,6 +88,7 @@ export default class LocalPlayer extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.emit('stateChange', 'LOADED')
|
this.emit('stateChange', 'LOADED')
|
||||||
|
|
||||||
if (this.playWhenReady) {
|
if (this.playWhenReady) {
|
||||||
this.playWhenReady = false
|
this.playWhenReady = false
|
||||||
this.play()
|
this.play()
|
||||||
@@ -204,10 +205,12 @@ export default class LocalPlayer extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
play() {
|
play() {
|
||||||
|
this.playWhenReady = true
|
||||||
if (this.player) this.player.play()
|
if (this.player) this.player.play()
|
||||||
}
|
}
|
||||||
|
|
||||||
pause() {
|
pause() {
|
||||||
|
this.playWhenReady = false
|
||||||
if (this.player) this.player.pause()
|
if (this.player) this.player.pause()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,8 +231,11 @@ export default class LocalPlayer extends EventEmitter {
|
|||||||
this.player.playbackRate = playbackRate
|
this.player.playbackRate = playbackRate
|
||||||
}
|
}
|
||||||
|
|
||||||
seek(time) {
|
seek(time, playWhenReady) {
|
||||||
if (!this.player) return
|
if (!this.player) return
|
||||||
|
|
||||||
|
this.playWhenReady = playWhenReady
|
||||||
|
|
||||||
if (this.isHlsTranscode) {
|
if (this.isHlsTranscode) {
|
||||||
// Seeking HLS stream
|
// Seeking HLS stream
|
||||||
var offsetTime = time - (this.currentTrack.startOffset || 0)
|
var offsetTime = time - (this.currentTrack.startOffset || 0)
|
||||||
@@ -254,7 +260,6 @@ export default class LocalPlayer extends EventEmitter {
|
|||||||
this.player.currentTime = Math.max(0, offsetTime)
|
this.player.currentTime = Math.max(0, offsetTime)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setVolume(volume) {
|
setVolume(volume) {
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
import Hls from 'hls.js'
|
||||||
|
import EventEmitter from 'events'
|
||||||
|
|
||||||
|
export default class LocalVideoPlayer extends EventEmitter {
|
||||||
|
constructor(ctx) {
|
||||||
|
super()
|
||||||
|
|
||||||
|
this.ctx = ctx
|
||||||
|
this.player = null
|
||||||
|
|
||||||
|
this.libraryItem = null
|
||||||
|
this.videoTrack = null
|
||||||
|
this.isHlsTranscode = null
|
||||||
|
this.hlsInstance = null
|
||||||
|
this.usingNativeplayer = false
|
||||||
|
this.startTime = 0
|
||||||
|
this.playWhenReady = false
|
||||||
|
this.defaultPlaybackRate = 1
|
||||||
|
|
||||||
|
this.playableMimeTypes = []
|
||||||
|
|
||||||
|
this.initialize()
|
||||||
|
}
|
||||||
|
|
||||||
|
initialize() {
|
||||||
|
if (document.getElementById('video-player')) {
|
||||||
|
document.getElementById('video-player').remove()
|
||||||
|
}
|
||||||
|
var videoEl = document.createElement('video')
|
||||||
|
videoEl.id = 'video-player'
|
||||||
|
// videoEl.style.display = 'none'
|
||||||
|
videoEl.className = 'absolute bg-black z-50'
|
||||||
|
videoEl.style.height = '216px'
|
||||||
|
videoEl.style.width = '384px'
|
||||||
|
videoEl.style.bottom = '80px'
|
||||||
|
videoEl.style.left = '16px'
|
||||||
|
document.body.appendChild(videoEl)
|
||||||
|
this.player = videoEl
|
||||||
|
|
||||||
|
this.player.addEventListener('play', this.evtPlay.bind(this))
|
||||||
|
this.player.addEventListener('pause', this.evtPause.bind(this))
|
||||||
|
this.player.addEventListener('progress', this.evtProgress.bind(this))
|
||||||
|
this.player.addEventListener('ended', this.evtEnded.bind(this))
|
||||||
|
this.player.addEventListener('error', this.evtError.bind(this))
|
||||||
|
this.player.addEventListener('loadedmetadata', this.evtLoadedMetadata.bind(this))
|
||||||
|
this.player.addEventListener('timeupdate', this.evtTimeupdate.bind(this))
|
||||||
|
|
||||||
|
var mimeTypes = ['video/mp4']
|
||||||
|
var mimeTypeCanPlayMap = {}
|
||||||
|
mimeTypes.forEach((mt) => {
|
||||||
|
var canPlay = this.player.canPlayType(mt)
|
||||||
|
mimeTypeCanPlayMap[mt] = canPlay
|
||||||
|
if (canPlay) this.playableMimeTypes.push(mt)
|
||||||
|
})
|
||||||
|
console.log(`[LocalVideoPlayer] Supported mime types`, mimeTypeCanPlayMap, this.playableMimeTypes)
|
||||||
|
}
|
||||||
|
|
||||||
|
evtPlay() {
|
||||||
|
this.emit('stateChange', 'PLAYING')
|
||||||
|
}
|
||||||
|
evtPause() {
|
||||||
|
this.emit('stateChange', 'PAUSED')
|
||||||
|
}
|
||||||
|
evtProgress() {
|
||||||
|
var lastBufferTime = this.getLastBufferedTime()
|
||||||
|
this.emit('buffertimeUpdate', lastBufferTime)
|
||||||
|
}
|
||||||
|
evtEnded() {
|
||||||
|
console.log(`[LocalVideoPlayer] Ended`)
|
||||||
|
this.emit('finished')
|
||||||
|
}
|
||||||
|
evtError(error) {
|
||||||
|
console.error('Player error', error)
|
||||||
|
this.emit('error', error)
|
||||||
|
}
|
||||||
|
evtLoadedMetadata(data) {
|
||||||
|
if (!this.isHlsTranscode) {
|
||||||
|
this.player.currentTime = this.startTime
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emit('stateChange', 'LOADED')
|
||||||
|
if (this.playWhenReady) {
|
||||||
|
this.playWhenReady = false
|
||||||
|
this.play()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
evtTimeupdate() {
|
||||||
|
if (this.player.paused) {
|
||||||
|
this.emit('timeupdate', this.getCurrentTime())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.destroyHlsInstance()
|
||||||
|
if (this.player) {
|
||||||
|
this.player.remove()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
set(libraryItem, videoTrack, isHlsTranscode, startTime, playWhenReady = false) {
|
||||||
|
this.libraryItem = libraryItem
|
||||||
|
this.videoTrack = videoTrack
|
||||||
|
this.isHlsTranscode = isHlsTranscode
|
||||||
|
this.playWhenReady = playWhenReady
|
||||||
|
this.startTime = startTime
|
||||||
|
|
||||||
|
if (this.hlsInstance) {
|
||||||
|
this.destroyHlsInstance()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isHlsTranscode) {
|
||||||
|
this.setHlsStream()
|
||||||
|
} else {
|
||||||
|
this.setDirectPlay()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setHlsStream() {
|
||||||
|
// iOS does not support Media Elements but allows for HLS in the native video player
|
||||||
|
if (!Hls.isSupported()) {
|
||||||
|
console.warn('HLS is not supported - fallback to using video element')
|
||||||
|
this.usingNativeplayer = true
|
||||||
|
this.player.src = this.videoTrack.relativeContentUrl
|
||||||
|
this.player.currentTime = this.startTime
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var hlsOptions = {
|
||||||
|
startPosition: this.startTime || -1
|
||||||
|
// No longer needed because token is put in a query string
|
||||||
|
// xhrSetup: (xhr) => {
|
||||||
|
// xhr.setRequestHeader('Authorization', `Bearer ${this.token}`)
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
this.hlsInstance = new Hls(hlsOptions)
|
||||||
|
|
||||||
|
this.hlsInstance.attachMedia(this.player)
|
||||||
|
this.hlsInstance.on(Hls.Events.MEDIA_ATTACHED, () => {
|
||||||
|
this.hlsInstance.loadSource(this.videoTrack.relativeContentUrl)
|
||||||
|
|
||||||
|
this.hlsInstance.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||||
|
console.log('[HLS] Manifest Parsed')
|
||||||
|
})
|
||||||
|
|
||||||
|
this.hlsInstance.on(Hls.Events.ERROR, (e, data) => {
|
||||||
|
console.error('[HLS] Error', data.type, data.details, data)
|
||||||
|
if (data.details === Hls.ErrorDetails.BUFFER_STALLED_ERROR) {
|
||||||
|
console.error('[HLS] BUFFER STALLED ERROR')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
this.hlsInstance.on(Hls.Events.DESTROYING, () => {
|
||||||
|
console.log('[HLS] Destroying HLS Instance')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
setDirectPlay() {
|
||||||
|
this.player.src = this.videoTrack.relativeContentUrl
|
||||||
|
console.log(`[LocalVideoPlayer] Loading track src ${this.videoTrack.relativeContentUrl}`)
|
||||||
|
this.player.load()
|
||||||
|
}
|
||||||
|
|
||||||
|
destroyHlsInstance() {
|
||||||
|
if (!this.hlsInstance) return
|
||||||
|
if (this.hlsInstance.destroy) {
|
||||||
|
var temp = this.hlsInstance
|
||||||
|
temp.destroy()
|
||||||
|
}
|
||||||
|
this.hlsInstance = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async resetStream(startTime) {
|
||||||
|
this.destroyHlsInstance()
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||||
|
this.set(this.libraryItem, this.videoTrack, this.isHlsTranscode, startTime, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
playPause() {
|
||||||
|
if (!this.player) return
|
||||||
|
if (this.player.paused) this.play()
|
||||||
|
else this.pause()
|
||||||
|
}
|
||||||
|
|
||||||
|
play() {
|
||||||
|
if (this.player) this.player.play()
|
||||||
|
}
|
||||||
|
|
||||||
|
pause() {
|
||||||
|
if (this.player) this.player.pause()
|
||||||
|
}
|
||||||
|
|
||||||
|
getCurrentTime() {
|
||||||
|
return this.player ? this.player.currentTime : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
getDuration() {
|
||||||
|
return this.videoTrack.duration
|
||||||
|
}
|
||||||
|
|
||||||
|
setPlaybackRate(playbackRate) {
|
||||||
|
if (!this.player) return
|
||||||
|
this.defaultPlaybackRate = playbackRate
|
||||||
|
this.player.playbackRate = playbackRate
|
||||||
|
}
|
||||||
|
|
||||||
|
seek(time) {
|
||||||
|
if (!this.player) return
|
||||||
|
this.player.currentTime = Math.max(0, time)
|
||||||
|
}
|
||||||
|
|
||||||
|
setVolume(volume) {
|
||||||
|
if (!this.player) return
|
||||||
|
this.player.volume = volume
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utils
|
||||||
|
isValidDuration(duration) {
|
||||||
|
if (duration && !isNaN(duration) && duration !== Number.POSITIVE_INFINITY && duration !== Number.NEGATIVE_INFINITY) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
getBufferedRanges() {
|
||||||
|
if (!this.player) return []
|
||||||
|
const ranges = []
|
||||||
|
const seekable = this.player.buffered || []
|
||||||
|
|
||||||
|
let offset = 0
|
||||||
|
|
||||||
|
for (let i = 0, length = seekable.length; i < length; i++) {
|
||||||
|
let start = seekable.start(i)
|
||||||
|
let end = seekable.end(i)
|
||||||
|
if (!this.isValidDuration(start)) {
|
||||||
|
start = 0
|
||||||
|
}
|
||||||
|
if (!this.isValidDuration(end)) {
|
||||||
|
end = 0
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
ranges.push({
|
||||||
|
start: start + offset,
|
||||||
|
end: end + offset
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return ranges
|
||||||
|
}
|
||||||
|
|
||||||
|
getLastBufferedTime() {
|
||||||
|
var bufferedRanges = this.getBufferedRanges()
|
||||||
|
if (!bufferedRanges.length) return 0
|
||||||
|
|
||||||
|
var buff = bufferedRanges.find((buff) => buff.start < this.player.currentTime && buff.end > this.player.currentTime)
|
||||||
|
if (buff) return buff.end
|
||||||
|
|
||||||
|
var last = bufferedRanges[bufferedRanges.length - 1]
|
||||||
|
return last.end
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import LocalPlayer from './LocalPlayer'
|
import LocalAudioPlayer from './LocalAudioPlayer'
|
||||||
|
import LocalVideoPlayer from './LocalVideoPlayer'
|
||||||
import CastPlayer from './CastPlayer'
|
import CastPlayer from './CastPlayer'
|
||||||
import AudioTrack from './AudioTrack'
|
import AudioTrack from './AudioTrack'
|
||||||
|
import VideoTrack from './VideoTrack'
|
||||||
|
|
||||||
export default class PlayerHandler {
|
export default class PlayerHandler {
|
||||||
constructor(ctx) {
|
constructor(ctx) {
|
||||||
@@ -14,9 +16,11 @@ export default class PlayerHandler {
|
|||||||
this.player = null
|
this.player = null
|
||||||
this.playerState = 'IDLE'
|
this.playerState = 'IDLE'
|
||||||
this.isHlsTranscode = false
|
this.isHlsTranscode = false
|
||||||
|
this.isVideo = false
|
||||||
this.currentSessionId = null
|
this.currentSessionId = null
|
||||||
this.startTime = 0
|
this.startTime = 0
|
||||||
|
|
||||||
|
this.failedProgressSyncs = 0
|
||||||
this.lastSyncTime = 0
|
this.lastSyncTime = 0
|
||||||
this.lastSyncedAt = 0
|
this.lastSyncedAt = 0
|
||||||
this.listeningTimeSinceSync = 0
|
this.listeningTimeSinceSync = 0
|
||||||
@@ -34,7 +38,7 @@ export default class PlayerHandler {
|
|||||||
return this.libraryItem && (this.player instanceof CastPlayer)
|
return this.libraryItem && (this.player instanceof CastPlayer)
|
||||||
}
|
}
|
||||||
get isPlayingLocalItem() {
|
get isPlayingLocalItem() {
|
||||||
return this.libraryItem && (this.player instanceof LocalPlayer)
|
return this.libraryItem && (this.player instanceof LocalAudioPlayer)
|
||||||
}
|
}
|
||||||
get userToken() {
|
get userToken() {
|
||||||
return this.ctx.$store.getters['user/getToken']
|
return this.ctx.$store.getters['user/getToken']
|
||||||
@@ -48,16 +52,17 @@ export default class PlayerHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
load(libraryItem, episodeId, playWhenReady, playbackRate) {
|
load(libraryItem, episodeId, playWhenReady, playbackRate) {
|
||||||
if (!this.player) this.switchPlayer()
|
|
||||||
|
|
||||||
this.libraryItem = libraryItem
|
this.libraryItem = libraryItem
|
||||||
this.episodeId = episodeId
|
this.episodeId = episodeId
|
||||||
this.playWhenReady = playWhenReady
|
this.playWhenReady = playWhenReady
|
||||||
this.initialPlaybackRate = playbackRate
|
this.initialPlaybackRate = playbackRate
|
||||||
this.prepare()
|
this.isVideo = libraryItem.mediaType === 'video'
|
||||||
|
|
||||||
|
if (!this.player) this.switchPlayer(playWhenReady)
|
||||||
|
else this.prepare()
|
||||||
}
|
}
|
||||||
|
|
||||||
switchPlayer() {
|
switchPlayer(playWhenReady) {
|
||||||
if (this.isCasting && !(this.player instanceof CastPlayer)) {
|
if (this.isCasting && !(this.player instanceof CastPlayer)) {
|
||||||
console.log('[PlayerHandler] Switching to cast player')
|
console.log('[PlayerHandler] Switching to cast player')
|
||||||
|
|
||||||
@@ -73,10 +78,10 @@ export default class PlayerHandler {
|
|||||||
|
|
||||||
if (this.libraryItem) {
|
if (this.libraryItem) {
|
||||||
// libraryItem was already loaded - prepare for cast
|
// libraryItem was already loaded - prepare for cast
|
||||||
this.playWhenReady = false
|
this.playWhenReady = playWhenReady
|
||||||
this.prepare()
|
this.prepare()
|
||||||
}
|
}
|
||||||
} else if (!this.isCasting && !(this.player instanceof LocalPlayer)) {
|
} else if (!this.isCasting && !(this.player instanceof LocalAudioPlayer) && !(this.player instanceof LocalVideoPlayer)) {
|
||||||
console.log('[PlayerHandler] Switching to local player')
|
console.log('[PlayerHandler] Switching to local player')
|
||||||
|
|
||||||
this.stopPlayInterval()
|
this.stopPlayInterval()
|
||||||
@@ -85,12 +90,18 @@ export default class PlayerHandler {
|
|||||||
if (this.player) {
|
if (this.player) {
|
||||||
this.player.destroy()
|
this.player.destroy()
|
||||||
}
|
}
|
||||||
this.player = new LocalPlayer(this.ctx)
|
|
||||||
|
if (this.isVideo) {
|
||||||
|
this.player = new LocalVideoPlayer(this.ctx)
|
||||||
|
} else {
|
||||||
|
this.player = new LocalAudioPlayer(this.ctx)
|
||||||
|
}
|
||||||
|
|
||||||
this.setPlayerListeners()
|
this.setPlayerListeners()
|
||||||
|
|
||||||
if (this.libraryItem) {
|
if (this.libraryItem) {
|
||||||
// libraryItem was already loaded - prepare for local play
|
// libraryItem was already loaded - prepare for local play
|
||||||
this.playWhenReady = false
|
this.playWhenReady = playWhenReady
|
||||||
this.prepare()
|
this.prepare()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -101,16 +112,27 @@ export default class PlayerHandler {
|
|||||||
this.player.on('timeupdate', this.playerTimeupdate.bind(this))
|
this.player.on('timeupdate', this.playerTimeupdate.bind(this))
|
||||||
this.player.on('buffertimeUpdate', this.playerBufferTimeUpdate.bind(this))
|
this.player.on('buffertimeUpdate', this.playerBufferTimeUpdate.bind(this))
|
||||||
this.player.on('error', this.playerError.bind(this))
|
this.player.on('error', this.playerError.bind(this))
|
||||||
|
this.player.on('finished', this.playerFinished.bind(this))
|
||||||
}
|
}
|
||||||
|
|
||||||
playerError() {
|
playerError() {
|
||||||
// Switch to HLS stream on error
|
// Switch to HLS stream on error
|
||||||
if (!this.isCasting && !this.currentStreamId && (this.player instanceof LocalPlayer)) {
|
if (!this.isCasting && !this.currentStreamId && (this.player instanceof LocalAudioPlayer)) {
|
||||||
console.log(`[PlayerHandler] Audio player error switching to HLS stream`)
|
console.log(`[PlayerHandler] Audio player error switching to HLS stream`)
|
||||||
this.prepare(true)
|
this.prepare(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
playerFinished() {
|
||||||
|
this.stopPlayInterval()
|
||||||
|
|
||||||
|
var currentTime = this.player.getCurrentTime()
|
||||||
|
this.ctx.setCurrentTime(currentTime)
|
||||||
|
|
||||||
|
// TODO: Add listening time between last sync and now?
|
||||||
|
this.sendProgressSync(currentTime)
|
||||||
|
}
|
||||||
|
|
||||||
playerStateChange(state) {
|
playerStateChange(state) {
|
||||||
console.log('[PlayerHandler] Player state change', state)
|
console.log('[PlayerHandler] Player state change', state)
|
||||||
this.playerState = state
|
this.playerState = state
|
||||||
@@ -144,7 +166,7 @@ export default class PlayerHandler {
|
|||||||
supportedMimeTypes: this.player.playableMimeTypes,
|
supportedMimeTypes: this.player.playableMimeTypes,
|
||||||
mediaPlayer: this.isCasting ? 'chromecast' : 'html5',
|
mediaPlayer: this.isCasting ? 'chromecast' : 'html5',
|
||||||
forceTranscode,
|
forceTranscode,
|
||||||
forceDirectPlay: this.isCasting // TODO: add transcode support for chromecast
|
forceDirectPlay: this.isCasting || this.isVideo // TODO: add transcode support for chromecast
|
||||||
}
|
}
|
||||||
|
|
||||||
var path = this.episodeId ? `/api/items/${this.libraryItem.id}/play/${this.episodeId}` : `/api/items/${this.libraryItem.id}/play`
|
var path = this.episodeId ? `/api/items/${this.libraryItem.id}/play/${this.episodeId}` : `/api/items/${this.libraryItem.id}/play`
|
||||||
@@ -155,31 +177,47 @@ export default class PlayerHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
prepareOpenSession(session, playbackRate) { // Session opened on init socket
|
prepareOpenSession(session, playbackRate) { // Session opened on init socket
|
||||||
if (!this.player) this.switchPlayer()
|
if (!this.player) this.switchPlayer() // Must set player first for open sessions
|
||||||
|
|
||||||
this.libraryItem = session.libraryItem
|
this.libraryItem = session.libraryItem
|
||||||
|
this.isVideo = session.libraryItem.mediaType === 'video'
|
||||||
this.playWhenReady = false
|
this.playWhenReady = false
|
||||||
this.initialPlaybackRate = playbackRate
|
this.initialPlaybackRate = playbackRate
|
||||||
|
|
||||||
this.prepareSession(session)
|
this.prepareSession(session)
|
||||||
}
|
}
|
||||||
|
|
||||||
prepareSession(session) {
|
prepareSession(session) {
|
||||||
|
this.failedProgressSyncs = 0
|
||||||
this.startTime = session.currentTime
|
this.startTime = session.currentTime
|
||||||
this.currentSessionId = session.id
|
this.currentSessionId = session.id
|
||||||
this.displayTitle = session.displayTitle
|
this.displayTitle = session.displayTitle
|
||||||
this.displayAuthor = session.displayAuthor
|
this.displayAuthor = session.displayAuthor
|
||||||
|
|
||||||
console.log('[PlayerHandler] Preparing Session', session)
|
console.log('[PlayerHandler] Preparing Session', session)
|
||||||
var audioTracks = session.audioTracks.map(at => new AudioTrack(at, this.userToken))
|
|
||||||
|
|
||||||
this.ctx.playerLoading = true
|
if (session.videoTrack) {
|
||||||
this.isHlsTranscode = true
|
var videoTrack = new VideoTrack(session.videoTrack, this.userToken)
|
||||||
if (session.playMethod === this.ctx.$constants.PlayMethod.DIRECTPLAY) {
|
|
||||||
this.isHlsTranscode = false
|
this.ctx.playerLoading = true
|
||||||
|
this.isHlsTranscode = true
|
||||||
|
if (session.playMethod === this.ctx.$constants.PlayMethod.DIRECTPLAY) {
|
||||||
|
this.isHlsTranscode = false
|
||||||
|
}
|
||||||
|
|
||||||
|
this.player.set(this.libraryItem, videoTrack, this.isHlsTranscode, this.startTime, this.playWhenReady)
|
||||||
|
} else {
|
||||||
|
var audioTracks = session.audioTracks.map(at => new AudioTrack(at, this.userToken))
|
||||||
|
|
||||||
|
this.ctx.playerLoading = true
|
||||||
|
this.isHlsTranscode = true
|
||||||
|
if (session.playMethod === this.ctx.$constants.PlayMethod.DIRECTPLAY) {
|
||||||
|
this.isHlsTranscode = false
|
||||||
|
}
|
||||||
|
|
||||||
|
this.player.set(this.libraryItem, audioTracks, this.isHlsTranscode, this.startTime, this.playWhenReady)
|
||||||
}
|
}
|
||||||
|
|
||||||
this.player.set(this.libraryItem, audioTracks, this.isHlsTranscode, this.startTime, this.playWhenReady)
|
|
||||||
|
|
||||||
// browser media session api
|
// browser media session api
|
||||||
this.ctx.setMediaSession()
|
this.ctx.setMediaSession()
|
||||||
}
|
}
|
||||||
@@ -251,8 +289,15 @@ export default class PlayerHandler {
|
|||||||
currentTime
|
currentTime
|
||||||
}
|
}
|
||||||
this.listeningTimeSinceSync = 0
|
this.listeningTimeSinceSync = 0
|
||||||
this.ctx.$axios.$post(`/api/session/${this.currentSessionId}/sync`, syncData, { timeout: 1000 }).catch((error) => {
|
this.ctx.$axios.$post(`/api/session/${this.currentSessionId}/sync`, syncData, { timeout: 1000 }).then(() => {
|
||||||
|
this.failedProgressSyncs = 0
|
||||||
|
}).catch((error) => {
|
||||||
console.error('Failed to update session progress', error)
|
console.error('Failed to update session progress', error)
|
||||||
|
this.failedProgressSyncs++
|
||||||
|
if (this.failedProgressSyncs >= 2) {
|
||||||
|
this.ctx.showFailedProgressSyncs()
|
||||||
|
this.failedProgressSyncs = 0
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
export default class VideoTrack {
|
||||||
|
constructor(track, userToken) {
|
||||||
|
this.index = track.index || 0
|
||||||
|
this.startOffset = track.startOffset || 0 // Total time of all previous tracks
|
||||||
|
this.duration = track.duration || 0
|
||||||
|
this.title = track.title || ''
|
||||||
|
this.contentUrl = track.contentUrl || null
|
||||||
|
this.mimeType = track.mimeType
|
||||||
|
this.metadata = track.metadata || {}
|
||||||
|
|
||||||
|
this.userToken = userToken
|
||||||
|
}
|
||||||
|
|
||||||
|
get fullContentUrl() {
|
||||||
|
if (!this.contentUrl || this.contentUrl.startsWith('http')) return this.contentUrl
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
return `${process.env.serverUrl}${this.contentUrl}?token=${this.userToken}`
|
||||||
|
}
|
||||||
|
return `${window.location.origin}${this.contentUrl}?token=${this.userToken}`
|
||||||
|
}
|
||||||
|
|
||||||
|
get relativeContentUrl() {
|
||||||
|
if (!this.contentUrl || this.contentUrl.startsWith('http')) return this.contentUrl
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
return `${process.env.serverUrl}${this.contentUrl}?token=${this.userToken}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.contentUrl + `?token=${this.userToken}`
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ export default (ctx) => {
|
|||||||
var castContext = cast.framework.CastContext.getInstance()
|
var castContext = cast.framework.CastContext.getInstance()
|
||||||
castContext.setOptions({
|
castContext.setOptions({
|
||||||
receiverApplicationId: process.env.chromecastReceiver,
|
receiverApplicationId: process.env.chromecastReceiver,
|
||||||
autoJoinPolicy: chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED
|
autoJoinPolicy: chrome.cast ? chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED : null
|
||||||
});
|
});
|
||||||
|
|
||||||
castContext.addEventListener(
|
castContext.addEventListener(
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
const SupportedFileTypes = {
|
const SupportedFileTypes = {
|
||||||
image: ['png', 'jpg', 'jpeg', 'webp'],
|
image: ['png', 'jpg', 'jpeg', 'webp'],
|
||||||
audio: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'mp4', 'aac', 'wma', 'aiff'],
|
audio: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'oga', 'mp4', 'aac', 'wma', 'aiff', 'wav'],
|
||||||
ebook: ['epub', 'pdf', 'mobi', 'azw3', 'cbr', 'cbz'],
|
ebook: ['epub', 'pdf', 'mobi', 'azw3', 'cbr', 'cbz'],
|
||||||
info: ['nfo'],
|
info: ['nfo'],
|
||||||
text: ['txt'],
|
text: ['txt'],
|
||||||
@@ -28,7 +28,8 @@ const BookshelfView = {
|
|||||||
const PlayMethod = {
|
const PlayMethod = {
|
||||||
DIRECTPLAY: 0,
|
DIRECTPLAY: 0,
|
||||||
DIRECTSTREAM: 1,
|
DIRECTSTREAM: 1,
|
||||||
TRANSCODE: 2
|
TRANSCODE: 2,
|
||||||
|
LOCAL: 3
|
||||||
}
|
}
|
||||||
|
|
||||||
const Constants = {
|
const Constants = {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Vue from 'vue'
|
import Vue from 'vue'
|
||||||
|
import Path from 'path'
|
||||||
import vClickOutside from 'v-click-outside'
|
import vClickOutside from 'v-click-outside'
|
||||||
import { formatDistance, format, addDays, isDate } from 'date-fns'
|
import { formatDistance, format, addDays, isDate } from 'date-fns'
|
||||||
|
|
||||||
@@ -57,6 +58,7 @@ Vue.prototype.$elapsedPretty = (seconds, useFullNames = false) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Vue.prototype.$secondsToTimestamp = (seconds) => {
|
Vue.prototype.$secondsToTimestamp = (seconds) => {
|
||||||
|
if (!seconds) return '0:00'
|
||||||
var _seconds = seconds
|
var _seconds = seconds
|
||||||
var _minutes = Math.floor(seconds / 60)
|
var _minutes = Math.floor(seconds / 60)
|
||||||
_seconds -= _minutes * 60
|
_seconds -= _minutes * 60
|
||||||
@@ -118,20 +120,36 @@ Vue.prototype.$sanitizeFilename = (input, colonReplacement = ' - ') => {
|
|||||||
if (typeof input !== 'string') {
|
if (typeof input !== 'string') {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Max is actually 255-260 for windows but this leaves padding incase ext wasnt put on yet
|
||||||
|
const MAX_FILENAME_LEN = 240
|
||||||
|
|
||||||
var replacement = ''
|
var replacement = ''
|
||||||
var illegalRe = /[\/\?<>\\:\*\|"]/g;
|
var illegalRe = /[\/\?<>\\:\*\|"]/g
|
||||||
var controlRe = /[\x00-\x1f\x80-\x9f]/g;
|
var controlRe = /[\x00-\x1f\x80-\x9f]/g
|
||||||
var reservedRe = /^\.+$/;
|
var reservedRe = /^\.+$/
|
||||||
var windowsReservedRe = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i;
|
var windowsReservedRe = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i
|
||||||
var windowsTrailingRe = /[\. ]+$/;
|
var windowsTrailingRe = /[\. ]+$/
|
||||||
|
var lineBreaks = /[\n\r]/g
|
||||||
|
|
||||||
var sanitized = input
|
var sanitized = input
|
||||||
.replace(':', colonReplacement) // Replace first occurrence of a colon
|
.replace(':', colonReplacement) // Replace first occurrence of a colon
|
||||||
.replace(illegalRe, replacement)
|
.replace(illegalRe, replacement)
|
||||||
.replace(controlRe, replacement)
|
.replace(controlRe, replacement)
|
||||||
.replace(reservedRe, replacement)
|
.replace(reservedRe, replacement)
|
||||||
|
.replace(lineBreaks, replacement)
|
||||||
.replace(windowsReservedRe, replacement)
|
.replace(windowsReservedRe, replacement)
|
||||||
.replace(windowsTrailingRe, replacement);
|
.replace(windowsTrailingRe, replacement)
|
||||||
|
|
||||||
|
|
||||||
|
if (sanitized.length > MAX_FILENAME_LEN) {
|
||||||
|
var lenToRemove = sanitized.length - MAX_FILENAME_LEN
|
||||||
|
var ext = Path.extname(sanitized)
|
||||||
|
var basename = Path.basename(sanitized, ext)
|
||||||
|
basename = basename.slice(0, basename.length - lenToRemove)
|
||||||
|
sanitized = basename + ext
|
||||||
|
}
|
||||||
|
|
||||||
return sanitized
|
return sanitized
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,7 +204,6 @@ Vue.prototype.$copyToClipboard = (str, ctx) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function xmlToJson(xml) {
|
function xmlToJson(xml) {
|
||||||
const json = {};
|
const json = {};
|
||||||
for (const res of xml.matchAll(/(?:<(\w*)(?:\s[^>]*)*>)((?:(?!<\1).)*)(?:<\/\1>)|<(\w*)(?:\s*)*\/>/gm)) {
|
for (const res of xml.matchAll(/(?:<(\w*)(?:\s[^>]*)*>)((?:(?!<\1).)*)(?:<\/\1>)|<(\w*)(?:\s*)*\/>/gm)) {
|
||||||
|
|||||||
@@ -23,14 +23,17 @@ function parseSemver(ver) {
|
|||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const currentVersion = packagejson.version
|
||||||
|
|
||||||
export async function checkForUpdate() {
|
export async function checkForUpdate() {
|
||||||
if (!packagejson.version) {
|
if (!packagejson.version) {
|
||||||
return
|
return null
|
||||||
}
|
}
|
||||||
var currVerObj = parseSemver('v' + packagejson.version)
|
var currVerObj = parseSemver('v' + packagejson.version)
|
||||||
if (!currVerObj) {
|
if (!currVerObj) {
|
||||||
console.error('Invalid version', packagejson.version)
|
console.error('Invalid version', packagejson.version)
|
||||||
return
|
return null
|
||||||
}
|
}
|
||||||
var largestVer = null
|
var largestVer = null
|
||||||
await axios.get(`https://api.github.com/repos/advplyr/audiobookshelf/releases`).then((res) => {
|
await axios.get(`https://api.github.com/repos/advplyr/audiobookshelf/releases`).then((res) => {
|
||||||
@@ -44,18 +47,24 @@ export async function checkForUpdate() {
|
|||||||
largestVer = verObj
|
largestVer = verObj
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (verObj.version == currVerObj.version) {
|
||||||
|
currVerObj.changelog = release.body
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
})
|
})
|
||||||
if (!largestVer) {
|
if (!largestVer) {
|
||||||
console.error('No valid version tags to compare with')
|
console.error('No valid version tags to compare with')
|
||||||
return
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hasUpdate: largestVer.total > currVerObj.total,
|
hasUpdate: largestVer.total > currVerObj.total,
|
||||||
latestVersion: largestVer.version,
|
latestVersion: largestVer.version,
|
||||||
githubTagUrl: `https://github.com/advplyr/audiobookshelf/releases/tag/v${largestVer.version}`,
|
githubTagUrl: `https://github.com/advplyr/audiobookshelf/releases/tag/v${largestVer.version}`,
|
||||||
currentVersion: currVerObj.version
|
currentVersion: currVerObj.version,
|
||||||
|
currentVersionChangelog: currVerObj.changelog
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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.
|
||||||
File diff suppressed because one or more lines are too long
Binary file not shown.
|
Before Width: | Height: | Size: 57 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 141 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 209 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 512 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 132 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user