Compare commits

...

218 Commits

Author SHA1 Message Date
advplyr
67f51c6de9 Version bump 2.0.22 2022-06-18 18:43:19 -05:00
advplyr
0c8fd6ab0e Update:Uploader to treat audio files as separate audiobooks if uploading only audio files #670 2022-06-18 16:44:20 -05:00
advplyr
5452a57a14 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-06-18 13:11:20 -05:00
advplyr
19f020e7a6 Fix:Open playback session on init #743 2022-06-18 13:11:15 -05:00
advplyr
825641f2a9 Merge pull request #742 from mrdth/feature/fetch-author-image
Fetch author photo from external URL
2022-06-18 12:10:53 -05:00
advplyr
35ab4cb2fe Update photo url input to photo path/url to be consistent with item covers 2022-06-18 12:05:30 -05:00
advplyr
fd13607d89 Update:Logs to use server local timezone instead of UTC #656 2022-06-18 11:34:28 -05:00
mrdth
f79b4d44b9 Fetch author photo from external URL
Add a new text field 'Photo URL' on the author edit modal, if there
is no existing image for an author.

When submitted, the image is saved from the URL provided
2022-06-18 17:04:38 +01:00
advplyr
91e30a6e84 Merge pull request #736 from cassieesposito/dateFormat
Date format
2022-06-18 10:34:32 -05:00
advplyr
8ab0f164a6 Update server settings ui and add display settings section 2022-06-18 10:32:12 -05:00
advplyr
578bb03404 Merge pull request #730 from jmt-gh/issue_719
Properly support multiple narrators when Matching (#719)
2022-06-18 09:32:46 -05:00
advplyr
06582b5371 Merge pull request #739 from jmt-gh/mobile_responsive_search
Show search bar on mobile responsive views (fix #715)
2022-06-18 09:30:17 -05:00
Cassie Esposito
7f6baf35b7 Merge branch 'advplyr:master' into dateFormat 2022-06-17 20:58:05 -07:00
advplyr
6227d0baa1 Update 2022-06-17 19:09:03 -05:00
advplyr
e334b585be Libraries dropdown updates for mobile 2022-06-17 16:50:46 -05:00
advplyr
c83b3f19f7 Merge pull request #735 from samosbor/master
Update BookFinder.js: Retry Search with Clean Title and Author if No Books Found
2022-06-17 15:46:57 -05:00
Sam
d31ec055f9 check if cleantitle and cleanauthor are different 2022-06-17 10:31:31 -07:00
jmt-gh
38c259a45e initial commit for responsive search bar on mobile 2022-06-17 07:48:42 -07:00
Cassie Esposito
b2ee24de98 Applied dateFormat setting to alternative bookshelf view 2022-06-17 01:35:24 -07:00
Cassie Esposito
9ba0e52bb7 Added an option to change date format 2022-06-17 01:26:10 -07:00
Sam
edc712e6f6 Merge branch 'master' of https://github.com/samosbor/audiobookshelf 2022-06-17 01:14:19 -07:00
Sam
485888b2d9 list includes syntax 2022-06-16 22:37:36 -07:00
advplyr
c2e90d4d83 Merge pull request #734 from jmt-gh/add_local_docker_build_commands
Add npm commands to build local docker containers
2022-06-16 17:09:48 -05:00
advplyr
f5d89b8f52 Merge pull request #733 from jmt-gh/multiline_pills
Update pills so that they handle multi-line properly
2022-06-16 17:08:59 -05:00
advplyr
378b40790a Merge branch 'master' into multiline_pills 2022-06-16 17:08:47 -05:00
advplyr
be3d38392d Merge pull request #732 from jmt-gh/update_pills
Update details "pills" so they don't overlap when they flow to new lines
2022-06-16 17:07:21 -05:00
Sam Osborne
27fef50983 retry search with clean title author 2022-06-16 13:48:37 -07:00
jmt-gh
167df85c1e add npm commands to build local docker containers 2022-06-15 22:23:36 -07:00
jmt-gh
009e16c9a4 add break-all for multiline support for long entries 2022-06-15 22:12:56 -07:00
jmt-gh
b40cc767b2 add margin on y so pills don't touch when they overflow 2022-06-15 20:49:27 -07:00
jmt-gh
4f5f2d32be Properly support multiple narrators when Matching
This commit adds proper support for multiple narrators when matching. We
were accidentally missing a split in the same way we handle it for
genres and tags
2022-06-15 20:23:56 -07:00
advplyr
66be3e0281 Update:Clear global search input when clicking dropdown item #723 2022-06-15 12:35:50 -05:00
advplyr
987f188f00 Merge pull request #720 from jmt-gh/fix_formatting
Fix indents from 4 spaces to 2 in parseOverdriveMediaMarkers.js
2022-06-14 13:28:11 -05:00
jmt-gh
daca2bdf2a fix spacing from 4 to 2 2022-06-13 22:23:02 -07:00
advplyr
8894f52439 Merge pull request #716 from jmt-gh/abs_overdrive
Add support for leveraging chapter data directly from Overdrive mp3s during scanning
2022-06-13 18:25:44 -05:00
jmt-gh
863f81e55a remove logger 2022-06-12 20:43:20 -07:00
jmt-gh
d03d3735e5 Add chapter end time support 2022-06-12 20:28:09 -07:00
advplyr
3bb2df6e12 Fix:Abs metadata parser to allow second equals sign in value #634 2022-06-12 17:47:14 -05:00
advplyr
80c9efc618 Update:Make m4b timeout to 30 mins 2022-06-12 15:59:36 -05:00
jmt-gh
3279901ab0 remove clientside changes 2022-06-12 12:24:29 -07:00
jmt-gh
d43d351721 remove loggers 2022-06-12 02:03:26 -07:00
jmt-gh
8210eba439 clean up loggers 2022-06-12 01:57:00 -07:00
jmt-gh
cbd7294b0b add getter to libraryscan.js for overdrivemediamarker 2022-06-12 01:54:58 -07:00
jmt-gh
6064e8af87 fix using OMMs with regular scan option 2022-06-12 01:46:50 -07:00
jmt-gh
8754f0c25f Cleaning up server code
Doing some literal cleaning
2022-06-12 01:38:09 -07:00
jmt-gh
f31700f668 update comments 2022-06-12 00:54:34 -07:00
jmt-gh
9877b139f6 add new parser for overdrive media markers 2022-06-12 00:53:56 -07:00
jmt-gh
5643c846ee Fix bug for certain scan types
Needed to look in to scanOptions to access the properties I wanted.
It's..... unclear to me if this needs to be done for those other ones as
well. I think so?
2022-06-12 00:47:54 -07:00
jmt-gh
5a071babe9 added library/ to .gitignore 2022-06-12 00:22:04 -07:00
jmt-gh
3d85d0bce6 add settings page setting 2022-06-12 00:21:37 -07:00
jmt-gh
68afc2c718 Add support for various scan types
This commit adds support for the various scan types, and ensures that we
only run Overdrive parsing on files that can actually support it
2022-06-11 23:56:36 -07:00
jmt-gh
b3d9323f66 Initial commit for server side approach
This is the first commit for bringing this over to the server side.

It works! Right now it fails if the autoscanner or or the manual
individual book scanner try to do it's thing. I'll need to update those
2022-06-11 23:17:22 -07:00
jmt-gh
effc63755b remove unused console.log 2022-06-11 11:07:49 -07:00
jmt-gh
b90934a72a remove unused modal 2022-06-11 11:06:19 -07:00
jmt-gh
e01748eb2f Full support for generating/applying chapter data
This commit adds the rest of the support for actually being able to use
Overdrive MediaMarkers.

Shoutout to benonymity's project OverdriveChapterize, where I was able
to port over the logic to actually do the timestamp conversions

https://github.com/benonymity/OverdriveChapterizer/blob/main/chapters.py

I still need to do a lot of cleanup of the actual code, and finish the UI.
2022-06-11 10:57:17 -07:00
jmt-gh
430fbf5e46 Parse out mediamarkers from all files
This commit updates the logic in
generateChaptersFromOverdriveMediaMarkers to create a single array of
objects that holds all of the clean MediaMarker data. it still needs to
be conveted to the NewChapters format.

I should also rename this function to "cleanChaptersFromOMM", but I"ll
do that later
2022-06-11 09:34:22 -07:00
jmt-gh
27e6b9ce0d DRAFT client support for overdrive media markers
Initial client side support. Still a good amount to do. Specifically
around actually parsing out all of the media markers, and generating a
single chapter object that can be applied
2022-06-11 02:01:37 -07:00
jmt-gh
fc614b9833 Add support for overdrive media marker file tag
This commit adds serverside support for grabbing the
overdrive_media_marker file tag that exists on mp3 files from overdrive
2022-06-11 02:00:07 -07:00
advplyr
a97c102369 Version bump 2.0.21 2022-06-09 18:24:03 -05:00
advplyr
745a491f90 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-06-09 17:56:22 -05:00
advplyr
b2880ab0a9 Fix:Match tab #708 2022-06-09 17:56:16 -05:00
advplyr
f916454c55 Merge pull request #707 from jmt-gh/fix_library_scanning
Fix library scanning failing
2022-06-09 04:54:08 -05:00
jmt-gh
701b8ea12e Fix bug with library scanning introduced in #697
Looks like #697 missed a reference update that caused scanning libraries
to fail. This fixes that
2022-06-08 19:15:35 -07:00
advplyr
2079942ccd Merge pull request #697 from jvanbruegge/patch-1
Use `show` and `episode_id` tags for audiobook series
2022-06-08 16:10:28 -05:00
advplyr
140b718592 Merge pull request #699 from jmt-gh/698_metadata_downloads_not_created
Update some instances of mkdir to ensureDir (#698)
2022-06-08 16:05:19 -05:00
Jan van Brügge
2de8c72131 Allow show and episode_id tags for audiobook series
FFmpeg only supports a very limited number of tags for m4b files (see https://wiki.multimedia.cx/index.php?title=FFmpeg_Metadata) by default. `series` and `series-part` are only possible by enabling custom tags with `-movflags use_metadata_tags`. To work around that, `show` and `episode_id` are added as second option.
2022-06-08 11:11:17 +02:00
advplyr
089d4b5cee Update:Remove fast-sort dependency 2022-06-07 20:22:23 -05:00
advplyr
e06a015d6e Update:Remove proper-lockfile dependency 2022-06-07 20:15:00 -05:00
advplyr
b7e546f2f5 Update:Remove node-cron dependency 2022-06-07 20:04:51 -05:00
advplyr
26ef275ab4 Update:Remove image-type dependency 2022-06-07 19:53:05 -05:00
advplyr
416db7c981 Update:Remove read-chunk dependency 2022-06-07 19:44:38 -05:00
advplyr
78079b2e60 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-06-07 19:25:28 -05:00
advplyr
03bffb725a Update:Remove rss feed dependencies add node-xml lib 2022-06-07 19:25:14 -05:00
advplyr
46fc89e247 Update:Cache feed xml 2022-06-07 18:37:37 -05:00
advplyr
fbbceaa642 Add:Persist RSS feeds in db #696, Update:RSS feed data model 2022-06-07 18:29:43 -05:00
jmt-gh
f5aae25cc8 remove random character 2022-06-06 18:52:08 -07:00
jmt-gh
8d03943acb remove extra debug log 2022-06-06 18:51:49 -07:00
jmt-gh
853513b926 update approach for ensuring download directory always exists 2022-06-06 18:51:08 -07:00
jmt-gh
c606a41314 update Cache folder creation to leverage ensureDirs 2022-06-06 08:18:15 -07:00
jmt-gh
35f29ca22b Use ensureDir instead of mkdir to fix 698
This commit updates the mkdir for creating the download location to
ensureDir, which is an alias for mkdirs and mkdirp, meaning they will
create the entire path of the directory if it does not exist.

https://github.com/jprichardson/node-fs-extra/blob/master/docs/ensureDir.md
2022-06-06 08:12:58 -07:00
advplyr
ac00f3ebe7 Merge pull request #692 from cassieesposito/getBookDataFromDir-refactor
Fixed bugs that caused getSequence to run twice and broke year recognition
2022-06-05 18:02:39 -05:00
Cassie Esposito
6846de98f8 Fixed bugs that caused getSequence to run twice and broke year recognition 2022-06-05 15:52:18 -07:00
advplyr
881baa818d Fix:Progress filter 2022-06-05 15:26:27 -05:00
advplyr
b671145e73 Merge pull request #682 from jmt-gh/update_cover_on_merge
Support embedding cover art metadata in the embed metadata tool
2022-06-05 13:04:12 -05:00
jmt-gh
8809c7b900 Handle null and delete cover cases
This commit adds in supporting if a cover path is null. If this is the
case, we completely remove the video stream from the file, as the user
either:

a) uploaded a file with no video stream (so removing it is a no-op)
b) removed the cover in ABS, so we should respect that on merge
2022-06-05 10:36:42 -07:00
advplyr
ae8f3aa918 Version bump 2.0.20 2022-06-05 10:59:01 -05:00
advplyr
5d4047c171 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-06-05 10:16:19 -05:00
advplyr
6f80591afd Fix:Switching to next track pausing player #685 2022-06-05 10:06:07 -05:00
jmt-gh
9b6fa8fe8c Merge branch 'advplyr:master' into update_cover_on_merge 2022-06-04 19:00:41 -07:00
jmt-gh
d6c02ebb2c Support embedding cover art metadata
Added support for chapter metadata in #678, but completely missed that
coverart wasn't getting embedded in the embed metadata tool. This commit
adds that in
2022-06-04 18:56:55 -07:00
advplyr
788d867ec3 Merge pull request #681 from jmt-gh/m4b_no_coverart
Fix cover art not being generated for M4B export
2022-06-04 20:56:04 -05:00
jmt-gh
3bc3914fd9 Fix cover art not being generated for m4b export
This commit fixes an issue where cover art was not being generated
properly when creating an M4B audiobook.

More context can be found in discord:
https://discord.com/channels/942908292873723984/981321213882282035/982777444631195681
2022-06-04 17:50:26 -07:00
advplyr
3d821dacb7 Fix:Sessions table cleanup 2022-06-04 15:51:00 -05:00
advplyr
e0546c6164 Version bump 2.0.19 2022-06-04 14:42:36 -05:00
advplyr
be7ccfb209 Merge pull request #678 from jmt-gh/issue_676_chapter_metadata
Support embedding updated chapter metadata (issue #676)
2022-06-04 14:02:44 -05:00
advplyr
938a8c6f80 Fix:Casing typo in LibraryItem 2022-06-04 13:00:51 -05:00
advplyr
5cd343cb01 Add:All listening sessions config page 2022-06-04 12:44:42 -05:00
jmt-gh
ab0094a53b Support embedding updated chapter metadata (676)
This commit resolves issue #676. The embed metadata tool was missing the
flag that tells ffmpeg to not only update the "top" metadata, but also
the chapter metadata.
2022-06-04 10:17:42 -07:00
advplyr
2d5e4ebcf0 Add:Audio player next/prev chapter buttons 2022-06-04 12:07:38 -05:00
advplyr
3171ce5aba Update:Paginated listening sessions 2022-06-04 10:52:37 -05:00
advplyr
0e1692d26b Fix:Matching authors with multiple authors split by comma #667 2022-06-03 19:21:31 -05:00
advplyr
e8cd18eac2 Add:Alert when progress is not syncing 2022-06-03 19:11:13 -05:00
advplyr
bf928692d5 Update:API route for getting playback session and getting media progress 2022-06-03 18:59:42 -05:00
advplyr
792490b629 Merge pull request #664 from bskrtich/docker_updates
feat: Updates to docker file and gh action
2022-06-03 05:02:11 -05:00
advplyr
0d1ff35c5e Add:Not Finished progress filter #650 2022-06-02 18:20:18 -05:00
advplyr
67e02fddbd Comment out expand on player ui 2022-06-02 17:54:07 -05:00
advplyr
09beb6a2ae Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-06-02 16:32:42 -05:00
advplyr
1bd657f07d Fix:Mark as finished once media has ended #635 2022-06-02 16:31:52 -05:00
advplyr
2dba17a7ae Merge pull request #651 from selfhost-alt/handle-another-backup-parse-error
Gracefully handle unexpected end of file when listing backup files
2022-06-02 07:26:42 -05:00
Brandon Skrtich
4900649908 feat: Updates to docker file and gh action
* Clean up Dockerfile
* Add health check to Dockerfile
* Update gh action versions
2022-06-02 05:55:01 +00: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
Selfhost Alt
1350a91fba Handle another type of corrupted backup file 2022-05-30 23:53:00 -07: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
260 changed files with 14627 additions and 4530 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

@@ -3,8 +3,15 @@
name: Build and Push Docker Image
on:
# Allows you to run workflow manually from Actions tab
workflow_dispatch:
inputs:
tags:
description: 'Docker Tag'
required: true
default: 'latest'
push:
branches: [master]
branches: [main,master]
tags:
- 'v*.*.*'
# Only build when files in these directories have been changed
@@ -13,8 +20,6 @@ on:
- server/**
- index.js
- package.json
# Allows you to run workflow manually from Actions tab
workflow_dispatch:
jobs:
build:
@@ -23,24 +28,25 @@ jobs:
steps:
- name: Check out
uses: actions/checkout@v2
uses: actions/checkout@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@v3
uses: docker/metadata-action@v4
with:
images: advplyr/audiobookshelf,ghcr.io/${{ github.repository_owner }}/audiobookshelf
tags: |
type=edge,branch=master
type=semver,pattern={{version}}
- name: Setup QEMU
uses: docker/setup-qemu-action@v1
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
uses: docker/setup-buildx-action@v2
- name: Cache Docker layers
uses: actions/cache@v2
uses: actions/cache@v3
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
@@ -48,28 +54,28 @@ jobs:
${{ runner.os }}-buildx-
- name: Login to Dockerhub
uses: docker/login-action@v1
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
- name: Login to ghcr
uses: docker/login-action@v1
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GHCR_PASSWORD }}
- name: Build image
uses: docker/build-push-action@v2
uses: docker/build-push-action@v3
with:
tags: ${{ steps.meta.outputs.tags }}
tags: ${{ github.event.inputs.tags || steps.meta.outputs.tags }}
context: .
platforms: linux/amd64,linux/arm64,linux/arm/v7
push: true
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
- name: Move cache
run: |
rm -rf /tmp/.buildx-cache

3
.gitignore vendored
View File

@@ -11,6 +11,7 @@ test/
/client/.nuxt/
/client/dist/
/dist/
library/
sw.*
.DS_STORE
.DS_STORE

View File

@@ -2,18 +2,28 @@
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 ###
FROM node:16-alpine
RUN apk update && apk add --no-cache --update ffmpeg
ENV NODE_ENV=production
RUN apk update && \
apk add --no-cache --update \
curl \
tzdata \
ffmpeg
COPY --from=build /client/dist /client/dist
COPY index.js index.js
COPY package-lock.json package-lock.json
COPY package.json package.json
COPY index.js package* /
COPY server server
RUN npm ci --production
RUN npm ci --only=production
EXPOSE 80
HEALTHCHECK \
--interval=30s \
--timeout=3s \
--start-period=10s \
CMD curl -f http://127.0.0.1/ping || exit 1
CMD ["npm", "start"]

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

@@ -3,7 +3,7 @@
<div id="appbar" class="absolute top-0 bottom-0 left-0 w-full h-full px-2 md:px-6 py-1 z-50">
<div class="flex h-full items-center">
<nuxt-link to="/">
<img src="/icon48.png" class="w-10 h-10 md:w-12 md:h-12 mr-4" />
<img src="/icon48.png" class="w-8 h-8 mr-8 sm:w-12 sm:h-12 sm:mr-4" />
</nuxt-link>
<nuxt-link to="/">
@@ -12,7 +12,7 @@
<ui-libraries-dropdown />
<controls-global-search class="hidden md:block" />
<controls-global-search v-if="currentLibrary" class="" />
<div class="flex-grow" />
<span v-if="showExperimentalFeatures" class="material-icons text-4xl text-warning pr-0 sm:pr-2 md:pr-4">logo_dev</span>
@@ -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',
@@ -54,6 +64,11 @@ export default {
title: 'Users',
path: '/config/users'
},
{
id: 'config-sessions',
title: 'Sessions',
path: '/config/sessions'
},
{
id: 'config-backups',
title: 'Backups',
@@ -63,18 +78,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"
@@ -70,13 +71,11 @@ export default {
sleepTimerRemaining: 0,
sleepTimer: null,
displayTitle: null,
initialPlaybackRate: 1
initialPlaybackRate: 1,
syncFailedToast: null
}
},
computed: {
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
},
coverAspectRatio() {
return this.$store.getters['getServerSetting']('coverAspectRatio')
},
@@ -148,6 +147,7 @@ export default {
setPlaying(isPlaying) {
this.isPlaying = isPlaying
this.$store.commit('setIsPlaying', isPlaying)
this.updateMediaSessionPlaybackState()
},
setSleepTimer(seconds) {
this.sleepTimerSet = true
@@ -240,6 +240,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,11 +377,14 @@ export default {
libraryItem,
episodeId
})
this.playerHandler.load(libraryItem, episodeId, true, this.initialPlaybackRate)
},
pauseItem() {
this.playerHandler.pause()
},
showFailedProgressSyncs() {
if (!isNaN(this.syncFailedToast)) this.$toast.dismiss(this.syncFailedToast)
this.syncFailedToast = this.$toast('Progress is not being synced. Restart playback', { timeout: false, type: 'error' })
}
},
mounted() {

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

@@ -144,9 +144,15 @@ export default {
}
},
computed: {
dateFormat() {
return this.store.state.serverSettings.dateFormat
},
showExperimentalFeatures() {
return this.store.state.showExperimentalFeatures
},
enableEReader() {
return this.store.getters['getServerSetting']('enableEReader')
},
_libraryItem() {
return this.libraryItem || {}
},
@@ -256,9 +262,9 @@ export default {
return this.author
},
displaySortLine() {
if (this.orderBy === 'mtimeMs') return 'Modified ' + this.$formatDate(this._libraryItem.mtimeMs)
if (this.orderBy === 'birthtimeMs') return 'Born ' + this.$formatDate(this._libraryItem.birthtimeMs)
if (this.orderBy === 'addedAt') return 'Added ' + this.$formatDate(this._libraryItem.addedAt)
if (this.orderBy === 'mtimeMs') return 'Modified ' + this.$formatDate(this._libraryItem.mtimeMs, this.dateFormat)
if (this.orderBy === 'birthtimeMs') return 'Born ' + this.$formatDate(this._libraryItem.birthtimeMs, this.dateFormat)
if (this.orderBy === 'addedAt') return 'Added ' + this.$formatDate(this._libraryItem.addedAt, this.dateFormat)
if (this.orderBy === 'media.duration') return 'Duration: ' + this.$elapsedPrettyExtended(this.media.duration, false)
if (this.orderBy === 'size') return 'Size: ' + this.$bytesPretty(this._libraryItem.size)
if (this.orderBy === 'media.numTracks') return `${this.numEpisodes} Episodes`
@@ -287,13 +293,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 +448,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

@@ -214,7 +214,7 @@ export default {
return this.filterData.languages || []
},
progress() {
return ['Finished', 'In Progress', 'Not Started']
return ['Finished', 'In Progress', 'Not Started', 'Not Finished']
},
missing() {
return ['ASIN', 'ISBN', 'Subtitle', 'Author', 'Publish Year', 'Series', 'Description', 'Genres', 'Tags', 'Narrator', 'Publisher', 'Language']

View File

@@ -1,5 +1,5 @@
<template>
<div class="w-80 ml-6 relative">
<div class="sm:w-80 w-full sm:ml-6 relative">
<form @submit.prevent="submitSearch">
<ui-text-input ref="input" v-model="search" placeholder="Search.." @input="inputUpdate" @focus="focussed" @blur="blurred" class="w-full h-8 text-sm" />
</form>
@@ -7,7 +7,7 @@
<span v-if="!search" class="material-icons" style="font-size: 1.2rem">search</span>
<span v-else class="material-icons" style="font-size: 1.2rem">close</span>
</div>
<div v-show="showMenu && (lastSearch || isTyping)" class="absolute z-40 -mt-px w-full bg-bg border border-black-200 shadow-lg rounded-md py-1 px-2 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm globalSearchMenu">
<div v-show="showMenu && (lastSearch || isTyping)" class="absolute z-40 -mt-px w-40 sm:w-full bg-bg border border-black-200 shadow-lg rounded-md py-1 px-2 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm globalSearchMenu">
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<li v-if="isTyping" class="py-2 px-2">
<p>Thinking...</p>
@@ -21,7 +21,7 @@
<template v-else>
<p v-if="bookResults.length" class="uppercase text-xs text-gray-400 my-1 px-1 font-semibold">Books</p>
<template v-for="item in bookResults">
<li :key="item.libraryItem.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
<li :key="item.libraryItem.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
<nuxt-link :to="`/item/${item.libraryItem.id}`">
<cards-item-search-card :library-item="item.libraryItem" :match-key="item.matchKey" :match-text="item.matchText" :search="lastSearch" />
</nuxt-link>
@@ -30,7 +30,7 @@
<p v-if="podcastResults.length" class="uppercase text-xs text-gray-400 my-1 px-1 font-semibold">Podcasts</p>
<template v-for="item in podcastResults">
<li :key="item.libraryItem.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
<li :key="item.libraryItem.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
<nuxt-link :to="`/item/${item.libraryItem.id}`">
<cards-item-search-card :library-item="item.libraryItem" :match-key="item.matchKey" :match-text="item.matchText" :search="lastSearch" />
</nuxt-link>
@@ -39,7 +39,7 @@
<p v-if="authorResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">Authors</p>
<template v-for="item in authorResults">
<li :key="item.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
<li :key="item.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=authors.${$encode(item.id)}`">
<cards-author-search-card :author="item" />
</nuxt-link>
@@ -48,7 +48,7 @@
<p v-if="seriesResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">Series</p>
<template v-for="item in seriesResults">
<li :key="item.series.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
<li :key="item.series.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
<nuxt-link :to="`/library/${currentLibraryId}/series/${item.series.id}`">
<cards-series-search-card :series="item.series" :book-items="item.books" />
</nuxt-link>
@@ -57,7 +57,7 @@
<p v-if="tagResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">Tags</p>
<template v-for="item in tagResults">
<li :key="item.name" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
<li :key="item.name" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=tags.${$encode(item.name)}`">
<cards-tag-search-card :tag="item.name" />
</nuxt-link>
@@ -97,6 +97,9 @@ export default {
}
},
methods: {
clickOption() {
this.clearResults()
},
submitSearch() {
if (!this.search) return
var search = this.search

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,153 @@
<template>
<modals-modal v-model="show" name="listening-session-modal" :width="700" :height="'unset'">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">Session {{ _session.id }}</p>
</div>
</template>
<div ref="container" class="w-full rounded-lg bg-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 v-if="hasDeviceInfo" class="font-semibold uppercase text-xs text-gray-400 tracking-wide mt-6 mb-2">Device</p>
<p v-if="deviceInfo.ipAddress" class="mb-1">{{ deviceInfo.ipAddress }}</p>
<p v-if="osDisplayName" class="mb-1">{{ osDisplayName }}</p>
<p v-if="deviceInfo.browserName" class="mb-1">{{ deviceInfo.browserName }}</p>
<p v-if="clientDisplayName" class="mb-1">{{ clientDisplayName }}</p>
<p v-if="deviceInfo.sdkVersion" class="mb-1">SDK Version: {{ deviceInfo.sdkVersion }}</p>
<p v-if="deviceInfo.deviceType" class="mb-1">Type: {{ deviceInfo.deviceType }}</p>
</div>
</div>
</div>
</modals-modal>
</template>
<script>
export default {
props: {
value: Boolean,
session: {
type: Object,
default: () => {}
}
},
data() {
return {}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
_session() {
return this.session || {}
},
deviceInfo() {
return this._session.deviceInfo || {}
},
hasDeviceInfo() {
return Object.keys(this.deviceInfo).length
},
osDisplayName() {
if (!this.deviceInfo.osName) return null
return `${this.deviceInfo.osName} ${this.deviceInfo.osVersion}`
},
clientDisplayName() {
if (!this.deviceInfo.manufacturer || !this.deviceInfo.model) return null
return `${this.deviceInfo.manufacturer} ${this.deviceInfo.model}`
},
playMethodName() {
const playMethod = this._session.playMethod
if (playMethod === this.$constants.PlayMethod.DIRECTPLAY) return 'Direct Play'
else if (playMethod === this.$constants.PlayMethod.TRANSCODE) return 'Transcode'
else if (playMethod === this.$constants.PlayMethod.DIRECTSTREAM) return 'Direct Stream'
else if (playMethod === this.$constants.PlayMethod.LOCAL) return 'Local'
return 'Unknown'
}
},
methods: {},
mounted() {}
}
</script>

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>
@@ -25,6 +25,9 @@
<ui-text-input-with-label v-model="authorCopy.asin" :disabled="processing" label="ASIN" />
</div>
</div>
<div class="p-2">
<ui-text-input-with-label v-model="authorCopy.imagePath" :disabled="processing" label="Photo Path/URL" />
</div>
<div class="p-2">
<ui-textarea-with-label v-model="authorCopy.description" :disabled="processing" label="Description" :rows="8" />
</div>
@@ -43,19 +46,13 @@
<script>
export default {
// props: {
// value: Boolean,
// author: {
// type: Object,
// default: () => {}
// }
// },
data() {
return {
authorCopy: {
name: '',
asin: '',
description: ''
description: '',
imagePath: ''
},
processing: false
}
@@ -95,9 +92,10 @@ export default {
this.authorCopy.name = this.author.name
this.authorCopy.asin = this.author.asin
this.authorCopy.description = this.author.description
this.authorCopy.imagePath = this.author.imagePath
},
async submitForm() {
var keysToCheck = ['name', 'asin', 'description']
var keysToCheck = ['name', 'asin', 'description', 'imagePath']
var updatePayload = {}
keysToCheck.forEach((key) => {
if (this.authorCopy[key] !== this.author[key]) {
@@ -139,12 +137,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
})
@@ -162,4 +165,4 @@ export default {
mounted() {},
beforeDestroy() {}
}
</script>
</script>

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>
@@ -69,7 +69,7 @@
<p v-if="showM4bDownload" class="text-left text-base mb-4 py-4">
<span class="text-error">* <strong>Experimental</strong></span
>&nbsp;-&nbsp;M4b merge can take several minutes and will be stored in <span class="bg-primary bg-opacity-75 font-mono p-1 text-base">/metadata/downloads</span>. After the download is ready, it will remain available for 60 minutes, then be deleted. Download will timeout after 20 minutes.
>&nbsp;-&nbsp;M4b merge can take several minutes and will be stored in <span class="bg-primary bg-opacity-75 font-mono p-1 text-base">/metadata/downloads</span>. After the download is ready, it will remain available for 60 minutes, then be deleted. Download will timeout after 30 minutes.
</p>
<!-- <p v-if="isSingleM4b" class="text-lg text-center my-8">Audiobook is already a single m4b!</p> -->
@@ -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,82 @@ export default {
else this.provider = localStorage.getItem('book-provider') || 'google'
},
selectMatch(match) {
if (match) {
if (match.series) {
if (!match.series.length) {
delete match.series
} else {
match.series = match.series.map((se) => {
return {
id: `new-${Math.floor(Math.random() * 10000)}`,
displayName: se.volumeNumber ? `${se.series} #${se.volumeNumber}` : se.series,
name: se.series,
sequence: se.volumeNumber || ''
}
})
}
}
if (match.genres && Array.isArray(match.genres)) {
match.genres = match.genres.join(',')
}
}
console.log('Select Match', match)
this.selectedMatch = match
},
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]
var authors = this.selectedMatch[key]
if (!Array.isArray(authors)) {
authors = authors.split(',').map((au) => au.trim())
}
updatePayload.authors = [authorItem]
var authorPayload = []
authors.forEach((authorName) =>
authorPayload.push({
id: `new-${Math.floor(Math.random() * 10000)}`,
name: authorName
})
)
updatePayload.metadata.authors = authorPayload
} else if (key === 'narrator') {
updatePayload.narrators = [this.selectedMatch[key]]
updatePayload.metadata.narrators = this.selectedMatch[key].split(',').map((v) => v.trim())
} else if (key === 'genres') {
updatePayload.metadata.genres = this.selectedMatch[key].split(',').map((v) => v.trim())
} else if (key === 'tags') {
updatePayload.tags = this.selectedMatch[key].split(',').map((v) => v.trim())
} else if (key === 'itunesId') {
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() {
@@ -357,11 +448,13 @@ export default {
if (!Object.keys(updatePayload).length) {
return
}
console.log('Match payload', updatePayload)
this.isProcessing = true
if (updatePayload.cover) {
if (updatePayload.metadata.cover) {
var coverPayload = {
url: updatePayload.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 +466,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

@@ -27,7 +27,6 @@
<p v-if="hasEpisodesWithoutPubDate" class="w-full pt-2 text-warning text-xs">Warning: 1 or more of your episodes do not have a Pub Date. Some podcast apps require this.</p>
</div>
<div v-show="userIsAdminOrUp" class="flex items-center pt-6">
<p class="text-xs text-gray-300">Note: RSS feed URLs are not authenticated</p>
<div class="flex-grow" />
<ui-btn v-if="currentFeedUrl" color="error" small @click="closeFeed">Close RSS Feed</ui-btn>
<ui-btn v-else color="success" small @click="openFeed">Open RSS Feed</ui-btn>
@@ -122,7 +121,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 +142,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,73 @@
<template>
<div class="flex pt-4 pb-2 md:pt-0 md:pb-2">
<div class="flex-grow" />
<template v-if="!loading">
<div class="cursor-pointer flex items-center justify-center text-gray-300 mr-4 md:mr-8" @mousedown.prevent @mouseup.prevent @click.stop="prevChapter">
<span class="material-icons text-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-4 md:mx-8" :class="seekLoading ? 'animate-spin' : ''" @mousedown.prevent @mouseup.prevent @click.stop="playPause">
<span class="material-icons">{{ seekLoading ? 'autorenew' : paused ? 'play_arrow' : 'pause' }}</span>
</div>
<div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpForward">
<span class="material-icons text-3xl">forward_10</span>
</div>
<div class="flex items-center justify-center ml-4 md:ml-8" :class="hasNextChapter ? 'text-gray-300 cursor-pointer' : 'text-gray-500'" @mousedown.prevent @mouseup.prevent @click.stop="nextChapter">
<span class="material-icons text-3xl">last_page</span>
</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,
hasNextChapter: Boolean
},
data() {
return {}
},
computed: {},
methods: {
playPause() {
this.$emit('playPause')
},
prevChapter() {
this.$emit('prevChapter')
},
nextChapter() {
if (!this.hasNextChapter) return
this.$emit('nextChapter')
},
jumpBackward() {
this.$emit('jumpBackward')
},
jumpForward() {
this.$emit('jumpForward')
},
playbackRateUpdated(playbackRate) {
this.$emit('setPlaybackRate', playbackRate)
},
playbackRateChanged(playbackRate) {
this.$emit('setPlaybackRate', playbackRate)
this.$store.dispatch('user/updateUserSettings', { playbackRate }).catch((err) => {
console.error('Failed to update settings', err)
})
}
},
mounted() {}
}
</script>

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" :has-next-chapter="hasNextChapter" @prevChapter="prevChapter" @nextChapter="nextChapter" @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,45 @@ export default {
currentChapterName() {
return this.currentChapter ? this.currentChapter.title : ''
},
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
isFullscreen() {
return this.$store.state.playerIsFullscreen
},
currentChapterIndex() {
if (!this.currentChapter) return 0
return this.chapters.findIndex((ch) => ch.id === this.currentChapter.id)
},
hasNextChapter() {
if (!this.chapters.length) return false
return this.currentChapterIndex < this.chapters.length - 1
}
},
methods: {
toggleFullscreen(isFullscreen) {
this.$store.commit('setPlayerIsFullscreen', isFullscreen)
var videoPlayerEl = document.getElementById('video-player')
if (videoPlayerEl) {
if (isFullscreen) {
videoPlayerEl.style.width = '100vw'
videoPlayerEl.style.height = '100vh'
videoPlayerEl.style.top = '0px'
videoPlayerEl.style.left = '0px'
} else {
videoPlayerEl.style.width = '384px'
videoPlayerEl.style.height = '216px'
videoPlayerEl.style.top = 'unset'
videoPlayerEl.style.bottom = '80px'
videoPlayerEl.style.left = '16px'
}
}
},
setDuration(duration) {
this.duration = duration
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 +195,28 @@ 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)
},
prevChapter() {
if (!this.currentChapter || this.currentChapterIndex === 0) {
return this.restart()
}
var timeInCurrentChapter = this.currentTime - this.currentChapter.start
if (timeInCurrentChapter <= 3 && this.chapters[this.currentChapterIndex - 1]) {
var prevChapter = this.chapters[this.currentChapterIndex - 1]
this.seek(prevChapter.start)
} else {
this.seek(this.currentChapter.start)
}
},
nextChapter() {
if (!this.currentChapter || !this.hasNextChapter) return
var nextChapter = this.chapters[this.currentChapterIndex + 1]
this.seek(nextChapter.start)
},
setStreamReady() {
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 +231,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 +242,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 +253,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 +260,11 @@ export default {
}
},
closePlayer() {
if (this.isFullscreen) {
this.toggleFullscreen(false)
return
}
if (this.loading) return
this.$emit('close')
},
@@ -382,19 +279,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

@@ -1,5 +1,5 @@
<template>
<button class="icon-btn rounded-md flex items-center justify-center h-9 w-9 relative" @mousedown.prevent :disabled="disabled || loading" :class="className" @click="clickBtn">
<button class="icon-btn rounded-md flex items-center justify-center relative" @mousedown.prevent :disabled="disabled || loading" :class="className" @click="clickBtn">
<div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100">
<svg class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24">
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
@@ -20,20 +20,29 @@ export default {
},
outlined: Boolean,
borderless: Boolean,
loading: Boolean
loading: Boolean,
iconFontSize: {
type: String,
default: ''
},
size: {
type: Number,
default: 9
}
},
data() {
return {}
},
computed: {
className() {
var classes = []
var classes = [`h-${this.size} w-${this.size}`]
if (!this.borderless) {
classes.push(`bg-${this.bgColor} border border-gray-600`)
}
return classes.join(' ')
},
fontSize() {
if (this.iconFontSize) return this.iconFontSize
if (this.icon === 'edit') return '1.25rem'
return '1.4rem'
}

View File

@@ -42,7 +42,8 @@ export default {
editable: {
type: Boolean,
default: true
}
},
showAllWhenEmpty: Boolean
},
data() {
return {
@@ -72,6 +73,7 @@ export default {
itemsToShow() {
if (!this.editable) return this.items
if (!this.textInput || this.textInput === this.input) {
if (this.showAllWhenEmpty) return this.items
return []
}
return this.items.filter((i) => {

View File

@@ -1,14 +1,14 @@
<template>
<div v-if="currentLibrary" class="relative w-36 h-8" v-click-outside="clickOutsideObj">
<button type="button" :disabled="disabled" class="relative h-full w-full border border-white border-opacity-10 hover:border-opacity-20 rounded shadow-sm pl-3 pr-3 text-left focus:outline-none cursor-pointer bg-black bg-opacity-20 text-gray-400 hover:text-gray-200" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
<span class="flex items-center">
<widgets-library-icon :icon="currentLibraryIcon" class="mr-2" />
<span class="block truncate text-sm">{{ currentLibrary.name }}</span>
<div v-if="currentLibrary" class="relative sm:w-36 h-8 px-1.5" v-click-outside="clickOutsideObj">
<button type="button" :disabled="disabled" class="w-10 sm:w-36 relative h-full border border-white border-opacity-10 hover:border-opacity-20 rounded shadow-sm px-2 text-left text-sm focus:outline-none cursor-pointer bg-black bg-opacity-20 text-gray-400 hover:text-gray-200" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
<span class="flex items-center justify-center sm:justify-start">
<widgets-library-icon :icon="currentLibraryIcon" class="sm:mr-2" />
<span class="hidden sm:block">{{ currentLibrary.name }}</span>
</span>
</button>
<transition name="menu">
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full bg-primary border border-black-200 shadow-lg max-h-56 rounded-b-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox">
<ul v-show="showMenu" class="absolute z-10 -mt-px w-36 bg-primary border border-black-200 shadow-lg max-h-56 rounded-b-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox">
<template v-for="library in librariesFiltered">
<li :key="library.id" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="selectLibrary(library)">
<div class="flex items-center px-3">

View File

@@ -4,7 +4,7 @@
<div ref="wrapper" class="relative">
<form @submit.prevent="submitForm">
<div ref="inputWrapper" style="min-height: 36px" class="flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-1" :class="wrapperClass" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent>
<div v-for="item in selected" :key="item" class="rounded-full px-2 py-1 mx-0.5 text-xs bg-bg flex flex-nowrap whitespace-nowrap items-center relative">
<div v-for="item in selected" :key="item" class="rounded-full px-2 py-1 mx-0.5 my-0.5 text-xs bg-bg flex flex-nowrap break-all items-center relative">
<div class="w-full h-full rounded-full absolute top-0 left-0 opacity-0 hover:opacity-100 px-1 bg-bg bg-opacity-75 flex items-center justify-end cursor-pointer">
<span v-if="showEdit" class="material-icons text-white hover:text-warning" style="font-size: 1.1rem" @click.stop="editItem(item)">edit</span>
<span class="material-icons text-white hover:text-error" style="font-size: 1.1rem" @click.stop="removeItem(item)">close</span>

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: {
@@ -100,13 +110,7 @@ export default {
}
console.log('Init Payload', payload)
if (payload.session) {
if (this.$refs.streamContainer) {
this.$refs.streamContainer.sessionOpen(payload.session)
} else {
console.warn('Stream Container not mounted')
}
}
if (payload.serverSettings) {
this.$refs.streamContainer.sessionOpen(payload.session)
}
// Start scans currently running
@@ -163,6 +167,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 +186,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 +509,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 +534,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 +551,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>

View File

@@ -28,11 +28,11 @@ export default {
var validOtherFiles = []
var ignoredFiles = []
files.forEach((file) => {
var filetype = this.checkFileType(file.name)
if (!filetype) ignoredFiles.push(file)
// var filetype = this.checkFileType(file.name)
if (!file.filetype) ignoredFiles.push(file)
else {
file.filetype = filetype
if (filetype === 'audio' || (filetype === 'ebook' && mediaType === 'book')) validItemFiles.push(file)
// file.filetype = filetype
if (file.filetype === 'audio' || (file.filetype === 'ebook' && mediaType === 'book')) validItemFiles.push(file)
else validOtherFiles.push(file)
}
})
@@ -82,12 +82,18 @@ export default {
items: itemResults,
ignoredFiles: ignoredFilesInRoot
}
} else {
} else if (filetree.some((f) => f.filetype !== 'audio') || mediaType !== 'book') {
// Single Book drop
return {
items: this.itemFromTreeItems(filetree, mediaType),
ignoredFiles: []
}
} else {
// Only audio files dropped so treat each one as an audiobook
return {
items: filetree.map((audioFile) => ({ itemFiles: [audioFile], otherFiles: [], ignoredFiles: [] })),
ignoredFiles: []
}
}
},
getFilesDropped(dataTransferItems) {
@@ -95,11 +101,12 @@ export default {
path: '/',
items: []
}
function traverseFileTreePromise(item, currtreemap) {
function traverseFileTreePromise(item, currtreemap, checkFileType) {
return new Promise((resolve) => {
if (item.isFile) {
item.file((file) => {
file.filepath = currtreemap.path + file.name //save full path
file.filetype = checkFileType(file.name)
currtreemap.items.push(file)
resolve(file)
})
@@ -119,7 +126,7 @@ export default {
dirReader.readEntries((entries) => {
if (entries.length > 0) {
for (let entr of entries) {
entriesPromises.push(traverseFileTreePromise(entr, newtreemap))
entriesPromises.push(traverseFileTreePromise(entr, newtreemap, checkFileType))
}
readEntries()
} else {
@@ -135,7 +142,7 @@ export default {
return new Promise((resolve, reject) => {
let entriesPromises = []
for (let it of dataTransferItems) {
var filetree = traverseFileTreePromise(it.webkitGetAsEntry(), treemap)
var filetree = traverseFileTreePromise(it.webkitGetAsEntry(), treemap, this.checkFileType)
entriesPromises.push(filetree)
}
Promise.all(entriesPromises).then(() => {
@@ -152,7 +159,9 @@ export default {
...book
}
var firstBookFile = book.itemFiles[0]
if (!firstBookFile.filepath) return audiobook // No path
if (!firstBookFile.filepath) {
return audiobook // No path
}
var firstBookPath = Path.dirname(firstBookFile.filepath)
@@ -165,6 +174,9 @@ export default {
if (dirs.length) {
audiobook.author = dirs.pop()
}
} else {
// Use file basename as title
audiobook.title = Path.basename(firstBookFile.name, Path.extname(firstBookFile.name))
}
return audiobook
},
@@ -178,7 +190,12 @@ export default {
if (!firstAudioFile.filepath) return podcast // No path
var firstPath = Path.dirname(firstAudioFile.filepath)
var dirs = firstPath.split('/').filter(d => !!d && d !== '.')
podcast.title = dirs.length > 1 ? dirs[1] : dirs[0]
if (dirs.length) {
podcast.title = dirs.length > 1 ? dirs[1] : dirs[0]
} else {
podcast.title = Path.basename(firstAudioFile.name, Path.extname(firstAudioFile.name))
}
return podcast
},
cleanItem(item, mediaType, index) {
@@ -188,6 +205,7 @@ export default {
async getItemsFromDataTransferItems(dataTransferItems, mediaType) {
var files = await this.getFilesDropped(dataTransferItems)
if (!files || !files.length) return { error: 'No files found ' }
var itemData = this.fileTreeToItems(files, mediaType)
if (!itemData.items.length && !itemData.ignoredFiles.length) {
return { error: 'Invalid file drop' }
@@ -218,9 +236,12 @@ export default {
else {
file.filetype = filetype
if (file.webkitRelativePath) file.filepath = file.webkitRelativePath
else file.filepath = file.name
if (filetype === 'audio' || (filetype === 'ebook' && mediaType === 'book')) {
var dir = file.filepath ? Path.dirname(file.filepath) : ''
if (dir === '.') dir = ''
if (!itemMap[dir]) {
itemMap[dir] = {
path: dir,
@@ -246,8 +267,17 @@ export default {
}
})
var items = []
var index = 1
var items = Object.values(itemMap).map(i => this.cleanItem(i, mediaType, index++))
// If book media type and all files are audio files then treat each one as an audiobook
if (itemMap[''] && !otherFiles.length && mediaType === 'book' && !itemMap[''].itemFiles.some(f => f.filetype !== 'audio')) {
items = itemMap[''].itemFiles.map((audioFile) => {
return this.cleanItem({ itemFiles: [audioFile], otherFiles: [], ignoredFiles: [] }, mediaType, index++)
})
} else {
items = Object.values(itemMap).map(i => this.cleanItem(i, mediaType, index++))
}
return {
items,
ignoredFiles: ignoredFiles

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.22",
"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

@@ -1,13 +1,13 @@
<template>
<div id="page-wrapper" class="bg-bg page overflow-y-auto p-8" :class="streamLibraryItem ? 'streaming' : ''">
<div id="page-wrapper" class="bg-bg page overflow-y-auto p-4 md:p-8" :class="streamLibraryItem ? 'streaming' : ''">
<div class="max-w-6xl mx-auto">
<div class="flex mb-6">
<div class="flex flex-wrap sm:flex-nowrap justify-center mb-6">
<div class="w-48 min-w-48">
<div class="w-full h-52">
<covers-author-image :author="author" rounded="0" />
</div>
</div>
<div class="flex-grow px-8">
<div class="flex-grow py-4 sm:py-0 px-4 md:px-8">
<div class="flex items-center mb-8">
<h1 class="text-2xl">{{ author.name }}</h1>
@@ -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

@@ -4,7 +4,7 @@
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8">
<div class="flex items-center mb-2">
<h1 class="text-xl">Settings</h1>
<h1 class="text-xl font-semibold">Settings</h1>
</div>
<div class="flex items-center py-2">
@@ -27,26 +27,6 @@
</ui-tooltip>
</div>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="useSquareBookCovers" :disabled="updatingServerSettings" @input="updateBookCoverAspectRatio" />
<ui-tooltip :text="tooltips.coverAspectRatio">
<p class="pl-4 text-lg">
Use square book covers
<span class="material-icons icon-text">info_outlined</span>
</p>
</ui-tooltip>
</div>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="useAlternativeBookshelfView" :disabled="updatingServerSettings" @input="updateAlternativeBookshelfView" />
<ui-tooltip :text="tooltips.bookshelfView">
<p class="pl-4 text-lg">
Use alternative bookshelf view
<span class="material-icons icon-text">info_outlined</span>
</p>
</ui-tooltip>
</div>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.sortingIgnorePrefix" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('sortingIgnorePrefix', val)" />
<ui-tooltip :text="tooltips.sortingIgnorePrefix">
@@ -66,7 +46,36 @@
</div>
<div class="flex items-center mb-2 mt-8">
<h1 class="text-xl">Scanner Settings</h1>
<h1 class="text-xl font-semibold">Display Settings</h1>
</div>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="useSquareBookCovers" :disabled="updatingServerSettings" @input="updateBookCoverAspectRatio" />
<ui-tooltip :text="tooltips.coverAspectRatio">
<p class="pl-4 text-lg">
Use square book covers
<span class="material-icons icon-text">info_outlined</span>
</p>
</ui-tooltip>
</div>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="useAlternativeBookshelfView" :disabled="updatingServerSettings" @input="updateAlternativeBookshelfView" />
<ui-tooltip :text="tooltips.bookshelfView">
<p class="pl-4 text-lg">
Use alternative bookshelf view
<span class="material-icons icon-text">info_outlined</span>
</p>
</ui-tooltip>
</div>
<div class="flex items-center py-2">
<p class="pr-4 text-lg">Date Format</p>
<ui-dropdown v-model="newServerSettings.dateFormat" :items="dateFormats" small class="max-w-40" @input="(val) => updateSettingsKey('dateFormat', val)" />
</div>
<div class="flex items-center mb-2 mt-8">
<h1 class="text-xl font-semibold">Scanner Settings</h1>
</div>
<div class="flex items-center py-2">
@@ -103,6 +112,16 @@
</ui-tooltip>
</div>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.scannerPreferOverdriveMediaMarker" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferOverdriveMediaMarker', val)" />
<ui-tooltip :text="tooltips.scannerPreferOverdriveMediaMarker">
<p class="pl-4 text-lg">
Scanner prefer Overdrive Media Markers for chapters
<span class="material-icons icon-text">info_outlined</span>
</p>
</ui-tooltip>
</div>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.scannerPreferOpfMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferOpfMetadata', val)" />
<ui-tooltip :text="tooltips.scannerPreferOpfMetadata">
@@ -113,6 +132,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 +151,20 @@
</p>
</ui-tooltip>
</div>
<div class="flex items-center mb-2 mt-8">
<h1 class="text-xl font-semibold">Experimental Feature Settings</h1>
</div>
<div class="flex items-center py-2">
<ui-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 +212,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 +252,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 +263,9 @@ 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)',
scannerPreferOverdriveMediaMarker: 'MP3 files from Overdrive come with chapter timings embedded as custom metadata. Enabling this will use these tags for chapter timings automatically'
},
showConfirmPurgeCache: false
}
@@ -229,9 +278,6 @@ export default {
}
},
computed: {
experimentalFeaturesTooltip() {
return 'Features in development that could use your feedback and help testing.'
},
serverSettings() {
return this.$store.state.serverSettings
},
@@ -245,12 +291,12 @@ export default {
set(val) {
this.$store.commit('setExperimentalFeatures', val)
}
},
dateFormats() {
return this.$store.state.globals.dateFormats
}
},
methods: {
updateEnableChromecast(val) {
this.updateServerSettings({ enableChromecast: val })
},
updateSortingPrefixes(val) {
if (!val || !val.length) {
this.$toast.error('Must have at least 1 prefix')
@@ -288,10 +334,12 @@ export default {
.then((success) => {
console.log('Updated Server Settings', success)
this.updatingServerSettings = false
this.$toast.success('Server settings updated')
})
.catch((error) => {
console.error('Failed to update server settings', error)
this.updatingServerSettings = false
this.$toast.error('Failed to update server settings')
})
},
initServerSettings() {

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

@@ -17,8 +17,8 @@
<div ref="container" class="relative w-full h-full bg-primary border-bg overflow-x-hidden overflow-y-auto text-red shadow-inner rounded-md" style="max-height: 550px; min-height: 550px">
<template v-for="(log, index) in logs">
<div :key="index" class="flex flex-nowrap px-2 py-1 items-start text-sm bg-opacity-10" :class="`bg-${logColors[log.level]}`">
<p class="text-gray-400 w-40 font-mono">{{ log.timestamp.split('.')[0].split('T').join(' ') }}</p>
<p class="font-semibold w-12 text-right" :class="`text-${logColors[log.level]}`">{{ log.levelName }}</p>
<p class="text-gray-400 w-36 font-mono text-xs">{{ log.timestamp }}</p>
<p class="font-semibold w-12 text-right text-sm" :class="`text-${logColors[log.level]}`">{{ log.levelName }}</p>
<p class="px-4 logmessage">{{ log.message }}</p>
</div>
</template>

View File

@@ -0,0 +1,196 @@
<template>
<div class="w-full h-full">
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-0 sm:p-4 mb-8">
<div class="py-2">
<div class="flex items-center mb-1">
<h1 class="text-lg mb-2 text-white text-opacity-90 px-2 sm:px-0">Listening Sessions</h1>
<div class="flex-grow" />
<ui-dropdown v-model="selectedUser" :items="userItems" label="Filter by User" small class="max-w-48" @input="updateUserFilter" />
</div>
<div v-if="listeningSessions.length" class="block max-w-full">
<table class="userSessionsTable">
<tr class="bg-primary bg-opacity-40">
<th class="w-48 min-w-48 text-left">Item</th>
<th class="w-20 min-w-20 text-left hidden md:table-cell">User</th>
<th class="w-32 min-w-32 text-left hidden md:table-cell">Play Method</th>
<th class="w-32 min-w-32 text-left hidden sm:table-cell">Device Info</th>
<th class="w-32 min-w-32">Listened</th>
<th class="w-16 min-w-16">Last Time</th>
<th class="flex-grow hidden sm:table-cell">Last Update</th>
</tr>
<tr v-for="session in listeningSessions" :key="session.id" class="cursor-pointer" @click="showSession(session)">
<td class="py-1 max-w-48">
<p class="text-xs text-gray-200 truncate">{{ session.displayTitle }}</p>
<p class="text-xs text-gray-400 truncate">{{ session.displayAuthor }}</p>
</td>
<td class="hidden md:table-cell">
<p v-if="filteredUserUsername" class="text-xs">{{ filteredUserUsername }}</p>
<p v-else class="text-xs">{{ session.user ? session.user.username : 'N/A' }}</p>
</td>
<td class="hidden md:table-cell">
<p class="text-xs">{{ getPlayMethodName(session.playMethod) }}</p>
</td>
<td class="hidden sm:table-cell">
<p class="text-xs" v-html="getDeviceInfoString(session.deviceInfo)" />
</td>
<td class="text-center">
<p class="text-xs font-mono">{{ $elapsedPretty(session.timeListening) }}</p>
</td>
<td class="text-center">
<p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p>
</td>
<td class="text-center hidden sm:table-cell">
<ui-tooltip v-if="session.updatedAt" direction="top" :text="$formatDate(session.updatedAt, 'MMMM do, yyyy HH:mm')">
<p class="text-xs text-gray-200">{{ $dateDistanceFromNow(session.updatedAt) }}</p>
</ui-tooltip>
</td>
</tr>
</table>
<div class="flex items-center justify-end py-1">
<ui-icon-btn icon="arrow_back_ios_new" :size="7" icon-font-size="1rem" class="mx-1" :disabled="currentPage === 0" @click="prevPage" />
<p class="text-sm mx-1">Page {{ currentPage + 1 }} of {{ numPages }}</p>
<ui-icon-btn icon="arrow_forward_ios" :size="7" icon-font-size="1rem" class="mx-1" :disabled="currentPage >= numPages - 1" @click="nextPage" />
</div>
</div>
<p v-else class="text-white text-opacity-50">No sessions yet...</p>
</div>
</div>
<modals-listening-session-modal v-model="showSessionModal" :session="selectedSession" />
</div>
</template>
<script>
export default {
async asyncData({ params, redirect, app }) {
var users = await app.$axios
.$get('/api/users')
.then((users) => {
return users.sort((a, b) => {
return a.createdAt - b.createdAt
})
})
.catch((error) => {
console.error('Failed', error)
return []
})
return {
users
}
},
data() {
return {
showSessionModal: false,
selectedSession: null,
listeningSessions: [],
numPages: 0,
total: 0,
currentPage: 0,
userFilter: null,
selectedUser: ''
}
},
computed: {
username() {
return this.user.username
},
userOnline() {
return this.$store.getters['users/getIsUserOnline'](this.user.id)
},
userItems() {
var userItems = [{ value: '', text: 'All Users' }]
return userItems.concat(this.users.map((u) => ({ value: u.id, text: u.username })))
},
filteredUserUsername() {
if (!this.userFilter) return null
var user = this.users.find((u) => u.id === this.userFilter)
return user ? user.username : null
}
},
methods: {
updateUserFilter() {
this.loadSessions(0)
},
prevPage() {
this.loadSessions(this.currentPage - 1)
},
nextPage() {
this.loadSessions(this.currentPage + 1)
},
showSession(session) {
this.selectedSession = session
this.showSessionModal = true
},
getDeviceInfoString(deviceInfo) {
if (!deviceInfo) return ''
var lines = []
if (deviceInfo.osName) lines.push(`${deviceInfo.osName} ${deviceInfo.osVersion}`)
if (deviceInfo.browserName) lines.push(deviceInfo.browserName)
if (deviceInfo.manufacturer && deviceInfo.model) lines.push(`${deviceInfo.manufacturer} ${deviceInfo.model}`)
if (deviceInfo.sdkVersion) lines.push(`SDK Version: ${deviceInfo.sdkVersion}`)
return lines.join('<br>')
},
getPlayMethodName(playMethod) {
if (playMethod === this.$constants.PlayMethod.DIRECTPLAY) return 'Direct Play'
else if (playMethod === this.$constants.PlayMethod.TRANSCODE) return 'Transcode'
else if (playMethod === this.$constants.PlayMethod.DIRECTSTREAM) return 'Direct Stream'
else if (playMethod === this.$constants.PlayMethod.LOCAL) return 'Local'
return 'Unknown'
},
async loadSessions(page) {
var userFilterQuery = this.selectedUser ? `&user=${this.selectedUser}` : ''
const data = await this.$axios.$get(`/api/sessions?page=${page}&itemsPerPage=10${userFilterQuery}`).catch((err) => {
console.error('Failed to load listening sesions', err)
return null
})
if (!data) {
this.$toast.error('Failed to load listening sessions')
return
}
this.numPages = data.numPages
this.total = data.total
this.currentPage = data.page
this.listeningSessions = data.sessions
this.userFilter = data.userFilter
},
init() {
this.loadSessions(0)
}
},
mounted() {
this.init()
}
}
</script>
<style scoped>
.userSessionsTable {
border-collapse: collapse;
width: 100%;
max-width: 100%;
border: 1px solid #474747;
}
.userSessionsTable tr:first-child {
background-color: #272727;
}
.userSessionsTable tr:not(:first-child) {
background-color: #373838;
}
.userSessionsTable tr:not(:first-child):nth-child(odd) {
background-color: #2f2f2f;
}
.userSessionsTable tr:hover:not(:first-child) {
background-color: #474747;
}
.userSessionsTable td {
padding: 4px 8px;
}
.userSessionsTable th {
padding: 4px 8px;
font-size: 0.75rem;
}
</style>

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
},
@@ -135,7 +138,9 @@ export default {
this.$copyToClipboard(str, this)
},
async init() {
this.listeningSessions = await this.$axios.$get(`/api/users/${this.user.id}/listening-sessions`).catch((err) => {
this.listeningSessions = await this.$axios.$get(`/api/users/${this.user.id}/listening-sessions?page=0&itemsPerPage=10`).then((data) => {
return data.sessions || []
}).catch((err) => {
console.error('Failed to load listening sesions', err)
return []
})

View File

@@ -0,0 +1,178 @@
<template>
<div class="w-full h-full">
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-0 sm:p-4 mb-8">
<nuxt-link :to="`/config/users/${user.id}`" class="text-white text-opacity-70 hover:text-opacity-100 hover:bg-white hover:bg-opacity-5 cursor-pointer rounded-full px-2 sm:px-0">
<div class="flex items-center">
<div class="h-10 w-10 flex items-center justify-center">
<span class="material-icons text-2xl">arrow_back</span>
</div>
<p class="pl-1">Back to User</p>
</div>
</nuxt-link>
<div class="flex items-center mb-2 mt-4 px-2 sm:px-0">
<widgets-online-indicator :value="!!userOnline" />
<h1 class="text-xl pl-2">{{ username }}</h1>
</div>
<div class="w-full h-px bg-white bg-opacity-10 my-2" />
<div class="py-2">
<h1 class="text-lg mb-2 text-white text-opacity-90 px-2 sm:px-0">Listening Sessions</h1>
<div v-if="listeningSessions.length">
<table class="userSessionsTable">
<tr class="bg-primary bg-opacity-40">
<th class="w-48 min-w-48 text-left">Item</th>
<th class="w-32 min-w-32 text-left hidden md:table-cell">Play Method</th>
<th class="w-32 min-w-32 text-left hidden sm:table-cell">Device Info</th>
<th class="w-32 min-w-32">Listened</th>
<th class="w-16 min-w-16">Last Time</th>
<th class="flex-grow hidden sm:table-cell">Last Update</th>
</tr>
<tr v-for="session in listeningSessions" :key="session.id" class="cursor-pointer" @click="showSession(session)">
<td class="py-1 max-w-48">
<p class="text-xs text-gray-200 truncate">{{ session.displayTitle }}</p>
<p class="text-xs text-gray-400 truncate">{{ session.displayAuthor }}</p>
</td>
<td class="hidden md:table-cell">
<p class="text-xs">{{ getPlayMethodName(session.playMethod) }}</p>
</td>
<td class="hidden sm:table-cell">
<p class="text-xs" v-html="getDeviceInfoString(session.deviceInfo)" />
</td>
<td class="text-center">
<p class="text-xs font-mono">{{ $elapsedPretty(session.timeListening) }}</p>
</td>
<td class="text-center">
<p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p>
</td>
<td class="text-center hidden sm:table-cell">
<ui-tooltip v-if="session.updatedAt" direction="top" :text="$formatDate(session.updatedAt, 'MMMM do, yyyy HH:mm')">
<p class="text-xs text-gray-200">{{ $dateDistanceFromNow(session.updatedAt) }}</p>
</ui-tooltip>
</td>
</tr>
</table>
<div class="flex items-center justify-end py-1">
<ui-icon-btn icon="arrow_back_ios_new" :size="7" icon-font-size="1rem" class="mx-1" :disabled="currentPage === 0" @click="prevPage" />
<p class="text-sm mx-1">Page {{ currentPage + 1 }} of {{ numPages }}</p>
<ui-icon-btn icon="arrow_forward_ios" :size="7" icon-font-size="1rem" class="mx-1" :disabled="currentPage >= numPages - 1" @click="nextPage" />
</div>
</div>
<p v-else class="text-white text-opacity-50">No sessions yet...</p>
</div>
</div>
<modals-listening-session-modal v-model="showSessionModal" :session="selectedSession" />
</div>
</template>
<script>
export default {
async asyncData({ params, redirect, app }) {
var user = await app.$axios.$get(`/api/users/${params.id}`).catch((error) => {
console.error('Failed to get user', error)
return null
})
if (!user) return redirect('/config/users')
return {
user
}
},
data() {
return {
showSessionModal: false,
selectedSession: null,
listeningSessions: [],
numPages: 0,
total: 0,
currentPage: 0
}
},
computed: {
username() {
return this.user.username
},
userOnline() {
return this.$store.getters['users/getIsUserOnline'](this.user.id)
}
},
methods: {
prevPage() {
this.loadSessions(this.currentPage - 1)
},
nextPage() {
this.loadSessions(this.currentPage + 1)
},
showSession(session) {
this.selectedSession = session
this.showSessionModal = true
},
getDeviceInfoString(deviceInfo) {
if (!deviceInfo) return ''
var lines = []
if (deviceInfo.osName) lines.push(`${deviceInfo.osName} ${deviceInfo.osVersion}`)
if (deviceInfo.browserName) lines.push(deviceInfo.browserName)
if (deviceInfo.manufacturer && deviceInfo.model) lines.push(`${deviceInfo.manufacturer} ${deviceInfo.model}`)
if (deviceInfo.sdkVersion) lines.push(`SDK Version: ${deviceInfo.sdkVersion}`)
return lines.join('<br>')
},
getPlayMethodName(playMethod) {
if (playMethod === this.$constants.PlayMethod.DIRECTPLAY) return 'Direct Play'
else if (playMethod === this.$constants.PlayMethod.TRANSCODE) return 'Transcode'
else if (playMethod === this.$constants.PlayMethod.DIRECTSTREAM) return 'Direct Stream'
else if (playMethod === this.$constants.PlayMethod.LOCAL) return 'Local'
return 'Unknown'
},
async loadSessions(page) {
const data = await this.$axios.$get(`/api/users/${this.user.id}/listening-sessions?page=${page}&itemsPerPage=10`).catch((err) => {
console.error('Failed to load listening sesions', err)
return null
})
if (!data) {
this.$toast.error('Failed to load listening sessions')
return
}
this.numPages = data.numPages
this.total = data.total
this.currentPage = data.page
this.listeningSessions = data.sessions
},
init() {
this.loadSessions(0)
}
},
mounted() {
this.init()
}
}
</script>
<style scoped>
.userSessionsTable {
border-collapse: collapse;
width: 100%;
max-width: 100%;
border: 1px solid #474747;
}
.userSessionsTable tr:first-child {
background-color: #272727;
}
.userSessionsTable tr:not(:first-child) {
background-color: #373838;
}
.userSessionsTable tr:not(:first-child):nth-child(odd) {
background-color: #2f2f2f;
}
.userSessionsTable tr:hover:not(:first-child) {
background-color: #474747;
}
.userSessionsTable td {
padding: 4px 8px;
}
.userSessionsTable th {
padding: 4px 8px;
font-size: 0.75rem;
}
</style>

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 -->
@@ -115,9 +118,9 @@
<!-- Progress -->
<div v-if="!isPodcast && progressPercent > 0" class="px-4 py-2 mt-4 bg-primary text-sm font-semibold rounded-md text-gray-100 relative max-w-max mx-auto md:mx-0" :class="resettingProgress ? 'opacity-25' : ''">
<p v-if="progressPercent < 1" class="leading-6">Your Progress: {{ Math.round(progressPercent * 100) }}%</p>
<p v-else class="text-xs">Finished {{ $formatDate(userProgressFinishedAt, 'MM/dd/yyyy') }}</p>
<p v-else class="text-xs">Finished {{ $formatDate(userProgressFinishedAt, dateFormat) }}</p>
<p v-if="progressPercent < 1" class="text-gray-200 text-xs">{{ $elapsedPretty(userTimeRemaining) }} remaining</p>
<p class="text-gray-400 text-xs pt-1">Started {{ $formatDate(userProgressStartedAt, 'MM/dd/yyyy') }}</p>
<p class="text-gray-400 text-xs pt-1">Started {{ $formatDate(userProgressStartedAt, dateFormat) }}</p>
<div v-if="!resettingProgress" class="absolute -top-1.5 -right-1.5 p-1 w-5 h-5 rounded-full bg-bg hover:bg-error border border-primary flex items-center justify-center cursor-pointer" @click.stop="clearProgressClick">
<span class="material-icons text-sm">close</span>
@@ -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,15 @@ export default {
}
},
computed: {
dateFormat() {
return this.$store.state.serverSettings.dateFormat
},
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
},
enableEReader() {
return this.$store.getters['getServerSetting']('enableEReader')
},
userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
},
@@ -241,12 +253,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 +266,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 +357,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 +402,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: {
@@ -518,13 +537,13 @@ export default {
}
},
rssFeedOpen(data) {
if (data.libraryItemId === this.libraryItemId) {
if (data.entityId === this.libraryItemId) {
console.log('RSS Feed Opened', data)
this.rssFeedUrl = data.feedUrl
}
},
rssFeedClosed(data) {
if (data.libraryItemId === this.libraryItemId) {
if (data.entityId === this.libraryItemId) {
console.log('RSS Feed Closed', data)
this.rssFeedUrl = null
}

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

@@ -29,7 +29,9 @@
</div>
</div>
<div class="pt-8 text-center">
<p class="text-xs text-white text-opacity-50 font-mono"><strong>Supported File Types: </strong>{{ inputAccept.join(', ') }}</p>
<p class="text-xs text-white text-opacity-50 font-mono mb-4"><strong>Supported File Types: </strong>{{ inputAccept.join(', ') }}</p>
<p class="text-sm text-white text-opacity-70">Folders with media files will be treated as separate library items. <span v-if="selectedLibraryMediaType === 'book'">If uploading only audio files then each audio file will be treated as a separate audiobook.</span></p>
</div>
</div>
<!-- Item list header -->
@@ -64,8 +66,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
@@ -124,6 +124,8 @@ export default class CastPlayer extends EventEmitter {
async seek(time, playWhenReady) {
if (!this.player) return
this.playWhenReady = playWhenReady
if (time < this.currentTrack.startOffset || time > this.currentTrack.startOffset + this.currentTrack.duration) {
// Change Track
var request = buildCastLoadRequest(this.libraryItem, this.coverUrl, this.audioTracks, time, playWhenReady, this.defaultPlaybackRate)

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()
@@ -71,11 +71,11 @@ export default class LocalPlayer extends EventEmitter {
console.log(`[LocalPlayer] Track ended - loading next track ${this.currentTrackIndex + 1}`)
// Has next track
this.currentTrackIndex++
this.playWhenReady = true
this.startTime = this.currentTrack.startOffset
this.loadCurrentTrack()
} else {
console.log(`[LocalPlayer] Ended`)
this.emit('finished')
}
}
evtError(error) {
@@ -88,6 +88,7 @@ export default class LocalPlayer extends EventEmitter {
}
this.emit('stateChange', 'LOADED')
if (this.playWhenReady) {
this.playWhenReady = false
this.play()
@@ -204,10 +205,12 @@ export default class LocalPlayer extends EventEmitter {
}
play() {
this.playWhenReady = true
if (this.player) this.player.play()
}
pause() {
this.playWhenReady = false
if (this.player) this.player.pause()
}
@@ -228,8 +231,11 @@ export default class LocalPlayer extends EventEmitter {
this.player.playbackRate = playbackRate
}
seek(time) {
seek(time, playWhenReady) {
if (!this.player) return
this.playWhenReady = playWhenReady
if (this.isHlsTranscode) {
// Seeking HLS stream
var offsetTime = time - (this.currentTrack.startOffset || 0)
@@ -254,7 +260,6 @@ export default class LocalPlayer extends EventEmitter {
this.player.currentTime = Math.max(0, offsetTime)
}
}
}
setVolume(volume) {

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,9 +16,11 @@ export default class PlayerHandler {
this.player = null
this.playerState = 'IDLE'
this.isHlsTranscode = false
this.isVideo = false
this.currentSessionId = null
this.startTime = 0
this.failedProgressSyncs = 0
this.lastSyncTime = 0
this.lastSyncedAt = 0
this.listeningTimeSinceSync = 0
@@ -34,7 +38,7 @@ export default class PlayerHandler {
return this.libraryItem && (this.player instanceof CastPlayer)
}
get isPlayingLocalItem() {
return this.libraryItem && (this.player instanceof LocalPlayer)
return this.libraryItem && (this.player instanceof LocalAudioPlayer)
}
get userToken() {
return this.ctx.$store.getters['user/getToken']
@@ -48,16 +52,17 @@ export default class PlayerHandler {
}
load(libraryItem, episodeId, playWhenReady, playbackRate) {
if (!this.player) this.switchPlayer()
this.libraryItem = libraryItem
this.episodeId = episodeId
this.playWhenReady = playWhenReady
this.initialPlaybackRate = playbackRate
this.prepare()
this.isVideo = libraryItem.mediaType === 'video'
if (!this.player) this.switchPlayer(playWhenReady)
else this.prepare()
}
switchPlayer() {
switchPlayer(playWhenReady) {
if (this.isCasting && !(this.player instanceof CastPlayer)) {
console.log('[PlayerHandler] Switching to cast player')
@@ -73,10 +78,10 @@ export default class PlayerHandler {
if (this.libraryItem) {
// libraryItem was already loaded - prepare for cast
this.playWhenReady = false
this.playWhenReady = playWhenReady
this.prepare()
}
} else if (!this.isCasting && !(this.player instanceof LocalPlayer)) {
} else if (!this.isCasting && !(this.player instanceof LocalAudioPlayer) && !(this.player instanceof LocalVideoPlayer)) {
console.log('[PlayerHandler] Switching to local player')
this.stopPlayInterval()
@@ -85,12 +90,18 @@ export default class PlayerHandler {
if (this.player) {
this.player.destroy()
}
this.player = new LocalPlayer(this.ctx)
if (this.isVideo) {
this.player = new LocalVideoPlayer(this.ctx)
} else {
this.player = new LocalAudioPlayer(this.ctx)
}
this.setPlayerListeners()
if (this.libraryItem) {
// libraryItem was already loaded - prepare for local play
this.playWhenReady = false
this.playWhenReady = playWhenReady
this.prepare()
}
}
@@ -101,16 +112,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 +166,7 @@ export default class PlayerHandler {
supportedMimeTypes: this.player.playableMimeTypes,
mediaPlayer: this.isCasting ? 'chromecast' : 'html5',
forceTranscode,
forceDirectPlay: this.isCasting // TODO: add transcode support for chromecast
forceDirectPlay: this.isCasting || this.isVideo // TODO: add transcode support for chromecast
}
var path = this.episodeId ? `/api/items/${this.libraryItem.id}/play/${this.episodeId}` : `/api/items/${this.libraryItem.id}/play`
@@ -155,30 +177,49 @@ export default class PlayerHandler {
}
prepareOpenSession(session, playbackRate) { // Session opened on init socket
if (!this.player) this.switchPlayer()
if (!this.player) this.switchPlayer() // Must set player first for open sessions
this.libraryItem = session.libraryItem
this.isVideo = session.libraryItem.mediaType === 'video'
this.playWhenReady = false
this.initialPlaybackRate = playbackRate
this.prepareSession(session)
}
prepareSession(session) {
this.failedProgressSyncs = 0
this.startTime = session.currentTime
this.currentSessionId = session.id
this.displayTitle = session.displayTitle
this.displayAuthor = session.displayAuthor
console.log('[PlayerHandler] Preparing Session', session)
var audioTracks = session.audioTracks.map(at => new AudioTrack(at, this.userToken))
this.ctx.playerLoading = true
this.isHlsTranscode = true
if (session.playMethod === this.ctx.$constants.PlayMethod.DIRECTPLAY) {
this.isHlsTranscode = false
if (session.videoTrack) {
var videoTrack = new VideoTrack(session.videoTrack, this.userToken)
this.ctx.playerLoading = true
this.isHlsTranscode = true
if (session.playMethod === this.ctx.$constants.PlayMethod.DIRECTPLAY) {
this.isHlsTranscode = false
}
this.player.set(this.libraryItem, videoTrack, this.isHlsTranscode, this.startTime, this.playWhenReady)
} else {
var audioTracks = session.audioTracks.map(at => new AudioTrack(at, this.userToken))
this.ctx.playerLoading = true
this.isHlsTranscode = true
if (session.playMethod === this.ctx.$constants.PlayMethod.DIRECTPLAY) {
this.isHlsTranscode = false
}
this.player.set(this.libraryItem, audioTracks, this.isHlsTranscode, this.startTime, this.playWhenReady)
}
this.player.set(this.libraryItem, audioTracks, this.isHlsTranscode, this.startTime, this.playWhenReady)
// browser media session api
this.ctx.setMediaSession()
}
closePlayer() {
@@ -248,8 +289,15 @@ export default class PlayerHandler {
currentTime
}
this.listeningTimeSinceSync = 0
this.ctx.$axios.$post(`/api/session/${this.currentSessionId}/sync`, syncData, { timeout: 1000 }).catch((error) => {
this.ctx.$axios.$post(`/api/session/${this.currentSessionId}/sync`, syncData, { timeout: 1000 }).then(() => {
this.failedProgressSyncs = 0
}).catch((error) => {
console.error('Failed to update session progress', error)
this.failedProgressSyncs++
if (this.failedProgressSyncs >= 2) {
this.ctx.showFailedProgressSyncs()
this.failedProgressSyncs = 0
}
})
}

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
}
@@ -149,7 +169,7 @@ Vue.prototype.$sanitizeSlug = (str) => {
str = str.replace(new RegExp(from.charAt(i), 'g'), to.charAt(i))
}
str = str.replace('.', '-') // replace a dot by a dash
str = str.replace('.', '-') // replace a dot by a dash
.replace(/[^a-z0-9 -_]/g, '') // remove invalid chars
.replace(/\s+/g, '-') // collapse whitespace and replace by a dash
.replace(/-+/g, '-') // collapse dashes
@@ -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

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