Compare commits

..

108 Commits

Author SHA1 Message Date
advplyr
1bd657f07d Fix:Mark as finished once media has ended #635 2022-06-02 16:31:52 -05:00
advplyr
c3b33ea37a Fix:Sanitize filename to remove line breaks and check filename length is not too long #663 2022-06-01 20:14:10 -05:00
advplyr
36bd6e649a Fix:Remove podcast episode to also remove library file #636 2022-06-01 17:45:52 -05:00
advplyr
4621c78573 Update:Show version number in bottom of siderail #660 and save previous version data to continue showing if update is available 2022-06-01 17:15:13 -05:00
advplyr
c88bbf1ce4 Fix:Authors landing page available on refresh #659 2022-06-01 16:29:29 -05:00
advplyr
d37b25a6f6 Update audio player to player ui and separate out components 2022-05-31 20:13:46 -05:00
advplyr
792268f5ee Merge branch 'master' into video 2022-05-31 18:53:30 -05:00
advplyr
5f2d6f4d5e Add:Support for wav #652 2022-05-31 18:45:40 -05:00
advplyr
acf22ca4fa Testing video media type 2022-05-30 19:26:53 -05:00
advplyr
705aac40d7 Remove experimental set bookshelf texture 2022-05-30 09:58:02 -05:00
advplyr
7456052620 Fix:Match update cover image #648 2022-05-30 09:52:42 -05:00
advplyr
6cd4ec7fce Version bump v2.0.18 2022-05-29 13:18:31 -05:00
advplyr
93b8e11378 Fix:Mark media as finished if less than 5 seconds remain on a sync and call progress sync again when last track ends #635 2022-05-29 12:55:14 -05:00
advplyr
6161daeef0 Fix:OPML file upload reset 2022-05-29 12:22:16 -05:00
advplyr
cfcd351570 Add:Match All Authors button #642 2022-05-29 12:15:39 -05:00
advplyr
514893646a Add:OPML Upload for bulk adding podcasts #588 2022-05-29 11:46:45 -05:00
advplyr
e5469cc0f8 Update:Podcast library items do not show incomplete error when it doesnt have audio files #636 2022-05-29 07:25:30 -05:00
advplyr
ec6e70725c Fix:Include Watcher as lib with no dependencies and fix tiny-readdir bug #610 2022-05-28 20:01:20 -05:00
advplyr
160dac109d Add:User permission restrict explicit content #637 2022-05-28 16:53:03 -05:00
advplyr
6be741045f Merge pull request #622 from kaldigo/master
Updated matching
2022-05-28 15:56:27 -05:00
advplyr
f41d6d5c77 Update multi-series edit for match and make into separate component with inner modal 2022-05-28 15:54:04 -05:00
advplyr
a5dacd7821 Merge master 2022-05-28 13:58:52 -05:00
advplyr
8b12508b0c Add:Rich text editor for podcast episode description 2022-05-28 13:36:58 -05:00
advplyr
a394f38fe9 Add:Full podcast episode description parsed and viewable in modal #492 2022-05-28 11:38:51 -05:00
advplyr
c4bfa266b0 Add:HTML sanitizer lib to support html in podcasts and replace strip html lib 2022-05-27 19:41:40 -05:00
advplyr
96232676cb Fix:Save RSS feed url passed in by user instead of using the RSS feed returned from the request #634 2022-05-27 17:50:56 -05:00
advplyr
b2aab06e01 Add:Listening session modal with all details 2022-05-27 17:39:24 -05:00
advplyr
f002532c1e Add:User listening sessions page, Update:Listening sessions to save media times and device info 2022-05-26 19:09:46 -05:00
advplyr
54663f0f01 Fix:Listening stats on users page and user listening-sessions api endpoint 2022-05-26 15:10:12 -05:00
advplyr
d8df9a9dff Update dockerfile for generating client 2022-05-25 10:26:21 -05:00
advplyr
68efd30a54 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-05-24 21:44:15 -05:00
advplyr
27407d49dd Version bump 2.0.17 2022-05-24 21:44:11 -05:00
advplyr
97d4330cda Merge pull request #632 from cassieesposito/getBookDataFromDir-refactor
URGENT: Fix to serious bug
2022-05-24 21:33:40 -05:00
Cassie Esposito
3153bdc5bb Fixed bug that caused scanner to fail to get title when subtitle parsing is off, refactored possibly confusing variable declarations. 2022-05-24 19:04:51 -07:00
Cassie Esposito
31fd75a895 Merge branch 'getBookDataFromDir-refactor' of github.com:cassieesposito/audiobookshelf into getBookDataFromDir-refactor 2022-05-24 18:43:20 -07:00
Cassie Esposito
b22173a631 Undoing changes caused by linter run amok 2022-05-24 17:30:16 -07:00
advplyr
d2e012d7b1 Version bump 2.0.16 2022-05-24 19:19:16 -05:00
advplyr
d4fe0be386 Merge pull request #631 from cassieesposito/getBookDataFromDir-refactor
Get book data from dir refactor
2022-05-24 19:17:42 -05:00
Cassie Esposito
6d947bbc29 Converted indentation from 4 spaces to 2 2022-05-24 17:06:44 -07:00
advplyr
5187d0e55f Add:Option to hard delete podcast episode from file system #488 2022-05-24 18:38:25 -05:00
Cassie Esposito
c6253e4fd4 Merge branch 'getBookDataFromDir-refactor' of github.com:cassieesposito/audiobookshelf into getBookDataFromDir-refactor 2022-05-24 16:26:59 -07:00
Cassie Esposito
1ab933c8b0 Refactored getSequence. Slight behavior changes introduced.
All components of the bottom level directory
except volume which can no longer use '-' for separation, but 'Vol 4 Title' is still valid
and '4. Title' or 'Vol 4.' are also now valid.
2022-05-24 16:24:10 -07:00
Cassie Esposito
e2e5dd372a Merge branch 'master' into getBookDataFromDir-refactor 2022-05-24 12:56:10 -07:00
Kaldigo
aeb87c81a1 Fix missed preferMatchedMetadata rename 2022-05-24 01:29:43 +01:00
advplyr
3e98b6f749 Update:Remove manual sorting of podcast episodes and default to sort by published at 2022-05-23 19:28:00 -05:00
advplyr
3c465994fe Fix:Hide remove icon from author images with no image 2022-05-23 19:12:40 -05:00
advplyr
6cfe583535 Fix:Static router for downloading single file library items #627 2022-05-23 18:31:11 -05:00
advplyr
0ad7a98fc7 Add:Support for single book files to be detected by Watcher #610, Fix:Single media file in library folder root is only supported for books not podcasts 2022-05-23 18:15:15 -05:00
Kaldigo
ce88ebb55b Removed response groups in Audible query and limited to 10 results 2022-05-23 22:48:11 +01:00
Kaldigo
c7e3f08d39 Merge branch 'advplyr:master' into master 2022-05-23 22:46:17 +01:00
Kaldigo
d15264832d Updated matching with latest changes, Added override toggle for quickmatch, added asin and isbn to quickmatch query, updated audible provider to use audnexus 2022-05-23 03:56:51 +01:00
advplyr
a8d5b543d7 Update:Parsing sequence from folder will strip leading zeros #562 2022-05-22 19:17:21 -05:00
advplyr
f2e16017f6 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-05-22 08:14:42 -05:00
advplyr
4d227cbade Add copy to clipboard fallback 2022-05-22 08:05:39 -05:00
advplyr
15a85299b9 Merge pull request #612 from cassieesposito/scan-for-narrator
Scan for narrator
2022-05-21 12:04:06 -05:00
advplyr
d22e9e32ed Remove dev dependency from package.json 2022-05-21 11:36:08 -05:00
advplyr
8beac53f5f Update:Send source back with auth request 2022-05-21 11:21:03 -05:00
Cassie Esposito
cbad435690 Merge branch 'advplyr:master' into scan-for-narrator 2022-05-21 08:07:42 -07:00
Cassie Esposito
169b637720 Removed dependency erroniously added by IDE 2022-05-21 08:06:06 -07:00
advplyr
f083d4b5f6 Update dockerfile failing with dev dependency 2022-05-20 18:15:54 -05:00
Cassie Esposito
3451a312e9 Merge branch 'advplyr:master' into getBookDataFromDir-refactor 2022-05-20 15:45:10 -07:00
Cassie Esposito
927c1a3514 Merge branch 'advplyr:master' into scan-for-narrator 2022-05-20 15:40:26 -07:00
advplyr
dabcad5ebd Update experimental e-reader alert 2022-05-20 16:47:00 -05:00
advplyr
796602d1b2 Add:Enable e-reader server setting to allow all users to access experimental e-reader #614 2022-05-20 16:34:51 -05:00
advplyr
302870a101 Fix:Continue series shelf show next book in series #608 2022-05-20 15:55:03 -05:00
advplyr
3954aa1963 Merge pull request #611 from cassieesposito/npm-watch
Added support for npm run watch
2022-05-20 09:31:37 -05:00
Cassie Esposito
2d8c840ad6 Cleaned up function getSequence, became more forgiving of whitespace around metadata elements 2022-05-20 01:03:36 -07:00
Cassie Esposito
f1f02b185e Cleaned function getPublishedYear 2022-05-19 22:55:00 -07:00
Cassie Esposito
13d21e90f8 Cleaned function getSubtitle 2022-05-19 22:31:55 -07:00
Cassie Esposito
dd664da871 Separated individual element parsing functions out of function getBookDataFromDir 2022-05-19 22:10:53 -07:00
Cassie Esposito
6ff66370fe Use {} instead of [] for narrators tag. Removed logging left over from debugging. 2022-05-19 21:07:04 -07:00
Cassie Esposito
23904d57ad Narrator data is sucessfully saved from folder name. 2022-05-19 20:59:59 -07:00
Cassie Esposito
efdb43e2d2 Merge branch 'advplyr:master' into scan-for-narrator 2022-05-19 19:45:35 -07:00
Cassie Esposito
67523095d6 Narrators successfully isolated from path. Next steps: parse multiple narrators and save to disk 2022-05-19 19:42:45 -07:00
Cassie Esposito
e2d869bb19 Added support for npm run watch to automatically reset server during development. 2022-05-19 19:10:51 -07:00
advplyr
d38e9499db Version bump 2.0.15 2022-05-19 19:58:09 -05:00
advplyr
c7429efe95 Update:Debian install script to create directories if they dont exist and set ownership #503 2022-05-19 19:51:27 -05:00
advplyr
b925dbcc95 Fix:Updating library folder paths will only set file permissions if it needed to create the folder #529 2022-05-19 19:00:34 -05:00
advplyr
2a235b8324 Add:RSS feeds for audiobooks #606 2022-05-19 18:51:58 -05:00
advplyr
06cc2a1b21 Fix:Increase width of MoreMenu since Mark as Not Finished was wrapping 2022-05-19 18:11:02 -05:00
advplyr
4bcca97b1f Update:Home page continue series shelf to use first unplayed book (instead of next book after most recently played) #608 2022-05-19 18:09:26 -05:00
advplyr
313b9026f1 Add:Authors page links to filter by author books and link to series page #609 2022-05-19 17:42:34 -05:00
advplyr
139ee013a7 Add:Parsing tags from OPF metadata #602, Update:OPF parser to check for prefix on package/metadata/meta objects 2022-05-18 19:25:18 -05:00
advplyr
7e5ab477b2 Update:Persist scroll position for bookshelves #604 2022-05-18 18:37:38 -05:00
advplyr
eba37c46cb Update:Confirmation when clicking force re-scan #591 2022-05-18 16:36:54 -05:00
advplyr
228d9cc301 Fix:Library scan toasts 2022-05-18 16:33:24 -05:00
advplyr
85946dd1d5 Update build-win add GZip 2022-05-17 19:57:50 -05:00
advplyr
b40598593d Merge pull request #593 from BCNelson/master
Add devcontainer
2022-05-17 19:18:47 -05:00
advplyr
e918a46d09 Fix:Siderail margin on mobile and tablets 2022-05-15 15:51:30 -05:00
advplyr
8061ee29d5 Add:Media session controls and metadata 2022-05-15 15:48:41 -05:00
Bradley Nelson
e15e04f085 Add dev container 2022-05-15 14:24:24 -06:00
advplyr
958d68ffa9 Update readme remove default username and password 2022-05-15 13:47:53 -05:00
advplyr
c8a743ccc1 Version bump 2.0.14 2022-05-15 12:53:41 -05:00
advplyr
09dc95f560 Fix:Create cache dirs on server init 2022-05-15 11:19:04 -05:00
advplyr
853858825b Fix:File permissions on cache dirs and cache images, Fix:Db delete read stream closing before write stream resulting in deletes sometimes not happening 2022-05-15 09:51:08 -05:00
advplyr
c962090c3a Update:No longer creating initial root user and initial library, add init root user page, web app works with no libraries 2022-05-14 17:23:22 -05:00
advplyr
63a8e2433e Fix:Manage and chapters item page available on refresh 2022-05-14 13:08:56 -05:00
advplyr
f78d287b59 Update:Matching authors uses the ASIN if set #572, Fix:Purge author image cache when updating author 2022-05-13 18:11:54 -05:00
advplyr
eaa383b6d8 Update:Show siderail on all pages not just library pages 2022-05-13 17:40:43 -05:00
advplyr
113026ce13 Fix:Sanitize new podcast folder names and ensure feedUrl is in feed metadata #589 2022-05-13 17:13:58 -05:00
advplyr
578a946ca5 Fix:Update changes to filterdata (authors, narrators, genres, tags, languages, series) 2022-05-13 16:51:54 -05:00
advplyr
f31306eda0 Fix:Realtime updates on book cards when changing series sequence #590 2022-05-13 16:26:34 -05:00
advplyr
c62b716a2c Fix:Duplicate keys on episode slider 2022-05-13 10:39:33 -05:00
advplyr
97ed20c683 Merge pull request #586 from jflattery/main
Fix for unsorted feeds, update npm, add docker tag
2022-05-12 17:36:51 -05:00
jflattery
d5c46dcbfb Dedupe packages 2022-05-12 21:14:36 +00:00
jflattery
30934edd57 Add tag
Addressing issues found on discord where users are not getting latest image
2022-05-12 21:01:47 +00:00
jflattery
d06fd1a1b1 Update npm packages 2022-05-12 20:58:30 +00:00
jflattery
6bb36381f1 Fix for unsorted feeds
Fix for Podcasts such as Beers with Talos who don't publish their feed in a chronological order
2022-05-12 20:26:21 +00:00
195 changed files with 9827 additions and 3951 deletions

4
.devcontainer/Dockerfile Normal file
View File

@@ -0,0 +1,4 @@
FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:16
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
&& apt-get install ffmpeg gnupg2 -y
ENV NODE_ENV=development

View File

@@ -0,0 +1,12 @@
{
"build": { "dockerfile": "Dockerfile" },
"mounts": [
"source=abs-node-node_modules,target=${containerWorkspaceFolder}/node_modules,type=volume"
],
"features": {
"fish": "latest"
},
"extensions": [
"eamodio.gitlens"
]
}

View File

@@ -2,7 +2,7 @@
FROM node:16-alpine AS build
WORKDIR /client
COPY /client /client
RUN npm install
RUN npm ci && npm cache clean --force
RUN npm run generate
### STAGE 1: Build server ###
@@ -14,6 +14,6 @@ COPY index.js index.js
COPY package-lock.json package-lock.json
COPY package.json package.json
COPY server server
RUN npm ci --production
RUN npm ci --only=production
EXPOSE 80
CMD ["npm", "start"]

View File

@@ -2,49 +2,11 @@
set -e
set -o pipefail
FFMPEG_INSTALL_DIR="/usr/lib/audiobookshelf-ffmpeg/"
ABS_LOG_DIR="/var/log/audiobookshelf"
declare -r init_type='auto'
declare -ri no_rebuild='0'
add_user() {
: "${1:?'User was not defined'}"
declare -r user="$1"
declare -r uid="$2"
if [ -z "$uid" ]; then
declare -r uid_flags=""
else
declare -r uid_flags="--uid $uid"
fi
declare -r group="${3:-$user}"
declare -r descr="${4:-No description}"
declare -r shell="${5:-/bin/false}"
if ! getent passwd | grep -q "^$user:"; then
echo "Creating system user: $user in $group with $descr and shell $shell"
useradd $uid_flags --gid $group --no-create-home --system --shell $shell -c "$descr" $user
fi
}
add_group() {
: "${1:?'Group was not defined'}"
declare -r group="$1"
declare -r gid="$2"
if [ -z "$gid" ]; then
declare -r gid_flags=""
else
declare -r gid_flags="--gid $gid"
fi
if ! getent group | grep -q "^$group:" ; then
echo "Creating system group: $group"
groupadd $gid_flags --system $group
fi
}
start_service () {
: "${1:?'Service name was not defined'}"
declare -r service_name="$1"
@@ -76,13 +38,10 @@ start_service () {
fi
}
add_group 'audiobookshelf' ''
add_user 'audiobookshelf' '' 'audiobookshelf' 'audiobookshelf user-daemon' '/bin/false'
mkdir -p '/var/log/audiobookshelf'
chown -R 'audiobookshelf:audiobookshelf' '/var/log/audiobookshelf'
chown -R 'audiobookshelf:audiobookshelf' '/usr/share/audiobookshelf'
chown -R 'audiobookshelf:audiobookshelf' "$FFMPEG_INSTALL_DIR"
# Create log directory if not there and set ownership
if [ ! -d "$ABS_LOG_DIR" ]; then
mkdir -p "$ABS_LOG_DIR"
chown -R 'audiobookshelf:audiobookshelf' "$ABS_LOG_DIR"
fi
start_service 'audiobookshelf'

View File

@@ -2,13 +2,51 @@
set -e
set -o pipefail
FFMPEG_INSTALL_DIR="/usr/lib/audiobookshelf-ffmpeg/"
DEFAULT_AUDIOBOOK_PATH="/usr/share/audiobookshelf/audiobooks"
DEFAULT_DATA_PATH="/usr/share/audiobookshelf"
FFMPEG_INSTALL_DIR="/usr/lib/audiobookshelf-ffmpeg"
DEFAULT_DATA_DIR="/usr/share/audiobookshelf"
CONFIG_PATH="/etc/default/audiobookshelf"
DEFAULT_PORT=7331
DEFAULT_HOST="0.0.0.0"
CONFIG_PATH="/etc/default/audiobookshelf"
add_user() {
: "${1:?'User was not defined'}"
declare -r user="$1"
declare -r uid="$2"
if [ -z "$uid" ]; then
declare -r uid_flags=""
else
declare -r uid_flags="--uid $uid"
fi
declare -r group="${3:-$user}"
declare -r descr="${4:-No description}"
declare -r shell="${5:-/bin/false}"
if ! getent passwd | grep -q "^$user:"; then
echo "Creating system user: $user in $group with $descr and shell $shell"
useradd $uid_flags --gid $group --no-create-home --system --shell $shell -c "$descr" $user
fi
}
add_group() {
: "${1:?'Group was not defined'}"
declare -r group="$1"
declare -r gid="$2"
if [ -z "$gid" ]; then
declare -r gid_flags=""
else
declare -r gid_flags="--gid $gid"
fi
if ! getent group | grep -q "^$group:" ; then
echo "Creating system group: $group"
groupadd $gid_flags --system $group
fi
}
install_ffmpeg() {
echo "Starting FFMPEG Install"
@@ -16,8 +54,9 @@ install_ffmpeg() {
WGET="wget https://johnvansickle.com/ffmpeg/builds/ffmpeg-git-amd64-static.tar.xz"
if ! cd "$FFMPEG_INSTALL_DIR"; then
echo "WARNING: can't access working directory ($FFMPEG_INSTALL_DIR) creating it" >&2
echo "Creating ffmpeg install dir at $FFMPEG_INSTALL_DIR"
mkdir "$FFMPEG_INSTALL_DIR"
chown -R 'audiobookshelf:audiobookshelf' "$FFMPEG_INSTALL_DIR"
cd "$FFMPEG_INSTALL_DIR"
fi
@@ -28,83 +67,23 @@ install_ffmpeg() {
echo "Good to go on Ffmpeg... hopefully"
}
should_build_config() {
if [ -f "$CONFIG_PATH" ]; then
echo "You already have a config file. Do you want to use it?"
options=("Yes" "No")
select yn in "${options[@]}"
do
case $yn in
"Yes")
false; return
;;
"No")
true; return
;;
esac
done
else
echo "No existing config found in $CONFIG_PATH"
true; return
fi
}
setup_config_interactive() {
if should_build_config; then
echo "Okay, let's setup a new config."
AUDIOBOOK_PATH=""
read -p "
Enter path for your audiobooks [Default: $DEFAULT_AUDIOBOOK_PATH]:" AUDIOBOOK_PATH
if [[ -z "$AUDIOBOOK_PATH" ]]; then
AUDIOBOOK_PATH="$DEFAULT_AUDIOBOOK_PATH"
fi
DATA_PATH=""
read -p "
Enter path for data files, i.e. streams, downloads, database [Default: $DEFAULT_DATA_PATH]:" DATA_PATH
if [[ -z "$DATA_PATH" ]]; then
DATA_PATH="$DEFAULT_DATA_PATH"
fi
PORT=""
read -p "
Port for the web ui [Default: $DEFAULT_PORT]:" PORT
if [[ -z "$PORT" ]]; then
PORT="$DEFAULT_PORT"
fi
config_text="AUDIOBOOK_PATH=$AUDIOBOOK_PATH
METADATA_PATH=$DATA_PATH/metadata
CONFIG_PATH=$DATA_PATH/config
FFMPEG_PATH=/usr/lib/audiobookshelf-ffmpeg/ffmpeg
FFPROBE_PATH=/usr/lib/audiobookshelf-ffmpeg/ffprobe
PORT=$PORT
HOST=$DEFAULT_HOST"
echo "$config_text"
echo "$config_text" > /etc/default/audiobookshelf;
echo "Config created"
fi
}
setup_config() {
if [ -f "$CONFIG_PATH" ]; then
echo "Existing config found."
cat $CONFIG_PATH
else
if [ ! -d "$DEFAULT_DATA_DIR" ]; then
# Create directory and set permissions
echo "Creating default data dir at $DEFAULT_DATA_DIR"
mkdir "$DEFAULT_DATA_DIR"
chown -R 'audiobookshelf:audiobookshelf' "$DEFAULT_DATA_DIR"
fi
echo "Creating default config."
config_text="AUDIOBOOK_PATH=$DEFAULT_AUDIOBOOK_PATH
METADATA_PATH=$DEFAULT_DATA_PATH/metadata
CONFIG_PATH=$DEFAULT_DATA_PATH/config
config_text="METADATA_PATH=$DEFAULT_DATA_DIR/metadata
CONFIG_PATH=$DEFAULT_DATA_DIR/config
FFMPEG_PATH=/usr/lib/audiobookshelf-ffmpeg/ffmpeg
FFPROBE_PATH=/usr/lib/audiobookshelf-ffmpeg/ffprobe
PORT=$DEFAULT_PORT
@@ -118,6 +97,10 @@ setup_config() {
fi
}
add_group 'audiobookshelf' ''
add_user 'audiobookshelf' '' 'audiobookshelf' 'audiobookshelf user-daemon' '/bin/false'
setup_config
install_ffmpeg

View File

@@ -1,6 +1,7 @@
@import './fonts.css';
@import './transitions.css';
@import './draggable.css';
@import './defaultStyles.css';
:root {
--bookshelf-texture-img: url(/textures/wood_default.jpg);

View File

@@ -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;
}

563
client/assets/trix.css Normal file
View File

@@ -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%;
}

View File

@@ -12,7 +12,7 @@
<ui-libraries-dropdown />
<controls-global-search class="hidden md:block" />
<controls-global-search v-if="currentLibrary" class="hidden md:block" />
<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>
@@ -24,11 +24,11 @@
<google-cast-launcher></google-cast-launcher>
</div>
<nuxt-link 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 flex items-center justify-center mx-1">
<span class="material-icons" aria-label="User Stats" role="button">equalizer</span>
</nuxt-link>
<nuxt-link v-if="userCanUpload" to="/upload" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
<nuxt-link v-if="userCanUpload && currentLibrary" to="/upload" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
<span class="material-icons" aria-label="Upload Media" role="button">upload</span>
</nuxt-link>
@@ -147,9 +147,6 @@ export default {
}
},
methods: {
toggleBookshelfTexture() {
this.$store.dispatch('setBookshelfTexture', 'wood2.png')
},
cancelSelectionMode() {
if (this.processingBatchDelete) return
this.$store.commit('setSelectedLibraryItems', [])

View File

@@ -2,10 +2,6 @@
<div id="bookshelf" ref="wrapper" class="w-full max-w-full h-full overflow-y-scroll relative">
<!-- Cover size widget -->
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-30" />
<!-- Experimental Bookshelf Texture -->
<div v-show="showExperimentalFeatures && !isAlternativeBookshelfView" class="fixed bottom-4 right-28 z-40">
<div class="rounded-full py-1 bg-primary hover:bg-bg cursor-pointer px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent @click="showBookshelfTextureModal"><p class="text-sm py-0.5">Texture</p></div>
</div>
<div v-if="loaded && !shelves.length && !search" class="w-full flex flex-col items-center justify-center py-12">
<p class="text-center text-2xl font-book mb-4 py-4">{{ libraryName }} Library is empty!</p>
@@ -100,9 +96,6 @@ export default {
}
},
methods: {
showBookshelfTextureModal() {
this.$store.commit('globals/setShowBookshelfTextureModal', true)
},
async init() {
this.wrapperClientWidth = this.$refs.wrapper ? this.$refs.wrapper.clientWidth : 0
@@ -190,7 +183,15 @@ export default {
},
settingsUpdated(settings) {},
scan() {
this.$store.dispatch('libraries/requestLibraryScan', { libraryId: this.$store.state.libraries.currentLibraryId })
this.$store
.dispatch('libraries/requestLibraryScan', { libraryId: this.$store.state.libraries.currentLibraryId })
.then(() => {
this.$toast.success('Library scan started')
})
.catch((error) => {
console.error('Failed to start scan', error)
this.$toast.error('Failed to start scan')
})
},
libraryItemAdded(libraryItem) {
console.log('libraryItem added', libraryItem)

View File

@@ -61,6 +61,10 @@
<p>Search results for "{{ searchQuery }}"</p>
<div class="flex-grow" />
</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>
</template>
@@ -75,7 +79,11 @@ export default {
default: () => null
},
searchQuery: String,
viewMode: String
viewMode: String,
authors: {
type: Array,
default: () => []
}
},
data() {
return {
@@ -85,13 +93,17 @@ export default {
keywordFilter: null,
keywordTimeout: null,
processingSeries: false,
processingIssues: false
processingIssues: false,
processingAuthors: false
}
},
computed: {
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
isPodcast() {
return this.$store.getters['libraries/getCurrentLibraryMediaType'] == 'podcast'
},
@@ -147,6 +159,35 @@ export default {
}
},
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() {
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

View File

@@ -9,9 +9,13 @@
<div v-show="routeName === route.iod" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<div class="w-full h-10 px-4 border-t border-black border-opacity-20 absolute left-0 flex flex-col justify-center" :style="{ bottom: streamLibraryItem && isMobileLandscape ? '300px' : '65px' }">
<p class="font-mono text-sm">v{{ $config.version }}</p>
<a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-sm">Update available: {{ latestVersion }}</a>
<div class="w-full h-12 px-4 border-t border-black border-opacity-20 absolute left-0 flex flex-col justify-center" :style="{ bottom: streamLibraryItem && isMobileLandscape ? '300px' : '65px' }">
<div class="flex justify-between">
<p class="font-mono text-sm">v{{ $config.version }}</p>
<p class="font-mono text-xs text-gray-300 italic">{{ Source }}</p>
</div>
<a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-xs">Latest: {{ latestVersion }}</a>
</div>
</div>
</template>
@@ -25,6 +29,12 @@ export default {
return {}
},
computed: {
Source() {
return this.$store.state.Source
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
},
@@ -38,7 +48,7 @@ export default {
}
]
}
return [
const configRoutes = [
{
id: 'config',
title: 'Settings',
@@ -63,18 +73,23 @@ export default {
id: 'config-log',
title: 'Log',
path: '/config/log'
},
{
}
]
if (this.currentLibraryId) {
configRoutes.push({
id: 'config-library-stats',
title: 'Library Stats',
path: '/config/library-stats'
},
{
})
configRoutes.push({
id: 'config-stats',
title: 'Your Stats',
path: '/config/stats'
}
]
})
}
return configRoutes
},
wrapperClass() {
var classes = []

View File

@@ -22,13 +22,6 @@
</div>
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-30" />
<!-- Experimental Bookshelf Texture -->
<div v-show="showExperimentalFeatures && !isAlternativeBookshelfView" class="fixed bottom-4 right-28 z-40">
<div class="rounded-full py-1 bg-primary hover:bg-bg cursor-pointer px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent @click="showBookshelfTextureModal">
<p class="text-sm py-0.5">Texture</p>
</div>
</div>
</div>
</template>
@@ -43,6 +36,7 @@ export default {
mixins: [bookshelfCardsHelpers],
data() {
return {
routeFullPath: null,
initialized: false,
bookshelfHeight: 0,
bookshelfWidth: 0,
@@ -205,9 +199,6 @@ export default {
}
},
methods: {
showBookshelfTextureModal() {
this.$store.commit('globals/setShowBookshelfTextureModal', true)
},
clearFilter() {
this.$store.dispatch('user/updateUserSettings', { filterBy: 'all' })
},
@@ -413,6 +404,8 @@ export default {
if (newSearchParams !== this.currentSFQueryString || newSearchParams !== currentQueryString) {
let newurl = window.location.protocol + '//' + window.location.host + window.location.pathname + '?' + newSearchParams
window.history.replaceState({ path: newurl }, '', newurl)
this.routeFullPath = window.location.pathname + (window.location.search || '') // Update for saving scroll position
return true
}
@@ -530,6 +523,15 @@ export default {
await this.fetchEntites(0)
var lastBookIndex = Math.min(this.totalEntities, this.shelvesPerPage * this.entitiesPerShelf)
this.mountEntites(0, lastBookIndex)
// Set last scroll position for this bookshelf page
if (this.$store.state.lastBookshelfScrollData[this.page] && window.bookshelf) {
const { path, scrollTop } = this.$store.state.lastBookshelfScrollData[this.page]
if (path === this.routeFullPath) {
// Exact path match with query so use scroll position
window.bookshelf.scrollTop = scrollTop
}
}
},
executeRebuild() {
clearTimeout(this.resizeTimeout)
@@ -605,13 +607,25 @@ export default {
}
},
scan() {
this.$store.dispatch('libraries/requestLibraryScan', { libraryId: this.currentLibraryId })
this.$store
.dispatch('libraries/requestLibraryScan', { libraryId: this.currentLibraryId })
.then(() => {
this.$toast.success('Library scan started')
})
.catch((error) => {
console.error('Failed to start scan', error)
this.$toast.error('Failed to start scan')
})
}
},
mounted() {
this.initListeners()
this.routeFullPath = window.location.pathname + (window.location.search || '')
},
updated() {
this.routeFullPath = window.location.pathname + (window.location.search || '')
setTimeout(() => {
if (window.innerWidth > 0 && window.innerWidth !== this.mountWindowWidth) {
console.log('Updated window width', window.innerWidth, 'from', this.mountWindowWidth)
@@ -622,6 +636,11 @@ export default {
beforeDestroy() {
this.destroyEntityComponents()
this.removeListeners()
// Set bookshelf scroll position for specific bookshelf page and query
if (window.bookshelf) {
this.$store.commit('setLastBookshelfScrollData', { scrollTop: window.bookshelf.scrollTop || 0, path: this.routeFullPath, name: this.page })
}
}
}
</script>

View File

@@ -1,6 +1,9 @@
<template>
<div class="w-20 bg-bg h-full relative box-shadow-side z-40" style="min-width: 80px">
<div class="absolute top-0 -right-4 w-4 bg-bg h-10 pointer-events-none" />
<!-- <div class="w-20 bg-bg h-full relative box-shadow-side z-40" style="min-width: 80px"> -->
<div class="w-20 bg-bg h-full fixed left-0 box-shadow-side z-40" style="min-width: 80px" :style="{ top: offsetTop + 'px' }">
<!-- ugly little workaround to cover up the shadow overlapping the bookshelf toolbar -->
<div v-if="isShowingBookshelfToolbar" class="absolute top-0 -right-4 w-4 bg-bg h-10 pointer-events-none" />
<nuxt-link :to="`/library/${currentLibraryId}`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="homePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
@@ -70,6 +73,12 @@
<p class="text-xs font-mono pb-0.5">{{ numIssues }}</p>
</div>
</nuxt-link>
<div class="w-full h-12 px-1 py-2 border-t border-black border-opacity-20 absolute left-0" :style="{ bottom: streamLibraryItem ? '240px' : '65px' }">
<p class="font-mono text-xs text-center text-gray-300 leading-3 mb-1">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>
</div>
</template>
@@ -79,8 +88,18 @@ export default {
return {}
},
computed: {
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
Source() {
return this.$store.state.Source
},
isMobileLandscape() {
return this.$store.state.globals.isMobileLandscape
},
isShowingBookshelfToolbar() {
if (!this.$route.name) return false
return this.$route.name.startsWith('library')
},
offsetTop() {
return 64
},
userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
@@ -124,6 +143,21 @@ export default {
},
numIssues() {
return this.$store.state.libraries.issues || 0
},
versionData() {
return this.$store.state.versionData || {}
},
hasUpdate() {
return !!this.versionData.hasUpdate
},
latestVersion() {
return this.versionData.latestVersion
},
githubTagUrl() {
return this.versionData.githubTagUrl
},
streamLibraryItem() {
return this.$store.state.streamLibraryItem
}
},
methods: {},

View File

@@ -1,14 +1,15 @@
<template>
<div v-if="streamLibraryItem" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-48 sm:h-44 md:h-40 z-40 bg-primary px-4 pb-1 md:pb-4 pt-2">
<nuxt-link :to="`/item/${streamLibraryItem.id}`" class="absolute left-4 cursor-pointer" :style="{ top: bookCoverPosTop + 'px' }">
<div id="videoDock" />
<nuxt-link v-if="!playerHandler.isVideo" :to="`/item/${streamLibraryItem.id}`" class="absolute left-4 cursor-pointer" :style="{ top: bookCoverPosTop + 'px' }">
<covers-book-cover :library-item="streamLibraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</nuxt-link>
<div class="flex items-start pl-24 mb-6 md:mb-0">
<div class="flex items-start mb-6 md:mb-0" :class="playerHandler.isVideo ? 'ml-4 pl-96' : 'pl-24'">
<div>
<nuxt-link :to="`/item/${streamLibraryItem.id}`" class="hover:underline cursor-pointer text-base sm:text-lg">
{{ title }}
</nuxt-link>
<div class="text-gray-400 flex items-center">
<div v-if="!playerHandler.isVideo" class="text-gray-400 flex items-center">
<span class="material-icons text-sm">person</span>
<p v-if="podcastAuthor">{{ podcastAuthor }}</p>
<p v-else-if="authors.length" class="pl-1.5 text-sm sm:text-base">
@@ -25,7 +26,7 @@
<div class="flex-grow" />
<span class="material-icons px-2 py-1 md:p-4 cursor-pointer" @click="closePlayer">close</span>
</div>
<audio-player
<player-ui
ref="audioPlayer"
:chapters="chapters"
:paused="!isPlaying"
@@ -74,9 +75,6 @@ export default {
}
},
computed: {
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
},
coverAspectRatio() {
return this.$store.getters['getServerSetting']('coverAspectRatio')
},
@@ -148,6 +146,7 @@ export default {
setPlaying(isPlaying) {
this.isPlaying = isPlaying
this.$store.commit('setIsPlaying', isPlaying)
this.updateMediaSessionPlaybackState()
},
setSleepTimer(seconds) {
this.sleepTimerSet = true
@@ -240,6 +239,71 @@ export default {
this.playerHandler.closePlayer()
this.$store.commit('setMediaPlaying', null)
},
mediaSessionPlay() {
console.log('Media session play')
this.playerHandler.play()
},
mediaSessionPause() {
console.log('Media session pause')
this.playerHandler.pause()
},
mediaSessionStop() {
console.log('Media session stop')
this.playerHandler.pause()
},
mediaSessionSeekBackward() {
console.log('Media session seek backward')
this.playerHandler.jumpBackward()
},
mediaSessionSeekForward() {
console.log('Media session seek forward')
this.playerHandler.jumpForward()
},
mediaSessionSeekTo(e) {
console.log('Media session seek to', e)
if (e.seekTime !== null && !isNaN(e.seekTime)) {
this.playerHandler.seek(e.seekTime)
}
},
updateMediaSessionPlaybackState() {
if ('mediaSession' in navigator) {
navigator.mediaSession.playbackState = this.isPlaying ? 'playing' : 'paused'
}
},
setMediaSession() {
if (!this.streamLibraryItem) {
console.error('setMediaSession: No library item set')
return
}
if ('mediaSession' in navigator) {
var coverImageSrc = this.$store.getters['globals/getLibraryItemCoverSrc'](this.streamLibraryItem, '/Logo.png')
const artwork = [
{
src: coverImageSrc
}
]
navigator.mediaSession.metadata = new MediaMetadata({
title: this.title,
artist: this.playerHandler.displayAuthor || this.mediaMetadata.authorName || 'Unknown',
album: this.mediaMetadata.seriesName || '',
artwork
})
console.log('Set media session metadata', navigator.mediaSession.metadata)
navigator.mediaSession.setActionHandler('play', this.mediaSessionPlay)
navigator.mediaSession.setActionHandler('pause', this.mediaSessionPause)
navigator.mediaSession.setActionHandler('stop', this.mediaSessionStop)
navigator.mediaSession.setActionHandler('seekbackward', this.mediaSessionSeekBackward)
navigator.mediaSession.setActionHandler('seekforward', this.mediaSessionSeekForward)
navigator.mediaSession.setActionHandler('seekto', this.mediaSessionSeekTo)
// navigator.mediaSession.setActionHandler('previoustrack')
// navigator.mediaSession.setActionHandler('nexttrack')
} else {
console.warn('Media session not available')
}
},
streamProgress(data) {
if (!data.numSegments) return
var chunks = data.chunks
@@ -312,7 +376,6 @@ export default {
libraryItem,
episodeId
})
this.playerHandler.load(libraryItem, episodeId, true, this.initialPlaybackRate)
},
pauseItem() {

View File

@@ -65,6 +65,9 @@ export default {
name() {
return this._author.name || ''
},
asin() {
return this._author.asin || ''
},
numBooks() {
return this._author.numBooks || 0
},
@@ -81,7 +84,11 @@ export default {
},
async searchAuthor() {
this.searching = true
var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, { q: this.name }).catch((error) => {
const payload = {}
if (this.asin) payload.asin = this.asin
else payload.q = this.name
var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, payload).catch((error) => {
console.error('Failed', error)
return null
})
@@ -94,8 +101,16 @@ export default {
this.$toast.info('No updates were made for Author')
}
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>

View File

@@ -109,19 +109,14 @@ export default {
hasValidCovers() {
var validCovers = this.bookItems.map((bookItem) => bookItem.media.coverPath)
return !!validCovers.length
},
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
}
},
methods: {
mouseoverCard() {
this.isHovering = true
// if (this.$refs.groupcover) this.$refs.groupcover.setHover(true)
},
mouseleaveCard() {
this.isHovering = false
// if (this.$refs.groupcover) this.$refs.groupcover.setHover(false)
},
clickCard() {
this.$emit('click', this.group)

View File

@@ -147,6 +147,9 @@ export default {
showExperimentalFeatures() {
return this.store.state.showExperimentalFeatures
},
enableEReader() {
return this.store.getters['getServerSetting']('enableEReader')
},
_libraryItem() {
return this.libraryItem || {}
},
@@ -287,13 +290,13 @@ export default {
return this.store.getters['getlibraryItemIdStreaming'] === this.libraryItemId
},
showReadButton() {
return !this.isSelectionMode && this.showExperimentalFeatures && !this.showPlayButton && this.hasEbook
return !this.isSelectionMode && !this.showPlayButton && this.hasEbook && (this.showExperimentalFeatures || this.enableEReader)
},
showPlayButton() {
return !this.isSelectionMode && !this.isMissing && !this.isInvalid && !this.isStreaming && (this.numTracks || this.recentEpisode)
},
showSmallEBookIcon() {
return !this.isSelectionMode && this.showExperimentalFeatures && this.hasEbook
return !this.isSelectionMode && this.hasEbook && (this.showExperimentalFeatures || this.enableEReader)
},
isMissing() {
return this._libraryItem.isMissing
@@ -442,7 +445,34 @@ export default {
this.isSelectionMode = val
if (!val) this.selected = false
},
setEntity(libraryItem) {
setEntity(_libraryItem) {
var libraryItem = _libraryItem
// this code block is only necessary when showing a selected series with sequence #
// it will update the selected series so we get realtime updates for series sequence changes
if (this.series) {
// i know.. but the libraryItem passed to this func cannot be modified so we need to create a copy
libraryItem = {
..._libraryItem,
media: {
..._libraryItem.media,
metadata: {
..._libraryItem.media.metadata
}
}
}
var mediaMetadata = libraryItem.media.metadata
if (mediaMetadata.series) {
var newSeries = mediaMetadata.series.find((se) => se.id === this.series.id)
if (newSeries) {
// update selected series
libraryItem.media.metadata.series = newSeries
this.libraryItem = libraryItem
return
}
}
}
this.libraryItem = libraryItem
},
clickCard(e) {

View File

@@ -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>

View File

@@ -1,73 +0,0 @@
<template>
<div>
<form @submit.prevent="submitSearch">
<div class="flex items-center justify-start -mx-1 h-20">
<!-- <div class="w-40 px-1">
<ui-dropdown v-model="provider" :items="providers" label="Provider" small />
</div> -->
<div class="flex-grow px-1">
<ui-text-input-with-label v-model="searchAuthor" label="Author" />
</div>
<ui-btn class="mt-5 ml-1" type="submit">Search</ui-btn>
</div>
</form>
</div>
</template>
<script>
export default {
props: {
authorName: String
},
data() {
return {
searchAuthor: null,
lastSearch: null,
isProcessing: false,
provider: 'audnexus',
providers: [
{
text: 'Audnexus',
value: 'audnexus'
}
]
}
},
watch: {
authorName: {
immediate: true,
handler(newVal) {
this.searchAuthor = newVal
}
}
},
computed: {},
methods: {
getSearchQuery() {
return `q=${this.searchAuthor}`
},
submitSearch() {
if (!this.searchAuthor) {
this.$toast.warning('Author name is required')
return
}
this.runSearch()
},
async runSearch() {
var searchQuery = this.getSearchQuery()
if (this.lastSearch === searchQuery) return
this.isProcessing = true
this.lastSearch = searchQuery
var result = await this.$axios.$get(`/api/authors/search?${searchQuery}`).catch((error) => {
console.error('Failed', error)
return []
})
this.isProcessing = false
if (result) {
this.$emit('match', result)
}
}
},
mounted() {}
}
</script>

View File

@@ -33,8 +33,8 @@ export default {
showMenu: false,
items: [
{
text: 'Current',
value: 'index'
text: 'Pub Date',
value: 'publishedAt'
},
{
text: 'Title',
@@ -47,10 +47,6 @@ export default {
{
text: 'Episode',
value: 'episode'
},
{
text: 'Pub Date',
value: 'publishedAt'
}
]
}

View File

@@ -59,9 +59,6 @@ export default {
if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6 * 2)
return this.width / 240
},
showExperimentalFeatures() {
return this.store.state.showExperimentalFeatures
},
store() {
return this.$store || this.$nuxt.$store
},

View File

@@ -65,6 +65,15 @@
</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="w-1/2">
<p>Can Access All Libraries</p>

View File

@@ -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>

View File

@@ -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-5 right-5 h-12 w-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300">
<span class="material-icons text-4xl">close</span>
</div>
<div ref="content" class="text-white">
<form v-if="selectedSeries" @submit.prevent="submitSeriesForm">
<div class="bg-bg rounded-lg p-8" @click.stop>
<div class="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>
</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>

View File

@@ -0,0 +1,150 @@
<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-primary 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 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 || {}
},
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>

View File

@@ -104,6 +104,7 @@ export default {
}
},
hotkey(action) {
if (this.$store.state.innerModalOpen) return
if (action === this.$hotkeys.Modal.CLOSE) {
this.show = false
}

View File

@@ -6,12 +6,12 @@
</div>
</template>
<div class="p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">
<form @submit.prevent="submitForm">
<form v-if="author" @submit.prevent="submitForm">
<div class="flex">
<div class="w-40 p-2">
<div class="w-full h-45 relative">
<covers-author-image :author="author" />
<div v-show="!processing" class="absolute top-0 left-0 w-full h-full opacity-0 hover:opacity-100">
<div v-show="!processing && author.imagePath" class="absolute top-0 left-0 w-full h-full opacity-0 hover:opacity-100">
<span class="absolute top-2 right-2 material-icons text-error transform hover:scale-125 transition-transform cursor-pointer text-lg" @click="removeCover">delete</span>
</div>
</div>
@@ -139,12 +139,17 @@ export default {
this.processing = false
},
async searchAuthor() {
if (!this.authorCopy.name) {
if (!this.authorCopy.name && !this.authorCopy.asin) {
this.$toast.error('Must enter an author name')
return
}
this.processing = true
var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, { q: this.authorCopy.name }).catch((error) => {
const payload = {}
if (this.authorCopy.asin) payload.asin = this.authorCopy.asin
else payload.q = this.authorCopy.name
var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, payload).catch((error) => {
console.error('Failed', error)
return null
})

View File

@@ -64,8 +64,7 @@ export default {
{
id: 'manage',
title: 'Manage',
component: 'modals-item-tabs-manage',
experimental: true
component: 'modals-item-tabs-manage'
}
]
}

View File

@@ -26,7 +26,7 @@
</div>
<!-- Split to mp3 -->
<div v-if="showMp3Split" class="w-full border border-black-200 p-4 my-8">
<div v-if="showMp3Split && showExperimentalFeatures" class="w-full border border-black-200 p-4 my-8">
<div class="flex items-center">
<div>
<p class="text-lg">Split M4B to MP3's</p>
@@ -51,7 +51,7 @@
</div>
<!-- Embed Metadata -->
<div v-if="mediaTracks.length" class="w-full border border-black-200 p-4 my-8">
<div v-if="mediaTracks.length && showExperimentalFeatures" class="w-full border border-black-200 p-4 my-8">
<div class="flex items-center">
<div>
<p class="text-lg">Embed Metadata</p>
@@ -113,6 +113,9 @@ export default {
}
},
computed: {
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
},
libraryItemId() {
return this.libraryItem ? this.libraryItem.id : null
},

View File

@@ -1,5 +1,5 @@
<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">
<div class="flex items-center justify-start -mx-1 h-20">
<div class="w-40 px-1">
@@ -87,7 +87,7 @@
<div v-if="selectedMatch.series" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.series" />
<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>
</div>
</div>
@@ -95,6 +95,27 @@
<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" />
</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">
<ui-checkbox v-model="selectedMatchUsage.isbn" />
<div class="flex-grow ml-4">
@@ -177,6 +198,10 @@ export default {
publishedYear: true,
series: true,
volumeNumber: true,
genres: true,
tags: true,
language: true,
explicit: true,
asin: true,
isbn: true,
// Podcast specific
@@ -204,6 +229,22 @@ export default {
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() {
return this.$store.getters['getBookCoverAspectRatio']
},
@@ -294,6 +335,10 @@ export default {
publishedYear: true,
series: true,
volumeNumber: true,
genres: true,
tags: true,
language: true,
explicit: true,
asin: true,
isbn: true,
// Podcast specific
@@ -320,36 +365,69 @@ export default {
else this.provider = localStorage.getItem('book-provider') || 'google'
},
selectMatch(match) {
if (match && match.series) {
match.series = match.series.map((se) => {
return {
id: `new-${Math.floor(Math.random() * 10000)}`,
displayName: se.volumeNumber ? `${se.series} #${se.volumeNumber}` : se.series,
name: se.series,
sequence: se.volumeNumber || ''
}
})
}
this.selectedMatch = match
},
buildMatchUpdatePayload() {
var updatePayload = {}
updatePayload.metadata = {}
var volumeNumber = this.selectedMatchUsage.volumeNumber ? this.selectedMatch.volumeNumber || null : null
for (const key in this.selectedMatchUsage) {
if (this.selectedMatchUsage[key] && this.selectedMatch[key]) {
if (key === 'series') {
var seriesItem = {
id: `new-${Math.floor(Math.random() * 10000)}`,
name: this.selectedMatch[key],
sequence: volumeNumber
var seriesPayload = []
if (!Array.isArray(this.selectedMatch[key])) {
seriesPayload.push({
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) {
var authorItem = {
id: `new-${Math.floor(Math.random() * 10000)}`,
name: this.selectedMatch[key]
}
updatePayload.authors = [authorItem]
if (!Array.isArray(this.selectedMatch[key])) this.selectedMatch[key] = [this.selectedMatch[key]]
var authorPayload = []
this.selectedMatch[key].forEach((authorName) =>
authorPayload.push({
id: `new-${Math.floor(Math.random() * 10000)}`,
name: authorName
})
)
updatePayload.metadata.authors = authorPayload
} else if (key === 'narrator') {
updatePayload.narrators = [this.selectedMatch[key]]
updatePayload.metadata.narrators = [this.selectedMatch[key]]
} else if (key === 'genres') {
updatePayload.metadata.genres = this.selectedMatch[key].split(',')
} else if (key === 'tags') {
updatePayload.tags = this.selectedMatch[key].split(',')
} else if (key === 'itunesId') {
updatePayload.itunesId = Number(this.selectedMatch[key])
} else if (key !== 'volumeNumber') {
updatePayload[key] = this.selectedMatch[key]
updatePayload.metadata.itunesId = Number(this.selectedMatch[key])
} else {
updatePayload.metadata[key] = this.selectedMatch[key]
}
}
}
return updatePayload
},
async submitMatchUpdate() {
@@ -359,9 +437,9 @@ export default {
}
this.isProcessing = true
if (updatePayload.cover) {
if (updatePayload.metadata.cover) {
var coverPayload = {
url: updatePayload.cover
url: updatePayload.metadata.cover
}
var success = await this.$axios.$post(`/api/items/${this.libraryItemId}/cover`, coverPayload).catch((error) => {
console.error('Failed to update', error)
@@ -373,13 +451,11 @@ export default {
this.$toast.error('Item Cover Failed to Update')
}
console.log('Updated cover')
delete updatePayload.cover
delete updatePayload.metadata.cover
}
if (Object.keys(updatePayload).length) {
var mediaUpdatePayload = {
metadata: updatePayload
}
var mediaUpdatePayload = updatePayload
var updateResult = await this.$axios.$patch(`/api/items/${this.libraryItemId}/media`, mediaUpdatePayload).catch((error) => {
console.error('Failed to update', error)
return false

View File

@@ -28,10 +28,9 @@
<ui-editable-text v-model="newFolderPath" placeholder="New folder path" type="text" class="w-full" @blur="newFolderInputBlurred" />
</div>
<ui-btn class="w-full mt-2" color="primary" @click="showDirectoryPicker = true">Browse for Folder</ui-btn>
<ui-btn class="w-full mt-2" color="primary" @click="browseForFolder">Browse for Folder</ui-btn>
</div>
</div>
<modals-libraries-folder-chooser v-else :paths="folderPaths" @back="showDirectoryPicker = false" @select="selectFolder" />
</div>
</template>
@@ -77,6 +76,9 @@ export default {
}
},
methods: {
browseForFolder() {
this.showDirectoryPicker = true
},
getLibraryData() {
return {
name: this.name,

View File

@@ -95,7 +95,7 @@ export default {
settings: {
disableWatcher: false,
skipMatchingMediaWithAsin: false,
skipMatchingMediaWithIsbn: false,
skipMatchingMediaWithIsbn: false
}
}
},
@@ -193,6 +193,11 @@ export default {
this.processing = false
this.show = false
this.$toast.success(`Library "${res.name}" created successfully`)
if (!this.$store.state.libraries.currentLibraryId) {
console.log('Setting initially library id', res.id)
// First library added
this.$store.dispatch('libraries/fetch', res.id)
}
})
.catch((error) => {
console.error(error)

View File

@@ -5,7 +5,7 @@
<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 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="w-1/5 p-1">
<ui-text-input-with-label v-model="newEpisode.season" label="Season" />
@@ -25,8 +25,8 @@
<div class="w-full p-1">
<ui-textarea-with-label v-model="newEpisode.subtitle" label="Subtitle" :rows="3" />
</div>
<div class="w-full p-1">
<ui-textarea-with-label v-model="newEpisode.description" label="Description" :rows="8" />
<div class="w-full p-1 default-style">
<ui-rich-text-editor v-if="show" label="Description" v-model="newEpisode.description" />
</div>
</div>
<div class="flex justify-end pt-4">

View File

@@ -148,6 +148,7 @@ export default {
})
},
init() {
this.episodes.sort((a, b) => (a.publishedAt < b.publishedAt) ? 1 : -1)
for (let i = 0; i < this.episodes.length; i++) {
var episode = this.episodes[i]
if (episode.enclosure && !this.itemEpisodeMap[episode.enclosure.url]) {

View File

@@ -151,7 +151,7 @@ export default {
this.fullPath = ''
return
}
this.fullPath = Path.join(this.selectedFolderPath, this.podcast.title)
this.fullPath = Path.join(this.selectedFolderPath, this.$sanitizeFilename(this.podcast.title))
},
submit() {
const podcastPayload = {

View File

@@ -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>

View File

@@ -0,0 +1,90 @@
<template>
<modals-modal v-model="show" name="podcast-episode-remove-modal" :width="500" :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="px-8 py-6 w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden">
<div class="mb-4">
<p class="text-lg text-gray-200 mb-4">
Are you sure you want to remove episode<br /><span class="text-base">{{ episodeTitle }}</span
>?
</p>
<p class="text-xs font-semibold text-warning text-opacity-90">Note: This does not delete the audio file unless toggling "Hard delete file"</p>
</div>
<div class="flex justify-between items-center pt-4">
<ui-checkbox v-model="hardDeleteFile" label="Hard delete file" check-color="error" checkbox-bg="bg" small label-class="text-base text-gray-200 pl-3" />
<ui-btn @click="submit">{{ hardDeleteFile ? 'Delete episode' : 'Remove episode' }}</ui-btn>
</div>
</div>
</modals-modal>
</template>
<script>
export default {
props: {
value: Boolean,
libraryItem: {
type: Object,
default: () => {}
},
episode: {
type: Object,
default: () => {}
}
},
data() {
return {
hardDeleteFile: false,
processing: false
}
},
watch: {
value(newVal) {
if (newVal) this.hardDeleteFile = false
}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
title() {
return 'Remove Episode'
},
episodeId() {
return this.episode ? this.episode.id : null
},
episodeTitle() {
return this.episode ? this.episode.title : null
}
},
methods: {
submit() {
this.processing = true
var queryString = this.hardDeleteFile ? '?hard=1' : ''
this.$axios
.$delete(`/api/podcasts/${this.libraryItem.id}/episode/${this.episodeId}${queryString}`)
.then(() => {
this.processing = false
this.$toast.success('Podcast episode removed')
this.show = false
})
.catch((error) => {
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed remove episode'
console.error('Failed update episode', error)
this.processing = false
this.$toast.error(errorMsg)
})
}
},
mounted() {}
}
</script>

View File

@@ -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>

View File

@@ -122,7 +122,7 @@ export default {
console.log('Payload', payload)
this.$axios
.$post(`/api/podcasts/${this.libraryItemId}/open-feed`, payload)
.$post(`/api/items/${this.libraryItemId}/open-feed`, payload)
.then((data) => {
if (data.success) {
console.log('Opened RSS Feed', data)
@@ -143,7 +143,7 @@ export default {
closeFeed() {
this.processing = true
this.$axios
.$post(`/api/podcasts/${this.libraryItem.id}/close-feed`)
.$post(`/api/items/${this.libraryItem.id}/close-feed`)
.then(() => {
this.$toast.success('RSS Feed Closed')
this.show = false

View File

@@ -0,0 +1,65 @@
<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-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>
</template>
<script>
export default {
props: {
loading: Boolean,
seekLoading: Boolean,
playbackRate: Number,
paused: Boolean
},
data() {
return {}
},
computed: {},
methods: {
playPause() {
this.$emit('playPause')
},
restart() {
this.$emit('restart')
},
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>

View File

@@ -0,0 +1,185 @@
<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 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>
</template>
<script>
export default {
props: {
loading: Boolean,
duration: Number,
chapters: {
type: Array,
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
}
},
watch: {
duration: {
immediate: true,
handler() {
this.setChapterTicks()
}
}
},
computed: {},
methods: {
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.$emit('seek', time)
},
setBufferTime(time) {
this.bufferTime = time
this.updateBufferTrack()
},
updateBufferTrack() {
var bufferlen = (this.bufferTime / this.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() {
var perc = this.currentTime / this.duration
var ptWidth = Math.round(perc * 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
var time = (offsetX / this.trackWidth) * this.duration
console.log('Mousemove track', 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
}
},
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>

View File

@@ -2,6 +2,8 @@
<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">
<span class="material-icons text-2xl cursor-pointer" @click="toggleFullscreen(true)">expand_less</span>
<controls-volume-control ref="volumeControl" v-model="volume" @input="setVolume" class="mx-2 hidden md:block" />
<div class="cursor-pointer text-gray-300 mx-1 md:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showSleepTimer')">
@@ -21,57 +23,11 @@
</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>
<player-playback-controls :loading="loading" :seek-loading="seekLoading" :playback-rate="playbackRate" :paused="paused" @restart="restart" @jumpForward="jumpForward" @jumpBackward="jumpBackward" @setPlaybackRate="setPlaybackRate" @playPause="playPause" />
</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>
<player-track-bar ref="trackbar" :loading="loading" :chapters="chapters" :duration="duration" @seek="seek" />
<div class="flex">
<p ref="currentTimestamp" class="font-mono text-sm text-gray-100 pointer-events-auto">00:00:00</p>
<p class="font-mono text-sm text-gray-100 pointer-events-auto">&nbsp;/&nbsp;{{ progressPercent }}%</p>
@@ -106,17 +62,11 @@ export default {
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: []
duration: 0
}
},
computed: {
@@ -154,26 +104,37 @@ export default {
currentChapterName() {
return this.currentChapter ? this.currentChapter.title : ''
},
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
isFullscreen() {
return this.$store.state.playerIsFullscreen
}
},
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
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()
if (this.$refs.trackbar) this.$refs.trackbar.setCurrentTime(time)
},
playPause() {
this.$emit('playPause')
@@ -226,67 +187,11 @@ export default {
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'
if (this.$refs.trackbar) this.$refs.trackbar.setPercentageReady(1)
},
setChunksReady(chunks, numSegments) {
var largestSeg = 0
@@ -301,10 +206,7 @@ export default {
}
}
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'
if (this.$refs.trackbar) this.$refs.trackbar.setPercentageReady(percentageReady)
},
updateTimestamp() {
var ts = this.$refs.currentTimestamp
@@ -315,36 +217,9 @@ export default {
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
if (this.$refs.trackbar) this.$refs.trackbar.setBufferTime(bufferTime)
},
showChapters() {
if (!this.chapters.length) return
@@ -353,14 +228,6 @@ export default {
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) {
@@ -368,6 +235,11 @@ export default {
}
},
closePlayer() {
if (this.isFullscreen) {
this.toggleFullscreen(false)
return
}
if (this.loading) return
this.$emit('close')
},
@@ -382,19 +254,14 @@ export default {
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)
}

View File

@@ -6,18 +6,20 @@
<span class="material-icons" style="font-size: 1.4rem">add</span>
</div>
</div>
<draggable :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" draggable=".item" tag="div" @start="startDrag" @end="endDrag">
<template v-for="library in libraryCopies">
<div :key="library.id" class="item">
<tables-library-item :library="library" :selected="currentLibraryId === library.id" :show-edit="true" :dragging="drag" @edit="editLibrary" @click="setLibrary" />
</div>
</template>
</draggable>
<modals-libraries-edit-modal v-model="showLibraryModal" :library="selectedLibrary" />
<div v-if="!libraries.length" class="pb-4">
<ui-btn @click="clickAddLibrary">Add your first library</ui-btn>
</div>
<p class="text-xs mt-4 text-gray-200">*<strong>Force Re-Scan</strong> will scan all files again like a fresh scan. Audio file ID3 tags, OPF files, and text files will be probed/parsed and used for book details.</p>
<p v-if="libraries.length" class="text-xs mt-4 text-gray-200">*<strong>Force Re-Scan</strong> will scan all files again like a fresh scan. Audio file ID3 tags, OPF files, and text files will be probed/parsed and used for book details.</p>
<p class="text-xs mt-4 text-gray-200">**<strong>Match Books</strong> will attempt to match books in library with a book from the selected search provider and fill in empty details and cover art. Does not overwrite details.</p>
<p v-if="libraries.length && libraries.some((li) => li.mediaType === 'book')" class="text-xs mt-4 text-gray-200">**<strong>Match Books</strong> will attempt to match books in library with a book from the selected search provider and fill in empty details and cover art. Does not overwrite details.</p>
</div>
</template>
@@ -32,8 +34,6 @@ export default {
return {
libraryCopies: [],
currentOrder: [],
showLibraryModal: false,
selectedLibrary: null,
drag: false,
dragOptions: {
animation: 200,
@@ -97,12 +97,10 @@ export default {
this.$router.push(`/library/${library.id}`)
},
clickAddLibrary() {
this.selectedLibrary = null
this.showLibraryModal = true
this.$emit('showLibraryModal', null)
},
editLibrary(library) {
this.selectedLibrary = library
this.showLibraryModal = true
this.$emit('showLibraryModal', library)
},
init() {
this.libraryCopies = this.libraries.map((lib) => {

View File

@@ -73,10 +73,28 @@ export default {
this.$emit('edit', this.library)
},
scan() {
this.$store.dispatch('libraries/requestLibraryScan', { libraryId: this.library.id })
this.$store
.dispatch('libraries/requestLibraryScan', { libraryId: this.library.id })
.then(() => {
this.$toast.success('Library scan started')
})
.catch((error) => {
console.error('Failed to start scan', error)
this.$toast.error('Failed to start scan')
})
},
forceScan() {
this.$store.dispatch('libraries/requestLibraryScan', { libraryId: this.library.id, force: 1 })
if (confirm(`Force Re-Scan will scan all files again like a fresh scan. Audio file ID3 tags, OPF files, and text files will be probed/parsed to be used for the library item.\n\nAre you sure you want to force re-scan?`)) {
this.$store
.dispatch('libraries/requestLibraryScan', { libraryId: this.library.id, force: 1 })
.then(() => {
this.$toast.success('Library scan started')
})
.catch((error) => {
console.error('Failed to start scan', error)
this.$toast.error('Failed to start scan')
})
}
},
deleteClick() {
if (this.isMain) return

View File

@@ -1,21 +1,18 @@
<template>
<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-show="userCanUpdate" class="w-12 min-w-12 max-w-16 h-full">
<div class="flex h-full items-center justify-center">
<span class="material-icons drag-handle text-lg text-white text-opacity-50 hover:text-opacity-100">menu</span>
</div>
</div>
<div v-if="episode" class="flex items-center h-24 cursor-pointer" @click="$emit('view', episode)">
<div class="flex-grow px-2">
<p class="text-sm font-semibold">
{{ title }}
</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="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>
<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-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" borderless class="mx-1 mt-0.5" @click="toggleFinished" />
@@ -49,8 +46,7 @@ export default {
episode: {
type: Object,
default: () => {}
},
isDragging: Boolean
}
},
data() {
return {
@@ -59,15 +55,6 @@ export default {
isHovering: false
}
},
watch: {
isDragging: {
handler(newVal) {
if (newVal) {
this.isHovering = false
}
}
}
},
computed: {
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
@@ -81,10 +68,11 @@ export default {
title() {
return this.episode.title || ''
},
subtitle() {
return this.episode.subtitle || ''
},
description() {
if (this.episode.subtitle) return this.episode.subtitle
var desc = this.episode.description || ''
return desc
return this.episode.description || ''
},
duration() {
return this.$secondsToTimestamp(this.episode.duration)
@@ -117,7 +105,7 @@ export default {
},
methods: {
mouseover() {
if (this.isDragging) return
// if (this.isDragging) return
this.isHovering = true
},
mouseleave() {
@@ -154,22 +142,7 @@ export default {
})
},
removeClick() {
if (confirm(`Are you sure you want to remove episode ${this.title}?\nNote: Does not delete from file system`)) {
this.processingRemove = true
this.$axios
.$delete(`/api/items/${this.libraryItemId}/episode/${this.episode.id}`)
.then((updatedPodcast) => {
console.log(`Episode removed from podcast`, updatedPodcast)
this.$toast.success('Episode removed from podcast')
this.processingRemove = false
})
.catch((error) => {
console.error('Failed to remove episode from podcast', error)
this.$toast.error('Failed to remove episode from podcast')
this.processingRemove = false
})
}
this.$emit('remove', this.episode)
}
}
}

View File

@@ -3,29 +3,19 @@
<div class="flex items-center mb-4">
<p class="text-lg mb-0 font-semibold">Episodes</p>
<div class="flex-grow" />
<controls-episode-sort-select v-model="sortKey" :descending.sync="sortDesc" class="w-36 sm:w-44 md:w-48 h-9 ml-1 sm:ml-4" @change="changeSort" />
<div v-if="userCanUpdate" class="w-12">
<ui-icon-btn v-if="orderChanged" :loading="savingOrder" icon="save" bg-color="primary" class="ml-auto" @click="saveOrder" />
</div>
<controls-episode-sort-select v-model="sortKey" :descending.sync="sortDesc" class="w-36 sm:w-44 md:w-48 h-9 ml-1 sm:ml-4" />
</div>
<p v-if="!episodes.length" class="py-4 text-center text-lg">No Episodes</p>
<draggable v-model="episodesCopy" v-bind="dragOptions" class="list-group" handle=".drag-handle" draggable=".item" tag="div" @start="drag = true" @end="drag = false" @update="draggableUpdate">
<transition-group type="transition" :name="!drag ? 'episode' : null">
<template v-for="episode in episodesCopy">
<tables-podcast-episode-table-row :key="episode.id" :is-dragging="drag" :episode="episode" :library-item-id="libraryItem.id" class="item" :class="drag ? '' : 'episode'" @edit="editEpisode" />
</template>
</transition-group>
</draggable>
<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" @view="viewEpisode" />
</template>
<modals-podcast-remove-episode v-model="showPodcastRemoveModal" :library-item="libraryItem" :episode="selectedEpisode" />
</div>
</template>
<script>
import draggable from 'vuedraggable'
export default {
components: {
draggable
},
props: {
libraryItem: {
type: Object,
@@ -34,30 +24,19 @@ export default {
},
data() {
return {
sortKey: 'index',
sortDesc: true,
drag: false,
episodesCopy: [],
orderChanged: false,
savingOrder: false
sortKey: 'publishedAt',
sortDesc: true,
selectedEpisode: null,
showPodcastRemoveModal: false
}
},
watch: {
libraryItem: {
handler(newVal) {
this.init()
}
libraryItem() {
this.init()
}
},
computed: {
dragOptions() {
return {
animation: 200,
group: 'description',
ghostClass: 'ghost',
disabled: !this.userCanUpdate
}
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
@@ -69,64 +48,33 @@ export default {
},
episodes() {
return this.media.episodes || []
}
},
methods: {
changeSort() {
this.episodesCopy.sort((a, b) => {
},
episodesSorted() {
return this.episodesCopy.sort((a, b) => {
if (this.sortDesc) {
return String(b[this.sortKey]).localeCompare(String(a[this.sortKey]), undefined, { numeric: true, sensitivity: 'base' })
}
return String(a[this.sortKey]).localeCompare(String(b[this.sortKey]), undefined, { numeric: true, sensitivity: 'base' })
})
this.orderChanged = this.checkHasOrderChanged()
},
checkHasOrderChanged() {
for (let i = 0; i < this.episodesCopy.length; i++) {
var epc = this.episodesCopy[i]
var ep = this.episodes[i]
if (epc.index != ep.index) {
return true
}
}
return false
}
},
methods: {
removeEpisode(episode) {
this.selectedEpisode = episode
this.showPodcastRemoveModal = true
},
editEpisode(episode) {
this.$store.commit('setSelectedLibraryItem', this.libraryItem)
this.$store.commit('globals/setSelectedEpisode', episode)
this.$store.commit('globals/setShowEditPodcastEpisodeModal', true)
},
draggableUpdate() {
this.orderChanged = this.checkHasOrderChanged()
},
async saveOrder() {
if (!this.userCanUpdate) return
this.savingOrder = true
var episodesUpdate = {
episodes: this.episodesCopy.map((b) => b.id)
}
await this.$axios
.$patch(`/api/items/${this.libraryItem.id}/episodes`, episodesUpdate)
.then((podcast) => {
console.log('Podcast updated', podcast)
this.$toast.success('Saved episode order')
this.orderChanged = false
})
.catch((error) => {
console.error('Failed to update podcast', error)
this.$toast.error('Failed to save podcast episode order')
})
this.savingOrder = false
viewEpisode(episode) {
this.$store.commit('setSelectedLibraryItem', this.libraryItem)
this.$store.commit('globals/setSelectedEpisode', episode)
this.$store.commit('globals/setShowViewPodcastEpisodeModal', true)
},
init() {
this.episodesCopy = this.episodes.map((ep) => {
return {
...ep
}
})
this.episodesCopy = this.episodes.map((ep) => ({ ...ep }))
}
},
mounted() {

View File

@@ -32,6 +32,7 @@ export default {
default: ''
},
paddingX: Number,
paddingY: Number,
small: Boolean,
loading: Boolean,
disabled: Boolean
@@ -48,14 +49,17 @@ export default {
if (this.small) {
list.push('text-sm')
if (this.paddingX === undefined) list.push('px-4')
list.push('py-1')
if (this.paddingY === undefined) list.push('py-1')
} else {
if (this.paddingX === undefined) list.push('px-8')
list.push('py-2')
if (this.paddingY === undefined) list.push('py-2')
}
if (this.paddingX !== undefined) {
list.push(`px-${this.paddingX}`)
}
if (this.paddingY !== undefined) {
list.push(`py-${this.paddingY}`)
}
if (this.disabled) {
list.push('cursor-not-allowed')
}

View File

@@ -1,6 +1,6 @@
<template>
<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>
</div>
</template>

View File

@@ -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>

View File

@@ -1,9 +1,12 @@
<template>
<div class="relative">
<input ref="input" v-model="inputValue" :type="type" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" class="rounded bg-primary text-gray-200 focus:border-gray-300 focus:bg-bg focus:outline-none border border-gray-600 h-full w-full" :class="classList" @keyup="keyup" @change="change" @focus="focused" @blur="blurred" />
<div ref="wrapper" class="relative">
<input ref="input" v-model="inputValue" :type="actualType" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" class="rounded bg-primary text-gray-200 focus:border-gray-300 focus:bg-bg focus:outline-none border border-gray-600 h-full w-full" :class="classList" @keyup="keyup" @change="change" @focus="focused" @blur="blurred" />
<div v-if="clearable && inputValue" class="absolute top-0 right-0 h-full px-2 flex items-center justify-center">
<span class="material-icons text-gray-300 cursor-pointer" style="font-size: 1.1rem" @click.stop.prevent="clear">close</span>
</div>
<div v-if="type === 'password' && isHovering" class="absolute top-0 right-0 h-full px-4 flex items-center justify-center">
<span class="material-icons-outlined text-gray-400 cursor-pointer text-lg" @click.stop.prevent="showPassword = !showPassword">{{ !showPassword ? 'visibility' : 'visibility_off' }}</span>
</div>
</div>
</template>
@@ -31,7 +34,10 @@ export default {
clearable: Boolean
},
data() {
return {}
return {
showPassword: false,
isHovering: false
}
},
computed: {
inputValue: {
@@ -49,6 +55,10 @@ export default {
if (this.noSpinner) _list.push('no-spinner')
if (this.textCenter) _list.push('text-center')
return _list.join(' ')
},
actualType() {
if (this.type === 'password' && this.showPassword) return 'text'
return this.type
}
},
methods: {
@@ -69,9 +79,20 @@ export default {
},
blur() {
if (this.$refs.input) this.$refs.input.blur()
},
mouseover() {
this.isHovering = true
},
mouseleave() {
this.isHovering = false
}
},
mounted() {}
mounted() {
if (this.type === 'password' && this.$refs.wrapper) {
this.$refs.wrapper.addEventListener('mouseover', this.mouseover)
this.$refs.wrapper.addEventListener('mouseleave', this.mouseleave)
}
}
}
</script>

View File

@@ -1,6 +1,6 @@
<template>
<div class="w-full">
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">
<p class="px-1 text-sm font-semibold" :class="{ 'text-gray-400': disabled }">
{{ label }}<em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em>
</p>
<ui-text-input ref="input" v-model="inputValue" :disabled="disabled" :readonly="readonly" :type="type" class="w-full" @blur="inputBlurred" />

View File

@@ -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>

View File

@@ -22,7 +22,7 @@
<div class="flex mt-2 -mx-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>
@@ -63,27 +63,6 @@
</div>
</div>
</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>
</template>
@@ -97,8 +76,6 @@ export default {
},
data() {
return {
selectedSeries: {},
showSeriesForm: false,
details: {
title: null,
subtitle: null,
@@ -146,24 +123,6 @@ export default {
},
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: {
@@ -214,50 +173,6 @@ export default {
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) {
// return false if different
if (array1.length !== array2.length) return false

View File

@@ -13,7 +13,7 @@
<div ref="slider" class="w-full overflow-y-hidden overflow-x-auto no-scroll -mx-2" style="scroll-behavior: smooth" @scroll="scrolled">
<div class="flex" :style="{ height: height + 'px' }">
<template v-for="(item, index) in items">
<cards-lazy-book-card :key="item.id" :ref="`slider-episode-${item.recentEpisode.id}`" :index="index" :book-mount="item" :height="cardHeight" :width="cardWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" :bookshelf-view="bookshelfView" class="relative mx-2" @edit="editEpisode" @editPodcast="editPodcast" @select="selectItem" @hook:updated="setScrollVars" />
<cards-lazy-book-card :key="item.recentEpisode.id" :ref="`slider-episode-${item.recentEpisode.id}`" :index="index" :book-mount="item" :height="cardHeight" :width="cardWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" :bookshelf-view="bookshelfView" class="relative mx-2" @edit="editEpisode" @editPodcast="editPodcast" @select="selectItem" @hook:updated="setScrollVars" />
</template>
</div>
</div>

View File

@@ -1,5 +1,5 @@
<template>
<div class="absolute w-32 bg-bg rounded-md border border-black-200 shadow-lg z-50" v-click-outside="clickOutsideObj" style="top: 0; left: 0">
<div class="absolute w-36 bg-bg rounded-md border border-black-200 shadow-lg z-50" v-click-outside="clickOutsideObj" style="top: 0; left: 0">
<template v-for="(item, index) in items">
<div :key="index" class="flex h-7 items-center px-2 hover:bg-white hover:bg-opacity-5 text-white text-xs cursor-pointer" @click="clickAction(item.func)">
<p>{{ item.text }}</p>

View File

@@ -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>

View File

@@ -2,15 +2,18 @@
<div class="text-white max-h-screen h-screen overflow-hidden bg-bg">
<app-appbar />
<Nuxt />
<app-side-rail v-if="isShowingSideRail" class="hidden md:block" />
<div id="app-content" class="h-full" :class="{ 'has-siderail': isShowingSideRail }">
<Nuxt />
</div>
<app-stream-container ref="streamContainer" />
<modals-item-edit-modal />
<modals-user-collections-modal />
<modals-edit-collection-modal />
<modals-bookshelf-texture-modal />
<modals-podcast-edit-episode />
<modals-podcast-view-episode />
<modals-authors-edit-modal />
<readers-reader />
</div>
@@ -45,6 +48,13 @@ export default {
},
isCasting() {
return this.$store.state.globals.isCasting
},
isShowingSideRail() {
if (!this.$route.name) return false
return !this.$route.name.startsWith('config') && this.$store.state.libraries.currentLibraryId
},
appContentMarginLeft() {
return this.isShowingSideRail ? 80 : 0
}
},
methods: {
@@ -163,6 +173,7 @@ export default {
this.$store.commit('libraries/addUpdate', library)
},
async libraryRemoved(library) {
console.log('Library removed', library)
this.$store.commit('libraries/remove', library)
// When removed currently selected library then set next accessible library
@@ -181,18 +192,20 @@ export default {
this.$router.push(`/library/${nextLibrary.id}`)
}
} else {
console.error('User has no accessible libraries')
console.error('User has no more accessible libraries')
this.$store.commit('libraries/setCurrentLibrary', null)
}
}
},
libraryItemAdded(libraryItem) {
// this.$store.commit('libraries/updateFilterDataWithAudiobook', libraryItem)
this.$store.commit('libraries/updateFilterDataWithItem', libraryItem)
},
libraryItemUpdated(libraryItem) {
if (this.$store.state.selectedLibraryItem && this.$store.state.selectedLibraryItem.id === libraryItem.id) {
this.$store.commit('setSelectedLibraryItem', libraryItem)
}
this.$eventBus.$emit(`${libraryItem.id}_updated`, libraryItem)
this.$store.commit('libraries/updateFilterDataWithItem', libraryItem)
},
libraryItemRemoved(item) {
if (this.$route.name.startsWith('item')) {
@@ -502,23 +515,12 @@ export default {
this.$store.commit('globals/updateWindowSize', { width: window.innerWidth, height: window.innerHeight })
},
checkVersionUpdate() {
// Version check is only run if time since last check was 5 minutes
const VERSION_CHECK_BUFF = 1000 * 60 * 5 // 5 minutes
var lastVerCheck = localStorage.getItem('lastVerCheck') || 0
if (Date.now() - Number(lastVerCheck) > VERSION_CHECK_BUFF) {
this.$store
.dispatch('checkForUpdate')
.then((res) => {
localStorage.setItem('lastVerCheck', Date.now())
if (res && res.hasUpdate) this.showUpdateToast(res)
})
.catch((err) => console.error(err))
if (this.$route.query.error) {
this.$toast.error(this.$route.query.error)
this.$router.replace(this.$route.path)
}
}
this.$store
.dispatch('checkForUpdate')
.then((res) => {
if (res && res.hasUpdate) this.showUpdateToast(res)
})
.catch((err) => console.error(err))
}
},
beforeMount() {
@@ -538,6 +540,11 @@ export default {
}
this.checkVersionUpdate()
if (this.$route.query.error) {
this.$toast.error(this.$route.query.error)
this.$router.replace(this.$route.path)
}
},
beforeDestroy() {
window.removeEventListener('resize', this.resize)
@@ -550,4 +557,20 @@ export default {
.Vue-Toastification__toast-body.custom-class-1 {
font-size: 14px;
}
#app-content {
width: 100%;
}
#app-content.has-siderail {
width: calc(100% - 80px);
max-width: calc(100% - 80px);
margin-left: 80px;
}
@media (max-width: 768px) {
#app-content.has-siderail {
width: 100%;
max-width: 100%;
margin-left: 0px;
}
}
</style>

2318
client/package-lock.json generated
View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"name": "audiobookshelf-client",
"version": "2.0.13",
"description": "Audiobook manager and player",
"version": "2.0.18",
"description": "Self-hosted audiobook and podcast client",
"main": "index.js",
"scripts": {
"dev": "nuxt",
@@ -22,6 +22,7 @@
"libarchive.js": "^1.3.0",
"nuxt": "^2.15.8",
"nuxt-socket-io": "^1.1.18",
"trix": "^1.3.1",
"v-click-outside": "^3.1.2",
"vue-pdf": "^4.3.0",
"vue-toastification": "^1.7.11",

View File

@@ -126,9 +126,6 @@ export default {
}
},
computed: {
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
},
media() {
return this.libraryItem.media || {}
},

View File

@@ -23,13 +23,17 @@
<div class="py-4">
<widgets-item-slider :items="libraryItems" :bookshelf-view="$constants.BookshelfView.AUTHOR">
<h2 class="text-lg">{{ libraryItems.length }} Books</h2>
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=authors.${$encode(author.id)}`" class="hover:underline">
<h2 class="text-lg">{{ libraryItems.length }} Books</h2>
</nuxt-link>
</widgets-item-slider>
</div>
<div v-for="series in authorSeries" :key="series.id" class="py-4">
<widgets-item-slider :items="series.items" :bookshelf-view="$constants.BookshelfView.AUTHOR">
<h2 class="text-lg">{{ series.name }}</h2>
<nuxt-link :to="`/library/${currentLibraryId}/series/${series.id}`" class="hover:underline">
<h2 class="text-lg">{{ series.name }}</h2>
</nuxt-link>
<p class="text-white text-opacity-40 text-base px-2">Series</p>
</widgets-item-slider>
</div>

View File

@@ -113,6 +113,16 @@
</ui-tooltip>
</div>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.scannerPreferMatchedMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferMatchedMetadata', val)" />
<ui-tooltip :text="tooltips.scannerPreferMatchedMetadata">
<p class="pl-4 text-lg">
Scanner prefer matched metadata
<span class="material-icons icon-text">info_outlined</span>
</p>
</ui-tooltip>
</div>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.scannerDisableWatcher" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerDisableWatcher', val)" />
<ui-tooltip :text="tooltips.scannerDisableWatcher">
@@ -122,6 +132,20 @@
</p>
</ui-tooltip>
</div>
<div class="flex items-center mb-2 mt-8">
<h1 class="text-xl">Experimental Feature Settings</h1>
</div>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.enableEReader" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('enableEReader', val)" />
<ui-tooltip :text="tooltips.enableEReader">
<p class="pl-4 text-lg">
Enable e-reader for all users
<span class="material-icons icon-text">info_outlined</span>
</p>
</ui-tooltip>
</div>
</div>
<div class="h-0.5 bg-primary bg-opacity-30 w-full" />
@@ -169,10 +193,12 @@
<div>
<div class="flex items-center">
<ui-toggle-switch v-model="showExperimentalFeatures" />
<ui-tooltip :text="experimentalFeaturesTooltip">
<ui-tooltip :text="tooltips.experimentalFeatures">
<p class="pl-4 text-lg">
Experimental Features
<span class="material-icons icon-text">info_outlined</span>
<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>
@@ -207,8 +233,10 @@ export default {
isPurgingCache: false,
newServerSettings: {},
tooltips: {
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',
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',
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"',
@@ -216,7 +244,8 @@ export default {
bookshelfView: 'Alternative view without wooden bookshelf',
storeCoverWithItem: 'By default covers are stored in /metadata/items, enabling this setting will store covers in your library item folder. Only one file named "cover" will be kept',
storeMetadataWithItem: 'By default metadata files are stored in /metadata/items, enabling this setting will store metadata files in your library item folders. Uses .abs file extension',
coverAspectRatio: 'Prefer to use square covers over standard 1.6:1 book covers'
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)'
},
showConfirmPurgeCache: false
}
@@ -229,9 +258,6 @@ export default {
}
},
computed: {
experimentalFeaturesTooltip() {
return 'Features in development that could use your feedback and help testing.'
},
serverSettings() {
return this.$store.state.serverSettings
},

View File

@@ -1,16 +1,26 @@
<template>
<div>
<tables-library-libraries-table />
<tables-library-libraries-table @showLibraryModal="setShowLibraryModal" />
<modals-libraries-edit-modal v-model="showLibraryModal" :library="selectedLibrary" />
</div>
</template>
<script>
export default {
data() {
return {}
return {
showLibraryModal: false,
selectedLibrary: null
}
},
computed: {},
methods: {},
methods: {
setShowLibraryModal(selectedLibrary) {
this.selectedLibrary = selectedLibrary
this.showLibraryModal = true
}
},
mounted() {}
}
</script>

View File

@@ -67,6 +67,12 @@
<script>
export default {
asyncData({ redirect, store }) {
if (!store.state.libraries.currentLibraryId) {
return redirect('/config')
}
return {}
},
data() {
return {
libraryStats: null

View File

@@ -37,7 +37,11 @@
<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" />
<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>
<template v-for="(item, index) in mostRecentListeningSessions">
<div :key="item.id" class="w-full py-0.5">

View File

@@ -22,6 +22,10 @@
<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 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">
Total Time Listened:&nbsp;
<span class="font-mono text-base">{{ listeningTimePretty }}</span>
@@ -33,12 +37,14 @@
<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>
<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 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">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">
<tr class="bg-primary bg-opacity-40">
<th class="w-16 text-left">Item</th>
@@ -70,7 +76,7 @@
</td>
</tr>
</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>
@@ -104,9 +110,6 @@ export default {
bookCoverAspectRatio() {
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE ? 1 : 1.6
},
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
},
username() {
return this.user.username
},

View File

@@ -0,0 +1,151 @@
<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 ({{ listeningSessions.length }})</h1>
<table v-if="listeningSessions.length" class="userSessionsTable">
<tr class="bg-primary bg-opacity-40">
<th class="flex-grow text-left">Item</th>
<th class="w-32 text-left hidden md:table-cell">Play Method</th>
<th class="w-40 text-left hidden sm:table-cell">Device Info</th>
<th class="w-20">Listened</th>
<th class="w-20">Last Time</th>
<th class="w-40 hidden sm:table-cell">Last Update</th>
</tr>
<tr v-for="session in listeningSessions" :key="session.id" class="cursor-pointer" @click="showSession(session)">
<td class="py-1">
<p class="text-sm text-gray-200">{{ session.displayTitle }}</p>
<p class="text-xs text-gray-400">{{ session.displayAuthor }}</p>
</td>
<td class="hidden md:table-cell">
<p class="text-xs">{{ getPlayMethodName(session.playMethod) }}</p>
</td>
<td class="hidden sm:table-cell">
<p class="text-xs" v-html="getDeviceInfoString(session.deviceInfo)" />
</td>
<td class="text-center">
<p class="text-xs font-mono">{{ $elapsedPretty(session.timeListening) }}</p>
</td>
<td class="text-center">
<p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p>
</td>
<td class="text-center hidden sm:table-cell">
<ui-tooltip v-if="session.updatedAt" direction="top" :text="$formatDate(session.updatedAt, 'MMMM do, yyyy HH:mm')">
<p class="text-xs">{{ $dateDistanceFromNow(session.updatedAt) }}</p>
</ui-tooltip>
</td>
</tr>
</table>
<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: []
}
},
computed: {
username() {
return this.user.username
},
userOnline() {
return this.$store.getters['users/getIsUserOnline'](this.user.id)
}
},
methods: {
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 init() {
console.log(navigator)
this.listeningSessions = await this.$axios.$get(`/api/users/${this.user.id}/listening-sessions`).catch((err) => {
console.error('Failed to load listening sesions', err)
return []
})
}
},
mounted() {
this.init()
}
}
</script>
<style>
.userSessionsTable {
border-collapse: collapse;
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>

View File

@@ -5,6 +5,9 @@
<script>
export default {
asyncData({ redirect, store }) {
if (!store.state.libraries.currentLibraryId) {
return redirect('/oops?message=No libraries')
}
redirect(`/library/${store.state.libraries.currentLibraryId}`)
},
data() {

View File

@@ -31,11 +31,13 @@
<p v-if="bookSubtitle" class="sm:ml-4 text-gray-400 text-xl md:text-2xl">{{ bookSubtitle }}</p>
</div>
<p v-if="isPodcast" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl">by {{ podcastAuthor || 'Unknown' }}</p>
<p v-else-if="authors.length" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl">
by <nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">,&nbsp;</span></nuxt-link>
</p>
<p v-else class="mb-2 mt-0.5 text-gray-200 text-xl">by Unknown</p>
<template v-if="!isVideo">
<p v-if="isPodcast" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl">by {{ podcastAuthor || 'Unknown' }}</p>
<p v-else-if="authors.length" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl">
by <nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">,&nbsp;</span></nuxt-link>
</p>
<p v-else class="mb-2 mt-0.5 text-gray-200 text-xl">by Unknown</p>
</template>
<nuxt-link v-for="_series in seriesList" :key="_series.id" :to="`/library/${libraryId}/series/${_series.id}`" class="hover:underline font-sans text-gray-300 text-lg leading-7"> {{ _series.text }}</nuxt-link>
@@ -92,7 +94,8 @@
<!-- Alerts -->
<div v-show="showExperimentalReadAlert" class="bg-error p-4 rounded-xl flex items-center">
<span class="material-icons text-2xl">warning_amber</span>
<p class="ml-4">Book has no audio tracks but has valid ebook files. The e-reader is experimental and can be turned on in config.</p>
<p v-if="userIsAdminOrUp" class="ml-4">Book has no audio tracks but has an ebook. The experimental e-reader can be enabled in config.</p>
<p v-else class="ml-4">Book has no audio tracks but has an ebook. The experimental e-reader must be enabled by a server admin.</p>
</div>
<!-- Podcast episode downloads queue -->
@@ -135,7 +138,7 @@
{{ isMissing ? 'Missing' : 'Incomplete' }}
</ui-btn>
<ui-btn v-if="showExperimentalFeatures && ebookFile" color="info" :padding-x="4" small class="flex items-center h-9 mr-2" @click="openEbook">
<ui-btn v-if="showReadButton" color="info" :padding-x="4" small class="flex items-center h-9 mr-2" @click="openEbook">
<span class="material-icons -ml-2 pr-2 text-white">auto_stories</span>
Read
</ui-btn>
@@ -223,6 +226,12 @@ export default {
}
},
computed: {
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
},
enableEReader() {
return this.$store.getters['getServerSetting']('enableEReader')
},
userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
},
@@ -241,12 +250,12 @@ export default {
isDeveloperMode() {
return this.$store.state.developerMode
},
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
},
isPodcast() {
return this.libraryItem.mediaType === 'podcast'
},
isVideo() {
return this.libraryItem.mediaType === 'video'
},
isMissing() {
return this.libraryItem.isMissing
},
@@ -254,14 +263,18 @@ export default {
return this.libraryItem.isInvalid
},
invalidAudioFiles() {
if (this.isPodcast) return []
if (this.isPodcast || this.isVideo) return []
return this.libraryItem.media.audioFiles.filter((af) => af.invalid)
},
showPlayButton() {
if (this.isMissing || this.isInvalid) return false
if (this.isVideo) return !!this.videoFile
if (this.isPodcast) return this.podcastEpisodes.length
return this.tracks.length
},
showReadButton() {
return this.ebookFile && (this.showExperimentalFeatures || this.enableEReader)
},
libraryId() {
return this.libraryItem.libraryId
},
@@ -341,8 +354,11 @@ export default {
ebookFile() {
return this.media.ebookFile
},
videoFile() {
return this.media.videoFile
},
showExperimentalReadAlert() {
return !this.tracks.length && this.ebookFile && !this.showExperimentalFeatures
return !this.tracks.length && this.ebookFile && !this.showExperimentalFeatures && !this.enableEReader
},
description() {
return this.mediaMetadata.description || ''
@@ -383,10 +399,10 @@ export default {
return this.$store.getters['user/getUserCanDownload']
},
showRssFeedBtn() {
if (!this.rssFeedUrl && !this.podcastEpisodes.length) return false // Cannot open RSS feed with no episodes
if (!this.rssFeedUrl && !this.podcastEpisodes.length && !this.tracks.length) return false // Cannot open RSS feed with no episodes/tracks
// If rss feed is open then show feed url to users otherwise just show to admins
return this.isPodcast && (this.userIsAdminOrUp || this.rssFeedUrl)
return this.userIsAdminOrUp || this.rssFeedUrl
}
},
methods: {

View File

@@ -1,16 +1,11 @@
<template>
<div class="page" :class="streamLibraryItem ? 'streaming' : ''">
<div class="flex h-full">
<app-side-rail class="hidden md:block" />
<div class="flex-grow">
<app-book-shelf-toolbar is-home />
<div id="bookshelf" class="w-full h-full p-8 overflow-y-auto">
<div class="flex flex-wrap justify-center">
<template v-for="author in authors">
<cards-author-card :key="author.id" :author="author" :width="160" :height="200" class="p-3" @edit="editAuthor" />
</template>
</div>
</div>
<app-book-shelf-toolbar page="authors" is-home :authors="authors" />
<div id="bookshelf" class="w-full h-full p-8 overflow-y-auto">
<div class="flex flex-wrap justify-center">
<template v-for="author in authors">
<cards-author-card :key="author.id" :author="author" :width="160" :height="200" class="p-3" @edit="editAuthor" />
</template>
</div>
</div>
</div>

View File

@@ -1,12 +1,7 @@
<template>
<div class="page" :class="streamLibraryItem ? 'streaming' : ''">
<div class="flex h-full">
<app-side-rail class="hidden md:block" />
<div class="flex-grow">
<app-book-shelf-toolbar :page="id || ''" :view-mode.sync="viewMode" />
<app-lazy-bookshelf :page="id || ''" :view-mode="viewMode" />
</div>
</div>
<app-book-shelf-toolbar :page="id || ''" :view-mode.sync="viewMode" />
<app-lazy-bookshelf :page="id || ''" :view-mode="viewMode" />
</div>
</template>

View File

@@ -1,12 +1,7 @@
<template>
<div class="page" :class="streamLibraryItem ? 'streaming' : ''">
<div class="flex h-full">
<app-side-rail class="hidden md:block" />
<div class="flex-grow">
<app-book-shelf-toolbar is-home />
<app-book-shelf-categorized />
</div>
</div>
<app-book-shelf-toolbar is-home />
<app-book-shelf-categorized />
</div>
</template>

View File

@@ -1,42 +1,40 @@
<template>
<div class="page" :class="streamLibraryItem ? 'streaming' : ''">
<div class="flex h-full">
<app-side-rail class="hidden md:block" />
<div class="flex-grow">
<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">
<form @submit.prevent="submit" class="flex">
<ui-text-input v-model="searchInput" :disabled="processing" placeholder="Enter search term or RSS feed URL" class="flex-grow mr-2" />
<ui-btn type="submit" :disabled="processing">Submit</ui-btn>
</form>
</div>
<app-book-shelf-toolbar page="podcast-search" />
<div class="w-full max-w-3xl mx-auto py-4">
<p v-if="termSearched && !results.length && !processing" class="text-center text-xl">No podcasts found</p>
<template v-for="podcast in results">
<div :key="podcast.id" class="flex p-1 hover:bg-primary hover:bg-opacity-25 cursor-pointer" @click="selectPodcast(podcast)">
<div class="w-24 min-w-24 h-24 bg-primary">
<img v-if="podcast.cover" :src="podcast.cover" class="h-full w-full" />
</div>
<div class="flex-grow pl-4 max-w-2xl">
<a :href="podcast.pageUrl" class="text-lg text-gray-200 hover:underline" target="_blank" @click.stop>{{ podcast.title }}</a>
<p class="text-base text-gray-300 whitespace-nowrap truncate">by {{ podcast.artistName }}</p>
<p class="text-xs text-gray-400 leading-5">{{ podcast.genres.join(', ') }}</p>
<p class="text-xs text-gray-400 leading-5">{{ podcast.trackCount }} Episodes</p>
</div>
</div>
</template>
</div>
<div class="w-full h-full overflow-y-auto p-12 relative">
<div class="w-full max-w-4xl mx-auto flex">
<form @submit.prevent="submit" class="flex flex-grow">
<ui-text-input v-model="searchInput" :disabled="processing" placeholder="Enter search term or RSS feed URL" class="flex-grow mr-2" />
<ui-btn type="submit" :disabled="processing">Submit</ui-btn>
</form>
<ui-file-input ref="fileInput" :accept="'.opml, .txt'" class="mx-2" @change="opmlFileUpload"> Upload OPML File </ui-file-input>
</div>
<div v-show="processing" class="absolute top-0 left-0 w-full h-full flex items-center justify-center bg-black bg-opacity-25 z-40">
<ui-loading-indicator />
<div class="w-full max-w-3xl mx-auto py-4">
<p v-if="termSearched && !results.length && !processing" class="text-center text-xl">No podcasts found</p>
<template v-for="podcast in results">
<div :key="podcast.id" class="flex p-1 hover:bg-primary hover:bg-opacity-25 cursor-pointer" @click="selectPodcast(podcast)">
<div class="w-24 min-w-24 h-24 bg-primary">
<img v-if="podcast.cover" :src="podcast.cover" class="h-full w-full" />
</div>
<div class="flex-grow pl-4 max-w-2xl">
<a :href="podcast.pageUrl" class="text-lg text-gray-200 hover:underline" target="_blank" @click.stop>{{ podcast.title }}</a>
<p class="text-base text-gray-300 whitespace-nowrap truncate">by {{ podcast.artistName }}</p>
<p class="text-xs text-gray-400 leading-5">{{ podcast.genres.join(', ') }}</p>
<p class="text-xs text-gray-400 leading-5">{{ podcast.trackCount }} Episodes</p>
</div>
</div>
</div>
</template>
</div>
<div v-show="processing" class="absolute top-0 left-0 w-full h-full flex items-center justify-center bg-black bg-opacity-25 z-40">
<ui-loading-indicator />
</div>
</div>
<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>
</template>
@@ -67,7 +65,9 @@ export default {
processing: false,
showNewPodcastModal: false,
selectedPodcast: null,
selectedPodcastFeed: null
selectedPodcastFeed: null,
showOPMLFeedsModal: false,
opmlFeeds: []
}
},
computed: {
@@ -76,6 +76,40 @@ export default {
}
},
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() {
if (!this.searchInput) return

View File

@@ -1,14 +1,9 @@
<template>
<div class="page" :class="streamLibraryItem ? 'streaming' : ''">
<div class="flex h-full">
<app-side-rail class="hidden md:block" />
<div class="flex-grow">
<app-book-shelf-toolbar is-home page="search" :search-query="query" />
<app-book-shelf-categorized v-if="hasResults" ref="bookshelf" search :results="results" />
<div v-else class="w-full py-16">
<p class="text-xl text-center">No Search results for "{{ query }}"</p>
</div>
</div>
<app-book-shelf-toolbar is-home page="search" :search-query="query" />
<app-book-shelf-categorized v-if="hasResults" ref="bookshelf" search :results="results" />
<div v-else class="w-full py-16">
<p class="text-xl text-center">No Search results for "{{ query }}"</p>
</div>
</div>
</template>

View File

@@ -1,12 +1,7 @@
<template>
<div class="page" :class="streamLibraryItem ? 'streaming' : ''">
<div class="flex h-full">
<app-side-rail class="hidden md:block" />
<div class="flex-grow">
<app-book-shelf-toolbar :selected-series="series" />
<app-lazy-bookshelf page="series-books" :series-id="seriesId" />
</div>
</div>
<app-book-shelf-toolbar :selected-series="series" />
<app-lazy-bookshelf page="series-books" :series-id="seriesId" />
</div>
</template>

View File

@@ -1,7 +1,29 @@
<template>
<div class="w-full h-screen bg-bg">
<div class="w-full flex h-1/2 items-center justify-center">
<div class="w-full max-w-md border border-opacity-0 rounded-xl px-8 pb-8 pt-4">
<div class="w-full flex h-full items-center justify-center">
<div v-if="criticalError" class="w-full max-w-md rounded border border-error border-opacity-25 bg-error bg-opacity-10 p-4">
<p class="text-center text-lg font-semibold">Server could not be reached</p>
</div>
<div v-else-if="showInitScreen" class="w-full max-w-lg px-4 md:px-8 pb-8 pt-4">
<p class="text-3xl text-white text-center mb-4">Initial Server Setup</p>
<div class="w-full h-px bg-white bg-opacity-10 my-4" />
<form @submit.prevent="submitServerSetup">
<p class="text-lg font-semibold mb-2 pl-1 text-center">Create Root User</p>
<ui-text-input-with-label v-model="newRoot.username" label="Username" :disabled="processing" class="w-full mb-3 text-sm" />
<ui-text-input-with-label v-model="newRoot.password" label="Password" type="password" :disabled="processing" class="w-full mb-3 text-sm" />
<ui-text-input-with-label v-model="confirmPassword" label="Confirm Password" type="password" :disabled="processing" class="w-full mb-3 text-sm" />
<p class="text-lg font-semibold mt-6 mb-2 pl-1 text-center">Directory Paths</p>
<ui-text-input-with-label v-model="ConfigPath" label="Config Path" disabled class="w-full mb-3 text-sm" />
<ui-text-input-with-label v-model="MetadataPath" label="Metadata Path" disabled class="w-full mb-3 text-sm" />
<div class="w-full flex justify-end py-3">
<ui-btn type="submit" :disabled="processing" color="primary" class="leading-none">{{ processing ? 'Initializing...' : 'Submit' }}</ui-btn>
</div>
</form>
</div>
<div v-else-if="isInit" class="w-full max-w-md px-8 pb-8 pt-4 -mt-40">
<p class="text-3xl text-white text-center mb-4">Login</p>
<div class="w-full h-px bg-white bg-opacity-10 my-4" />
<p v-if="error" class="text-error text-center py-2">{{ error }}</p>
@@ -11,8 +33,8 @@
<label class="text-xs text-gray-300 uppercase">Password</label>
<ui-text-input v-model="password" type="password" :disabled="processing" class="w-full mb-3" />
<div class="w-full flex justify-end">
<button type="submit" :disabled="processing" class="bg-blue-600 hover:bg-blue-800 px-8 py-1 mt-3 rounded-md text-white text-center transition duration-300 ease-in-out focus:outline-none">{{ processing ? 'Checking...' : 'Submit' }}</button>
<div class="w-full flex justify-end py-3">
<ui-btn type="submit" :disabled="processing" color="primary" class="leading-none">{{ processing ? 'Checking...' : 'Submit' }}</ui-btn>
</div>
</form>
</div>
@@ -26,15 +48,33 @@ export default {
data() {
return {
error: null,
criticalError: null,
processing: false,
username: '',
password: null
password: null,
showInitScreen: false,
isInit: false,
newRoot: {
username: 'root',
password: ''
},
confirmPassword: '',
ConfigPath: '',
MetadataPath: ''
}
},
watch: {
user(newVal) {
if (newVal) {
if (this.$route.query.redirect) {
if (!this.$store.state.libraries.currentLibraryId) {
// No libraries available to this user
if (this.$store.getters['user/getIsRoot']) {
// If root user go to config/libraries
this.$router.replace('/config/libraries')
} else {
this.$router.replace('/oops?message=No libraries available')
}
} else if (this.$route.query.redirect) {
this.$router.replace(this.$route.query.redirect)
} else {
this.$router.replace(`/library/${this.$store.state.libraries.currentLibraryId}`)
@@ -48,8 +88,45 @@ export default {
}
},
methods: {
setUser({ user, userDefaultLibraryId, serverSettings }) {
async submitServerSetup() {
if (!this.newRoot.username || !this.newRoot.username.trim()) {
this.$toast.error('Must enter a root username')
return
}
if (this.newRoot.password !== this.confirmPassword) {
this.$toast.error('Password mismatch')
return
}
if (!this.newRoot.password) {
if (!confirm('Are you sure you want to create the root user with no password?')) {
return
}
}
this.processing = true
const payload = {
newRoot: { ...this.newRoot }
}
var success = await this.$axios
.$post('/init', payload)
.then(() => true)
.catch((error) => {
console.error('Failed', error.response)
const errorMsg = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
this.$toast.error(errorMsg)
return false
})
if (!success) {
this.processing = false
return
}
location.reload()
},
setUser({ user, userDefaultLibraryId, serverSettings, Source }) {
this.$store.commit('setServerSettings', serverSettings)
this.$store.commit('setSource', Source)
if (serverSettings.chromecastEnabled) {
console.log('Chromecast enabled import script')
@@ -81,32 +158,54 @@ export default {
this.processing = false
},
checkAuth() {
if (localStorage.getItem('token')) {
var token = localStorage.getItem('token')
var token = localStorage.getItem('token')
if (!token) return false
if (token) {
this.processing = true
this.processing = true
this.$axios
.$post('/api/authorize', null, {
headers: {
Authorization: `Bearer ${token}`
}
})
.then((res) => {
this.setUser(res)
this.processing = false
})
.catch((error) => {
console.error('Authorize error', error)
this.processing = false
})
}
}
return this.$axios
.$post('/api/authorize', null, {
headers: {
Authorization: `Bearer ${token}`
}
})
.then((res) => {
this.setUser(res)
this.processing = false
return true
})
.catch((error) => {
console.error('Authorize error', error)
this.processing = false
return false
})
},
checkStatus() {
this.processing = true
this.$axios
.$get('/status')
.then((res) => {
this.processing = false
this.isInit = res.isInit
this.showInitScreen = !res.isInit
if (this.showInitScreen) {
this.ConfigPath = res.ConfigPath || ''
this.MetadataPath = res.MetadataPath || ''
}
})
.catch((error) => {
console.error('Status check failed', error)
this.processing = false
this.criticalError = 'Status check failed'
})
}
},
mounted() {
this.checkAuth()
async mounted() {
if (localStorage.getItem('token')) {
var userfound = await this.checkAuth()
if (userfound) return // if valid user no need to check status
}
this.checkStatus()
}
}
</script>

View File

@@ -64,8 +64,8 @@
</div>
</div>
<input ref="fileInput" id="hidden-input" 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="fileInput" type="file" multiple :accept="inputAccept" class="hidden" @change="inputChanged" />
<input ref="fileFolderInput" type="file" webkitdirectory multiple :accept="inputAccept" class="hidden" @change="inputChanged" />
</div>
</template>

View File

@@ -52,12 +52,12 @@ export default class CastPlayer extends EventEmitter {
return
}
// var currentItemId = media.currentItemId
var currentItemId = media.media.itemId
if (currentItemId && this.currentTrackIndex !== currentItemId - 1) {
this.currentTrackIndex = currentItemId - 1
}
// TODO: Emit finished event
if (media.playerState !== this.castPlayerState) {
this.emit('stateChange', media.playerState)
this.castPlayerState = media.playerState

View File

@@ -1,7 +1,7 @@
import Hls from 'hls.js'
import EventEmitter from 'events'
export default class LocalPlayer extends EventEmitter {
export default class LocalAudioPlayer extends EventEmitter {
constructor(ctx) {
super()
@@ -76,6 +76,7 @@ export default class LocalPlayer extends EventEmitter {
this.loadCurrentTrack()
} else {
console.log(`[LocalPlayer] Ended`)
this.emit('finished')
}
}
evtError(error) {

View File

@@ -0,0 +1,260 @@
import Hls from 'hls.js'
import EventEmitter from 'events'
export default class LocalVideoPlayer extends EventEmitter {
constructor(ctx) {
super()
this.ctx = ctx
this.player = null
this.libraryItem = null
this.videoTrack = null
this.isHlsTranscode = null
this.hlsInstance = null
this.usingNativeplayer = false
this.startTime = 0
this.playWhenReady = false
this.defaultPlaybackRate = 1
this.playableMimeTypes = []
this.initialize()
}
initialize() {
if (document.getElementById('video-player')) {
document.getElementById('video-player').remove()
}
var videoEl = document.createElement('video')
videoEl.id = 'video-player'
// videoEl.style.display = 'none'
videoEl.className = 'absolute bg-black z-50'
videoEl.style.height = '216px'
videoEl.style.width = '384px'
videoEl.style.bottom = '80px'
videoEl.style.left = '16px'
document.body.appendChild(videoEl)
this.player = videoEl
this.player.addEventListener('play', this.evtPlay.bind(this))
this.player.addEventListener('pause', this.evtPause.bind(this))
this.player.addEventListener('progress', this.evtProgress.bind(this))
this.player.addEventListener('ended', this.evtEnded.bind(this))
this.player.addEventListener('error', this.evtError.bind(this))
this.player.addEventListener('loadedmetadata', this.evtLoadedMetadata.bind(this))
this.player.addEventListener('timeupdate', this.evtTimeupdate.bind(this))
var mimeTypes = ['video/mp4']
var mimeTypeCanPlayMap = {}
mimeTypes.forEach((mt) => {
var canPlay = this.player.canPlayType(mt)
mimeTypeCanPlayMap[mt] = canPlay
if (canPlay) this.playableMimeTypes.push(mt)
})
console.log(`[LocalVideoPlayer] Supported mime types`, mimeTypeCanPlayMap, this.playableMimeTypes)
}
evtPlay() {
this.emit('stateChange', 'PLAYING')
}
evtPause() {
this.emit('stateChange', 'PAUSED')
}
evtProgress() {
var lastBufferTime = this.getLastBufferedTime()
this.emit('buffertimeUpdate', lastBufferTime)
}
evtEnded() {
console.log(`[LocalVideoPlayer] Ended`)
this.emit('finished')
}
evtError(error) {
console.error('Player error', error)
this.emit('error', error)
}
evtLoadedMetadata(data) {
if (!this.isHlsTranscode) {
this.player.currentTime = this.startTime
}
this.emit('stateChange', 'LOADED')
if (this.playWhenReady) {
this.playWhenReady = false
this.play()
}
}
evtTimeupdate() {
if (this.player.paused) {
this.emit('timeupdate', this.getCurrentTime())
}
}
destroy() {
this.destroyHlsInstance()
if (this.player) {
this.player.remove()
}
}
set(libraryItem, videoTrack, isHlsTranscode, startTime, playWhenReady = false) {
this.libraryItem = libraryItem
this.videoTrack = videoTrack
this.isHlsTranscode = isHlsTranscode
this.playWhenReady = playWhenReady
this.startTime = startTime
if (this.hlsInstance) {
this.destroyHlsInstance()
}
if (this.isHlsTranscode) {
this.setHlsStream()
} else {
this.setDirectPlay()
}
}
setHlsStream() {
// iOS does not support Media Elements but allows for HLS in the native video player
if (!Hls.isSupported()) {
console.warn('HLS is not supported - fallback to using video element')
this.usingNativeplayer = true
this.player.src = this.videoTrack.relativeContentUrl
this.player.currentTime = this.startTime
return
}
var hlsOptions = {
startPosition: this.startTime || -1
// No longer needed because token is put in a query string
// xhrSetup: (xhr) => {
// xhr.setRequestHeader('Authorization', `Bearer ${this.token}`)
// }
}
this.hlsInstance = new Hls(hlsOptions)
this.hlsInstance.attachMedia(this.player)
this.hlsInstance.on(Hls.Events.MEDIA_ATTACHED, () => {
this.hlsInstance.loadSource(this.videoTrack.relativeContentUrl)
this.hlsInstance.on(Hls.Events.MANIFEST_PARSED, () => {
console.log('[HLS] Manifest Parsed')
})
this.hlsInstance.on(Hls.Events.ERROR, (e, data) => {
console.error('[HLS] Error', data.type, data.details, data)
if (data.details === Hls.ErrorDetails.BUFFER_STALLED_ERROR) {
console.error('[HLS] BUFFER STALLED ERROR')
}
})
this.hlsInstance.on(Hls.Events.DESTROYING, () => {
console.log('[HLS] Destroying HLS Instance')
})
})
}
setDirectPlay() {
this.player.src = this.videoTrack.relativeContentUrl
console.log(`[LocalVideoPlayer] Loading track src ${this.videoTrack.relativeContentUrl}`)
this.player.load()
}
destroyHlsInstance() {
if (!this.hlsInstance) return
if (this.hlsInstance.destroy) {
var temp = this.hlsInstance
temp.destroy()
}
this.hlsInstance = null
}
async resetStream(startTime) {
this.destroyHlsInstance()
await new Promise((resolve) => setTimeout(resolve, 1000))
this.set(this.libraryItem, this.videoTrack, this.isHlsTranscode, startTime, true)
}
playPause() {
if (!this.player) return
if (this.player.paused) this.play()
else this.pause()
}
play() {
if (this.player) this.player.play()
}
pause() {
if (this.player) this.player.pause()
}
getCurrentTime() {
return this.player ? this.player.currentTime : 0
}
getDuration() {
return this.videoTrack.duration
}
setPlaybackRate(playbackRate) {
if (!this.player) return
this.defaultPlaybackRate = playbackRate
this.player.playbackRate = playbackRate
}
seek(time) {
if (!this.player) return
this.player.currentTime = Math.max(0, time)
}
setVolume(volume) {
if (!this.player) return
this.player.volume = volume
}
// Utils
isValidDuration(duration) {
if (duration && !isNaN(duration) && duration !== Number.POSITIVE_INFINITY && duration !== Number.NEGATIVE_INFINITY) {
return true
}
return false
}
getBufferedRanges() {
if (!this.player) return []
const ranges = []
const seekable = this.player.buffered || []
let offset = 0
for (let i = 0, length = seekable.length; i < length; i++) {
let start = seekable.start(i)
let end = seekable.end(i)
if (!this.isValidDuration(start)) {
start = 0
}
if (!this.isValidDuration(end)) {
end = 0
continue
}
ranges.push({
start: start + offset,
end: end + offset
})
}
return ranges
}
getLastBufferedTime() {
var bufferedRanges = this.getBufferedRanges()
if (!bufferedRanges.length) return 0
var buff = bufferedRanges.find((buff) => buff.start < this.player.currentTime && buff.end > this.player.currentTime)
if (buff) return buff.end
var last = bufferedRanges[bufferedRanges.length - 1]
return last.end
}
}

View File

@@ -1,6 +1,8 @@
import LocalPlayer from './LocalPlayer'
import LocalAudioPlayer from './LocalAudioPlayer'
import LocalVideoPlayer from './LocalVideoPlayer'
import CastPlayer from './CastPlayer'
import AudioTrack from './AudioTrack'
import VideoTrack from './VideoTrack'
export default class PlayerHandler {
constructor(ctx) {
@@ -14,6 +16,7 @@ export default class PlayerHandler {
this.player = null
this.playerState = 'IDLE'
this.isHlsTranscode = false
this.isVideo = false
this.currentSessionId = null
this.startTime = 0
@@ -34,7 +37,7 @@ export default class PlayerHandler {
return this.libraryItem && (this.player instanceof CastPlayer)
}
get isPlayingLocalItem() {
return this.libraryItem && (this.player instanceof LocalPlayer)
return this.libraryItem && (this.player instanceof LocalAudioPlayer)
}
get userToken() {
return this.ctx.$store.getters['user/getToken']
@@ -48,16 +51,17 @@ export default class PlayerHandler {
}
load(libraryItem, episodeId, playWhenReady, playbackRate) {
if (!this.player) this.switchPlayer()
this.libraryItem = libraryItem
this.episodeId = episodeId
this.playWhenReady = playWhenReady
this.initialPlaybackRate = playbackRate
this.prepare()
this.isVideo = libraryItem.mediaType === 'video'
if (!this.player) this.switchPlayer(playWhenReady)
else this.prepare()
}
switchPlayer() {
switchPlayer(playWhenReady) {
if (this.isCasting && !(this.player instanceof CastPlayer)) {
console.log('[PlayerHandler] Switching to cast player')
@@ -73,10 +77,10 @@ export default class PlayerHandler {
if (this.libraryItem) {
// libraryItem was already loaded - prepare for cast
this.playWhenReady = false
this.playWhenReady = playWhenReady
this.prepare()
}
} else if (!this.isCasting && !(this.player instanceof LocalPlayer)) {
} else if (!this.isCasting && !(this.player instanceof LocalAudioPlayer) && !(this.player instanceof LocalVideoPlayer)) {
console.log('[PlayerHandler] Switching to local player')
this.stopPlayInterval()
@@ -85,12 +89,18 @@ export default class PlayerHandler {
if (this.player) {
this.player.destroy()
}
this.player = new LocalPlayer(this.ctx)
if (this.isVideo) {
this.player = new LocalVideoPlayer(this.ctx)
} else {
this.player = new LocalAudioPlayer(this.ctx)
}
this.setPlayerListeners()
if (this.libraryItem) {
// libraryItem was already loaded - prepare for local play
this.playWhenReady = false
this.playWhenReady = playWhenReady
this.prepare()
}
}
@@ -101,16 +111,27 @@ export default class PlayerHandler {
this.player.on('timeupdate', this.playerTimeupdate.bind(this))
this.player.on('buffertimeUpdate', this.playerBufferTimeUpdate.bind(this))
this.player.on('error', this.playerError.bind(this))
this.player.on('finished', this.playerFinished.bind(this))
}
playerError() {
// Switch to HLS stream on error
if (!this.isCasting && !this.currentStreamId && (this.player instanceof LocalPlayer)) {
if (!this.isCasting && !this.currentStreamId && (this.player instanceof LocalAudioPlayer)) {
console.log(`[PlayerHandler] Audio player error switching to HLS stream`)
this.prepare(true)
}
}
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) {
console.log('[PlayerHandler] Player state change', state)
this.playerState = state
@@ -144,7 +165,7 @@ export default class PlayerHandler {
supportedMimeTypes: this.player.playableMimeTypes,
mediaPlayer: this.isCasting ? 'chromecast' : 'html5',
forceTranscode,
forceDirectPlay: this.isCasting // TODO: add transcode support for chromecast
forceDirectPlay: this.isCasting || this.isVideo // TODO: add transcode support for chromecast
}
var path = this.episodeId ? `/api/items/${this.libraryItem.id}/play/${this.episodeId}` : `/api/items/${this.libraryItem.id}/play`
@@ -155,11 +176,12 @@ export default class PlayerHandler {
}
prepareOpenSession(session, playbackRate) { // Session opened on init socket
if (!this.player) this.switchPlayer()
this.libraryItem = session.libraryItem
this.isVideo = session.libraryItem.mediaType === 'video'
this.playWhenReady = false
this.initialPlaybackRate = playbackRate
if (!this.player) this.switchPlayer()
this.prepareSession(session)
}
@@ -170,15 +192,31 @@ export default class PlayerHandler {
this.displayAuthor = session.displayAuthor
console.log('[PlayerHandler] Preparing Session', session)
var audioTracks = session.audioTracks.map(at => new AudioTrack(at, this.userToken))
this.ctx.playerLoading = true
this.isHlsTranscode = true
if (session.playMethod === this.ctx.$constants.PlayMethod.DIRECTPLAY) {
this.isHlsTranscode = false
if (session.videoTrack) {
var videoTrack = new VideoTrack(session.videoTrack, this.userToken)
this.ctx.playerLoading = true
this.isHlsTranscode = true
if (session.playMethod === this.ctx.$constants.PlayMethod.DIRECTPLAY) {
this.isHlsTranscode = false
}
this.player.set(this.libraryItem, videoTrack, this.isHlsTranscode, this.startTime, this.playWhenReady)
} else {
var audioTracks = session.audioTracks.map(at => new AudioTrack(at, this.userToken))
this.ctx.playerLoading = true
this.isHlsTranscode = true
if (session.playMethod === this.ctx.$constants.PlayMethod.DIRECTPLAY) {
this.isHlsTranscode = false
}
this.player.set(this.libraryItem, audioTracks, this.isHlsTranscode, this.startTime, this.playWhenReady)
}
this.player.set(this.libraryItem, audioTracks, this.isHlsTranscode, this.startTime, this.playWhenReady)
// browser media session api
this.ctx.setMediaSession()
}
closePlayer() {

View File

@@ -0,0 +1,32 @@
export default class VideoTrack {
constructor(track, userToken) {
this.index = track.index || 0
this.startOffset = track.startOffset || 0 // Total time of all previous tracks
this.duration = track.duration || 0
this.title = track.title || ''
this.contentUrl = track.contentUrl || null
this.mimeType = track.mimeType
this.metadata = track.metadata || {}
this.userToken = userToken
}
get fullContentUrl() {
if (!this.contentUrl || this.contentUrl.startsWith('http')) return this.contentUrl
if (process.env.NODE_ENV === 'development') {
return `${process.env.serverUrl}${this.contentUrl}?token=${this.userToken}`
}
return `${window.location.origin}${this.contentUrl}?token=${this.userToken}`
}
get relativeContentUrl() {
if (!this.contentUrl || this.contentUrl.startsWith('http')) return this.contentUrl
if (process.env.NODE_ENV === 'development') {
return `${process.env.serverUrl}${this.contentUrl}?token=${this.userToken}`
}
return this.contentUrl + `?token=${this.userToken}`
}
}

View File

@@ -1,6 +1,6 @@
const SupportedFileTypes = {
image: ['png', 'jpg', 'jpeg', 'webp'],
audio: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'mp4', 'aac', 'wma', 'aiff'],
audio: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'mp4', 'aac', 'wma', 'aiff', 'wav'],
ebook: ['epub', 'pdf', 'mobi', 'azw3', 'cbr', 'cbz'],
info: ['nfo'],
text: ['txt'],
@@ -28,7 +28,8 @@ const BookshelfView = {
const PlayMethod = {
DIRECTPLAY: 0,
DIRECTSTREAM: 1,
TRANSCODE: 2
TRANSCODE: 2,
LOCAL: 3
}
const Constants = {

View File

@@ -1,4 +1,5 @@
import Vue from 'vue'
import Path from 'path'
import vClickOutside from 'v-click-outside'
import { formatDistance, format, addDays, isDate } from 'date-fns'
@@ -57,6 +58,7 @@ Vue.prototype.$elapsedPretty = (seconds, useFullNames = false) => {
}
Vue.prototype.$secondsToTimestamp = (seconds) => {
if (!seconds) return '0:00'
var _seconds = seconds
var _minutes = Math.floor(seconds / 60)
_seconds -= _minutes * 60
@@ -114,22 +116,40 @@ Vue.prototype.$calculateTextSize = (text, styles = {}) => {
}
}
Vue.prototype.$sanitizeFilename = (input, replacement = '') => {
Vue.prototype.$sanitizeFilename = (input, colonReplacement = ' - ') => {
if (typeof input !== 'string') {
return false
}
var illegalRe = /[\/\?<>\\:\*\|"]/g;
var controlRe = /[\x00-\x1f\x80-\x9f]/g;
var reservedRe = /^\.+$/;
var windowsReservedRe = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i;
var windowsTrailingRe = /[\. ]+$/;
// Max is actually 255-260 for windows but this leaves padding incase ext wasnt put on yet
const MAX_FILENAME_LEN = 240
var replacement = ''
var illegalRe = /[\/\?<>\\:\*\|"]/g
var controlRe = /[\x00-\x1f\x80-\x9f]/g
var reservedRe = /^\.+$/
var windowsReservedRe = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i
var windowsTrailingRe = /[\. ]+$/
var lineBreaks = /[\n\r]/g
var sanitized = input
.replace(':', colonReplacement) // Replace first occurrence of a colon
.replace(illegalRe, replacement)
.replace(controlRe, replacement)
.replace(reservedRe, replacement)
.replace(lineBreaks, replacement)
.replace(windowsReservedRe, replacement)
.replace(windowsTrailingRe, replacement);
.replace(windowsTrailingRe, replacement)
if (sanitized.length > MAX_FILENAME_LEN) {
var lenToRemove = sanitized.length - MAX_FILENAME_LEN
var ext = Path.extname(sanitized)
var basename = Path.basename(sanitized, ext)
basename = basename.slice(0, basename.length - lenToRemove)
sanitized = basename + ext
}
return sanitized
}
@@ -161,17 +181,26 @@ Vue.prototype.$sanitizeSlug = (str) => {
Vue.prototype.$copyToClipboard = (str, ctx) => {
return new Promise((resolve) => {
if (!navigator.clipboard) {
console.warn('Clipboard not supported')
return resolve(false)
navigator.clipboard.writeText(str).then(() => {
if (ctx) ctx.$toast.success('Copied to clipboard')
resolve(true)
}, (err) => {
console.error('Clipboard copy failed', str, err)
resolve(false)
})
} else {
const el = document.createElement('textarea')
el.value = str
el.setAttribute('readonly', '')
el.style.position = 'absolute'
el.style.left = '-9999px'
document.body.appendChild(el)
el.select()
document.execCommand('copy')
document.body.removeChild(el)
if (ctx) ctx.$toast.success('Copied to clipboard')
}
navigator.clipboard.writeText(str).then(() => {
console.log('Clipboard copy success', str)
ctx.$toast.success('Copied to clipboard')
resolve(true)
}, (err) => {
console.error('Clipboard copy failed', str, err)
resolve(false)
})
})
}

View File

@@ -23,14 +23,17 @@ function parseSemver(ver) {
}
return null
}
export const currentVersion = packagejson.version
export async function checkForUpdate() {
if (!packagejson.version) {
return
return null
}
var currVerObj = parseSemver('v' + packagejson.version)
if (!currVerObj) {
console.error('Invalid version', packagejson.version)
return
return null
}
var largestVer = null
await axios.get(`https://api.github.com/repos/advplyr/audiobookshelf/releases`).then((res) => {
@@ -49,7 +52,7 @@ export async function checkForUpdate() {
})
if (!largestVer) {
console.error('No valid version tags to compare with')
return
return null
}
return {

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 209 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 512 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

View File

@@ -6,11 +6,11 @@ export const state = () => ({
showUserCollectionsModal: false,
showEditCollectionModal: false,
showEditPodcastEpisode: false,
showViewPodcastEpisodeModal: false,
showEditAuthorModal: false,
selectedEpisode: null,
selectedCollection: null,
selectedAuthor: null,
showBookshelfTextureModal: false,
isCasting: false, // Actively casting
isChromecastInitialized: false // Script loaded
})
@@ -53,6 +53,9 @@ export const mutations = {
setShowEditPodcastEpisodeModal(state, val) {
state.showEditPodcastEpisode = val
},
setShowViewPodcastEpisodeModal(state, val) {
state.showViewPodcastEpisodeModal = val
},
setEditCollection(state, collection) {
state.selectedCollection = collection
state.showEditCollectionModal = true
@@ -60,9 +63,6 @@ export const mutations = {
setSelectedEpisode(state, episode) {
state.selectedEpisode = episode
},
setShowBookshelfTextureModal(state, val) {
state.showBookshelfTextureModal = val
},
showEditAuthorModal(state, author) {
state.selectedAuthor = author
state.showEditAuthorModal = true

View File

@@ -1,12 +1,14 @@
import { checkForUpdate } from '@/plugins/version'
import { checkForUpdate, currentVersion } from '@/plugins/version'
import Vue from 'vue'
export const state = () => ({
Source: null,
versionData: null,
serverSettings: null,
streamLibraryItem: null,
streamEpisodeId: null,
streamIsPlaying: false,
playerIsFullscreen: false,
editModalTab: 'details',
showEditModal: false,
showEReader: false,
@@ -19,7 +21,8 @@ export const state = () => ({
backups: [],
bookshelfBookIds: [],
openModal: null,
selectedBookshelfTexture: '/textures/wood_default.jpg'
innerModalOpen: false,
lastBookshelfScrollData: {}
})
export const getters = {
@@ -62,24 +65,57 @@ export const actions = {
})
},
checkForUpdate({ commit }) {
return checkForUpdate()
.then((res) => {
commit('setVersionData', res)
return res
})
.catch((error) => {
console.error('Update check failed', error)
return false
})
},
setBookshelfTexture({ commit, state }, img) {
let root = document.documentElement;
commit('setBookshelfTexture', img)
root.style.setProperty('--bookshelf-texture-img', `url(${img})`);
const VERSION_CHECK_BUFF = 1000 * 60 * 5 // 5 minutes
var lastVerCheck = localStorage.getItem('lastVerCheck') || 0
var savedVersionData = localStorage.getItem('versionData')
if (savedVersionData) {
try {
savedVersionData = JSON.parse(localStorage.getItem('versionData'))
} catch (error) {
console.error('Failed to parse version data', error)
savedVersionData = null
localStorage.removeItem('versionData')
}
}
var shouldCheckForUpdate = Date.now() - Number(lastVerCheck) > VERSION_CHECK_BUFF
if (!shouldCheckForUpdate && savedVersionData && savedVersionData.version !== currentVersion) {
// Version mismatch between saved data so check for update anyway
shouldCheckForUpdate = true
}
if (shouldCheckForUpdate) {
return checkForUpdate()
.then((res) => {
if (res) {
localStorage.setItem('lastVerCheck', Date.now())
localStorage.setItem('versionData', JSON.stringify(res))
commit('setVersionData', res)
}
return res && res.hasUpdate
})
.catch((error) => {
console.error('Update check failed', error)
return false
})
} else if (savedVersionData) {
commit('setVersionData', savedVersionData)
}
return null
}
}
export const mutations = {
setSource(state, source) {
state.Source = source
},
setPlayerIsFullscreen(state, val) {
state.playerIsFullscreen = val
},
setLastBookshelfScrollData(state, { scrollTop, path, name }) {
state.lastBookshelfScrollData[name] = { scrollTop, path }
},
setBookshelfBookIds(state, val) {
state.bookshelfBookIds = val || []
},
@@ -169,7 +205,7 @@ export const mutations = {
setOpenModal(state, val) {
state.openModal = val
},
setBookshelfTexture(state, val) {
state.selectedBookshelfTexture = val
setInnerModalOpen(state, val) {
state.innerModalOpen = val
}
}

View File

@@ -2,7 +2,7 @@ export const state = () => ({
libraries: [],
lastLoad: 0,
listeners: [],
currentLibraryId: 'main',
currentLibraryId: null,
folders: [],
issues: 0,
folderLastUpdate: 0,
@@ -47,12 +47,7 @@ export const getters = {
export const actions = {
requestLibraryScan({ state, commit }, { libraryId, force }) {
this.$axios.$get(`/api/libraries/${libraryId}/scan`, { params: { force } }).then(() => {
this.$toast.success('Library scan started')
}).catch((error) => {
console.error('Failed to start scan', error)
this.$toast.error('Failed to start scan')
})
return this.$axios.$get(`/api/libraries/${libraryId}/scan`, { params: { force } })
},
loadFolders({ state, commit }) {
if (state.folders.length) {
@@ -206,11 +201,11 @@ export const mutations = {
setLibraryFilterData(state, filterData) {
state.filterData = filterData
},
updateFilterDataWithAudiobook(state, audiobook) {
if (!audiobook || !audiobook.book || !state.filterData) return
if (state.currentLibraryId !== audiobook.libraryId) return
updateFilterDataWithItem(state, libraryItem) {
if (!libraryItem || !state.filterData) return
if (state.currentLibraryId !== libraryItem.libraryId) return
/*
var filterdata = {
var data = {
authors: [],
genres: [],
tags: [],
@@ -219,36 +214,70 @@ export const mutations = {
languages: []
}
*/
var mediaMetadata = libraryItem.media.metadata
if (audiobook.book.authorFL) {
audiobook.book.authorFL.split(', ').forEach((author) => {
if (author && !state.filterData.authors.includes(author)) {
// Add/update book authors
if (mediaMetadata.authors && mediaMetadata.authors.length) {
mediaMetadata.authors.forEach((author) => {
var indexOf = state.filterData.authors.findIndex(au => au.id === author.id)
if (indexOf >= 0) {
state.filterData.authors.splice(indexOf, 1, author)
} else {
state.filterData.authors.push(author)
state.filterData.authors.sort((a, b) => (a.name || '').localeCompare((b.name || '')))
}
})
}
if (audiobook.book.narratorFL) {
audiobook.book.narratorFL.split(', ').forEach((narrator) => {
if (narrator && !state.filterData.narrators.includes(narrator)) {
// Add/update series
if (mediaMetadata.series && mediaMetadata.series.length) {
mediaMetadata.series.forEach((series) => {
var indexOf = state.filterData.series.findIndex(se => se.id === series.id)
if (indexOf >= 0) {
state.filterData.series.splice(indexOf, 1, { id: series.id, name: series.name })
} else {
state.filterData.series.push({ id: series.id, name: series.name })
state.filterData.series.sort((a, b) => (a.name || '').localeCompare((b.name || '')))
}
})
}
// Add genres
if (mediaMetadata.genres && mediaMetadata.genres.length) {
mediaMetadata.genres.forEach((genre) => {
if (!state.filterData.genres.includes(genre)) {
state.filterData.genres.push(genre)
state.filterData.genres.sort((a, b) => a.localeCompare(b))
}
})
}
// Add tags
if (libraryItem.media.tags && libraryItem.media.tags.length) {
libraryItem.media.tags.forEach((tag) => {
if (!state.filterData.tags.includes(tag)) {
state.filterData.tags.push(tag)
state.filterData.tags.sort((a, b) => a.localeCompare(b))
}
})
}
// Add narrators
if (mediaMetadata.narrators && mediaMetadata.narrators.length) {
mediaMetadata.narrators.forEach((narrator) => {
if (!state.filterData.narrators.includes(narrator)) {
state.filterData.narrators.push(narrator)
state.filterData.narrators.sort((a, b) => a.localeCompare(b))
}
})
}
if (audiobook.book.series && !state.filterData.series.includes(audiobook.book.series)) {
state.filterData.series.push(audiobook.book.series)
}
if (audiobook.tags && audiobook.tags.length) {
audiobook.tags.forEach((tag) => {
if (tag && !state.filterData.tags.includes(tag)) state.filterData.tags.push(tag)
})
}
if (audiobook.book.genres && audiobook.book.genres.length) {
audiobook.book.genres.forEach((genre) => {
if (genre && !state.filterData.genres.includes(genre)) state.filterData.genres.push(genre)
})
}
if (audiobook.book.language && !state.filterData.languages.includes(audiobook.book.language)) {
state.filterData.languages.push(audiobook.book.language)
// Add language
if (mediaMetadata.language) {
if (!state.filterData.languages.includes(mediaMetadata.language)) {
state.filterData.languages.push(mediaMetadata.language)
state.filterData.languages.sort((a, b) => a.localeCompare(b))
}
}
}
}

View File

@@ -8,7 +8,8 @@ module.exports = {
'bg-red-600',
'text-green-500',
'py-1.5',
'bg-info'
'bg-info',
'px-1.5'
]
}
},
@@ -37,6 +38,7 @@ module.exports = {
minWidth: {
'6': '1.5rem',
'12': '3rem',
'16': '4rem',
'24': '6rem',
'32': '8rem',
'48': '12rem',
@@ -75,6 +77,9 @@ module.exports = {
mono: ['Ubuntu Mono', ...defaultTheme.fontFamily.mono],
book: ['Gentium Book Basic', 'serif']
},
fontSize: {
xxs: '0.625rem'
},
zIndex: {
'50': 50
}

View File

@@ -1,4 +1,4 @@
if(process.env.TOKEN_SECRET == null) process.env.TOKEN_SECRET = '09f26e402586e2faa8da4c98a35f1b20d6b033c6097befa8be3486a829587fe2f90a832bd3ff9d42710a4da095a2ce285b009f0c3730cd9b8e1af3eb84df6611'
if (process.env.TOKEN_SECRET == null) process.env.TOKEN_SECRET = '09f26e402586e2faa8da4c98a35f1b20d6b033c6097befa8be3486a829587fe2f90a832bd3ff9d42710a4da095a2ce285b009f0c3730cd9b8e1af3eb84df6611'
const server = require('./server/Server')
global.appRoot = __dirname
@@ -9,20 +9,20 @@ if (isDev) {
process.env.PORT = devEnv.Port
process.env.CONFIG_PATH = devEnv.ConfigPath
process.env.METADATA_PATH = devEnv.MetadataPath
process.env.AUDIOBOOK_PATH = devEnv.AudiobookPath
process.env.FFMPEG_PATH = devEnv.FFmpegPath
process.env.FFPROBE_PATH = devEnv.FFProbePath
process.env.SOURCE = 'local'
}
const PORT = process.env.PORT || 80
const HOST = process.env.HOST || '0.0.0.0'
const CONFIG_PATH = process.env.CONFIG_PATH || '/config'
const AUDIOBOOK_PATH = process.env.AUDIOBOOK_PATH || '/audiobooks'
const METADATA_PATH = process.env.METADATA_PATH || '/metadata'
const UID = process.env.AUDIOBOOKSHELF_UID || 99
const GID = process.env.AUDIOBOOKSHELF_GID || 100
const SOURCE = process.env.SOURCE || 'docker'
console.log('Config', CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH)
console.log('Config', CONFIG_PATH, METADATA_PATH)
const Server = new server(PORT, HOST, UID, GID, CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH)
const Server = new server(SOURCE, PORT, HOST, UID, GID, CONFIG_PATH, METADATA_PATH)
Server.start()

1285
package-lock.json generated
View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,14 @@
{
"name": "audiobookshelf",
"version": "2.0.13",
"description": "Self-hosted audiobook server for managing and playing audiobooks",
"version": "2.0.18",
"description": "Self-hosted audiobook and podcast server",
"main": "index.js",
"scripts": {
"dev": "node index.js",
"start": "node index.js",
"client": "cd client && npm install && npm run generate",
"prod": "npm run client && npm install && node prod.js",
"build-win": "pkg -t node16-win-x64 -o ./dist/win/audiobookshelf .",
"client": "cd client && npm ci && npm run generate",
"prod": "npm run client && npm ci && node prod.js",
"build-win": "npm run client && pkg -t node16-win-x64 -o ./dist/win/audiobookshelf -C GZip .",
"build-linux": "build/linuxpackager",
"docker": "docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 --push . -t advplyr/audiobookshelf",
"deploy": "node dist/autodeploy"
@@ -38,6 +38,7 @@
"fast-sort": "^3.1.1",
"fluent-ffmpeg": "^2.1.2",
"fs-extra": "^10.0.0",
"htmlparser2": "^8.0.1",
"image-type": "^4.1.0",
"jsonwebtoken": "^8.5.1",
"libgen": "^2.1.0",
@@ -49,9 +50,6 @@
"read-chunk": "^3.1.0",
"recursive-readdir-async": "^1.1.8",
"socket.io": "^4.4.1",
"string-strip-html": "^8.3.0",
"watcher": "^1.2.0",
"xml2js": "^0.4.23"
},
"devDependencies": {}
}
}

Some files were not shown because too many files have changed in this diff Show More