Compare commits

...

322 Commits

Author SHA1 Message Date
advplyr
104cadb0b3 Version bump 2.3.2 2023-07-17 17:49:12 -05:00
advplyr
6814adffcc Update:Only load feeds when needed 2023-07-17 16:48:46 -05:00
advplyr
20c11e381e Update docker file heap size 2023-07-17 14:41:21 -05:00
advplyr
b5952f16eb Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2023-07-17 13:58:25 -05:00
advplyr
5b6878e5de Fix:Crash on local playback sessions #1912 2023-07-17 13:58:19 -05:00
advplyr
89a25bcf39 Merge pull request #1917 from burghy86/patch-10
Update it.json
2023-07-17 08:09:28 -05:00
advplyr
d0cd512be8 Fix:Crash when updating sequence on series #1919 2023-07-17 08:09:08 -05:00
advplyr
3543dea0fb Merge pull request #1923 from JBlond/master
Update German language file
2023-07-17 08:09:07 -05:00
advplyr
1949e25ccb Merge pull request #1925 from Machou/patch-1
Update fr.json
2023-07-17 08:08:43 -05:00
advplyr
b715ef3bfc Increase heap size to 4gb in Dockerfile 2023-07-17 07:48:23 -05:00
Machou
954050df81 Update fr.json 2023-07-17 14:28:56 +02:00
JBlond
e4aa7f10fa Update German language file 2023-07-17 10:02:54 +02:00
advplyr
2afd0e2acd Update dbMigration for old main library ids 2023-07-16 16:39:59 -05:00
advplyr
0829237166 Fix:Libraries out of order #1911 2023-07-16 15:43:46 -05:00
advplyr
541975f038 Version bump 2.3.1 2023-07-16 15:34:35 -05:00
advplyr
01bf58ab97 Fix createAuthor 2023-07-16 15:29:43 -05:00
advplyr
d99b2c25e8 Fixes for db migration & local playback sessions 2023-07-16 15:05:51 -05:00
burghy86
a31df5ff81 Update it.json
traslate new string and fix error
2023-07-16 21:50:15 +02:00
advplyr
63e5cf2e60 Fix:Accessing series page for some users #787 2023-07-16 08:39:08 -05:00
advplyr
7beca048e7 Version bump v2.3.0 2023-07-15 15:29:25 -05:00
advplyr
ec998dc1ac Update:Podcast library item covers show number of episodes incomplete #782 2023-07-15 14:45:08 -05:00
advplyr
ddc54c8811 Update:Downloading library item shows log on the server with username #1461 2023-07-15 13:39:12 -05:00
advplyr
72e306935f Update:Support and as separator between multiple authors #1790 2023-07-15 13:28:31 -05:00
advplyr
96a7c7f4d1 Fix:Embedded chapters with invalid IDs, update chapter ids to always be the index #1783 2023-07-15 12:46:51 -05:00
advplyr
9c65d655b8 Fix:Realtime update cover on cover tab in item edit modal 2023-07-15 12:37:33 -05:00
advplyr
b108f2241b Add:Library filter for publishers & link to publisher filter on book page #1813 2023-07-15 12:22:13 -05:00
advplyr
9439acf300 Merge pull request #1906 from warnwar/master
stop opf importer from adding duplicate info
2023-07-15 11:44:41 -05:00
advplyr
d181e66d83 Update server/utils/parsers/parseOpfMetadata.js 2023-07-15 11:41:44 -05:00
advplyr
a87c3f2c77 Update server/utils/parsers/parseOpfMetadata.js 2023-07-15 11:41:40 -05:00
advplyr
2834f6077e Update server/utils/parsers/parseOpfMetadata.js 2023-07-15 11:41:35 -05:00
advplyr
918013ccb3 Add:Option on podcast page to mark all episodes as finished/unfinished #1862 2023-07-15 11:27:06 -05:00
advplyr
4c4672c6c1 Update:Item page UI for details that take up multiple lines 2023-07-15 11:00:07 -05:00
advplyr
b3991574c7 Merge pull request #1907 from advplyr/sqlite_2
Migration to use sqlite3
2023-07-14 15:11:23 -05:00
advplyr
c881bcbe59 Update logs for cache purge 2023-07-14 15:04:27 -05:00
advplyr
89aa4a8bdc Update logger to support dev only log, remove old model docs 2023-07-14 14:50:37 -05:00
advplyr
c5a4f63670 Update Backup to use key to check for old backups no longer supported 2023-07-14 14:20:35 -05:00
advplyr
1b97582975 Update dbMigration mappings 2023-07-14 14:04:47 -05:00
advplyr
9b7aacf3ea Update dbMigration mappings 2023-07-14 14:04:28 -05:00
WarWar
47b9ee557e stop opf importer from adding duplicate info 2023-07-14 05:15:29 +00:00
advplyr
e40e0bfa25 Update:Listening session modal UI 2023-07-13 17:44:20 -05:00
advplyr
d56e3a3617 Merge branch 'master' into sqlite_2 2023-07-11 17:07:13 -05:00
advplyr
78fe6d47ba Fix:Library settings context menu actions for mobile view #1886 2023-07-11 17:06:14 -05:00
advplyr
995cf51ae3 Update:Default m4b encoding bitrate to 128k #1892 2023-07-11 16:57:30 -05:00
advplyr
d838ff2f2e Merge branch 'master' into sqlite_2 2023-07-10 17:37:47 -05:00
advplyr
f2f07ff534 Update:Show num episodes on podcast item page #1891 2023-07-10 17:37:35 -05:00
advplyr
8cff68ca64 Fix purge metadata/items paths 2023-07-10 17:00:31 -05:00
advplyr
eb5331d34a Update playlist & collection models to use sort order 2023-07-10 16:07:22 -05:00
advplyr
f425185575 Merge branch 'master' into sqlite_2 2023-07-09 15:50:50 -05:00
advplyr
9fc352a5a4 Fix:Download episode from rss feed with very long description #1893 2023-07-09 15:50:40 -05:00
advplyr
e85ddc1aa1 Update package.json pkg assets, remove njodb and dependencies 2023-07-09 14:22:30 -05:00
advplyr
b9be7510f8 Remove purge-media-progress api route 2023-07-09 14:08:14 -05:00
advplyr
f4497acd48 Remove API routes for removing all items and purging media progress 2023-07-09 14:07:30 -05:00
advplyr
f73a0cce72 Update Dockerfile for sqlite3, update models for cascade delete, fix backup schedule 2023-07-09 11:39:15 -05:00
advplyr
254ba1f089 Migrate backups manager 2023-07-08 14:40:49 -05:00
advplyr
0a179e4eed Update author and series to include libraryId 2023-07-08 10:07:57 -05:00
advplyr
0ac63b2678 Update Series and Author model to be library specific 2023-07-08 09:57:32 -05:00
advplyr
1d13d0a553 Merge master 2023-07-08 08:25:33 -05:00
advplyr
fc6ff016a7 Update:Playback sync request timeout to 9s and show sync alerts after 4 failed syncs #1884 2023-07-08 08:08:14 -05:00
advplyr
e378b79fbc Fix:Access series that are in multiple libraries and user does not have access to all #1899, new libraries/series endpoint 2023-07-07 17:59:17 -05:00
advplyr
7e377297d7 Update:Remove toast notifications for marking items as finished #1900 2023-07-07 17:22:38 -05:00
advplyr
00a02921dd Fix:RSS feeds that include an id as a query string #1896 2023-07-06 18:06:26 -05:00
advplyr
b5d4c11f6f Fix RSS feeds to use slug instead of id 2023-07-06 17:07:10 -05:00
advplyr
a0bc959850 Add feed migration and cleanup 2023-07-05 18:18:37 -05:00
advplyr
a4b0f6c202 Merge branch 'master' into sqlite_2 2023-07-04 18:15:52 -05:00
advplyr
65cf928afe Fix:Delete ereader device 2023-07-04 18:15:43 -05:00
advplyr
cf7fd315b6 Init sqlite take 2 2023-07-04 18:14:44 -05:00
advplyr
d86a3b3dc2 Update:Filter out podcasts from search that dont have an RSS feed url #1514 2023-07-01 09:00:40 -05:00
advplyr
e07e2cd359 Update:Select all episodes showing option #1878 & add translations to episodes modal 2023-06-30 17:30:15 -05:00
advplyr
8140d7021a Update:Increase timeout for progress sync to 6s and sync interval to 10s #1884 2023-06-30 16:28:02 -05:00
advplyr
bdbc5e3161 Add:Library setting to hide single book series #1433 2023-06-29 17:55:17 -05:00
advplyr
bb9013541b Update:Get all users api endpoint to include latest session, display device info on users table #724 2023-06-28 17:57:46 -05:00
advplyr
1668153acd Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2023-06-27 17:13:38 -05:00
advplyr
aeba7674f8 Add new api route for downloading backup, remove static metadata route 2023-06-27 16:41:32 -05:00
advplyr
5b0d105e21 Remove deprecated /s/ and /ebook/ api routes 2023-06-27 15:56:33 -05:00
advplyr
feb54d0629 Merge pull request #1874 from springsunx/master
update zh-cn.json
2023-06-27 08:53:23 -05:00
SunX
3284fe8f31 update zh-cn.json
update zh-cn.json
2023-06-27 21:23:20 +08:00
advplyr
18cb394884 Update:Remove episodes from newest shelf when finished #1871 2023-06-26 17:32:45 -05:00
advplyr
d0bce2949e Add:FFProbe api endpoint 2023-06-25 16:16:11 -05:00
advplyr
a0e80772cd Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2023-06-23 17:32:09 -05:00
advplyr
e44595521d Update:Cleanup collections edit modal ui for mobile 2023-06-23 17:32:03 -05:00
advplyr
fdf647eb32 Update:Cleanup chapters page ui on mobile 2023-06-23 17:28:35 -05:00
advplyr
71369bd2a0 Update:Podcast rss feed fetch timeout to 12s #1856 2023-06-22 17:27:09 -05:00
advplyr
36b1f43f4c Fix:epub ereader on mobile #1854 2023-06-18 14:10:01 -05:00
advplyr
a8bc1df3e7 Fix epub ereader theme sticking for other ebook formats 2023-06-18 12:56:32 -05:00
advplyr
a96869f547 Add ereader translations 2023-06-16 17:00:40 -05:00
advplyr
77b030199e Fix:Non-admin access to config pages #1848 and dev proxy #1848 2023-06-15 17:41:27 -05:00
advplyr
0e1c6c0ba7 Merge pull request #1849 from Nab0y/master
Update Russian localization
2023-06-15 16:10:24 -05:00
Dmitry Naboychenko
c397422d3b Update russian localization 2023-06-15 23:28:14 +03:00
advplyr
15313826bf Add:Epub ereader settings for font scale, line spacing, theme and spread 2023-06-14 17:30:08 -05:00
advplyr
c6405b9013 Merge pull request #1838 from daVinci2793/master
Updates to Email settings/manager to include test email
2023-06-12 17:16:18 -05:00
advplyr
d748d43efc Fallback to using from address if test address is not set, add reset button when form has changes 2023-06-12 17:12:52 -05:00
daVinci2793
d54edb93d6 Updates to Email settings/manager to include test email 2023-06-12 04:53:51 +00:00
advplyr
b8ca6671fc Minor cleanup 2023-06-11 13:22:58 -05:00
advplyr
cb7fb646ba Fix:Comic reader next/prev buttons 2023-06-11 11:37:28 -05:00
advplyr
aa82c8a253 Version bump 2.2.23 2023-06-10 16:00:48 -05:00
advplyr
aae92649b1 Add:Ebook and supplementary ebook library filters 2023-06-10 15:59:44 -05:00
advplyr
a9f5c64204 Update:Cleanup UI/UX for filter and sort dropdowns 2023-06-10 15:46:12 -05:00
advplyr
1392baf1eb Fix:Remove experimental features 2023-06-10 15:28:21 -05:00
advplyr
0ec50bb570 Remove experimental features and experimental ereader setting 2023-06-10 14:11:51 -05:00
advplyr
b60473d7ae Update:Setting new other ebook files as supplementary #1809 2023-06-10 13:20:38 -05:00
advplyr
014fc45c15 Add:Audiobooks only library settings, supplementary ebooks #1664 2023-06-10 12:46:57 -05:00
advplyr
4b4fb33d8f Fix:Pressing edit on a podcast episode from a playlist #1833 2023-06-09 17:12:38 -05:00
advplyr
35e3458fb4 Add:Download button in comic reader to download current page image #1822 2023-06-07 17:03:23 -05:00
advplyr
8f42153bee Add:Save progress for comics #1829 2023-06-07 16:14:48 -05:00
advplyr
2f04d34bce Fix:Submenu overflowing page #1828 2023-06-07 15:48:23 -05:00
advplyr
09566c02ea Fix:Series page In Progress filter showing completed series #1827 2023-06-07 14:01:03 -05:00
advplyr
d714ef37d9 Fix:Using arrow keys when editing podcast description #1826 2023-06-07 11:01:11 -05:00
advplyr
fde07d26e5 Update:Prefer epub ebook file when setting ebook #1825, validate ebookLocation 2023-06-06 16:53:11 -05:00
advplyr
9547824aaa Merge pull request #1819 from mayli/usetemp
Fix: useTempFiles=true, upload use tmp instead of ram
2023-06-06 15:41:28 -05:00
advplyr
5a01be1ee3 Add tempFileDir for uploads 2023-06-06 15:40:52 -05:00
advplyr
5dc4606657 Add:Support for CAF audio files 2023-06-05 16:23:40 -05:00
Coda
2fd3238576 Fix: useTempFiles=true, upload use tmp instead of ram 2023-06-04 15:56:41 -07:00
advplyr
c1bcfe8304 Merge pull request #1816 from mayli/utf8-filename
Fix: decode filename as utf8 on upload
2023-06-04 08:39:38 -05:00
Coda
a3642b204d Fix: decode filename as utf8 on upload 2023-06-03 21:44:13 -07:00
advplyr
8243da69f6 Merge pull request #1814 from depwl9992/patch-1
Update readme.md - Expand required Apache modules for reverse proxy and detail how to handle acme challenges
2023-06-03 17:30:16 -05:00
Daniel Powell
6d5987b2e0 Update readme.md
After setting up the reverse proxy for Apache per the current instructions, I received the error: "AH01144: No protocol handler was valid for the URL / (scheme 'http')". Installing proxy_http and proxy_balancer modules in addition to those listed fixed this issue.

a2enmod command does not include the redundant '_module' suffix.

Let's Encrypt was not able to validate certificates either automatically or manually as ABS does not respond to ACME challenges directly. Editing the virtual environment configuration to ignore proxy requests to .well-known and serving that directly from Apache allowed LE to validate the challenge instead.
2023-06-03 11:46:42 -06:00
advplyr
a2fdc3e876 Update:Increase max height of libraries dropdown 2023-06-01 17:09:04 -05:00
advplyr
f92b66a469 Merge pull request #1810 from glacasa/master
Update french translations
2023-06-01 16:29:48 -05:00
Guillaume Lacasa
c3d256c42b Update french translations 2023-06-01 09:38:00 +02:00
advplyr
fdc792cb82 Version bump v2.2.22 2023-05-30 16:59:04 -05:00
advplyr
a16fb31e6e Update:Library filter max height #1673 2023-05-30 16:55:52 -05:00
advplyr
4d8a1b5b6d Add:Ebook library filter, and update e-book to ebook 2023-05-30 16:37:24 -05:00
advplyr
c382f07b05 Fix:Close player resetting progress #1807 2023-05-30 16:08:30 -05:00
advplyr
9f6a7d065c Merge pull request #1808 from Lionfox2/patch-1
Update de.json
2023-05-30 04:14:33 -05:00
Lionfox2
11aa75ecbe Update de.json
I corrected a spelling mistake (starseite --> startseite)
2023-05-30 00:56:27 +02:00
advplyr
05ce9c6eda Add:Email smtp config & send ebooks to devices #1474 2023-05-29 17:38:38 -05:00
advplyr
15aaf2863c Add:OPML Export #1260 2023-05-28 15:10:34 -05:00
advplyr
019063e6f4 Update:New API routes for library files and downloads 2023-05-28 12:34:22 -05:00
advplyr
ea79948122 Fix:Podcast episode downloads where RSS feed uses the same title #1802 2023-05-28 11:24:51 -05:00
advplyr
7a0f27e3cc Fix:Epub3 background color #1804 2023-05-28 10:55:37 -05:00
advplyr
4f75a89633 Update:New EBook API endpoint 2023-05-28 10:47:28 -05:00
advplyr
b3f19ef628 Fix:Static file route check authorization 2023-05-28 09:34:03 -05:00
advplyr
f16e312319 Fix:Series api check user has access to library 2023-05-28 08:51:34 -05:00
advplyr
056da0ef70 Fix:Static ebook route 2023-05-28 08:39:41 -05:00
advplyr
ca5f781531 Version bump v2.2.21 2023-05-27 17:54:20 -05:00
advplyr
53c96b2540 Update:Handle multiple sessions open, sync when paused, show alert of multiple sessions open when both are playing #1660 2023-05-27 17:21:43 -05:00
advplyr
9712bdf5f0 Update:Check if directory already exists before upload #1497 2023-05-27 16:00:34 -05:00
advplyr
0678c26627 Fix:Catch exception when podcast episode download request fails #1759 2023-05-27 15:24:08 -05:00
advplyr
b52e240025 Add:Batch re-scan #1754 2023-05-27 14:51:03 -05:00
advplyr
2fa73f7a8d Update:Audio player visible when ereader is open #1800 and adding zoom to PDF reader 2023-05-27 11:58:01 -05:00
advplyr
2cc23b6d6b Update:Auto update home page shelves when new episode is added #716 2023-05-27 09:13:44 -05:00
advplyr
9a617226b3 Update:Continue Reading shelf Remove from Continue Reading menu item 2023-05-27 09:00:54 -05:00
advplyr
fbfc015d92 Fix:Hide Chapters tab for ebook only items #1795 2023-05-27 08:31:47 -05:00
advplyr
3e4c94e2b4 Update:Continue Reading and Read Again home page shelves for ebook only items #1782 2023-05-27 08:20:09 -05:00
advplyr
1da471e136 Fix:Embed metadata tool embed ASIN, SERIES and SERIESPART #1794 2023-05-26 17:57:56 -05:00
advplyr
4dba95c000 Update:Show series on collection items #1449 2023-05-24 17:37:51 -05:00
advplyr
36477a832c Add:Saving progress for PDF ebooks #1791 2023-05-24 16:41:16 -05:00
advplyr
b4aa8f0c9a Merge pull request #1786 from 92Kev/master
Update Dutch translation
2023-05-23 17:27:15 -05:00
advplyr
6a974d5ef0 Update:Epub TOC shows subitems #1787 2023-05-23 15:38:49 -05:00
92Kev
304eda9f8c 'Audio tracks' to 'Audiotracks'. 2023-05-21 18:30:55 +02:00
92Kev
581f2e3d15 Fixes some errors and changes some translations to more intuitive terms. 2023-05-21 18:16:00 +02:00
advplyr
be2d317325 Fix initialize currentTime and ebookProgress for MediaProgress 2023-05-20 15:37:44 -05:00
advplyr
9f6bfeb839 Fix:Removing media progress that was started local 2023-05-20 15:19:09 -05:00
advplyr
f4f5f79af7 Update:Add chapters to playback session so they can be used for podcast episodes in mobile apps 2023-05-20 13:30:07 -05:00
advplyr
92bb2fb23d Fix:Levenshtein distance crash when passed non-strings 2023-05-18 17:07:58 -05:00
advplyr
3c406c12b4 Updates to metadata file format changing, use chapters from metadata file 2023-05-16 18:58:01 -05:00
advplyr
81d4ac3ed2 Add:metadata.json format #1775 #916 2023-05-15 18:23:31 -05:00
advplyr
32bdae31a8 Add:All providers option for searching covers #1774 2023-05-14 13:43:20 -05:00
advplyr
84c16c4a39 Fix:Audible india provider 2023-05-14 13:42:29 -05:00
advplyr
b8b3d05f5e Fix:Authors page includes library id in url #1767 2023-05-13 16:47:38 -05:00
advplyr
bac09de23d Fix:getNarrators API endpoint check narrators are strings #1770 2023-05-12 18:22:09 -05:00
advplyr
b0bf9604bb Update:First sync after 20 seconds listening #1546 2023-05-11 15:31:47 -04:00
advplyr
688531f0a7 Update:Podcast episodes fallback to description when subtitle is null #1752 2023-05-10 20:18:29 -04:00
advplyr
dfc7877f69 Fix:Persist book cover provider separate from match provider #1764 2023-05-09 19:26:37 -04:00
advplyr
e00116a0e3 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2023-05-08 16:21:02 -04:00
advplyr
2ab287e2a9 Fix:Podcast cron filter out failed library item 2023-05-08 16:20:09 -04:00
advplyr
1e0da09b2f Merge pull request #1760 from Alistair1231/seek-instead-of-skip
seek instead of skip with action handlers
2023-05-07 15:16:04 -05:00
Alistair1231
0e7a5649cc seek instead of skip with action handlers 2023-05-07 19:23:07 +00:00
advplyr
30009e45da Update:Book subtitle searchable #1755 2023-05-06 09:43:13 -05:00
advplyr
f9a668cb41 Version bump 2.2.20 2023-05-05 17:03:42 -05:00
advplyr
c848f366de Update:Audio file disc meta tag support for TPA #1749 2023-05-03 17:33:01 -05:00
advplyr
25daab2f34 Update:Show publisher on book page #1751 2023-05-03 17:29:54 -05:00
advplyr
7170ab7239 Add:Dutch language option 2023-05-03 07:52:32 -05:00
advplyr
063b3bb8db Merge pull request #1747 from 92Kev/Add-Dutch
Add Dutch translation
2023-05-03 07:43:55 -05:00
advplyr
6eb6a7b115 Merge pull request #1750 from pilabor/master
Added `part` to supported tags for `file_tag_seriespart`
2023-05-03 07:43:16 -05:00
Andreas
d0972348b9 Added part to supported tags for file_tag_seriespart
Since `part` is a supported tag for `m4b` files, it's now added as another fallback option.
2023-05-03 10:40:11 +02:00
92Kev
0e70af77c6 Update nl.json
Translated some remaining terms, changed others to more appropriate Dutch translations
2023-05-03 09:40:33 +02:00
92Kev
4efca78602 Merge branch 'advplyr:master' into Add-Dutch 2023-05-03 07:52:51 +02:00
advplyr
87d10bd6f5 Merge pull request #1748 from jkuehnemundt/de-lang
Update de lang file
2023-05-02 15:45:32 -05:00
Jannik Kühnemundt
0f82aed4ce Update de.json 2023-05-02 21:49:03 +02:00
Jannik Kühnemundt
58f10ad7af Update de.json 2023-05-02 21:45:13 +02:00
92Kev
68dcf87aea Update nl.json
Add Dutch translations
2023-05-02 08:55:14 +02:00
92Kev
c2f85deb11 Added Dutch language string file
Initial translation to Dutch from English string file
2023-05-02 08:51:57 +02:00
advplyr
0dd3a52cc8 Add narrator confirm translations 2023-05-01 16:25:01 -05:00
advplyr
c07c73c649 Remove sortablejs 2023-05-01 16:24:31 -05:00
advplyr
dbde5f773c Merge pull request #1745 from springsunx/patch-1
update zh-cn translation
2023-05-01 14:45:08 -05:00
SunX
68bf038205 update zh-cn translation 2023-05-01 11:29:33 +08:00
advplyr
eb7f66c89e Add:Narrators page #860 #1139 2023-04-30 14:11:54 -05:00
advplyr
58ebde2982 Update:Podcast episode audio files More Info option 2023-04-30 09:45:28 -05:00
advplyr
604a671549 Update:Show tags and podcast type on library item page 2023-04-30 09:41:49 -05:00
advplyr
5286b53334 Add:Progress bar on series covers #1734 2023-04-29 16:26:56 -05:00
advplyr
4db26f9f79 Add:Log user and ip on successful login #1740 2023-04-28 16:16:47 -05:00
advplyr
ff8a58c7bc Remove log about not modifying permissions 2023-04-28 16:08:57 -05:00
advplyr
6f67c7bfa2 Merge pull request #1686 from divyangbw/feat-user-access-by-tag-enhancement
Invert Tag Selection
2023-04-27 17:20:16 -05:00
advplyr
e9f5bd9bfe Merge branch 'master' into feat-user-access-by-tag-enhancement 2023-04-27 17:18:56 -05:00
advplyr
56e213d654 Update itemTagsSelected migration 2023-04-27 17:18:54 -05:00
advplyr
98323de64c Merge pull request #1736 from divyangbw/fix-fr-json-corrupted
Remove what seems to be a paste error in the french translations
2023-04-27 16:44:13 -05:00
Divyang Joshi
4a13712b1c fix: Remove what seems to be a paste error in the french translations 2023-04-27 17:07:09 -04:00
Divyang Joshi
0387436111 feat: add support for inverting the selection on libraries and tags 2023-04-27 17:02:15 -04:00
advplyr
7685ead000 Merge pull request #1729 from apineiro97/master
update es translation
2023-04-27 04:46:39 -05:00
advplyr
8665d66923 Merge pull request #1730 from Hallo951/master
Update german strings
2023-04-27 04:46:15 -05:00
Hallo951
9a808602c4 Update german strings 2023-04-27 10:41:06 +02:00
Arturo Pineiro
813e553dbb update es translation 2023-04-26 18:20:46 -07:00
advplyr
be050a7d57 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2023-04-26 18:15:55 -05:00
advplyr
065675697d Fix:Catch exception when failing to download podcast episodes 2023-04-26 18:15:50 -05:00
advplyr
8f6832fc2e Merge pull request #1727 from tomazed/translation-fr
update on pending translation
2023-04-26 10:50:54 -05:00
Tomazed
bdb154a6e5 update on pending translation 2023-04-26 17:39:15 +02:00
advplyr
f557289274 Update:Set lang in HTML tag #1399 2023-04-25 19:00:57 -05:00
advplyr
a5627a1b52 Add:Search for narrators #1495 2023-04-24 18:25:30 -05:00
advplyr
33f20d54cc Updated docker template xml 2023-04-23 17:08:36 -05:00
advplyr
dadd41cb5c Fix:Podcast episode quick match crash #1711 2023-04-21 17:49:25 -05:00
advplyr
35e27e4f61 Merge pull request #1710 from Weldawadyathink/audiobook-covers-2
Add AudiobookCovers.com metadata provider
2023-04-21 16:17:48 -05:00
advplyr
84839bea44 Cleanup audiobookcovers.com addition 2023-04-21 16:17:52 -05:00
Spenser Bushey
1342897858 Removed useless comments 2023-04-20 16:39:04 -07:00
advplyr
c32efb8db8 Fix:Podcast episode search modal search filter #1699 2023-04-20 17:51:06 -05:00
Spenser Bushey
f9ed412e4e Add AudiobookCovers.com metadata provider
AudiobookCovers.com acts as a cover-only metadata provider, therefore will only show up in the covers selector.
2023-04-19 22:13:52 -07:00
advplyr
6ae3ad508e Readme update 2023-04-19 18:03:43 -05:00
advplyr
24af702b41 Merge pull request #1707 from coldshouldermedia/master
Updated SWAG Reverse Proxy guide
2023-04-19 17:59:59 -05:00
advplyr
a57ff20f35 Merge pull request #1692 from divyangbw/fix-show-all-genres-on-match-tab
fix: Make sure all existing genres also show up on the match tab
2023-04-19 17:39:04 -05:00
advplyr
39e710deb1 Merge pull request #1706 from ajaxbits/master
Update:Fix filename issue in tone, bump to v0.1.5
2023-04-19 17:22:24 -05:00
advplyr
3b6fa73ac0 Update tone in debian package to v0.1.5 2023-04-19 17:22:25 -05:00
coldshouldermedia
e2dd66d450 Updated SWAG Reverse Proxy guide 2023-04-19 16:19:29 -06:00
Alex Jackson
b1b53a1eae Update:Bump tone version in devcontainer to v0.1.5 2023-04-19 16:40:46 -05:00
Alex Jackson
6f73345f39 Merge branch 'advplyr:master' into master 2023-04-19 16:24:21 -05:00
Alex Jackson
c7b4b3bd3e Update:Bump tone version
Addresses #1703. Paths with quotations were not handled by tone<v0.1.3.
2023-04-19 16:22:15 -05:00
advplyr
98d543e3e5 Merge pull request #1695 from jkuehnemundt/de-lang-fix
Improve German (de.json) translation
2023-04-18 18:07:43 -05:00
advplyr
4de4e958a0 Merge pull request #1684 from ThinkSalat/bugfix/notification-url-blur
blur the url input when clicking submit to add info currently in input
2023-04-18 18:06:28 -05:00
advplyr
cc5e92ec8e Update client/components/modals/notification/NotificationEditModal.vue 2023-04-18 18:06:22 -05:00
Jannik Kühnemundt
6cb9dfaa85 Update de.json 2023-04-18 17:55:59 +02:00
Jannik Kühnemundt
8790166ac1 Fix further translation 2023-04-18 16:37:34 +02:00
Divyang Joshi
3b97e2146d fix: Make sure all existing genres also show up on the match tab 2023-04-17 20:09:59 -05:00
advplyr
0bb1cf002d Fix:Crash when podcasts put empty spaces with episode file path in RSS feed #1650 2023-04-17 17:03:58 -05:00
advplyr
307c7ebc9d Merge pull request #1688 from Machou/patch-1
Update fr.json
2023-04-17 16:35:31 -05:00
Jannik Kühnemundt
cc1b41995d Fixed german translation 2023-04-17 23:17:40 +02:00
Machou
730d60575e Update fr.json 2023-04-17 22:42:48 +02:00
Shawn Salat
1b96297cc7 blur the url input when clicking submit to add info currently in input 2023-04-17 08:25:20 -06:00
advplyr
128c554543 Version bump 2.2.19 2023-04-16 16:34:09 -05:00
advplyr
1b5ab6c378 Update xml2js 0.5.0 2023-04-16 16:33:28 -05:00
advplyr
e4961feffb Update:Remove item metadata path when removing item #1561 2023-04-16 16:23:13 -05:00
advplyr
eb5f257b8c Merge pull request #1680 from lukeIam/region_authors
Use region for author queries
2023-04-16 15:54:49 -05:00
advplyr
e271e89835 Author API requests to use region from library provider 2023-04-16 15:53:46 -05:00
advplyr
f5009f76f4 Update proper lockfile settings #1326 2023-04-16 15:21:04 -05:00
lukeIam
a3e63e03d2 Use region for author queries 2023-04-16 13:36:50 +02:00
advplyr
2ae3ea346f Update:Show abridged icon next to title #1656 2023-04-15 18:28:06 -05:00
advplyr
8542d433a2 Add:Audio file info modal #1667 2023-04-15 18:09:49 -05:00
advplyr
03984f96d4 Remove experimental tone probe 2023-04-15 16:21:16 -05:00
advplyr
eab019c577 Use horizontal kebab icon 2023-04-14 16:48:24 -05:00
advplyr
179f11f55d Add:Delete library items from file system #1439 2023-04-14 16:44:41 -05:00
advplyr
5a21e63d0b Add:Delete library files, condense item options in more menu #1439 2023-04-13 18:03:39 -05:00
advplyr
24ef105732 Fix:Empty podcasts marked as missing & removing episodes when deleted in folder #1671 2023-04-12 17:20:11 -05:00
advplyr
589c4f73d2 Cleanup scanner 2023-04-12 16:45:52 -05:00
advplyr
55fdc48d5d Merge pull request #1670 from divyangbw/feat-new-sortBy-last-book
Add sortBy Last Book Added and Updated to series
2023-04-12 16:23:52 -05:00
advplyr
4d45a902bb Add translations to other languages 2023-04-12 16:25:02 -05:00
Divyang Joshi
69bac2ec1e Add sorted by value to the series card 2023-04-12 12:55:59 -04:00
Divyang Joshi
122ec140e8 Add sortBy Last Book Added and Updated to series 2023-04-11 23:18:25 -04:00
advplyr
6a0adf7433 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2023-04-11 16:55:28 -05:00
advplyr
c1b2aaec9f Fix:Set tone path for debian tone usage #1643 2023-04-11 16:55:22 -05:00
advplyr
a49acdb2e4 Merge pull request #1669 from Nab0y/master
Fix Russian localization
2023-04-11 15:06:50 -05:00
Dmitry Naboychenko
9b67fbe8d9 Fix Russian localization 2023-04-11 21:09:18 +03:00
advplyr
2d13215f1f Merge pull request #1665 from tomazed/translation-fr
MessageConfirmRemoveAllChapters translation fr
2023-04-11 09:32:45 -05:00
Tomazed
a77c3aae93 MessageConfirmRemoveAllChapters translation fr 2023-04-11 09:41:38 +02:00
advplyr
164937b454 Merge pull request #1659 from Dr-Blank/gujarati-translation
Kick off Gujarati translation.
2023-04-10 17:24:59 -05:00
Dr-Blank
b0a8f3d207 Kick off Gujarati translation. 2023-04-09 23:58:51 -04:00
advplyr
77cc0934be Update:Episodes table sort by pub date treats episodes with no pub date as the oldest #1454 2023-04-09 17:20:56 -05:00
advplyr
718890cfad Add:Download button to download full library item #580 2023-04-09 17:05:35 -05:00
advplyr
418adcf891 Update:Only admin users can see full file path #1411 2023-04-09 16:10:03 -05:00
advplyr
b96f878d69 Update:Sleep timer presets and add custom time input #1357 2023-04-09 15:37:49 -05:00
advplyr
22b8622c67 Fix:Crash for invalid payload to update cover endpoint #1644 2023-04-09 15:01:14 -05:00
advplyr
3dc9416da6 Add:Chapters to podcast episodes #1646 2023-04-09 14:32:51 -05:00
advplyr
5e5b674c17 Add:Remove all chapters button in chapter editor #1603 2023-04-09 12:47:36 -05:00
advplyr
3656eab8bf Update:Add audible_asin meta tag #1640 2023-04-09 11:23:02 -05:00
advplyr
25ca950dd0 Update listening sessions per device and show open sessions 2023-04-08 18:01:24 -05:00
advplyr
8fca84e4bd Fix:Chapter editor show save button when shifting times #1648 2023-04-08 14:32:12 -05:00
advplyr
56579f440b Update playback rate hotkey adjustment 2023-04-08 11:17:17 -05:00
advplyr
a59311f795 Update:Adjust timestamps in player for playback speed #1647 2023-04-07 18:05:23 -05:00
advplyr
042c89039c Merge pull request #1654 from Dr-Blank/hindi-translation
Kicked off Hindi Translation.
2023-04-07 16:10:44 -05:00
Dr-Blank
d94482827a Kicked of Hindi Translation. 2023-04-07 04:41:38 -04:00
advplyr
a8dab5653b Merge pull request #1651 from Demian98/patch-1
Added german translation for abridged
2023-04-06 09:56:07 -05:00
Demian98
1d1200a3f2 Added german translation for abridged 2023-04-06 16:04:56 +02:00
advplyr
4d110ebe7e Fix:Podcast RSS feed parse when element has attributes #1650 2023-04-05 17:40:40 -05:00
advplyr
b300f0d10c Merge pull request #1107 from ruoti/dev-documentation
Development documentation
2023-04-04 16:47:36 -05:00
Scott Ruoti
6dc4dc8f49 Updating devcontainer setup and related documentation 2023-04-04 12:10:45 -04:00
advplyr
dfae6cf89f Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2023-04-03 17:50:47 -05:00
advplyr
d7f18bdd8b Remove deprecated user settings 2023-04-03 17:41:03 -05:00
advplyr
05b102722b Remove unused ebook routes 2023-04-03 17:33:02 -05:00
advplyr
ef954ee68f Remove downloads folder in metadata dir 2023-04-03 17:28:55 -05:00
advplyr
dbaea9f87d Merge pull request #1645 from tomazed/translation-fr
update fr strings
2023-04-03 08:15:22 -05:00
Tomazed
64768ec2f9 update fr strings 2023-04-03 10:21:15 +02:00
advplyr
14ee17de47 Version bump 2.2.18 2023-04-02 17:40:55 -05:00
advplyr
034b8956a2 Add:Batch embed metadata and queue system for metadata embedding #700 2023-04-02 16:13:18 -05:00
advplyr
1a3f0e332e Fix download podcast episode that is not mp3 2023-04-01 16:31:04 -05:00
advplyr
fc36e86db7 Update:Match tab show current cover and include resolutions #1605 2023-03-31 18:00:45 -05:00
advplyr
60b4bc1a7e Update:Show resolution under cover in book details modal #1547 2023-03-31 17:42:52 -05:00
advplyr
9fdc8df8bc Update:API endpoint for updating book media does not require an id for new series/authors #1540 2023-03-31 17:04:26 -05:00
advplyr
212b97fa20 Add:Parsing meta tags from podcast episode audio file #1488 2023-03-30 18:04:21 -05:00
advplyr
704fbaced8 Update:Download podcast episodes and embed meta tags #1488 2023-03-29 18:05:53 -05:00
advplyr
575a162f8b Update:API endpoint for get all users to use minimal payload 2023-03-29 14:56:50 -05:00
advplyr
d2e0844493 Epub reader updates for mobile 2023-03-26 14:44:59 -05:00
advplyr
f2baf3fafd Update epub media progress update 2023-03-26 13:50:44 -05:00
advplyr
916fd039ca Remove keydown event listener in epub reader 2023-03-26 13:40:47 -05:00
advplyr
e248b6d8d8 Fix:Parsing id3 tags case insensitive 2023-03-25 16:09:41 -05:00
advplyr
936de68622 Update epub reader only store up to 3MB of locations cache 2023-03-25 15:53:19 -05:00
advplyr
a99257e758 Fix getAllLibraryItemsInProgress route 2023-03-25 14:07:35 -05:00
advplyr
c89d77dd06 Merge pull request #1627 from vincentscode/epub-reader
Save Progress for EPUBs
2023-03-24 18:01:13 -05:00
advplyr
3138865d69 Update toc menu and media progress display 2023-03-24 17:57:41 -05:00
Vincent Schmandt
4d29ebd647 Save Locations locally, add separate progress tracker 2023-03-23 08:45:00 +01:00
advplyr
fd58df4729 Add:Abridged book detail, parse from audible, abridged book filter #1408 2023-03-22 18:05:43 -05:00
Vincent Schmandt
5078818295 Add MediaProgress fields
Add Table of Contents
2023-03-22 11:16:01 +01:00
advplyr
7181df0479 Fix:Patreon episodes with variable query strings #1622 2023-03-21 17:59:37 -05:00
Vincent Schmandt
6c618d7760 Adjust height to fit metadata 2023-03-21 13:36:06 +01:00
Vincent Schmandt
17b8cf19b7 Add Location Storage 2023-03-21 13:34:21 +01:00
Vincent Schmandt
e018f8341e EPUB progress persistence 2023-03-21 13:27:21 +01:00
advplyr
59b5f8cbbe Merge pull request #1624 from maltejur/master
Truncate long title in stream container
2023-03-20 16:01:23 -05:00
advplyr
d6108a0722 Merge pull request #1621 from burghy86/patch-9
Update it.json
2023-03-20 15:49:31 -05:00
advplyr
1af7e59d88 Merge pull request #1618 from fidoriel/transcode-continue-bug
Fix transcoded streams fail to continue listening
2023-03-20 15:49:11 -05:00
advplyr
7b425e9a9d Update client/players/LocalAudioPlayer.js 2023-03-20 15:48:42 -05:00
advplyr
596a03900b Update client/players/LocalAudioPlayer.js 2023-03-20 15:48:36 -05:00
advplyr
b283644d95 Update client/players/LocalAudioPlayer.js 2023-03-20 15:48:31 -05:00
Malte Jürgens
808690c137 truncate long title in stream container 2023-03-20 21:28:15 +01:00
burghy86
136c347586 Update it.json
fix new line
2023-03-20 12:20:02 +01:00
fidoriel
e81238038e m3u8url 2023-03-19 22:26:36 +00:00
fidoriel
fcf6964d7d hlsurl 2023-03-19 21:41:49 +00:00
301 changed files with 18765 additions and 8031 deletions

View File

@@ -1,4 +1,15 @@
FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:16
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
&& apt-get install ffmpeg gnupg2 -y
# [Choice] Node.js version (use -bullseye variants on local arm64/Apple Silicon): 18, 16, 14, 18-bullseye, 16-bullseye, 14-bullseye, 18-buster, 16-buster, 14-buster
ARG VARIANT=16
FROM mcr.microsoft.com/devcontainers/javascript-node:0-${VARIANT} as base
# Setup the node environment
ENV NODE_ENV=development
# Install additional OS packages.
RUN apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get -y install --no-install-recommends \
curl tzdata ffmpeg && \
rm -rf /var/lib/apt/lists/*
# Move tone executable to appropriate directory
COPY --from=sandreas/tone:v0.1.5 /usr/local/bin/tone /usr/local/bin/

9
.devcontainer/dev.js Normal file
View File

@@ -0,0 +1,9 @@
// Using port 3333 is important when running the client web app separately
const Path = require('path')
module.exports.config = {
Port: 3333,
ConfigPath: Path.resolve('config'),
MetadataPath: Path.resolve('metadata'),
FFmpegPath: '/usr/bin/ffmpeg',
FFProbePath: '/usr/bin/ffprobe'
}

View File

@@ -1,12 +1,40 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/javascript-node
{
"build": { "dockerfile": "Dockerfile" },
"mounts": [
"source=abs-node-node_modules,target=${containerWorkspaceFolder}/node_modules,type=volume"
],
"features": {
"fish": "latest"
"name": "Audiobookshelf",
"build": {
"dockerfile": "Dockerfile",
// Update 'VARIANT' to pick a Node version: 18, 16, 14.
// Append -bullseye or -buster to pin to an OS version.
// Use -bullseye variants on local arm64/Apple Silicon.
"args": {
"VARIANT": "16"
}
},
"extensions": [
"eamodio.gitlens"
]
"mounts": [
"source=abs-server-node_modules,target=${containerWorkspaceFolder}/node_modules,type=volume",
"source=abs-client-node_modules,target=${containerWorkspaceFolder}/client/node_modules,type=volume"
],
// Features to add to the dev container. More info: https://containers.dev/features.
// "features": {},
// Use 'forwardPorts' to make a list of ports inside the container available locally.
"forwardPorts": [
3000,
3333
],
// Use 'postCreateCommand' to run commands after the container is created.
"postCreateCommand": "sh .devcontainer/post-create.sh",
// Configure tool-specific properties.
"customizations": {
// Configure properties specific to VS Code.
"vscode": {
// Add the IDs of extensions you want installed when the container is created.
"extensions": [
"dbaeumer.vscode-eslint",
"octref.vetur"
]
}
}
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
// "remoteUser": "root"
}

View File

@@ -0,0 +1,29 @@
#!/bin/sh
# Mark the working directory as safe for use with git
git config --global --add safe.directory $PWD
# If there is no dev.js file, create it
if [ ! -f dev.js ]; then
cp .devcontainer/dev.js .
fi
# Update permissions for node_modules folders
# https://code.visualstudio.com/remote/advancedcontainers/improve-performance#_use-a-targeted-named-volume
if [ -d node_modules ]; then
sudo chown $(id -u):$(id -g) node_modules
fi
if [ -d client/node_modules ]; then
sudo chown $(id -u):$(id -g) client/node_modules
fi
# Install packages for the server
if [ -f package.json ]; then
npm ci
fi
# Install packages and build the client
if [ -f client/package.json ]; then
(cd client; npm ci; npm run generate)
fi

5
.gitattributes vendored Normal file
View File

@@ -0,0 +1,5 @@
# Set the default behavior, in case people don't have core.autocrlf set.
* text=auto
# Declare files that will always have CRLF line endings on checkout.
.devcontainer/post-create.sh text eol=lf

4
.gitignore vendored
View File

@@ -1,6 +1,6 @@
.env
dev.js
node_modules/
/dev.js
**/node_modules/
/config/
/audiobooks/
/audiobooks2/

44
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,44 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Debug server",
"runtimeExecutable": "npm",
"args": [
"run",
"dev"
],
"skipFiles": [
"<node_internals>/**"
]
},
{
"type": "node",
"request": "launch",
"name": "Debug client (nuxt)",
"runtimeExecutable": "npm",
"args": [
"run",
"dev"
],
"cwd": "${workspaceFolder}/client",
"skipFiles": [
"${workspaceFolder}/<node_internals>/**"
]
}
],
"compounds": [
{
"name": "Debug server and client (nuxt)",
"configurations": [
"Debug server",
"Debug client (nuxt)"
]
}
]
}

40
.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,40 @@
{
"version": "2.0.0",
"tasks": [
{
"path": "client",
"type": "npm",
"script": "generate",
"detail": "nuxt generate",
"label": "Build client",
"group": {
"kind": "build",
"isDefault": true
}
},
{
"dependsOn": [
"Build client"
],
"type": "npm",
"script": "dev",
"detail": "nodemon --watch server index.js",
"label": "Run server",
"group": {
"kind": "test",
"isDefault": true
}
},
{
"path": "client",
"type": "npm",
"script": "dev",
"detail": "nuxt",
"label": "Run Live-reload client",
"group": {
"kind": "test",
"isDefault": false
}
}
]
}

View File

@@ -6,15 +6,19 @@ RUN npm ci && npm cache clean --force
RUN npm run generate
### STAGE 1: Build server ###
FROM sandreas/tone:v0.1.2 AS tone
FROM sandreas/tone:v0.1.5 AS tone
FROM node:16-alpine
ENV NODE_ENV=production
RUN apk update && \
apk add --no-cache --update \
curl \
tzdata \
ffmpeg
ffmpeg \
make \
python3 \
g++
COPY --from=tone /usr/local/bin/tone /usr/local/bin/
COPY --from=build /client/dist /client/dist
@@ -23,6 +27,10 @@ COPY server server
RUN npm ci --only=production
RUN apk del make python3 g++
ENV NODE_OPTIONS=--max-old-space-size=8192
EXPOSE 80
HEALTHCHECK \
--interval=30s \

View File

@@ -50,7 +50,7 @@ install_ffmpeg() {
echo "Starting FFMPEG Install"
WGET="wget https://johnvansickle.com/ffmpeg/builds/ffmpeg-git-amd64-static.tar.xz --output-document=ffmpeg-git-amd64-static.tar.xz"
WGET_TONE="wget https://github.com/sandreas/tone/releases/download/v0.1.2/tone-0.1.2-linux-x64.tar.gz --output-document=tone-0.1.2-linux-x64.tar.gz"
WGET_TONE="wget https://github.com/sandreas/tone/releases/download/v0.1.5/tone-0.1.5-linux-x64.tar.gz --output-document=tone-0.1.5-linux-x64.tar.gz"
if ! cd "$FFMPEG_INSTALL_DIR"; then
echo "Creating ffmpeg install dir at $FFMPEG_INSTALL_DIR"
@@ -66,8 +66,8 @@ install_ffmpeg() {
# Temp downloading tone library to the ffmpeg dir
echo "Getting tone.."
$WGET_TONE
tar xvf tone-0.1.2-linux-x64.tar.gz --strip-components=1
rm tone-0.1.2-linux-x64.tar.gz
tar xvf tone-0.1.5-linux-x64.tar.gz --strip-components=1
rm tone-0.1.5-linux-x64.tar.gz
echo "Good to go on Ffmpeg (& tone)... hopefully"
}

View File

@@ -112,7 +112,7 @@ input[type=number] {
background-color: #373838;
}
.tracksTable tr:hover {
.tracksTable tr:hover:not(:has(th)) {
background-color: #474747;
}
@@ -232,6 +232,20 @@ Bookshelf Label
-webkit-box-orient: vertical;
}
.episode-subtitle-long {
word-break: break-word;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
line-height: 16px;
/* fallback */
max-height: 72px;
/* fallback */
-webkit-line-clamp: 6;
/* number of lines to show */
-webkit-box-orient: vertical;
}
/* Padding for toastification toasts in the top right to not cover appbar/toolbar */
.app-bar-and-toolbar .Vue-Toastification__container.top-right {

View File

@@ -7,7 +7,7 @@
</nuxt-link>
<nuxt-link to="/">
<h1 class="text-xl mr-6 hidden lg:block hover:underline">audiobookshelf <span v-if="showExperimentalFeatures" class="material-icons text-lg text-warning pr-1">logo_dev</span></h1>
<h1 class="text-xl mr-6 hidden lg:block hover:underline">audiobookshelf</h1>
</nuxt-link>
<ui-libraries-dropdown class="mr-2" />
@@ -15,8 +15,6 @@
<controls-global-search v-if="currentLibrary" class="mr-1 sm:mr-0" />
<div class="flex-grow" />
<widgets-notification-widget class="hidden md:block" />
<ui-tooltip v-if="isChromecastInitialized && !isHttps" direction="bottom" text="Casting requires a secure connection" class="flex items-center">
<span class="material-icons-outlined text-2xl text-warning text-opacity-50"> cast </span>
</ui-tooltip>
@@ -24,6 +22,8 @@
<google-cast-launcher></google-cast-launcher>
</div>
<widgets-notification-widget class="hidden md:block" />
<nuxt-link v-if="currentLibrary" to="/config/stats" class="hover:text-gray-200 cursor-pointer w-8 h-8 hidden sm:flex items-center justify-center mx-1">
<ui-tooltip :text="$strings.HeaderYourStats" direction="bottom" class="flex items-center">
<span class="material-icons text-2xl" aria-label="User Stats" role="button">equalizer</span>
@@ -58,9 +58,6 @@
<span class="material-icons text-2xl -ml-2 pr-1 text-white">play_arrow</span>
{{ $strings.ButtonPlay }}
</ui-btn>
<ui-tooltip v-if="userIsAdminOrUp && isBookLibrary" :text="$strings.ButtonQuickMatch" direction="bottom">
<ui-icon-btn :disabled="processingBatch" icon="auto_awesome" @click="batchAutoMatchClick" class="mx-1.5" />
</ui-tooltip>
<ui-tooltip v-if="isBookLibrary" :text="selectedIsFinished ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" direction="bottom">
<ui-read-icon-btn :disabled="processingBatch" :is-read="selectedIsFinished" @click="toggleBatchRead" class="mx-1.5" />
</ui-tooltip>
@@ -75,8 +72,11 @@
<ui-tooltip v-if="userCanDelete" :text="$strings.ButtonRemove" direction="bottom">
<ui-icon-btn :disabled="processingBatch" icon="delete" bg-color="error" class="mx-1.5" @click="batchDeleteClick" />
</ui-tooltip>
<ui-tooltip :text="$strings.LabelDeselectAll" direction="bottom">
<span class="material-icons text-4xl px-4 hover:text-gray-100 cursor-pointer" :class="processingBatch ? 'text-gray-400' : ''" @click="cancelSelectionMode">close</span>
<ui-context-menu-dropdown v-if="contextMenuItems.length && !processingBatch" :items="contextMenuItems" class="ml-1" @action="contextMenuAction" />
<ui-tooltip :text="$strings.LabelDeselectAll" direction="bottom" class="flex items-center">
<span class="material-icons text-3xl px-4 hover:text-gray-100 cursor-pointer" :class="processingBatch ? 'text-gray-400' : ''" @click="cancelSelectionMode">close</span>
</ui-tooltip>
</div>
</div>
@@ -149,9 +149,6 @@ export default {
processingBatch() {
return this.$store.state.processingBatch
},
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
},
isChromecastEnabled() {
return this.$store.getters['getServerSetting']('chromecastEnabled')
},
@@ -160,9 +157,90 @@ export default {
},
isHttps() {
return location.protocol === 'https:' || process.env.NODE_ENV === 'development'
},
contextMenuItems() {
if (!this.userIsAdminOrUp) return []
const options = [
{
text: this.$strings.ButtonQuickMatch,
action: 'quick-match'
}
]
if (!this.isPodcastLibrary && this.selectedMediaItemsArePlayable) {
options.push({
text: 'Quick Embed Metadata',
action: 'quick-embed'
})
}
options.push({
text: 'Re-Scan',
action: 'rescan'
})
return options
}
},
methods: {
requestBatchQuickEmbed() {
const payload = {
message: 'Warning! Quick embed will not backup your audio files. Make sure that you have a backup of your audio files. <br><br>Would you like to continue?',
callback: (confirmed) => {
if (confirmed) {
this.$axios
.$post(`/api/tools/batch/embed-metadata`, {
libraryItemIds: this.selectedMediaItems.map((i) => i.id)
})
.then(() => {
console.log('Audio metadata embed started')
this.cancelSelectionMode()
})
.catch((error) => {
console.error('Audio metadata embed failed', error)
const errorMsg = error.response.data || 'Failed to embed metadata'
this.$toast.error(errorMsg)
})
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
contextMenuAction({ action }) {
if (action === 'quick-embed') {
this.requestBatchQuickEmbed()
} else if (action === 'quick-match') {
this.batchAutoMatchClick()
} else if (action === 'rescan') {
this.batchRescan()
}
},
async batchRescan() {
const payload = {
message: `Are you sure you want to re-scan ${this.selectedMediaItems.length} items?`,
callback: (confirmed) => {
if (confirmed) {
this.$axios
.$post(`/api/items/batch/scan`, {
libraryItemIds: this.selectedMediaItems.map((i) => i.id)
})
.then(() => {
console.log('Batch Re-Scan started')
this.cancelSelectionMode()
})
.catch((error) => {
console.error('Batch Re-Scan failed', error)
const errorMsg = error.response.data || 'Failed to batch re-scan'
this.$toast.error(errorMsg)
})
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
async playSelectedItems() {
this.$store.commit('setProcessingBatch', true)
@@ -225,38 +303,49 @@ export default {
this.$axios
.patch(`/api/me/progress/batch/update`, updateProgressPayloads)
.then(() => {
this.$toast.success('Batch update success!')
this.$toast.success(this.$strings.ToastBatchUpdateSuccess)
this.$store.commit('setProcessingBatch', false)
this.$store.commit('globals/resetSelectedMediaItems', [])
this.$eventBus.$emit('bookshelf_clear_selection')
})
.catch((error) => {
this.$toast.error('Batch update failed')
this.$toast.error(this.$strings.ToastBatchUpdateFailed)
console.error('Failed to batch update read/not read', error)
this.$store.commit('setProcessingBatch', false)
})
},
batchDeleteClick() {
const audiobookText = this.numMediaItemsSelected > 1 ? `these ${this.numMediaItemsSelected} items` : 'this item'
const confirmMsg = `Are you sure you want to remove ${audiobookText}?\n\n*Does not delete your files, only removes the items from Audiobookshelf`
if (confirm(confirmMsg)) {
this.$store.commit('setProcessingBatch', true)
this.$axios
.$post(`/api/items/batch/delete`, {
libraryItemIds: this.selectedMediaItems.map((i) => i.id)
})
.then(() => {
this.$toast.success('Batch delete success!')
this.$store.commit('setProcessingBatch', false)
this.$store.commit('globals/resetSelectedMediaItems', [])
this.$eventBus.$emit('bookshelf_clear_selection')
})
.catch((error) => {
this.$toast.error('Batch delete failed')
console.error('Failed to batch delete', error)
this.$store.commit('setProcessingBatch', false)
})
const payload = {
message: `This will delete ${this.numMediaItemsSelected} library items from the database and your file system. Are you sure?`,
checkboxLabel: 'Delete from file system. Uncheck to only remove from database.',
yesButtonText: this.$strings.ButtonDelete,
yesButtonColor: 'error',
checkboxDefaultValue: true,
callback: (confirmed, hardDelete) => {
if (confirmed) {
this.$store.commit('setProcessingBatch', true)
this.$axios
.$post(`/api/items/batch/delete?hard=${hardDelete ? 1 : 0}`, {
libraryItemIds: this.selectedMediaItems.map((i) => i.id)
})
.then(() => {
this.$toast.success('Batch delete success')
this.$store.commit('globals/resetSelectedMediaItems', [])
this.$eventBus.$emit('bookshelf_clear_selection')
})
.catch((error) => {
console.error('Batch delete failed', error)
this.$toast.error('Batch delete failed')
})
.finally(() => {
this.$store.commit('setProcessingBatch', false)
})
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
batchEditClick() {
this.$router.push('/batch')

View File

@@ -16,7 +16,7 @@
<!-- Alternate plain view -->
<div v-else-if="isAlternativeBookshelfView" class="w-full mb-24">
<template v-for="(shelf, index) in shelves">
<widgets-item-slider v-if="shelf.type === 'book' || shelf.type === 'podcast'" :shelf-id="shelf.id" :key="index + '.'" :items="shelf.entities" :continue-listening-shelf="shelf.id === 'continue-listening'" :height="232 * sizeMultiplier" class="bookshelf-row pl-8 my-6" @selectEntity="(payload) => selectEntity(payload, index)">
<widgets-item-slider v-if="shelf.type === 'book' || shelf.type === 'podcast'" :shelf-id="shelf.id" :key="index + '.'" :items="shelf.entities" :continue-listening-shelf="shelf.id === 'continue-listening' || shelf.id === 'continue-reading'" :height="232 * sizeMultiplier" class="bookshelf-row pl-8 my-6" @selectEntity="(payload) => selectEntity(payload, index)">
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ $strings[shelf.labelStringKey] }}</p>
</widgets-item-slider>
<widgets-episode-slider v-else-if="shelf.type === 'episode'" :key="index + '.'" :items="shelf.entities" :continue-listening-shelf="shelf.id === 'continue-listening'" :height="232 * sizeMultiplier" class="bookshelf-row pl-8 my-6" @selectEntity="(payload) => selectEntity(payload, index)">
@@ -28,12 +28,15 @@
<widgets-authors-slider v-else-if="shelf.type === 'authors'" :key="index + '.'" :items="shelf.entities" :height="192 * sizeMultiplier" class="bookshelf-row pl-8 my-6">
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ $strings[shelf.labelStringKey] }}</p>
</widgets-authors-slider>
<widgets-narrators-slider v-else-if="shelf.type === 'narrators'" :key="index + '.'" :items="shelf.entities" :height="100 * sizeMultiplier" class="bookshelf-row pl-8 my-6">
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ $strings[shelf.labelStringKey] }}</p>
</widgets-narrators-slider>
</template>
</div>
<!-- Regular bookshelf view -->
<div v-else class="w-full">
<template v-for="(shelf, index) in shelves">
<app-book-shelf-row :key="index" :index="index" :shelf="shelf" :size-multiplier="sizeMultiplier" :book-cover-width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" :continue-listening-shelf="shelf.id === 'continue-listening'" @selectEntity="(payload) => selectEntity(payload, index)" />
<app-book-shelf-row :key="index" :index="index" :shelf="shelf" :size-multiplier="sizeMultiplier" :book-cover-width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" :continue-listening-shelf="shelf.id === 'continue-listening' || shelf.id === 'continue-reading'" @selectEntity="(payload) => selectEntity(payload, index)" />
</template>
</div>
</div>
@@ -62,9 +65,6 @@ export default {
userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
},
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
@@ -168,7 +168,7 @@ export default {
},
async fetchCategories() {
const categories = await this.$axios
.$get(`/api/libraries/${this.currentLibraryId}/personalized?include=rssfeed`)
.$get(`/api/libraries/${this.currentLibraryId}/personalized?include=rssfeed,numEpisodesIncomplete`)
.then((data) => {
return data
})
@@ -185,8 +185,8 @@ export default {
this.shelves = categories
},
async setShelvesFromSearch() {
var shelves = []
if (this.results.books && this.results.books.length) {
const shelves = []
if (this.results.books?.length) {
shelves.push({
id: 'books',
label: 'Books',
@@ -196,7 +196,7 @@ export default {
})
}
if (this.results.podcasts && this.results.podcasts.length) {
if (this.results.podcasts?.length) {
shelves.push({
id: 'podcasts',
label: 'Podcasts',
@@ -206,7 +206,7 @@ export default {
})
}
if (this.results.series && this.results.series.length) {
if (this.results.series?.length) {
shelves.push({
id: 'series',
label: 'Series',
@@ -221,7 +221,7 @@ export default {
})
})
}
if (this.results.tags && this.results.tags.length) {
if (this.results.tags?.length) {
shelves.push({
id: 'tags',
label: 'Tags',
@@ -236,7 +236,7 @@ export default {
})
})
}
if (this.results.authors && this.results.authors.length) {
if (this.results.authors?.length) {
shelves.push({
id: 'authors',
label: 'Authors',
@@ -250,6 +250,20 @@ export default {
})
})
}
if (this.results.narrators?.length) {
shelves.push({
id: 'narrators',
label: 'Narrators',
labelStringKey: 'LabelNarrators',
type: 'narrators',
entities: this.results.narrators.map((n) => {
return {
...n,
type: 'narrator'
}
})
})
}
this.shelves = shelves
},
scan() {
@@ -269,7 +283,8 @@ export default {
}
if (user.mediaProgress.length) {
const mediaProgressToHide = user.mediaProgress.filter((mp) => mp.hideFromContinueListening)
this.removeItemsFromContinueListening(mediaProgressToHide)
this.removeItemsFromContinueListeningReading(mediaProgressToHide, 'continue-listening')
this.removeItemsFromContinueListeningReading(mediaProgressToHide, 'continue-reading')
}
},
libraryItemAdded(libraryItem) {
@@ -319,8 +334,9 @@ export default {
},
libraryItemsAdded(libraryItems) {
console.log('libraryItems added', libraryItems)
// TODO: Check if audiobook would be on this shelf
if (!this.search) {
const isThisLibrary = !libraryItems.some((li) => li.libraryId !== this.currentLibraryId)
if (!this.search && isThisLibrary) {
this.fetchCategories()
}
},
@@ -329,6 +345,14 @@ export default {
this.libraryItemUpdated(li)
})
},
episodeAdded(episodeWithLibraryItem) {
console.log('Podcast episode added', episodeWithLibraryItem)
const isThisLibrary = episodeWithLibraryItem.libraryItem?.libraryId === this.currentLibraryId
if (!this.search && isThisLibrary) {
this.fetchCategories()
}
},
removeAllSeriesFromContinueSeries(seriesIds) {
this.shelves.forEach((shelf) => {
if (shelf.type == 'book' && shelf.id == 'continue-series') {
@@ -340,8 +364,8 @@ export default {
}
})
},
removeItemsFromContinueListening(mediaProgressItems) {
const continueListeningShelf = this.shelves.find((s) => s.id === 'continue-listening')
removeItemsFromContinueListeningReading(mediaProgressItems, categoryId) {
const continueListeningShelf = this.shelves.find((s) => s.id === categoryId)
if (continueListeningShelf) {
if (continueListeningShelf.type === 'book') {
continueListeningShelf.entities = continueListeningShelf.entities.filter((ent) => {
@@ -356,17 +380,6 @@ export default {
})
}
}
// this.shelves.forEach((shelf) => {
// if (shelf.id == 'continue-listening') {
// if (shelf.type == 'book') {
// // Filter out books from continue listening shelf
// shelf.entities = shelf.entities.filter((ent) => {
// if (mediaProgressItems.some(mp => mp.libraryItemId === ent.id)) return false
// return true
// })
// }
// }
// })
},
authorUpdated(author) {
this.shelves.forEach((shelf) => {
@@ -400,6 +413,7 @@ export default {
this.$root.socket.on('item_removed', this.libraryItemRemoved)
this.$root.socket.on('items_updated', this.libraryItemsUpdated)
this.$root.socket.on('items_added', this.libraryItemsAdded)
this.$root.socket.on('episode_added', this.episodeAdded)
} else {
console.error('Error socket not initialized')
}
@@ -414,6 +428,7 @@ export default {
this.$root.socket.off('item_removed', this.libraryItemRemoved)
this.$root.socket.off('items_updated', this.libraryItemsUpdated)
this.$root.socket.off('items_added', this.libraryItemsAdded)
this.$root.socket.off('episode_added', this.episodeAdded)
} else {
console.error('Error socket not initialized')
}

View File

@@ -41,6 +41,11 @@
<cards-author-card :key="entity.id" :width="bookCoverWidth / 1.25" :height="bookCoverWidth" :author="entity" :size-multiplier="sizeMultiplier" @hook:updated="updatedBookCard" class="pb-6 mx-2" @edit="editAuthor" />
</template>
</div>
<div v-if="shelf.type === 'narrators'" class="flex items-center">
<template v-for="entity in shelf.entities">
<cards-narrator-card :key="entity.name" :width="150" :height="100" :narrator="entity" :size-multiplier="sizeMultiplier" @hook:updated="updatedBookCard" class="pb-6 mx-2" />
</template>
</div>
</div>
</div>
@@ -88,6 +93,7 @@ export default {
return this.bookCoverWidth * this.bookCoverAspectRatio
},
shelfHeight() {
if (this.shelf.type === 'narrators') return 148
return this.bookCoverHeight + 48
},
paddingLeft() {

View File

@@ -81,6 +81,8 @@
<!-- issues page remove all button -->
<ui-btn v-if="isIssuesFilter && userCanDelete && !isBatchSelecting" :loading="processingIssues" color="error" small class="ml-4" @click="removeAllIssues">{{ $strings.ButtonRemoveAll }} {{ numShowing }} {{ entityName }}</ui-btn>
<ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" :menu-width="110" class="ml-2" @action="contextMenuAction" />
</template>
<!-- search page -->
<template v-else-if="page === 'search'">
@@ -163,6 +165,14 @@ export default {
text: this.$strings.LabelAddedAt,
value: 'addedAt'
},
{
text: this.$strings.LabelLastBookAdded,
value: 'lastBookAdded'
},
{
text: this.$strings.LabelLastBookUpdated,
value: 'lastBookUpdated'
},
{
text: this.$strings.LabelTotalDuration,
value: 'totalDuration'
@@ -178,9 +188,15 @@ export default {
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
userCanDownload() {
return this.$store.getters['user/getUserCanDownload']
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
libraryProvider() {
return this.$store.getters['libraries/getLibraryProvider'](this.currentLibraryId) || 'google'
},
currentLibraryMediaType() {
return this.$store.getters['libraries/getCurrentLibraryMediaType']
},
@@ -265,10 +281,30 @@ export default {
},
isIssuesFilter() {
return this.filterBy === 'issues' && this.$route.query.filter === 'issues'
},
contextMenuItems() {
const items = []
if (this.isPodcastLibrary && this.isLibraryPage && this.userCanDownload) {
items.push({
text: 'Export OPML',
action: 'export-opml'
})
}
return items
}
},
methods: {
seriesContextMenuAction(action) {
contextMenuAction({ action }) {
if (action === 'export-opml') {
this.exportOPML()
}
},
exportOPML() {
this.$downloadFile(`/api/libraries/${this.currentLibraryId}/opml?token=${this.$store.getters['user/getToken']}`, null, true)
},
seriesContextMenuAction({ action }) {
if (action === 'open-rss-feed') {
this.showOpenSeriesRSSFeed()
} else if (action === 're-add-to-continue-listening') {
@@ -315,7 +351,11 @@ export default {
const payload = {}
if (author.asin) payload.asin = author.asin
else payload.q = author.name
console.log('Payload', payload, 'author', author)
payload.region = 'us'
if (this.libraryProvider.startsWith('audible.')) {
payload.region = this.libraryProvider.split('.').pop() || 'us'
}
this.$eventBus.$emit(`searching-author-${author.id}`, true)

View File

@@ -90,6 +90,11 @@ export default {
title: this.$strings.HeaderNotifications,
path: '/config/notifications'
},
{
id: 'config-email',
title: this.$strings.HeaderEmail,
path: '/config/email'
},
{
id: 'config-item-metadata-utils',
title: this.$strings.HeaderItemMetadataUtils,

View File

@@ -78,9 +78,6 @@ export default {
userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
},
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
},
libraryMediaType() {
return this.$store.getters['libraries/getCurrentLibraryMediaType']
},
@@ -318,10 +315,10 @@ export default {
const entityPath = this.entityName === 'series-books' ? 'items' : this.entityName
const sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : ''
const fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1&include=rssfeed`
const fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1&include=rssfeed,numEpisodesIncomplete`
const payload = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/${entityPath}${fullQueryString}`).catch((error) => {
console.error('failed to fetch books', error)
console.error('failed to fetch items', error)
return null
})

View File

@@ -49,6 +49,14 @@
<div v-show="paramId === 'collections'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link v-if="showPlaylists" :to="`/library/${currentLibraryId}/bookshelf/playlists`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPlaylistsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<span class="material-icons text-2.5xl">queue_music</span>
<p class="pt-0.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonPlaylists }}</p>
<div v-show="isPlaylistsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/authors`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isAuthorsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<svg class="w-6 h-6" viewBox="0 0 24 24">
<path
@@ -62,6 +70,14 @@
<div v-show="isAuthorsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/narrators`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isNarratorsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<span class="material-icons text-2xl">record_voice_over</span>
<p class="pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.LabelNarrators }}</p>
<div v-show="isNarratorsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/search`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPodcastSearchPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<span class="abs-icons icon-podcast text-xl"></span>
@@ -78,14 +94,6 @@
<div v-show="isMusicAlbumsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link v-if="showPlaylists" :to="`/library/${currentLibraryId}/bookshelf/playlists`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPlaylistsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<span class="material-icons text-2.5xl">queue_music</span>
<p class="pt-0.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonPlaylists }}</p>
<div v-show="isPlaylistsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/download-queue`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPodcastDownloadQueuePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<span class="material-icons text-2xl">file_download</span>
@@ -178,6 +186,9 @@ export default {
isAuthorsPage() {
return this.$route.name === 'library-library-authors'
},
isNarratorsPage() {
return this.$route.name === 'library-library-narrators'
},
isPlaylistsPage() {
return this.paramId === 'playlists'
},

View File

@@ -1,12 +1,12 @@
<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-50 bg-primary px-2 md:px-4 pb-1 md:pb-4 pt-2">
<div v-if="streamLibraryItem" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-48 md:h-40 z-50 bg-primary px-2 md:px-4 pb-1 md:pb-4 pt-2">
<div id="videoDock" />
<nuxt-link v-if="!playerHandler.isVideo" :to="`/item/${streamLibraryItem.id}`" class="absolute left-2 top-2 md:left-4 cursor-pointer">
<covers-book-cover :library-item="streamLibraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" />
</nuxt-link>
<div class="flex items-start mb-6 md:mb-0" :class="playerHandler.isVideo ? 'ml-4 pl-96' : isSquareCover ? 'pl-18 sm:pl-24' : 'pl-12 sm:pl-16'">
<div>
<nuxt-link :to="`/item/${streamLibraryItem.id}`" class="hover:underline cursor-pointer text-sm sm:text-lg">
<div class="min-w-0">
<nuxt-link :to="`/item/${streamLibraryItem.id}`" class="hover:underline cursor-pointer text-sm sm:text-lg block truncate">
{{ title }}
</nuxt-link>
<div v-if="!playerHandler.isVideo" class="text-gray-400 flex items-center">
@@ -15,7 +15,7 @@
<div v-if="podcastAuthor" class="pl-1 sm:pl-1.5 text-xs sm:text-base">{{ podcastAuthor }}</div>
<div v-else-if="musicArtists" class="pl-1 sm:pl-1.5 text-xs sm:text-base">{{ musicArtists }}</div>
<div v-else-if="authors.length" class="pl-1 sm:pl-1.5 text-xs sm:text-base">
<nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">,&nbsp;</span></nuxt-link>
<nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}?library=${libraryId}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">,&nbsp;</span></nuxt-link>
</div>
<div v-else class="text-xs sm:text-base cursor-pointer pl-1 sm:pl-1.5">{{ $strings.LabelUnknown }}</div>
<widgets-explicit-indicator :explicit="isExplicit"></widgets-explicit-indicator>
@@ -81,7 +81,7 @@ export default {
sleepTimerRemaining: 0,
sleepTimer: null,
displayTitle: null,
initialPlaybackRate: 1,
currentPlaybackRate: 1,
syncFailedToast: null
}
},
@@ -120,17 +120,22 @@ export default {
streamLibraryItem() {
return this.$store.state.streamLibraryItem
},
streamEpisode() {
if (!this.$store.state.streamEpisodeId) return null
const episodes = this.streamLibraryItem.media.episodes || []
return episodes.find((ep) => ep.id === this.$store.state.streamEpisodeId)
},
libraryItemId() {
return this.streamLibraryItem ? this.streamLibraryItem.id : null
return this.streamLibraryItem?.id || null
},
media() {
return this.streamLibraryItem ? this.streamLibraryItem.media || {} : {}
return this.streamLibraryItem?.media || {}
},
isPodcast() {
return this.streamLibraryItem ? this.streamLibraryItem.mediaType === 'podcast' : false
return this.streamLibraryItem?.mediaType === 'podcast'
},
isMusic() {
return this.streamLibraryItem ? this.streamLibraryItem.mediaType === 'music' : false
return this.streamLibraryItem?.mediaType === 'music'
},
isExplicit() {
return this.mediaMetadata.explicit || false
@@ -139,6 +144,7 @@ export default {
return this.media.metadata || {}
},
chapters() {
if (this.streamEpisode) return this.streamEpisode.chapters || []
return this.media.chapters || []
},
title() {
@@ -152,7 +158,8 @@ export default {
return this.streamLibraryItem ? this.streamLibraryItem.libraryId : null
},
totalDurationPretty() {
return this.$secondsToTimestamp(this.totalDuration)
// Adjusted by playback rate
return this.$secondsToTimestamp(this.totalDuration / this.currentPlaybackRate)
},
podcastAuthor() {
if (!this.isPodcast) return null
@@ -255,12 +262,16 @@ export default {
this.playerHandler.setVolume(volume)
},
setPlaybackRate(playbackRate) {
this.initialPlaybackRate = playbackRate
this.currentPlaybackRate = playbackRate
this.playerHandler.setPlaybackRate(playbackRate)
},
seek(time) {
this.playerHandler.seek(time)
},
playbackTimeUpdate(time) {
// When updating progress from another session
this.playerHandler.seek(time, false)
},
setCurrentTime(time) {
this.currentTime = time
if (this.$refs.audioPlayer) {
@@ -359,9 +370,8 @@ export default {
navigator.mediaSession.setActionHandler('seekbackward', this.mediaSessionSeekBackward)
navigator.mediaSession.setActionHandler('seekforward', this.mediaSessionSeekForward)
navigator.mediaSession.setActionHandler('seekto', this.mediaSessionSeekTo)
navigator.mediaSession.setActionHandler('previoustrack', this.mediaSessionPreviousTrack)
const hasNextChapter = this.$refs.audioPlayer && this.$refs.audioPlayer.hasNextChapter
navigator.mediaSession.setActionHandler('nexttrack', hasNextChapter ? this.mediaSessionNextTrack : null)
navigator.mediaSession.setActionHandler('previoustrack', this.mediaSessionSeekBackward)
navigator.mediaSession.setActionHandler('nexttrack', this.mediaSessionSeekForward)
} else {
console.warn('Media session not available')
}
@@ -384,7 +394,7 @@ export default {
libraryItem: session.libraryItem,
episodeId: session.episodeId
})
this.playerHandler.prepareOpenSession(session, this.initialPlaybackRate)
this.playerHandler.prepareOpenSession(session, this.currentPlaybackRate)
},
streamOpen(session) {
console.log(`[StreamContainer] Stream session open`, session)
@@ -451,7 +461,7 @@ export default {
if (this.$refs.audioPlayer) this.$refs.audioPlayer.checkUpdateChapterTrack()
})
this.playerHandler.load(libraryItem, episodeId, true, this.initialPlaybackRate, payload.startTime)
this.playerHandler.load(libraryItem, episodeId, true, this.currentPlaybackRate, payload.startTime)
},
pauseItem() {
this.playerHandler.pause()
@@ -459,17 +469,26 @@ export default {
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' })
},
sessionClosedEvent(sessionId) {
if (this.playerHandler.currentSessionId === sessionId) {
console.log('sessionClosedEvent closing current session', sessionId)
this.playerHandler.resetPlayer() // Closes player without reporting to server
this.$store.commit('setMediaPlaying', null)
}
}
},
mounted() {
this.$eventBus.$on('cast-session-active', this.castSessionActive)
this.$eventBus.$on('playback-seek', this.seek)
this.$eventBus.$on('playback-time-update', this.playbackTimeUpdate)
this.$eventBus.$on('play-item', this.playLibraryItem)
this.$eventBus.$on('pause-item', this.pauseItem)
},
beforeDestroy() {
this.$eventBus.$off('cast-session-active', this.castSessionActive)
this.$eventBus.$off('playback-seek', this.seek)
this.$eventBus.$off('playback-time-update', this.playbackTimeUpdate)
this.$eventBus.$off('play-item', this.playLibraryItem)
this.$eventBus.$off('pause-item', this.pauseItem)
}

View File

@@ -1,5 +1,5 @@
<template>
<nuxt-link :to="`/author/${author.id}`">
<nuxt-link :to="`/author/${author.id}?library=${currentLibraryId}`">
<div @mouseover="mouseover" @mouseleave="mouseleave">
<div :style="{ width: width + 'px', height: height + 'px' }" class="bg-primary box-shadow-book rounded-md relative overflow-hidden">
<!-- Image or placeholder -->
@@ -77,6 +77,12 @@ export default {
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
libraryProvider() {
return this.$store.getters['libraries/getLibraryProvider'](this.currentLibraryId) || 'google'
}
},
methods: {
@@ -92,6 +98,11 @@ export default {
if (this.asin) payload.asin = this.asin
else payload.q = this.name
payload.region = 'us'
if (this.libraryProvider.startsWith('audible.')) {
payload.region = this.libraryProvider.split('.').pop() || 'us'
}
var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, payload).catch((error) => {
console.error('Failed', error)
return null

View File

@@ -5,12 +5,12 @@
<p v-if="matchKey !== 'title'" class="truncate text-sm">{{ title }}</p>
<p v-else class="truncate text-sm" v-html="matchHtml" />
<p v-if="matchKey === 'subtitle'" class="truncate text-xs text-gray-300">{{ matchHtml }}</p>
<p v-if="matchKey === 'subtitle'" class="truncate text-xs text-gray-300" v-html="matchHtml" />
<p v-if="matchKey !== 'authors'" class="text-xs text-gray-200 truncate">by {{ authorName }}</p>
<p v-else class="truncate text-xs text-gray-200" v-html="matchHtml" />
<div v-if="matchKey === 'series' || matchKey === 'tags' || matchKey === 'isbn' || matchKey === 'asin' || matchKey === 'episode'" class="m-0 p-0 truncate text-xs" v-html="matchHtml" />
<div v-if="matchKey === 'series' || matchKey === 'tags' || matchKey === 'isbn' || matchKey === 'asin' || matchKey === 'episode' || matchKey === 'narrators'" class="m-0 p-0 truncate text-xs" v-html="matchHtml" />
</div>
</div>
</template>
@@ -61,18 +61,19 @@ export default {
},
matchHtml() {
if (!this.matchText || !this.search) return ''
if (this.matchKey === 'subtitle') return ''
// This used to highlight the part of the search found
// but with removing commas periods etc this is no longer plausible
const html = this.matchText
if (this.matchKey === 'episode') return `<p class="truncate">Episode: ${html}</p>`
if (this.matchKey === 'tags') return `<p class="truncate">Tags: ${html}</p>`
if (this.matchKey === 'episode') return `<p class="truncate">${this.$strings.LabelEpisode}: ${html}</p>`
if (this.matchKey === 'tags') return `<p class="truncate">${this.$strings.LabelTags}: ${html}</p>`
if (this.matchKey === 'subtitle') return `<p class="truncate">${html}</p>`
if (this.matchKey === 'authors') return `by ${html}`
if (this.matchKey === 'isbn') return `<p class="truncate">ISBN: ${html}</p>`
if (this.matchKey === 'asin') return `<p class="truncate">ASIN: ${html}</p>`
if (this.matchKey === 'series') return `<p class="truncate">Series: ${html}</p>`
if (this.matchKey === 'series') return `<p class="truncate">${this.$strings.LabelSeries}: ${html}</p>`
if (this.matchKey === 'narrators') return `<p class="truncate">${this.$strings.LabelNarrator}: ${html}</p>`
return `${html}`
}
},

View File

@@ -1,8 +1,10 @@
<template>
<div class="flex items-center h-full px-1 overflow-hidden">
<div class="h-5 w-5 min-w-5 text-lg mr-1.5 flex items-center justify-center">
<span v-if="isFinished" :class="taskIconStatus" class="material-icons text-base">{{actionIcon}}</span>
<div class="flex items-center px-1 overflow-hidden">
<div class="w-8 flex items-center justify-center">
<!-- <div class="text-lg"> -->
<span v-if="isFinished" :class="taskIconStatus" class="material-icons text-base">{{ actionIcon }}</span>
<widgets-loading-spinner v-else />
<!-- </div> -->
</div>
<div class="flex-grow px-2 taskRunningCardContent">
<p class="truncate text-sm">{{ title }}</p>
@@ -36,10 +38,13 @@ export default {
return this.task.details || 'Unknown'
},
isFinished() {
return this.task.isFinished || false
return !!this.task.isFinished
},
isFailed() {
return this.task.isFailed || false
return !!this.task.isFailed
},
isSuccess() {
return this.isFinished && !this.isFailed
},
failedMessage() {
return this.task.error || ''
@@ -48,6 +53,11 @@ export default {
return this.task.action || ''
},
actionIcon() {
if (this.isFailed) {
return 'error'
} else if (this.isSuccess) {
return 'done'
}
switch (this.action) {
case 'download-podcast-episode':
return 'cloud_download'
@@ -68,16 +78,15 @@ export default {
return ''
}
},
methods: {
},
methods: {},
mounted() {}
}
</script>
<style>
.taskRunningCardContent {
width: calc(100% - 80px);
height: 75px;
width: calc(100% - 84px);
height: 60px;
display: flex;
flex-direction: column;
justify-content: center;

View File

@@ -114,6 +114,7 @@ export default {
var files = this.item.itemFiles.concat(this.item.otherFiles)
return {
index: this.item.index,
directory: this.directory,
...this.itemData,
files
}

View File

@@ -76,6 +76,10 @@
<div ref="moreIcon" v-show="!isSelectionMode && moreMenuItems.length" class="hidden md:block absolute cursor-pointer hover:text-yellow-300 300 hover:scale-125 transform duration-150" :style="{ bottom: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="clickShowMore">
<span class="material-icons" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">more_vert</span>
</div>
<div v-if="ebookFormat" class="absolute" :style="{ bottom: 0.375 * sizeMultiplier + 'rem', left: 0.375 * sizeMultiplier + 'rem' }">
<span class="text-white/80" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ ebookFormat }}</span>
</div>
</div>
<!-- Processing/loading spinner overlay -->
@@ -112,9 +116,14 @@
</div>
<!-- Podcast Num Episodes -->
<div v-else-if="numEpisodes && !isHovering && !isSelectionMode" class="absolute rounded-full bg-black bg-opacity-90 box-shadow-md z-10 flex items-center justify-center" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', width: 1.25 * sizeMultiplier + 'rem', height: 1.25 * sizeMultiplier + 'rem' }">
<div v-else-if="!numEpisodesIncomplete && numEpisodes && !isHovering && !isSelectionMode" class="absolute rounded-full bg-black bg-opacity-90 box-shadow-md z-10 flex items-center justify-center" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', width: 1.25 * sizeMultiplier + 'rem', height: 1.25 * sizeMultiplier + 'rem' }">
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">{{ numEpisodes }}</p>
</div>
<!-- Podcast Num Episodes -->
<div v-else-if="numEpisodesIncomplete && !isHovering && !isSelectionMode" class="absolute rounded-full bg-yellow-400 text-black font-semibold box-shadow-md z-10 flex items-center justify-center" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', width: 1.25 * sizeMultiplier + 'rem', height: 1.25 * sizeMultiplier + 'rem' }">
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">{{ numEpisodesIncomplete }}</p>
</div>
</div>
</template>
@@ -170,12 +179,6 @@ export default {
dateFormat() {
return this.store.state.serverSettings.dateFormat
},
showExperimentalFeatures() {
return this.store.state.showExperimentalFeatures
},
enableEReader() {
return this.store.getters['getServerSetting']('enableEReader')
},
_libraryItem() {
return this.libraryItem || {}
},
@@ -221,7 +224,7 @@ export default {
libraryId() {
return this._libraryItem.libraryId
},
hasEbook() {
ebookFormat() {
return this.media.ebookFormat
},
numTracks() {
@@ -229,9 +232,11 @@ export default {
return this.media.numTracks || 0 // toJSONMinified
},
numEpisodes() {
if (!this.isPodcast) return 0
return this.media.numEpisodes || 0
},
numEpisodesIncomplete() {
return this._libraryItem.numEpisodesIncomplete || 0
},
processingBatch() {
return this.store.state.processingBatch
},
@@ -252,14 +257,14 @@ export default {
},
booksInSeries() {
// Only added to item object when collapseSeries is enabled
return this.collapsedSeries ? this.collapsedSeries.numBooks : 0
return this.collapsedSeries?.numBooks || 0
},
seriesSequenceList() {
return this.collapsedSeries ? this.collapsedSeries.seriesSequenceList : null
return this.collapsedSeries?.seriesSequenceList || null
},
libraryItemIdsInSeries() {
// Only added to item object when collapseSeries is enabled
return this.collapsedSeries ? this.collapsedSeries.libraryItemIds || [] : []
return this.collapsedSeries?.libraryItemIds || []
},
hasCover() {
return !!this.media.coverPath
@@ -325,8 +330,16 @@ export default {
if (this.episodeProgress) return this.episodeProgress
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId)
},
isEBookOnly() {
return !this.numTracks && this.ebookFormat
},
useEBookProgress() {
if (!this.userProgress || this.userProgress.progress) return false
return this.userProgress.ebookProgress > 0
},
userProgressPercent() {
return this.userProgress ? this.userProgress.progress || 0 : 0
if (this.useEBookProgress) return Math.max(Math.min(1, this.userProgress.ebookProgress), 0)
return this.userProgress ? Math.max(Math.min(1, this.userProgress.progress), 0) || 0 : 0
},
itemIsFinished() {
return this.userProgress ? !!this.userProgress.isFinished : false
@@ -355,13 +368,13 @@ export default {
return this.store.getters['getIsStreamingFromDifferentLibrary']
},
showReadButton() {
return !this.isSelectionMode && !this.showPlayButton && this.hasEbook && (this.showExperimentalFeatures || this.enableEReader)
return !this.isSelectionMode && !this.showPlayButton && this.ebookFormat
},
showPlayButton() {
return !this.isSelectionMode && !this.isMissing && !this.isInvalid && !this.isStreaming && (this.numTracks || this.recentEpisode || this.isMusic)
},
showSmallEBookIcon() {
return !this.isSelectionMode && this.hasEbook && (this.showExperimentalFeatures || this.enableEReader)
return !this.isSelectionMode && this.ebookFormat
},
isMissing() {
return this._libraryItem.isMissing
@@ -477,6 +490,18 @@ export default {
text: this.$strings.LabelAddToPlaylist
})
}
if (this.ebookFormat && this.store.state.libraries.ereaderDevices?.length) {
items.push({
text: this.$strings.LabelSendEbookToDevice,
subitems: this.store.state.libraries.ereaderDevices.map((d) => {
return {
text: d.name,
func: 'sendToDevice',
data: d.name
}
})
})
}
}
if (this.userCanUpdate) {
items.push({
@@ -503,7 +528,7 @@ export default {
if (this.continueListeningShelf) {
items.push({
func: 'removeFromContinueListening',
text: this.$strings.ButtonRemoveFromContinueListening
text: this.isEBookOnly ? this.$strings.ButtonRemoveFromContinueReading : this.$strings.ButtonRemoveFromContinueListening
})
}
if (!this.isPodcast) {
@@ -521,6 +546,14 @@ export default {
}
}
}
if (this.userCanDelete) {
items.push({
func: 'deleteLibraryItem',
text: this.$strings.ButtonDelete
})
}
return items
},
_socket() {
@@ -654,7 +687,6 @@ export default {
.$patch(apiEndpoint, updatePayload)
.then(() => {
this.processing = false
toast.success(updatePayload.isFinished ? this.$strings.ToastItemMarkedAsFinishedSuccess : this.$strings.ToastItemMarkedAsNotFinishedSuccess)
})
.catch((error) => {
console.error('Failed', error)
@@ -699,7 +731,40 @@ export default {
// More menu func
this.store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'match' })
},
sendToDevice(deviceName) {
// More menu func
const payload = {
// message: `Are you sure you want to send ${this.ebookFormat} ebook "${this.title}" to device "${deviceName}"?`,
message: this.$getString('MessageConfirmSendEbookToDevice', [this.ebookFormat, this.title, deviceName]),
callback: (confirmed) => {
if (confirmed) {
const payload = {
libraryItemId: this.libraryItemId,
deviceName
}
this.processing = true
const axios = this.$axios || this.$nuxt.$axios
axios
.$post(`/api/emails/send-ebook-to-device`, payload)
.then(() => {
this.$toast.success(this.$getString('ToastSendEbookToDeviceSuccess', [deviceName]))
})
.catch((error) => {
console.error('Failed to send ebook to device', error)
this.$toast.error(this.$strings.ToastSendEbookToDeviceFailed)
})
.finally(() => {
this.processing = false
})
}
},
type: 'yesNo'
}
this.store.commit('globals/setConfirmPrompt', payload)
},
removeSeriesFromContinueListening() {
if (!this.series) return
const axios = this.$axios || this.$nuxt.$axios
this.processing = true
axios
@@ -772,6 +837,35 @@ export default {
this.store.commit('globals/setSelectedPlaylistItems', [{ libraryItem: this.libraryItem, episode: this.recentEpisode }])
this.store.commit('globals/setShowPlaylistsModal', true)
},
deleteLibraryItem() {
const payload = {
message: 'This will delete the library item from the database and your file system. Are you sure?',
checkboxLabel: 'Delete from file system. Uncheck to only remove from database.',
yesButtonText: this.$strings.ButtonDelete,
yesButtonColor: 'error',
checkboxDefaultValue: true,
callback: (confirmed, hardDelete) => {
if (confirmed) {
this.processing = true
const axios = this.$axios || this.$nuxt.$axios
axios
.$delete(`/api/items/${this.libraryItemId}?hard=${hardDelete ? 1 : 0}`)
.then(() => {
this.$toast.success('Item deleted')
})
.catch((error) => {
console.error('Failed to delete item', error)
this.$toast.error('Failed to delete item')
})
.finally(() => {
this.processing = false
})
}
},
type: 'yesNo'
}
this.store.commit('globals/setConfirmPrompt', payload)
},
createMoreMenu() {
if (!this.$refs.moreIcon) return
@@ -783,8 +877,8 @@ export default {
items: this.moreMenuItems
},
created() {
this.$on('action', (func) => {
if (_this[func]) _this[func]()
this.$on('action', (action) => {
if (action.func && _this[action.func]) _this[action.func](action.data)
})
this.$on('close', () => {
_this.isMoreMenuOpen = false
@@ -796,7 +890,7 @@ export default {
var wrapperBox = this.$refs.moreIcon.getBoundingClientRect()
var el = instance.$el
var elHeight = this.moreMenuItems.length * 28 + 2
var elHeight = this.moreMenuItems.length * 28 + 10
var elWidth = 130
var bottomOfIcon = wrapperBox.top + wrapperBox.height
@@ -823,12 +917,13 @@ export default {
this.createMoreMenu()
},
async clickReadEBook() {
var libraryItem = await this.$axios.$get(`/api/items/${this.libraryItemId}?expanded=1`).catch((error) => {
const axios = this.$axios || this.$nuxt.$axios
var libraryItem = await axios.$get(`/api/items/${this.libraryItemId}?expanded=1`).catch((error) => {
console.error('Failed to get lirbary item', this.libraryItemId)
return null
})
if (!libraryItem) return
this.store.commit('showEReader', libraryItem)
this.store.commit('showEReader', { libraryItem, keepProgress: true })
},
selectBtnClick(evt) {
if (this.processingBatch) return

View File

@@ -7,7 +7,7 @@
<div class="absolute z-10 top-1.5 right-1.5 rounded-md leading-3 text-sm p-1 font-semibold text-white flex items-center justify-center" style="background-color: #cd9d49dd">{{ books.length }}</div>
<div v-if="isSeriesFinished" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b bg-success w-full" />
<div v-if="seriesPercentInProgress > 0" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b w-full" :class="isSeriesFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: seriesPercentInProgress * 100 + '%' }" />
<div v-if="hasValidCovers" class="bg-black bg-opacity-60 absolute top-0 left-0 w-full h-full flex items-center justify-center text-center transition-opacity" :class="isHovering ? '' : 'opacity-0'" :style="{ padding: `${sizeMultiplier}rem` }">
<p :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">{{ displayTitle }}</p>
@@ -81,13 +81,20 @@ export default {
return this.title
},
displaySortLine() {
if (this.orderBy === 'addedAt') {
// return this.addedAt
return 'Added ' + this.$formatDate(this.addedAt, this.dateFormat)
} else if (this.orderBy === 'totalDuration') {
return 'Duration: ' + this.$elapsedPrettyExtended(this.totalDuration, false)
switch (this.orderBy) {
case 'addedAt':
return `${this.$strings.LabelAdded} ${this.$formatDate(this.addedAt, this.dateFormat)}`
case 'totalDuration':
return `${this.$strings.LabelDuration} ${this.$elapsedPrettyExtended(this.totalDuration, false)}`
case 'lastBookUpdated':
const lastUpdated = Math.max(...this.books.map((x) => x.updatedAt), 0)
return `${this.$strings.LabelLastBookUpdated} ${this.$formatDate(lastUpdated, this.dateFormat)}`
case 'lastBookAdded':
const lastBookAdded = Math.max(...this.books.map((x) => x.addedAt), 0)
return `${this.$strings.LabelLastBookAdded} ${this.$formatDate(lastBookAdded, this.dateFormat)}`
default:
return null
}
return null
},
books() {
return this.series ? this.series.books || [] : []
@@ -108,6 +115,14 @@ export default {
seriesBooksFinished() {
return this.seriesBookProgress.filter((p) => p.isFinished)
},
hasSeriesBookInProgress() {
return this.seriesBookProgress.some((p) => !p.isFinished && p.progress > 0)
},
seriesPercentInProgress() {
let totalFinishedAndInProgress = this.seriesBooksFinished.length
if (this.hasSeriesBookInProgress) totalFinishedAndInProgress += 1
return Math.min(1, Math.max(0, totalFinishedAndInProgress / this.books.length))
},
isSeriesFinished() {
return this.books.length === this.seriesBooksFinished.length
},

View File

@@ -0,0 +1,50 @@
<template>
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=narrators.${$encode(narrator.name)}`">
<div :style="{ width: width + 'px', height: height + 'px' }" class="bg-primary box-shadow-book rounded-md relative overflow-hidden">
<div class="absolute inset-0 w-full h-full flex items-center justify-center pointer-events-none opacity-40">
<span class="material-icons-outlined text-[10rem]">record_voice_over</span>
</div>
<!-- Narrator name & num books overlay -->
<div class="absolute bottom-0 left-0 w-full py-1 bg-black bg-opacity-60 px-2">
<p class="text-center font-semibold truncate" :style="{ fontSize: sizeMultiplier * 0.75 + 'rem' }">{{ name }}</p>
<p class="text-center text-gray-200" :style="{ fontSize: sizeMultiplier * 0.65 + 'rem' }">{{ numBooks }} Book{{ numBooks === 1 ? '' : 's' }}</p>
</div>
</div>
</nuxt-link>
</template>
<script>
export default {
props: {
narrator: {
type: Object,
default: () => {}
},
width: Number,
height: Number,
sizeMultiplier: {
type: Number,
default: 1
}
},
data() {
return {}
},
computed: {
name() {
return this.narrator?.name || ''
},
numBooks() {
return this.narrator?.books?.length || 0
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
}
},
methods: {}
}
</script>

View File

@@ -0,0 +1,34 @@
<template>
<div class="flex h-full px-1 overflow-hidden">
<div class="w-10 h-10 flex items-center justify-center">
<span class="material-icons text-2xl text-gray-200">record_voice_over</span>
</div>
<div class="flex-grow px-2 narratorSearchCardContent h-full">
<p class="truncate text-sm">{{ narrator }}</p>
</div>
</div>
</template>
<script>
export default {
props: {
narrator: String
},
data() {
return {}
},
computed: {},
methods: {},
mounted() {}
}
</script>
<style scoped>
.narratorSearchCardContent {
width: calc(100% - 40px);
height: 40px;
display: flex;
flex-direction: column;
justify-content: center;
}
</style>

View File

@@ -0,0 +1,212 @@
<template>
<div>
<div v-if="narrators?.length" class="flex py-0.5 mt-4">
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelNarrators }}</span>
</div>
<div class="max-w-[calc(100vw-10rem)] overflow-hidden overflow-ellipsis">
<template v-for="(narrator, index) in narrators">
<nuxt-link :key="narrator" :to="`/library/${libraryId}/bookshelf?filter=narrators.${$encode(narrator)}`" class="hover:underline">{{ narrator }}</nuxt-link
><span :key="index" v-if="index < narrators.length - 1">,&nbsp;</span>
</template>
</div>
</div>
<div v-if="publishedYear" class="flex py-0.5">
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelPublishYear }}</span>
</div>
<div>
{{ publishedYear }}
</div>
</div>
<div v-if="publisher" class="flex py-0.5">
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelPublisher }}</span>
</div>
<div>
<nuxt-link :to="`/library/${libraryId}/bookshelf?filter=publishers.${$encode(publisher)}`" class="hover:underline">{{ publisher }}</nuxt-link>
</div>
</div>
<div v-if="musicAlbum" class="flex py-0.5">
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
<span class="text-white text-opacity-60 uppercase text-sm">Album</span>
</div>
<div>
{{ musicAlbum }}
</div>
</div>
<div v-if="musicAlbumArtist" class="flex py-0.5">
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
<span class="text-white text-opacity-60 uppercase text-sm">Album Artist</span>
</div>
<div>
{{ musicAlbumArtist }}
</div>
</div>
<div v-if="musicTrackPretty" class="flex py-0.5">
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
<span class="text-white text-opacity-60 uppercase text-sm">Track</span>
</div>
<div>
{{ musicTrackPretty }}
</div>
</div>
<div v-if="musicDiscPretty" class="flex py-0.5">
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
<span class="text-white text-opacity-60 uppercase text-sm">Disc</span>
</div>
<div>
{{ musicDiscPretty }}
</div>
</div>
<div v-if="podcastType" class="flex py-0.5">
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelPodcastType }}</span>
</div>
<div class="capitalize">
{{ podcastType }}
</div>
</div>
<div class="flex py-0.5" v-if="genres.length">
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelGenres }}</span>
</div>
<div class="max-w-[calc(100vw-10rem)] overflow-hidden overflow-ellipsis">
<template v-for="(genre, index) in genres">
<nuxt-link :key="genre" :to="`/library/${libraryId}/bookshelf?filter=genres.${$encode(genre)}`" class="hover:underline">{{ genre }}</nuxt-link
><span :key="index" v-if="index < genres.length - 1">,&nbsp;</span>
</template>
</div>
</div>
<div class="flex py-0.5" v-if="tags.length">
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelTags }}</span>
</div>
<div class="max-w-[calc(100vw-10rem)] overflow-hidden overflow-ellipsis">
<template v-for="(tag, index) in tags">
<nuxt-link :key="tag" :to="`/library/${libraryId}/bookshelf?filter=tags.${$encode(tag)}`" class="hover:underline">{{ tag }}</nuxt-link
><span :key="index" v-if="index < tags.length - 1">,&nbsp;</span>
</template>
</div>
</div>
<div v-if="tracks.length || audioFile || (isPodcast && totalPodcastDuration)" class="flex py-0.5">
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelDuration }}</span>
</div>
<div>
{{ durationPretty }}
</div>
</div>
<div class="flex py-0.5">
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelSize }}</span>
</div>
<div>
{{ sizePretty }}
</div>
</div>
</div>
</template>
<script>
export default {
props: {
libraryItem: {
type: Object,
default: () => {}
}
},
data() {
return {}
},
computed: {
libraryId() {
return this.libraryItem.libraryId
},
isPodcast() {
return this.libraryItem.mediaType === 'podcast'
},
audioFile() {
// Music track
return this.media.audioFile
},
media() {
return this.libraryItem.media || {}
},
tracks() {
return this.media.tracks || []
},
podcastEpisodes() {
return this.media.episodes || []
},
mediaMetadata() {
return this.media.metadata || {}
},
publishedYear() {
return this.mediaMetadata.publishedYear
},
genres() {
return this.mediaMetadata.genres || []
},
tags() {
return this.media.tags || []
},
podcastAuthor() {
return this.mediaMetadata.author || ''
},
authors() {
return this.mediaMetadata.authors || []
},
publisher() {
return this.mediaMetadata.publisher || ''
},
musicArtists() {
return this.mediaMetadata.artists || []
},
musicAlbum() {
return this.mediaMetadata.album || ''
},
musicAlbumArtist() {
return this.mediaMetadata.albumArtist || ''
},
musicTrackPretty() {
if (!this.mediaMetadata.trackNumber) return null
if (!this.mediaMetadata.trackTotal) return this.mediaMetadata.trackNumber
return `${this.mediaMetadata.trackNumber} / ${this.mediaMetadata.trackTotal}`
},
musicDiscPretty() {
if (!this.mediaMetadata.discNumber) return null
if (!this.mediaMetadata.discTotal) return this.mediaMetadata.discNumber
return `${this.mediaMetadata.discNumber} / ${this.mediaMetadata.discTotal}`
},
narrators() {
return this.mediaMetadata.narrators || []
},
durationPretty() {
if (this.isPodcast) return this.$elapsedPrettyExtended(this.totalPodcastDuration)
if (!this.tracks.length && !this.audioFile) return 'N/A'
if (this.audioFile) return this.$elapsedPrettyExtended(this.duration)
return this.$elapsedPretty(this.duration)
},
duration() {
if (!this.tracks.length && !this.audioFile) return 0
return this.media.duration
},
totalPodcastDuration() {
if (!this.podcastEpisodes.length) return 0
let totalDuration = 0
this.podcastEpisodes.forEach((ep) => (totalDuration += ep.duration || 0))
return totalDuration
},
sizePretty() {
return this.$bytesPretty(this.media.size)
},
podcastType() {
return this.mediaMetadata.type
}
},
methods: {},
mounted() {}
}
</script>

View File

@@ -1,6 +1,6 @@
<template>
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
<button type="button" class="relative w-full h-full border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
<button type="button" class="relative w-full h-full border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
<span class="flex items-center justify-between">
<span class="block truncate text-xs">{{ selectedText }}</span>
</span>
@@ -14,12 +14,17 @@
</div>
</button>
<div v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm">
<div v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 rounded-md py-1 text-sm ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none">
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<template v-for="item in items">
<li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" :class="item.value === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedOption(item)">
<li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="option" @click="clickedOption(item)">
<div class="flex items-center justify-between">
<span class="font-normal ml-3 block truncate text-sm md:text-base">{{ item.text }}</span>
<span class="font-normal ml-3 block truncate">{{ item.text }}</span>
</div>
<!-- selected checkmark icon -->
<div v-if="item.value === selected" class="absolute inset-y-0 right-2 h-full flex items-center pointer-events-none">
<span class="material-icons text-base text-yellow-400">check</span>
</div>
</li>
</template>

View File

@@ -63,6 +63,15 @@
</nuxt-link>
</li>
</template>
<p v-if="narratorResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">{{ $strings.LabelNarrators }}</p>
<template v-for="narrator in narratorResults">
<li :key="narrator.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=narrators.${$encode(narrator.name)}`">
<cards-narrator-search-card :narrator="narrator.name" />
</nuxt-link>
</li>
</template>
</template>
</ul>
</div>
@@ -84,6 +93,7 @@ export default {
authorResults: [],
seriesResults: [],
tagResults: [],
narratorResults: [],
searchTimeout: null,
lastSearch: null
}
@@ -114,6 +124,7 @@ export default {
this.authorResults = []
this.seriesResults = []
this.tagResults = []
this.narratorResults = []
this.showMenu = false
this.isFetching = false
this.isTyping = false
@@ -142,7 +153,7 @@ export default {
}
this.isFetching = true
var searchResults = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/search?q=${value}&limit=3`).catch((error) => {
const searchResults = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/search?q=${value}&limit=3`).catch((error) => {
console.error('Search error', error)
return []
})
@@ -155,6 +166,7 @@ export default {
this.authorResults = searchResults.authors || []
this.seriesResults = searchResults.series || []
this.tagResults = searchResults.tags || []
this.narratorResults = searchResults.narrators || []
this.isFetching = false
if (!this.showMenu) {
@@ -185,8 +197,8 @@ export default {
}
</script>
<style>
<style scoped>
.globalSearchMenu {
max-height: 80vh;
max-height: calc(100vh - 75px);
}
</style>

View File

@@ -1,6 +1,6 @@
<template>
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
<button type="button" class="relative w-full h-full bg-fg border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
<button type="button" class="relative w-full h-full bg-bg border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
<span class="flex items-center justify-between">
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
</span>
@@ -9,31 +9,35 @@
<path fill-rule="evenodd" d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</span>
<div v-else class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 cursor-pointer text-gray-400 hover:text-gray-300" @mousedown.stop @mouseup.stop @click.stop.prevent="clearSelected">
<div v-else class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 cursor-pointer text-gray-400 hover:text-gray-200" @mousedown.stop @mouseup.stop @click.stop.prevent="clearSelected">
<span class="material-icons" style="font-size: 1.1rem">close</span>
</div>
</button>
<div v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm">
<div v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-sm libraryFilterMenu">
<ul v-show="!sublist" class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<template v-for="item in selectItems">
<li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" :class="item.value === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedOption(item)">
<li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="option" @click="clickedOption(item)">
<div class="flex items-center justify-between">
<span class="font-normal ml-3 block truncate text-sm md:text-base">{{ item.text }}</span>
<span class="font-normal ml-3 block truncate text-sm">{{ item.text }}</span>
</div>
<div v-if="item.sublist" class="absolute right-1 top-0 bottom-0 h-full flex items-center">
<span class="material-icons text-2xl">arrow_right</span>
</div>
<!-- selected checkmark icon -->
<div v-if="item.value === selected" class="absolute inset-y-0 right-2 h-full flex items-center pointer-events-none">
<span class="material-icons text-base text-yellow-400">check</span>
</div>
</li>
</template>
</ul>
<ul v-show="sublist" class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<li class="text-gray-50 select-none relative py-2 pl-9 cursor-pointer hover:bg-black-400" role="option" @click="sublist = null">
<li class="text-gray-50 select-none relative py-2 pl-9 cursor-pointer hover:bg-white/5" role="option" @click="sublist = null">
<div class="absolute left-1 top-0 bottom-0 h-full flex items-center">
<span class="material-icons text-2xl">arrow_left</span>
</div>
<div class="flex items-center justify-between">
<span class="font-normal ml-3 block truncate">Back</span>
<span class="font-normal block truncate">Back</span>
</div>
</li>
<li v-if="!sublistItems.length" class="text-gray-400 select-none relative px-2" role="option">
@@ -41,16 +45,15 @@
<span class="font-normal block truncate py-2">No {{ sublist }}</span>
</div>
</li>
<li v-else-if="sublist === 'series'" class="text-gray-50 select-none relative px-2 cursor-pointer hover:bg-black-400" role="option" @click="clickedSublistOption($encode('no-series'))">
<div class="flex items-center">
<span class="font-normal block truncate py-2 text-xs text-white text-opacity-80">{{ $strings.MessageNoSeries }}</span>
</div>
</li>
<template v-for="item in sublistItems">
<li :key="item.value" class="text-gray-50 select-none relative px-2 cursor-pointer hover:bg-black-400" :class="`${sublist}.${item.value}` === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedSublistOption(item.value)">
<li :key="item.value" class="select-none relative px-2 cursor-pointer hover:bg-white/5" :class="`${sublist}.${item.value}` === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="option" @click="clickedSublistOption(item.value)">
<div class="flex items-center">
<span class="font-normal truncate py-2 text-xs">{{ item.text }}</span>
</div>
<!-- selected checkmark icon -->
<div v-if="`${sublist}.${item.value}` === selected" class="absolute inset-y-0 right-2 h-full flex items-center pointer-events-none">
<span class="material-icons text-base text-yellow-400">check</span>
</div>
</li>
</template>
</ul>
@@ -72,9 +75,8 @@ export default {
},
watch: {
showMenu(newVal) {
if (!newVal) {
if (this.sublist && !this.selectedItemSublist) this.sublist = null
if (!this.sublist && this.selectedItemSublist) this.sublist = this.selectedItemSublist
if (newVal) {
this.sublist = this.selectedItemSublist
}
}
},
@@ -122,6 +124,11 @@ export default {
value: 'narrators',
sublist: true
},
{
text: this.$strings.LabelPublisher,
value: 'publishers',
sublist: true
},
{
text: this.$strings.LabelLanguage,
value: 'languages',
@@ -165,6 +172,11 @@ export default {
value: 'narrators',
sublist: true
},
{
text: this.$strings.LabelPublisher,
value: 'publishers',
sublist: true
},
{
text: this.$strings.LabelLanguage,
value: 'languages',
@@ -185,6 +197,16 @@ export default {
value: 'tracks',
sublist: true
},
{
text: this.$strings.LabelEbooks,
value: 'ebooks',
sublist: true
},
{
text: this.$strings.LabelAbridged,
value: 'abridged',
sublist: false
},
{
text: this.$strings.ButtonIssues,
value: 'issues',
@@ -250,21 +272,25 @@ export default {
return this.bookItems
},
selectedItemSublist() {
return this.selected && this.selected.includes('.') ? this.selected.split('.')[0] : false
return this.selected?.includes('.') ? this.selected.split('.')[0] : null
},
selectedText() {
if (!this.selected) return ''
var parts = this.selected.split('.')
var filterName = this.selectItems.find((i) => i.value === parts[0])
var filterValue = null
const parts = this.selected.split('.')
const filterName = this.selectItems.find((i) => i.value === parts[0])
let filterValue = null
if (parts.length > 1) {
var decoded = this.$decode(parts[1])
if (decoded.startsWith('aut_')) {
var author = this.authors.find((au) => au.id == decoded)
const decoded = this.$decode(parts[1])
if (parts[0] === 'authors') {
const author = this.authors.find((au) => au.id == decoded)
if (author) filterValue = author.name
} else if (decoded.startsWith('ser_')) {
var series = this.series.find((se) => se.id == decoded)
if (series) filterValue = series.name
} else if (parts[0] === 'series') {
if (decoded === 'no-series') {
filterValue = this.$strings.MessageNoSeries
} else {
const series = this.series.find((se) => se.id == decoded)
if (series) filterValue = series.name
}
} else {
filterValue = decoded
}
@@ -297,6 +323,9 @@ export default {
languages() {
return this.filterData.languages || []
},
publishers() {
return this.filterData.publishers || []
},
progress() {
return [
{
@@ -329,6 +358,18 @@ export default {
}
]
},
ebooks() {
return [
{
id: 'ebook',
name: this.$strings.LabelHasEbook
},
{
id: 'supplementary',
name: this.$strings.LabelHasSupplementaryEbook
}
]
},
missing() {
return [
{
@@ -386,7 +427,7 @@ export default {
]
},
sublistItems() {
return (this[this.sublist] || []).map((item) => {
const sublistItems = (this[this.sublist] || []).map((item) => {
if (typeof item === 'string') {
return {
text: item,
@@ -399,6 +440,13 @@ export default {
}
}
})
if (this.sublist === 'series') {
sublistItems.unshift({
text: this.$strings.MessageNoSeries,
value: this.$encode('no-series')
})
}
return sublistItems
},
filterData() {
return this.$store.state.libraries.filterData || {}
@@ -423,7 +471,7 @@ export default {
return
}
var val = option.value
const val = option.value
if (this.selected === val) {
this.showMenu = false
return
@@ -434,4 +482,10 @@ export default {
}
}
}
</script>
</script>
<style scoped>
.libraryFilterMenu {
max-height: calc(100vh - 125px);
}
</style>

View File

@@ -7,11 +7,11 @@
</span>
</button>
<ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
<ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-sm" role="listbox" aria-labelledby="listbox-label">
<template v-for="item in selectItems">
<li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" :class="item.value === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedOption(item.value)">
<li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="option" @click="clickedOption(item.value)">
<div class="flex items-center">
<span class="font-normal ml-3 block truncate text-xs">{{ item.text }}</span>
<span class="font-normal ml-3 block truncate">{{ item.text }}</span>
</div>
<span v-if="item.value === selected" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
<span class="material-icons text-xl">{{ descending ? 'expand_more' : 'expand_less' }}</span>

View File

@@ -1,17 +1,17 @@
<template>
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
<button type="button" class="relative w-full h-full border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
<button type="button" class="relative w-full h-full border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
<span class="flex items-center justify-between">
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
<span class="material-icons text-lg text-yellow-400">{{ descending ? 'expand_more' : 'expand_less' }}</span>
</span>
</button>
<ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-80 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
<ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-80 rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-sm" role="listbox" aria-labelledby="listbox-label">
<template v-for="item in items">
<li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" :class="item.value === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedOption(item.value)">
<li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="option" @click="clickedOption(item.value)">
<div class="flex items-center">
<span class="font-normal ml-3 block truncate text-xs">{{ item.text }}</span>
<span class="font-normal ml-3 block truncate">{{ item.text }}</span>
</div>
<span v-if="item.value === selected" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
<span class="material-icons text-xl">{{ descending ? 'expand_more' : 'expand_less' }}</span>

View File

@@ -6,7 +6,7 @@
</div>
</template>
<form @submit.prevent="submitForm">
<div class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300">
<div class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 overflow-y-auto overflow-x-hidden" style="min-height: 400px; max-height: 80vh">
<div class="w-full p-8">
<div class="flex py-2">
<div class="w-1/2 px-2">
@@ -96,7 +96,14 @@
</div>
</div>
<div v-if="!newUser.permissions.accessAllTags" class="my-4">
<ui-multi-select-dropdown v-model="newUser.itemTagsAccessible" :items="itemTags" :label="$strings.LabelTagsAccessibleToUser" />
<div class="flex items-center">
<ui-multi-select-dropdown v-model="newUser.itemTagsSelected" :items="itemTags" :label="tagsSelectionText" />
<div class="flex items-center pt-4 px-2">
<p class="px-3 font-semibold" id="selected-tags-not-accessible--permissions-toggle">{{ $strings.LabelInvert }}</p>
<ui-toggle-switch labeledBy="selected-tags-not-accessible--permissions-toggle" v-model="newUser.permissions.selectedTagsNotAccessible" />
</div>
</div>
</div>
</div>
@@ -185,6 +192,9 @@ export default {
value: t
}
})
},
tagsSelectionText() {
return this.newUser.permissions.selectedTagsNotAccessible ? this.$strings.LabelTagsNotAccessibleToUser : this.$strings.LabelTagsAccessibleToUser
}
},
methods: {
@@ -193,8 +203,11 @@ export default {
if (this.$refs.modal) this.$refs.modal.setHide()
},
accessAllTagsToggled(val) {
if (val && this.newUser.itemTagsAccessible.length) {
this.newUser.itemTagsAccessible = []
if (val) {
if (this.newUser.itemTagsSelected?.length) {
this.newUser.itemTagsSelected = []
}
this.newUser.permissions.selectedTagsNotAccessible = false
}
},
fetchAllTags() {
@@ -226,7 +239,7 @@ export default {
this.$toast.error('Must select at least one library')
return
}
if (!this.newUser.permissions.accessAllTags && !this.newUser.itemTagsAccessible.length) {
if (!this.newUser.permissions.accessAllTags && !this.newUser.itemTagsSelected.length) {
this.$toast.error('Must select at least one tag')
return
}
@@ -307,12 +320,12 @@ export default {
delete: type === 'admin',
upload: type === 'admin',
accessAllLibraries: true,
accessAllTags: true
accessAllTags: true,
selectedTagsNotAccessible: false
}
},
init() {
this.fetchAllTags()
this.isNew = !this.account
if (this.account) {
this.newUser = {
@@ -322,9 +335,10 @@ export default {
isActive: this.account.isActive,
permissions: { ...this.account.permissions },
librariesAccessible: [...(this.account.librariesAccessible || [])],
itemTagsAccessible: [...(this.account.itemTagsAccessible || [])]
itemTagsSelected: [...(this.account.itemTagsSelected || [])]
}
} else {
this.fetchAllTags()
this.newUser = {
username: null,
password: null,
@@ -336,7 +350,8 @@ export default {
delete: false,
upload: false,
accessAllLibraries: true,
accessAllTags: true
accessAllTags: true,
selectedTagsNotAccessible: false
},
librariesAccessible: []
}

View File

@@ -0,0 +1,174 @@
<template>
<modals-modal v-model="show" name="audiofile-data-modal" :width="700" :height="'unset'">
<div v-if="audioFile" ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden p-6" style="max-height: 80vh">
<div class="flex items-center justify-between">
<p class="text-base text-gray-200 truncate">{{ metadata.filename }}</p>
<ui-btn v-if="ffprobeData" small class="ml-2" @click="ffprobeData = null">{{ $strings.ButtonReset }}</ui-btn>
<ui-btn v-else-if="userIsAdminOrUp" small :loading="probingFile" class="ml-2" @click="getFFProbeData">Probe Audio File</ui-btn>
</div>
<div class="w-full h-px bg-white bg-opacity-10 my-4" />
<template v-if="!ffprobeData">
<ui-text-input-with-label :value="metadata.path" readonly :label="$strings.LabelPath" class="mb-4 text-sm" />
<div class="flex flex-col sm:flex-row text-sm">
<div class="w-full sm:w-1/2">
<div class="flex mb-1">
<p class="w-32 text-black-50">
{{ $strings.LabelSize }}
</p>
<p>{{ $bytesPretty(metadata.size) }}</p>
</div>
<div class="flex mb-1">
<p class="w-32 text-black-50">
{{ $strings.LabelDuration }}
</p>
<p>{{ $secondsToTimestamp(audioFile.duration) }}</p>
</div>
<div class="flex mb-1">
<p class="w-32 text-black-50">{{ $strings.LabelFormat }}</p>
<p>{{ audioFile.format }}</p>
</div>
<div class="flex mb-1">
<p class="w-32 text-black-50">
{{ $strings.LabelChapters }}
</p>
<p>{{ audioFile.chapters?.length || 0 }}</p>
</div>
<div v-if="audioFile.embeddedCoverArt" class="flex mb-1">
<p class="w-32 text-black-50">
{{ $strings.LabelEmbeddedCover }}
</p>
<p>{{ audioFile.embeddedCoverArt || '' }}</p>
</div>
</div>
<div class="w-full sm:w-1/2">
<div class="flex mb-1">
<p class="w-32 text-black-50">
{{ $strings.LabelCodec }}
</p>
<p>{{ audioFile.codec }}</p>
</div>
<div class="flex mb-1">
<p class="w-32 text-black-50">
{{ $strings.LabelChannels }}
</p>
<p>{{ audioFile.channels }} ({{ audioFile.channelLayout }})</p>
</div>
<div class="flex mb-1">
<p class="w-32 text-black-50">
{{ $strings.LabelBitrate }}
</p>
<p>{{ $bytesPretty(audioFile.bitRate || 0, 0) }}</p>
</div>
<div class="flex mb-1">
<p class="w-32 text-black-50">{{ $strings.LabelTimeBase }}</p>
<p>{{ audioFile.timeBase }}</p>
</div>
<div v-if="audioFile.language" class="flex mb-1">
<p class="w-32 text-black-50">
{{ $strings.LabelLanguage }}
</p>
<p>{{ audioFile.language || '' }}</p>
</div>
</div>
</div>
<div class="w-full h-px bg-white bg-opacity-10 my-4" />
<p class="font-bold mb-2">{{ $strings.LabelMetaTags }}</p>
<div v-for="(value, key) in metaTags" :key="key" class="flex mb-1 text-sm">
<p class="w-32 min-w-32 text-black-50 mb-1">
{{ key.replace('tag', '') }}
</p>
<p>{{ value }}</p>
</div>
</template>
<div v-else class="w-full">
<div class="relative">
<ui-textarea-with-label :value="prettyFfprobeData" readonly :rows="30" class="text-xs" />
<button class="absolute top-4 right-4" :class="copiedToClipboard ? 'text-success' : 'text-white/50 hover:text-white/80'" @click.stop="copyFfprobeData">
<span class="material-icons">{{ copiedToClipboard ? 'check' : 'content_copy' }}</span>
</button>
</div>
</div>
</div>
</modals-modal>
</template>
<script>
export default {
props: {
value: Boolean,
audioFile: {
type: Object,
default: () => {}
},
libraryItemId: String
},
data() {
return {
probingFile: false,
ffprobeData: null,
copiedToClipboard: false
}
},
watch: {
show(newVal) {
if (newVal) {
this.ffprobeData = null
this.copiedToClipboard = false
this.probingFile = false
}
}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
metadata() {
return this.audioFile?.metadata || {}
},
metaTags() {
return this.audioFile?.metaTags || {}
},
userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
},
prettyFfprobeData() {
if (!this.ffprobeData) return ''
return JSON.stringify(this.ffprobeData, null, 2)
}
},
methods: {
getFFProbeData() {
this.probingFile = true
this.$axios
.$get(`/api/items/${this.libraryItemId}/ffprobe/${this.audioFile.ino}`)
.then((data) => {
console.log('Got ffprobe data', data)
this.ffprobeData = data
})
.catch((error) => {
console.error('Failed to get ffprobe data', error)
this.$toast.error('FFProbe failed')
})
.finally(() => {
this.probingFile = false
})
},
async copyFfprobeData() {
this.copiedToClipboard = await this.$copyToClipboard(this.prettyFfprobeData)
}
},
mounted() {}
}
</script>

View File

@@ -2,13 +2,13 @@
<modals-modal v-model="show" name="chapters" :width="600" :height="'unset'">
<div id="chapter-modal-wrapper" ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
<template v-for="chap in chapters">
<div :key="chap.id" :id="`chapter-row-${chap.id}`" class="flex items-center px-6 py-3 justify-start cursor-pointer hover:bg-bg relative" :class="chap.id === currentChapterId ? 'bg-yellow-400 bg-opacity-10' : chap.end <= currentChapterStart ? 'bg-success bg-opacity-5' : 'bg-opacity-20'" @click="clickChapter(chap)">
<div :key="chap.id" :id="`chapter-row-${chap.id}`" class="flex items-center px-6 py-3 justify-start cursor-pointer hover:bg-bg relative" :class="chap.id === currentChapterId ? 'bg-yellow-400 bg-opacity-10' : chap.end / _playbackRate <= currentChapterStart ? 'bg-success bg-opacity-5' : 'bg-opacity-20'" @click="clickChapter(chap)">
<p class="chapter-title truncate text-sm md:text-base">
{{ chap.title }}
</p>
<span class="font-mono text-xxs sm:text-xs text-gray-400 pl-2 whitespace-nowrap">{{ $elapsedPrettyExtended(chap.end - chap.start) }}</span>
<span class="font-mono text-xxs sm:text-xs text-gray-400 pl-2 whitespace-nowrap">{{ $elapsedPrettyExtended((chap.end - chap.start) / _playbackRate) }}</span>
<span class="flex-grow" />
<span class="font-mono text-xs sm:text-sm text-gray-300">{{ $secondsToTimestamp(chap.start) }}</span>
<span class="font-mono text-xs sm:text-sm text-gray-300">{{ $secondsToTimestamp(chap.start / _playbackRate) }}</span>
<div v-show="chap.id === currentChapterId" class="w-0.5 h-full absolute top-0 left-0 bg-yellow-400" />
</div>
@@ -28,7 +28,8 @@ export default {
currentChapter: {
type: Object,
default: () => null
}
},
playbackRate: Number
},
data() {
return {}
@@ -47,11 +48,15 @@ export default {
this.$emit('input', val)
}
},
_playbackRate() {
if (!this.playbackRate || isNaN(this.playbackRate)) return 1
return this.playbackRate
},
currentChapterId() {
return this.currentChapter ? this.currentChapter.id : null
},
currentChapterStart() {
return this.currentChapter ? this.currentChapter.start : 0
return (this.currentChapter?.start || 0) / this._playbackRate
}
},
methods: {
@@ -61,13 +66,11 @@ export default {
scrollToChapter() {
if (!this.currentChapterId) return
var container = this.$refs.container
if (container) {
var currChapterEl = document.getElementById(`chapter-row-${this.currentChapterId}`)
if (this.$refs.container) {
const currChapterEl = document.getElementById(`chapter-row-${this.currentChapterId}`)
if (currChapterEl) {
var offsetTop = currChapterEl.offsetTop
var containerHeight = container.clientHeight
container.scrollTo({ top: offsetTop - containerHeight / 2 })
const containerHeight = this.$refs.container.clientHeight
this.$refs.container.scrollTo({ top: currChapterEl.offsetTop - containerHeight / 2 })
}
}
}

View File

@@ -48,7 +48,7 @@ export default {
},
methods: {
clickedOption(action) {
this.$emit('action', action)
this.$emit('action', { action })
}
},
mounted() {}

View File

@@ -2,7 +2,7 @@
<modals-modal v-model="show" name="listening-session-modal" :processing="processing" :width="700" :height="'unset'">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="text-3xl text-white truncate">{{ $strings.HeaderSession }} {{ _session.id }}</p>
<p class="text-lg md:text-2xl text-white truncate">{{ $strings.HeaderSession }} {{ _session.id }}</p>
</div>
</template>
<div ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden p-6" style="max-height: 80vh">
@@ -50,19 +50,19 @@
<p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mt-6 mb-2">{{ $strings.LabelItem }}</p>
<div v-if="_session.libraryId" class="flex items-center -mx-1 mb-1">
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelLibrary }} Id</div>
<div class="px-1">
<div class="px-1 text-xs">
{{ _session.libraryId }}
</div>
</div>
<div class="flex items-center -mx-1 mb-1">
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelLibraryItem }} Id</div>
<div class="px-1">
<div class="px-1 text-xs">
{{ _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">{{ $strings.LabelEpisode }} Id</div>
<div class="px-1">
<div class="px-1 text-xs">
{{ _session.episodeId }}
</div>
</div>
@@ -81,7 +81,7 @@
</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">{{ $strings.LabelUser }}</p>
<p class="mb-1">{{ _session.userId }}</p>
<p class="mb-1 text-xs">{{ _session.userId }}</p>
<p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mt-6 mb-2">{{ $strings.LabelMediaPlayer }}</p>
<p class="mb-1">{{ playMethodName }}</p>
@@ -98,7 +98,8 @@
</div>
<div class="flex items-center">
<ui-btn small color="error" @click.stop="deleteSessionClick">{{ $strings.ButtonDelete }}</ui-btn>
<ui-btn v-if="!isOpenSession" small color="error" @click.stop="deleteSessionClick">{{ $strings.ButtonDelete }}</ui-btn>
<ui-btn v-else small color="error" @click.stop="closeSessionClick">Close Open Session</ui-btn>
</div>
</div>
</modals-modal>
@@ -157,6 +158,9 @@ export default {
},
timeFormat() {
return this.$store.state.serverSettings.timeFormat
},
isOpenSession() {
return !!this._session.open
}
},
methods: {
@@ -188,6 +192,24 @@ export default {
var errMsg = error.response ? error.response.data || '' : ''
this.$toast.error(errMsg || this.$strings.ToastSessionDeleteFailed)
})
},
closeSessionClick() {
this.processing = true
this.$axios
.$post(`/api/session/${this._session.id}/close`)
.then(() => {
this.$toast.success('Session closed')
this.show = false
this.$emit('closedSession')
})
.catch((error) => {
console.error('Failed to close session', error)
const errMsg = error.response?.data || ''
this.$toast.error(errMsg || 'Failed to close open session')
})
.finally(() => {
this.processing = false
})
}
},
mounted() {}

View File

@@ -2,11 +2,11 @@
<div ref="wrapper" class="modal modal-bg w-full h-full fixed top-0 left-0 bg-primary items-center justify-center opacity-0 hidden" :class="`z-${zIndex} bg-opacity-${bgOpacity}`">
<div class="absolute top-0 left-0 right-0 w-full h-36 bg-gradient-to-t from-transparent via-black-500 to-black-700 opacity-90 pointer-events-none" />
<div class="absolute top-3 right-3 landscape:top-2 landscape:right-2 md:portrait:top-5 md:portrait:right-5 lg:top-5 lg:right-5 h-8 w-8 landscape:h-8 landscape:w-8 md:portrait:h-12 md:portrait:w-12 lg:w-12 lg:h-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300" @click="clickClose">
<button class="absolute top-4 right-4 landscape:top-4 landscape:right-4 md:portrait:top-5 md:portrait:right-5 lg:top-5 lg:right-5 inline-flex text-gray-200 hover:text-white" aria-label="Close modal" @click="clickClose">
<span class="material-icons text-2xl landscape:text-2xl md:portrait:text-4xl lg:text-4xl">close</span>
</div>
</button>
<slot name="outer" />
<div ref="content" style="min-width: 380px; min-height: 200px; max-width: 100vw" class="relative text-white" :style="{ height: modalHeight, width: modalWidth, marginTop: contentMarginTop + 'px' }" @mousedown="mousedownModal" @mouseup="mouseupModal" v-click-outside="clickBg">
<div ref="content" style="min-width: 380px; min-height: 200px; max-width: 100vw" class="relative text-white" aria-modal="true" :style="{ height: modalHeight, width: modalWidth, marginTop: contentMarginTop + 'px' }" @mousedown="mousedownModal" @mouseup="mouseupModal" v-click-outside="clickBg">
<slot />
<div v-if="processing" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-black bg-opacity-60 rounded-lg flex items-center justify-center">
<ui-loading-indicator />

View File

@@ -9,10 +9,14 @@
<div ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
<div v-if="!timerSet" class="w-full">
<template v-for="time in sleepTimes">
<div :key="time.text" class="flex items-center px-6 py-3 justify-center cursor-pointer hover:bg-bg relative" @click="setTime(time)">
<div :key="time.text" class="flex items-center px-6 py-3 justify-center cursor-pointer hover:bg-bg relative" @click="setTime(time.seconds)">
<p class="text-xl text-center">{{ time.text }}</p>
</div>
</template>
<form class="flex items-center justify-center px-6 py-3" @submit.prevent="submitCustomTime">
<ui-text-input v-model="customTime" type="number" step="any" min="0.1" placeholder="Time in minutes" class="w-48" />
<ui-btn color="success" type="submit" :padding-x="0" class="h-9 w-12 flex items-center justify-center ml-1">Set</ui-btn>
</form>
</div>
<div v-else class="w-full p-4">
<div class="mb-4 flex items-center justify-center">
@@ -48,19 +52,28 @@ export default {
},
data() {
return {
customTime: null,
sleepTimes: [
{
seconds: 10,
text: '10 seconds'
},
{
seconds: 60 * 5,
text: '5 minutes'
},
{
seconds: 60 * 15,
text: '15 minutes'
},
{
seconds: 60 * 20,
text: '20 minutes'
},
{
seconds: 60 * 30,
text: '30 minutes'
},
{
seconds: 60 * 45,
text: '45 minutes'
},
{
seconds: 60 * 60,
text: '60 minutes'
@@ -72,10 +85,6 @@ export default {
{
seconds: 60 * 120,
text: '2 hours'
},
{
seconds: 60 * 180,
text: '3 hours'
}
]
}
@@ -97,8 +106,17 @@ export default {
}
},
methods: {
setTime(time) {
this.$emit('set', time.seconds)
submitCustomTime() {
if (!this.customTime || isNaN(this.customTime) || Number(this.customTime) <= 0) {
this.customTime = null
return
}
const timeInSeconds = Math.round(Number(this.customTime) * 60)
this.setTime(timeInSeconds)
},
setTime(seconds) {
this.$emit('set', seconds)
},
increment(amount) {
this.$emit('increment', amount)

View File

@@ -85,6 +85,12 @@ export default {
},
title() {
return this.$strings.HeaderUpdateAuthor
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
libraryProvider() {
return this.$store.getters['libraries/getLibraryProvider'](this.currentLibraryId) || 'google'
}
},
methods: {
@@ -151,6 +157,11 @@ export default {
if (this.authorCopy.asin) payload.asin = this.authorCopy.asin
else payload.q = this.authorCopy.name
payload.region = 'us'
if (this.libraryProvider.startsWith('audible.')) {
payload.region = this.libraryProvider.split('.').pop() || 'us'
}
var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, payload).catch((error) => {
console.error('Failed', error)
return null

View File

@@ -8,10 +8,9 @@
<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">
<template v-if="!showImageUploader">
<form @submit.prevent="submitForm">
<div class="flex">
<div>
<div class="flex flex-wrap">
<div class="w-full flex justify-center mb-2 md:w-auto md:mb-0 md:block">
<covers-collection-cover :book-items="books" :width="200" :height="100 * bookCoverAspectRatio" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<!-- <ui-btn type="button" @click="showImageUploader = true">Upload</ui-btn> -->
</div>
<div class="flex-grow px-4">
<ui-text-input-with-label v-model="newCollectionName" :label="$strings.LabelName" class="mb-2" />
@@ -41,7 +40,6 @@
<ui-btn color="success">Upload</ui-btn>
</div>
</template>
<!-- <modals-upload-image-modal v-model="showUploadImageModal" entity="collection" :entity-id="collection.id" /> -->
</div>
</modals-modal>
</template>

View File

@@ -0,0 +1,171 @@
<template>
<modals-modal ref="modal" v-model="show" name="ereader-device-edit" :width="800" :height="'unset'" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="text-3xl text-white truncate">{{ title }}</p>
</div>
</template>
<form @submit.prevent="submitForm">
<div class="w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300">
<div class="w-full px-3 py-5 md:p-12">
<div class="flex items-center -mx-1 mb-2">
<div class="w-full md:w-1/2 px-1">
<ui-text-input-with-label ref="ereaderNameInput" v-model="newDevice.name" :disabled="processing" :label="$strings.LabelName" />
</div>
<div class="w-full md:w-1/2 px-1">
<ui-text-input-with-label ref="ereaderEmailInput" v-model="newDevice.email" :disabled="processing" :label="$strings.LabelEmail" />
</div>
</div>
<div class="flex items-center pt-4">
<div class="flex-grow" />
<ui-btn color="success" type="submit">{{ $strings.ButtonSubmit }}</ui-btn>
</div>
</div>
</div>
</form>
</modals-modal>
</template>
<script>
export default {
props: {
value: Boolean,
existingDevices: {
type: Array,
default: () => []
},
ereaderDevice: {
type: Object,
default: () => null
}
},
data() {
return {
processing: false,
newDevice: {
name: '',
email: ''
}
}
},
watch: {
show: {
handler(newVal) {
if (newVal) {
this.init()
}
}
}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
title() {
return this.ereaderDevice ? 'Create Device' : 'Update Device'
}
},
methods: {
submitForm() {
this.$refs.ereaderNameInput.blur()
this.$refs.ereaderEmailInput.blur()
if (!this.newDevice.name?.trim() || !this.newDevice.email?.trim()) {
this.$toast.error('Name and email required')
return
}
this.newDevice.name = this.newDevice.name.trim()
this.newDevice.email = this.newDevice.email.trim()
if (!this.ereaderDevice) {
if (this.existingDevices.some((d) => d.name === this.newDevice.name)) {
this.$toast.error('EReader device with that name already exists')
return
}
this.submitCreate()
} else {
if (this.ereaderDevice.name !== this.newDevice.name && this.existingDevices.some((d) => d.name === this.newDevice.name)) {
this.$toast.error('EReader device with that name already exists')
return
}
this.submitUpdate()
}
},
submitUpdate() {
this.processing = true
const existingDevicesWithoutThisOne = this.existingDevices.filter((d) => d.name !== this.ereaderDevice.name)
const payload = {
ereaderDevices: [
...existingDevicesWithoutThisOne,
{
...this.newDevice
}
]
}
this.$axios
.$post(`/api/emails/ereader-devices`, payload)
.then((data) => {
this.$emit('update', data.ereaderDevices)
this.$toast.success('Device updated')
this.show = false
})
.catch((error) => {
console.error('Failed to update device', error)
this.$toast.error('Failed to update device')
})
.finally(() => {
this.processing = false
})
},
submitCreate() {
this.processing = true
const payload = {
ereaderDevices: [
...this.existingDevices,
{
...this.newDevice
}
]
}
this.$axios
.$post('/api/emails/ereader-devices', payload)
.then((data) => {
this.$emit('update', data.ereaderDevices || [])
this.$toast.success('Device added')
this.show = false
})
.catch((error) => {
console.error('Failed to add device', error)
this.$toast.error('Failed to add device')
})
.finally(() => {
this.processing = false
})
},
init() {
if (this.ereaderDevice) {
this.newDevice.name = this.ereaderDevice.name
this.newDevice.email = this.ereaderDevice.email
} else {
this.newDevice.name = ''
this.newDevice.email = ''
}
}
},
mounted() {}
}
</script>

View File

@@ -74,6 +74,9 @@ export default {
this.$store.commit('setEditModalTab', val)
}
},
height() {
return Math.min(this.availableHeight, 650)
},
tabs() {
return [
{
@@ -124,9 +127,6 @@ export default {
}
]
},
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
@@ -136,14 +136,26 @@ export default {
userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
},
selectedLibraryItem() {
return this.$store.state.selectedLibraryItem || {}
},
selectedLibraryItemId() {
return this.selectedLibraryItem.id
},
media() {
return this.libraryItem?.media || {}
},
mediaMetadata() {
return this.media.metadata || {}
},
availableTabs() {
if (!this.userCanUpdate && !this.userCanDownload) return []
return this.tabs.filter((tab) => {
if (tab.experimental && !this.showExperimentalFeatures) return false
if (tab.mediaType && this.mediaType !== tab.mediaType) return false
if (tab.admin && !this.userIsAdminOrUp) return false
if (tab.id === 'tools' && this.isMissing) return false
if (tab.id === 'chapters' && this.isEBookOnly) return false
if ((tab.id === 'tools' || tab.id === 'files') && this.userCanDownload) return true
if (tab.id !== 'tools' && tab.id !== 'files' && this.userCanUpdate) return true
@@ -151,9 +163,6 @@ export default {
return false
})
},
height() {
return Math.min(this.availableHeight, 650)
},
tabName() {
var _tab = this.tabs.find((t) => t.id === this.selectedTab)
return _tab ? _tab.component : ''
@@ -161,20 +170,11 @@ export default {
isMissing() {
return this.selectedLibraryItem.isMissing
},
selectedLibraryItem() {
return this.$store.state.selectedLibraryItem || {}
},
selectedLibraryItemId() {
return this.selectedLibraryItem.id
},
media() {
return this.libraryItem ? this.libraryItem.media || {} : {}
},
mediaMetadata() {
return this.media.metadata || {}
isEBookOnly() {
return this.media.ebookFile && !this.media.tracks?.length
},
mediaType() {
return this.libraryItem ? this.libraryItem.mediaType : null
return this.libraryItem?.mediaType || null
},
title() {
return this.mediaMetadata.title || 'No Title'

View File

@@ -1,8 +1,9 @@
<template>
<div class="w-full h-full overflow-hidden overflow-y-auto px-2 sm:px-4 py-6 relative">
<div class="flex flex-wrap">
<div class="flex flex-wrap mb-4">
<div class="relative">
<covers-book-cover :library-item="libraryItem" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<covers-preview-cover :src="$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId, libraryItemUpdatedAt, true)" :width="120" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<!-- book cover overlay -->
<div v-if="media.coverPath" class="absolute top-0 left-0 w-full h-full z-10 opacity-0 hover:opacity-100 transition-opacity duration-100">
<div class="absolute top-0 left-0 w-full h-16 bg-gradient-to-b from-black-600 to-transparent" />
@@ -16,10 +17,10 @@
<div class="flex-grow sm:pl-2 md:pl-6 sm:pr-2 mt-2 md:mt-0">
<div class="flex items-center">
<div v-if="userCanUpload" class="w-10 md:w-40 pr-2 pt-4 md:min-w-32">
<ui-file-input ref="fileInput" @change="fileUploadSelected"
><span class="hidden md:inline-block">{{ $strings.ButtonUploadCover }}</span
><span class="material-icons text-2xl inline-block md:!hidden">upload</span></ui-file-input
>
<ui-file-input ref="fileInput" @change="fileUploadSelected">
<span class="hidden md:inline-block">{{ $strings.ButtonUploadCover }}</span>
<span class="material-icons text-2xl inline-block md:!hidden">upload</span>
</ui-file-input>
</div>
<form @submit.prevent="submitForm" class="flex flex-grow">
<ui-text-input-with-label v-model="imageUrl" :label="$strings.LabelCoverImageURL" />
@@ -27,18 +28,18 @@
</form>
</div>
<div v-if="localCovers.length" class="mb-4 mt-6 border-t border-b border-primary">
<div v-if="localCovers.length" class="mb-4 mt-6 border-t border-b border-white border-opacity-10">
<div class="flex items-center justify-center py-2">
<p>{{ localCovers.length }} local image{{ localCovers.length !== 1 ? 's' : '' }}</p>
<div class="flex-grow" />
<ui-btn small @click="showLocalCovers = !showLocalCovers">{{ showLocalCovers ? $strings.ButtonHide : $strings.ButtonShow }}</ui-btn>
</div>
<div v-if="showLocalCovers" class="flex items-center justify-center">
<template v-for="cover in localCovers">
<div :key="cover.path" class="m-0.5 mb-5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover.metadata.path === coverPath ? 'border-yellow-300' : ''" @click="setCover(cover)">
<div v-if="showLocalCovers" class="flex items-center justify-center pb-2">
<template v-for="localCoverFile in localCovers">
<div :key="localCoverFile.ino" class="m-0.5 mb-5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="localCoverFile.metadata.path === coverPath ? 'border-yellow-300' : ''" @click="setCover(localCoverFile)">
<div class="h-24 bg-primary" :style="{ width: 96 / bookCoverAspectRatio + 'px' }">
<covers-preview-cover :src="`${cover.localPath}?token=${userToken}`" :width="96 / bookCoverAspectRatio" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<covers-preview-cover :src="localCoverFile.localPath" :width="96 / bookCoverAspectRatio" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</div>
</div>
</template>
@@ -48,13 +49,13 @@
</div>
<form @submit.prevent="submitSearchForm">
<div class="flex items-center justify-start -mx-1 h-20">
<div class="w-40 px-1">
<div class="w-48 px-1">
<ui-dropdown v-model="provider" :items="providers" :label="$strings.LabelProvider" small />
</div>
<div class="w-72 px-1">
<ui-text-input-with-label v-model="searchTitle" :label="searchTitleLabel" :placeholder="$strings.PlaceholderSearch" />
</div>
<div v-show="provider != 'itunes'" class="w-72 px-1">
<div v-show="provider != 'itunes' && provider != 'audiobookcovers'" class="w-72 px-1">
<ui-text-input-with-label v-model="searchAuthor" :label="$strings.LabelAuthor" />
</div>
<ui-btn class="mt-5 ml-1" type="submit">{{ $strings.ButtonSearch }}</ui-btn>
@@ -127,7 +128,7 @@ export default {
},
providers() {
if (this.isPodcast) return this.$store.state.scanners.podcastProviders
return this.$store.state.scanners.providers
return [{ text: 'All', value: 'all' }, ...this.$store.state.scanners.providers, ...this.$store.state.scanners.coverOnlyProviders]
},
searchTitleLabel() {
if (this.provider.startsWith('audible')) return this.$strings.LabelSearchTitleOrASIN
@@ -138,16 +139,19 @@ export default {
return this.$store.getters['libraries/getBookCoverAspectRatio']
},
libraryItemId() {
return this.libraryItem ? this.libraryItem.id : null
return this.libraryItem?.id || null
},
libraryItemUpdatedAt() {
return this.libraryItem?.updatedAt || null
},
mediaType() {
return this.libraryItem ? this.libraryItem.mediaType : null
return this.libraryItem?.mediaType || null
},
isPodcast() {
return this.mediaType == 'podcast'
},
media() {
return this.libraryItem ? this.libraryItem.media || {} : {}
return this.libraryItem?.media || {}
},
coverPath() {
return this.media.coverPath
@@ -156,7 +160,7 @@ export default {
return this.media.metadata || {}
},
libraryFiles() {
return this.libraryItem ? this.libraryItem.libraryFiles || [] : []
return this.libraryItem?.libraryFiles || []
},
userCanUpload() {
return this.$store.getters['user/getUserCanUpload']
@@ -168,8 +172,8 @@ export default {
return this.libraryFiles
.filter((f) => f.fileType === 'image')
.map((file) => {
var _file = { ...file }
_file.localPath = `${process.env.serverUrl}/s/item/${this.libraryItemId}/${this.$encodeUriPath(file.metadata.relPath).replace(/^\//, '')}`
const _file = { ...file }
_file.localPath = `${process.env.serverUrl}/api/items/${this.libraryItemId}/file/${file.ino}?token=${this.userToken}`
return _file
})
}
@@ -222,7 +226,7 @@ export default {
this.searchTitle = this.mediaMetadata.title || ''
this.searchAuthor = this.mediaMetadata.authorName || ''
if (this.isPodcast) this.provider = 'itunes'
else this.provider = localStorage.getItem('book-provider') || 'google'
else this.provider = localStorage.getItem('book-cover-provider') || localStorage.getItem('book-provider') || 'google'
},
removeCover() {
if (!this.media.coverPath) {
@@ -287,13 +291,13 @@ export default {
},
getSearchQuery() {
var searchQuery = `provider=${this.provider}&title=${this.searchTitle}`
if (this.searchAuthor) searchQuery += `&author=${this.searchAuthor}`
if (this.searchAuthor) searchQuery += `&author=${this.searchAuthor || ''}`
if (this.isPodcast) searchQuery += '&podcast=1'
return searchQuery
},
persistProvider() {
try {
localStorage.setItem('book-provider', this.provider)
localStorage.setItem('book-cover-provider', this.provider)
} catch (error) {
console.error('PersistProvider', error)
}

View File

@@ -7,11 +7,6 @@
<div class="absolute bottom-0 left-0 w-full py-2 md:py-4 bg-bg" :class="isScrollable ? 'box-shadow-md-up' : 'border-t border-white border-opacity-5'">
<div class="flex items-center px-4">
<ui-btn v-if="userCanDelete" color="error" type="button" class="h-8 hidden md:block" :padding-x="3" small @click.stop.prevent="removeItem">{{ $strings.ButtonRemove }}</ui-btn>
<ui-icon-btn bg-color="error" icon="delete" class="md:hidden" :size="7" icon-font-size="1rem" @click.stop.prevent="removeItem" />
<div class="flex-grow" />
<ui-tooltip :disabled="!!quickMatching" :text="$getString('MessageQuickMatchDescription', [libraryProvider])" direction="bottom" class="mr-2 md:mr-4">
<ui-btn v-if="userIsAdminOrUp" :loading="quickMatching" color="bg" type="button" class="h-full" small @click.stop.prevent="quickMatch">{{ $strings.ButtonQuickMatch }}</ui-btn>
</ui-tooltip>
@@ -20,6 +15,8 @@
<ui-btn v-if="userIsAdminOrUp && !isFile" :loading="rescanning" :disabled="!!libraryScan" color="bg" type="button" class="h-full" small @click.stop.prevent="rescan">{{ $strings.ButtonReScan }}</ui-btn>
</ui-tooltip>
<div class="flex-grow" />
<!-- desktop -->
<ui-btn @click="save" class="mx-2 hidden md:block">{{ $strings.ButtonSave }}</ui-btn>
<ui-btn @click="saveAndClose" class="mx-2 hidden md:block">{{ $strings.ButtonSaveAndClose }}</ui-btn>
@@ -77,9 +74,6 @@ export default {
mediaMetadata() {
return this.media.metadata || {}
},
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
},
libraryId() {
return this.libraryItem ? this.libraryItem.libraryId : null
},
@@ -184,23 +178,6 @@ export default {
}
return false
},
removeItem() {
if (confirm(`Are you sure you want to remove this item?\n\n*Does not delete your files, only removes the item from audiobookshelf`)) {
this.isProcessing = true
this.$axios
.$delete(`/api/items/${this.libraryItemId}`)
.then(() => {
console.log('Item removed')
this.$toast.success('Item Removed')
this.$emit('close')
this.isProcessing = false
})
.catch((error) => {
console.error('Remove item failed', error)
this.isProcessing = false
})
}
},
checkIsScrollable() {
this.$nextTick(() => {
var formWrapper = document.getElementById('formWrapper')

View File

@@ -1,6 +1,6 @@
<template>
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
<tables-library-files-table expanded :files="libraryFiles" :library-item-id="libraryItem.id" :is-missing="isMissing" />
<tables-library-files-table expanded :library-item="libraryItem" :is-missing="isMissing" in-modal />
</div>
</template>
@@ -30,9 +30,6 @@ export default {
media() {
return this.libraryItem.media || {}
},
libraryFiles() {
return this.libraryItem.libraryFiles || []
},
userToken() {
return this.$store.getters['user/getToken']
},

View File

@@ -34,13 +34,25 @@
</div>
<ui-checkbox v-model="selectAll" checkbox-bg="bg" @input="selectAllToggled" />
<form @submit.prevent="submitMatchUpdate">
<div v-if="selectedMatchOrig.cover" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.cover" checkbox-bg="bg" @input="checkboxToggled" />
<ui-text-input-with-label v-model="selectedMatch.cover" :disabled="!selectedMatchUsage.cover" readonly :label="$strings.LabelCover" class="flex-grow mx-4" />
<div class="min-w-12 max-w-12 md:min-w-16 md:max-w-16">
<a :href="selectedMatch.cover" target="_blank" class="w-full bg-primary">
<img :src="selectedMatch.cover" class="h-full w-full object-contain" />
</a>
<div v-if="selectedMatchOrig.cover" class="flex flex-wrap md:flex-nowrap items-center justify-center">
<div class="flex flex-grow items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.cover" checkbox-bg="bg" @input="checkboxToggled" />
<ui-text-input-with-label v-model="selectedMatch.cover" :disabled="!selectedMatchUsage.cover" readonly :label="$strings.LabelCover" class="flex-grow mx-4" />
</div>
<div class="flex py-2">
<div>
<p class="text-center text-gray-200">New</p>
<a :href="selectedMatch.cover" target="_blank" class="bg-primary">
<covers-preview-cover :src="selectedMatch.cover" :width="100" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</a>
</div>
<div v-if="media.coverPath">
<p class="text-center text-gray-200">Current</p>
<a :href="$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId, null, true)" target="_blank" class="bg-primary">
<covers-preview-cover :src="$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId, null, true)" :width="100" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</a>
</div>
</div>
</div>
<div v-if="selectedMatchOrig.title" class="flex items-center py-2">
@@ -103,7 +115,7 @@
<div v-if="selectedMatchOrig.genres && selectedMatchOrig.genres.length" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.genres" checkbox-bg="bg" @input="checkboxToggled" />
<div class="flex-grow ml-4">
<ui-multi-select v-model="selectedMatch.genres" :items="selectedMatch.genres" :disabled="!selectedMatchUsage.genres" :label="$strings.LabelGenres" />
<ui-multi-select v-model="selectedMatch.genres" :items="genres" :disabled="!selectedMatchUsage.genres" :label="$strings.LabelGenres" />
<p v-if="mediaMetadata.genres" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.genres.join(', ') }}</p>
</div>
</div>
@@ -164,13 +176,20 @@
<p v-if="mediaMetadata.releaseDate" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.releaseDate || '' }}</p>
</div>
</div>
<div v-if="selectedMatchOrig.explicit != null" class="flex items-center py-2">
<div v-if="selectedMatchOrig.explicit != null" class="flex items-center pb-2" :class="{ 'pt-2': mediaMetadata.explicit == null }">
<ui-checkbox v-model="selectedMatchUsage.explicit" checkbox-bg="bg" @input="checkboxToggled" />
<div class="flex-grow ml-4">
<ui-checkbox v-model="selectedMatch.explicit" :label="$strings.LabelExplicit" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
<div class="flex-grow ml-4" :class="{ 'pt-4': mediaMetadata.explicit != null }">
<ui-checkbox v-model="selectedMatch.explicit" :label="$strings.LabelExplicit" :disabled="!selectedMatchUsage.explicit" :checkbox-bg="!selectedMatchUsage.explicit ? 'bg' : 'primary'" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
<p v-if="mediaMetadata.explicit != null" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.explicit ? 'Explicit (checked)' : 'Not Explicit (unchecked)' }}</p>
</div>
</div>
<div v-if="selectedMatchOrig.abridged != null" class="flex items-center pb-2" :class="{ 'pt-2': mediaMetadata.abridged == null }">
<ui-checkbox v-model="selectedMatchUsage.abridged" checkbox-bg="bg" @input="checkboxToggled" />
<div class="flex-grow ml-4" :class="{ 'pt-4': mediaMetadata.abridged != null }">
<ui-checkbox v-model="selectedMatch.abridged" :label="$strings.LabelAbridged" :disabled="!selectedMatchUsage.abridged" :checkbox-bg="!selectedMatchUsage.abridged ? 'bg' : 'primary'" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
<p v-if="mediaMetadata.abridged != null" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.abridged ? 'Abridged (checked)' : 'Unabridged (unchecked)' }}</p>
</div>
</div>
<div class="flex items-center justify-end py-2">
<ui-btn color="success" type="submit">{{ $strings.ButtonSubmit }}</ui-btn>
@@ -216,6 +235,7 @@ export default {
explicit: true,
asin: true,
isbn: true,
abridged: true,
// Podcast specific
itunesPageUrl: true,
itunesId: true,
@@ -280,6 +300,12 @@ export default {
},
isPodcast() {
return this.mediaType == 'podcast'
},
genres() {
const filterData = this.$store.state.libraries.filterData || {}
const currentGenres = filterData.genres || []
const selectedMatchGenres = this.selectedMatch.genres || []
return [...new Set([...currentGenres ,...selectedMatchGenres])]
}
},
methods: {
@@ -360,6 +386,7 @@ export default {
explicit: true,
asin: true,
isbn: true,
abridged: true,
// Podcast specific
itunesPageUrl: true,
itunesId: true,
@@ -476,7 +503,6 @@ export default {
} else if (key === 'narrator') {
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())
updatePayload.metadata.genres = [...this.selectedMatch[key]]
} else if (key === 'tags') {
updatePayload.tags = this.selectedMatch[key].split(',').map((v) => v.trim())

View File

@@ -20,7 +20,7 @@
</div>
<!-- Split to mp3 -->
<div v-if="showMp3Split && showExperimentalFeatures" class="w-full border border-black-200 p-4 my-8">
<!-- <div v-if="showMp3Split" class="w-full border border-black-200 p-4 my-8">
<div class="flex items-center">
<div>
<p class="text-lg">{{ $strings.LabelToolsSplitM4b }}</p>
@@ -31,7 +31,7 @@
<ui-btn :disabled="true">{{ $strings.MessageNotYetImplemented }}</ui-btn>
</div>
</div>
</div>
</div> -->
<!-- Embed Metadata -->
<div v-if="mediaTracks.length" class="w-full border border-black-200 p-4 my-8">
@@ -46,8 +46,20 @@
>{{ $strings.ButtonOpenManager }}
<span class="material-icons text-lg ml-2">launch</span>
</ui-btn>
<ui-btn v-if="!isMetadataEmbedQueued && !isEmbedTaskRunning" class="w-full mt-4" small @click.stop="quickEmbed">Quick Embed</ui-btn>
</div>
</div>
<!-- queued alert -->
<widgets-alert v-if="isMetadataEmbedQueued" type="warning" class="mt-4">
<p class="text-lg">Queued for metadata embed ({{ queuedEmbedLIds.length }} in queue)</p>
</widgets-alert>
<!-- processing alert -->
<widgets-alert v-if="isEmbedTaskRunning" type="warning" class="mt-4">
<p class="text-lg">Currently embedding metadata</p>
</widgets-alert>
</div>
<p v-if="!mediaTracks.length" class="text-lg text-center my-8">{{ $strings.MessageNoAudioTracks }}</p>
@@ -67,14 +79,11 @@ export default {
return {}
},
computed: {
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
},
libraryItemId() {
return this.libraryItem ? this.libraryItem.id : null
return this.libraryItem?.id || null
},
media() {
return this.libraryItem ? this.libraryItem.media || {} : {}
return this.libraryItem?.media || {}
},
mediaTracks() {
return this.media.tracks || []
@@ -92,9 +101,49 @@ export default {
showMp3Split() {
if (!this.mediaTracks.length) return false
return this.isSingleM4b && this.chapters.length
},
queuedEmbedLIds() {
return this.$store.state.tasks.queuedEmbedLIds || []
},
isMetadataEmbedQueued() {
return this.queuedEmbedLIds.some((lid) => lid === this.libraryItemId)
},
tasks() {
return this.$store.getters['tasks/getTasksByLibraryItemId'](this.libraryItemId)
},
embedTask() {
return this.tasks.find((t) => t.action === 'embed-metadata')
},
encodeTask() {
return this.tasks.find((t) => t.action === 'encode-m4b')
},
isEmbedTaskRunning() {
return this.embedTask && !this.embedTask?.isFinished
},
isEncodeTaskRunning() {
return this.encodeTask && !this.encodeTask?.isFinished
}
},
methods: {},
mounted() {}
methods: {
quickEmbed() {
const payload = {
message: 'Warning! Quick embed will not backup your audio files. Make sure that you have a backup of your audio files. <br><br>Would you like to continue?',
callback: (confirmed) => {
if (confirmed) {
this.$axios
.$post(`/api/tools/item/${this.libraryItemId}/embed-metadata`)
.then(() => {
console.log('Audio metadata encode started')
})
.catch((error) => {
console.error('Audio metadata encode failed', error)
})
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
}
}
}
</script>

View File

@@ -1,6 +1,6 @@
<template>
<div class="w-full h-full px-1 md:px-4 py-1 mb-4">
<div class="flex items-center py-2">
<div class="flex items-center py-3">
<ui-toggle-switch v-model="useSquareBookCovers" @input="formUpdated" />
<ui-tooltip :text="$strings.LabelSettingsSquareBookCoversHelp">
<p class="pl-4 text-base">
@@ -17,18 +17,38 @@
</div>
<p v-if="globalWatcherDisabled" class="text-xs text-warning">*{{ $strings.MessageWatcherIsDisabledGlobally }}</p>
</div>
<div v-if="mediaType == 'book'" class="py-3">
<div v-if="isBookLibrary" class="flex items-center py-3">
<ui-toggle-switch v-model="audiobooksOnly" @input="formUpdated" />
<ui-tooltip :text="$strings.LabelSettingsAudiobooksOnlyHelp">
<p class="pl-4 text-base">
{{ $strings.LabelSettingsAudiobooksOnly }}
<span class="material-icons icon-text text-sm">info_outlined</span>
</p>
</ui-tooltip>
</div>
<div v-if="isBookLibrary" class="py-3">
<div class="flex items-center">
<ui-toggle-switch v-model="skipMatchingMediaWithAsin" @input="formUpdated" />
<p class="pl-4 text-base">{{ $strings.LabelSettingsSkipMatchingBooksWithASIN }}</p>
</div>
</div>
<div v-if="mediaType == 'book'" class="py-3">
<div v-if="isBookLibrary" class="py-3">
<div class="flex items-center">
<ui-toggle-switch v-model="skipMatchingMediaWithIsbn" @input="formUpdated" />
<p class="pl-4 text-base">{{ $strings.LabelSettingsSkipMatchingBooksWithISBN }}</p>
</div>
</div>
<div v-if="isBookLibrary" class="py-3">
<div class="flex items-center">
<ui-toggle-switch v-model="hideSingleBookSeries" @input="formUpdated" />
<ui-tooltip :text="$strings.LabelSettingsHideSingleBookSeriesHelp">
<p class="pl-4 text-base">
{{ $strings.LabelSettingsHideSingleBookSeries }}
<span class="material-icons icon-text text-sm">info_outlined</span>
</p>
</ui-tooltip>
</div>
</div>
</div>
</template>
@@ -47,7 +67,9 @@ export default {
useSquareBookCovers: false,
disableWatcher: false,
skipMatchingMediaWithAsin: false,
skipMatchingMediaWithIsbn: false
skipMatchingMediaWithIsbn: false,
audiobooksOnly: false,
hideSingleBookSeries: false
}
},
computed: {
@@ -60,6 +82,9 @@ export default {
mediaType() {
return this.library.mediaType
},
isBookLibrary() {
return this.mediaType === 'book'
},
providers() {
if (this.mediaType === 'podcast') return this.$store.state.scanners.podcastProviders
return this.$store.state.scanners.providers
@@ -72,7 +97,9 @@ export default {
coverAspectRatio: this.useSquareBookCovers ? this.$constants.BookCoverAspectRatio.SQUARE : this.$constants.BookCoverAspectRatio.STANDARD,
disableWatcher: !!this.disableWatcher,
skipMatchingMediaWithAsin: !!this.skipMatchingMediaWithAsin,
skipMatchingMediaWithIsbn: !!this.skipMatchingMediaWithIsbn
skipMatchingMediaWithIsbn: !!this.skipMatchingMediaWithIsbn,
audiobooksOnly: !!this.audiobooksOnly,
hideSingleBookSeries: !!this.hideSingleBookSeries
}
}
},
@@ -84,6 +111,8 @@ export default {
this.disableWatcher = !!this.librarySettings.disableWatcher
this.skipMatchingMediaWithAsin = !!this.librarySettings.skipMatchingMediaWithAsin
this.skipMatchingMediaWithIsbn = !!this.librarySettings.skipMatchingMediaWithIsbn
this.audiobooksOnly = !!this.librarySettings.audiobooksOnly
this.hideSingleBookSeries = !!this.librarySettings.hideSingleBookSeries
}
},
mounted() {

View File

@@ -10,7 +10,7 @@
<div class="w-full px-3 py-5 md:p-12">
<ui-dropdown v-model="newNotification.eventName" :label="$strings.LabelNotificationEvent" :items="eventOptions" class="mb-4" @input="eventOptionUpdated" />
<ui-multi-select v-model="newNotification.urls" :label="$strings.LabelNotificationAppriseURL" class="mb-2" />
<ui-multi-select ref="urlsInput" v-model="newNotification.urls" :label="$strings.LabelNotificationAppriseURL" class="mb-2" />
<ui-text-input-with-label v-model="newNotification.titleTemplate" :label="$strings.LabelNotificationTitleTemplate" class="mb-2" />
@@ -103,6 +103,8 @@ export default {
if (this.$refs.modal) this.$refs.modal.setHide()
},
submitForm() {
this.$refs.urlsInput?.forceBlur()
if (!this.newNotification.urls.length) {
this.$toast.error('Must enter an Apprise URL')
return

View File

@@ -6,7 +6,7 @@
</div>
</template>
<div ref="wrapper" id="podcast-wrapper" class="p-4 w-full text-sm py-2 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden">
<div v-if="episodes.length" class="w-full py-3 mx-auto flex">
<div v-if="episodesCleaned.length" class="w-full py-3 mx-auto flex">
<form @submit.prevent="submit" class="flex flex-grow">
<ui-text-input v-model="search" @input="inputUpdate" type="search" :placeholder="$strings.PlaceholderSearchEpisode" class="flex-grow mr-2 text-sm md:text-base" />
</form>
@@ -16,12 +16,12 @@
v-for="(episode, index) in episodesList"
:key="index"
class="relative"
:class="itemEpisodeMap[episode.enclosure.url] ? 'bg-primary bg-opacity-40' : selectedEpisodes[String(index)] ? 'cursor-pointer bg-success bg-opacity-10' : index % 2 == 0 ? 'cursor-pointer bg-primary bg-opacity-25 hover:bg-opacity-40' : 'cursor-pointer bg-primary bg-opacity-5 hover:bg-opacity-25'"
@click="toggleSelectEpisode(index, episode)"
:class="itemEpisodeMap[episode.cleanUrl] ? 'bg-primary bg-opacity-40' : selectedEpisodes[episode.cleanUrl] ? 'cursor-pointer bg-success bg-opacity-10' : index % 2 == 0 ? 'cursor-pointer bg-primary bg-opacity-25 hover:bg-opacity-40' : 'cursor-pointer bg-primary bg-opacity-5 hover:bg-opacity-25'"
@click="toggleSelectEpisode(episode)"
>
<div class="absolute top-0 left-0 h-full flex items-center p-2">
<span v-if="itemEpisodeMap[episode.enclosure.url]" class="material-icons text-success text-xl">download_done</span>
<ui-checkbox v-else v-model="selectedEpisodes[String(index)]" small checkbox-bg="primary" border-color="gray-600" />
<span v-if="itemEpisodeMap[episode.cleanUrl]" class="material-icons text-success text-xl">download_done</span>
<ui-checkbox v-else v-model="selectedEpisodes[episode.cleanUrl]" small checkbox-bg="primary" border-color="gray-600" />
</div>
<div class="px-8 py-2">
<div class="flex items-center font-semibold text-gray-200">
@@ -39,7 +39,7 @@
</div>
</div>
<div class="flex justify-end pt-4">
<ui-checkbox v-if="!allDownloaded" v-model="selectAll" @input="toggleSelectAll" label="Select all episodes" small checkbox-bg="primary" border-color="gray-600" class="mx-8" />
<ui-checkbox v-if="!allDownloaded" v-model="selectAll" @input="toggleSelectAll" :label="selectAllLabel" small checkbox-bg="primary" border-color="gray-600" class="mx-8" />
<ui-btn v-if="!allDownloaded" :disabled="!episodesSelected.length" @click="submit">{{ buttonText }}</ui-btn>
<p v-else class="text-success text-base px-2 py-4">All episodes are downloaded</p>
</div>
@@ -63,11 +63,12 @@ export default {
data() {
return {
processing: false,
episodesCleaned: [],
selectedEpisodes: {},
selectAll: false,
search: null,
searchTimeout: null,
searchText: null,
searchText: null
}
},
watch: {
@@ -92,78 +93,109 @@ export default {
return this.libraryItem.media.metadata.title || 'Unknown'
},
allDownloaded() {
return !this.episodes.some((episode) => !this.itemEpisodeMap[episode.enclosure.url])
return !this.episodesCleaned.some((episode) => !this.itemEpisodeMap[episode.cleanUrl])
},
episodesSelected() {
return Object.keys(this.selectedEpisodes).filter((key) => !!this.selectedEpisodes[key])
},
buttonText() {
if (!this.episodesSelected.length) return 'No Episodes Selected'
return `Download ${this.episodesSelected.length} Episode${this.episodesSelected.length > 1 ? 's' : ''}`
if (!this.episodesSelected.length) return this.$strings.LabelNoEpisodesSelected
if (this.episodesSelected.length === 1) return `${this.$strings.LabelDownload} ${this.$strings.LabelEpisode.toLowerCase()}`
return this.$getString('LabelDownloadNEpisodes', [this.episodesSelected.length])
},
itemEpisodes() {
if (!this.libraryItem) return []
return this.libraryItem.media.episodes || []
},
itemEpisodeMap() {
var map = {}
const map = {}
this.itemEpisodes.forEach((item) => {
if (item.enclosure) map[item.enclosure.url] = true
if (item.enclosure) {
const cleanUrl = this.getCleanEpisodeUrl(item.enclosure.url)
map[cleanUrl] = true
}
})
return map
},
episodesList() {
return this.episodes.filter((episode) => {
return this.episodesCleaned.filter((episode) => {
if (!this.searchText) return true
return (
(episode.title && episode.title.toLowerCase().includes(this.searchText)) ||
(episode.subtitle && episode.subtitle.toLowerCase().includes(this.searchText))
)
return episode.title?.toLowerCase().includes(this.searchText) || episode.subtitle?.toLowerCase().includes(this.searchText)
})
},
selectAllLabel() {
if (this.episodesList.length === this.episodesCleaned.length) {
return this.$strings.LabelSelectAllEpisodes
}
const episodesNotDownloaded = this.episodesList.filter((ep) => !this.itemEpisodeMap[ep.cleanUrl]).length
return this.$getString('LabelSelectEpisodesShowing', [episodesNotDownloaded])
}
},
methods: {
/**
* RSS feed episode url is used for matching with existing downloaded episodes.
* Some RSS feeds include timestamps in the episode url (e.g. patreon) that can change on requests.
* These need to be removed in order to detect the same episode each time the feed is pulled.
*
* An RSS feed may include an `id` in the query string. In these cases we want to leave the `id`.
* @see https://github.com/advplyr/audiobookshelf/issues/1896
*
* @param {string} url - rss feed episode url
* @returns {string} rss feed episode url without dynamic query strings
*/
getCleanEpisodeUrl(url) {
let queryString = url.split('?')[1]
if (!queryString) return url
const searchParams = new URLSearchParams(queryString)
for (const p of Array.from(searchParams.keys())) {
if (p !== 'id') searchParams.delete(p)
}
if (!searchParams.toString()) return url
return `${url}?${searchParams.toString()}`
},
inputUpdate() {
clearTimeout(this.searchTimeout)
this.searchTimeout = setTimeout(() => {
if (!this.search || !this.search.trim()) {
if (!this.search?.trim()) {
this.searchText = ''
this.checkSetIsSelectedAll()
return
}
this.searchText = this.search.toLowerCase().trim()
this.checkSetIsSelectedAll()
}, 500)
},
toggleSelectAll(val) {
for (let i = 0; i < this.episodes.length; i++) {
const episode = this.episodes[i]
if (this.itemEpisodeMap[episode.enclosure.url]) this.selectedEpisodes[String(i)] = false
else this.$set(this.selectedEpisodes, String(i), val)
for (const episode of this.episodesList) {
if (this.itemEpisodeMap[episode.cleanUrl]) this.selectedEpisodes[episode.cleanUrl] = false
else this.$set(this.selectedEpisodes, episode.cleanUrl, val)
}
},
checkSetIsSelectedAll() {
for (let i = 0; i < this.episodes.length; i++) {
const episode = this.episodes[i]
if (!this.itemEpisodeMap[episode.enclosure.url] && !this.selectedEpisodes[String(i)]) {
for (const episode of this.episodesList) {
if (!this.itemEpisodeMap[episode.cleanUrl] && !this.selectedEpisodes[episode.cleanUrl]) {
this.selectAll = false
return
}
}
this.selectAll = true
},
toggleSelectEpisode(index, episode) {
if (this.itemEpisodeMap[episode.enclosure.url]) return
this.$set(this.selectedEpisodes, String(index), !this.selectedEpisodes[String(index)])
toggleSelectEpisode(episode) {
if (this.itemEpisodeMap[episode.cleanUrl]) return
this.$set(this.selectedEpisodes, episode.cleanUrl, !this.selectedEpisodes[episode.cleanUrl])
this.checkSetIsSelectedAll()
},
submit() {
var episodesToDownload = []
let episodesToDownload = []
if (this.episodesSelected.length) {
episodesToDownload = this.episodesSelected.map((episodeIndex) => this.episodes[Number(episodeIndex)])
episodesToDownload = this.episodesSelected.map((cleanUrl) => this.episodesCleaned.find((ep) => ep.cleanUrl == cleanUrl))
}
var payloadSize = JSON.stringify(episodesToDownload).length
var sizeInMb = payloadSize / 1024 / 1024
var sizeInMbPretty = sizeInMb.toFixed(2) + 'MB'
const payloadSize = JSON.stringify(episodesToDownload).length
const sizeInMb = payloadSize / 1024 / 1024
const sizeInMbPretty = sizeInMb.toFixed(2) + 'MB'
console.log('Request size', sizeInMb)
if (sizeInMb > 4.99) {
return this.$toast.error(`Request is too large (${sizeInMbPretty}) should be < 5Mb`)
@@ -178,17 +210,24 @@ export default {
this.show = false
})
.catch((error) => {
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed to download episodes'
console.error('Failed to download episodes', error)
this.processing = false
this.$toast.error(errorMsg)
this.$toast.error(error.response?.data || 'Failed to download episodes')
this.selectedEpisodes = {}
this.selectAll = false
})
},
init() {
this.episodes.sort((a, b) => (a.publishedAt < b.publishedAt ? 1 : -1))
this.episodesCleaned = this.episodes
.filter((ep) => ep.enclosure?.url)
.map((_ep) => {
return {
..._ep,
cleanUrl: this.getCleanEpisodeUrl(_ep.enclosure.url)
}
})
this.episodesCleaned.sort((a, b) => (a.publishedAt < b.publishedAt ? 1 : -1))
this.selectAll = false
this.selectedEpisodes = {}
}

View File

@@ -31,9 +31,10 @@
<!-- mobile -->
<ui-btn @click="saveAndClose" class="mx-2 md:hidden">{{ $strings.ButtonSave }}</ui-btn>
</div>
<div v-if="enclosureUrl" class="py-4">
<p class="text-xs text-gray-300 font-semibold">Episode URL from RSS feed</p>
<a :href="enclosureUrl" target="_blank" class="text-xs text-blue-400 hover:text-blue-500 hover:underline">{{ enclosureUrl }}</a>
<div v-if="enclosureUrl" class="pb-4 pt-6">
<ui-text-input-with-label :value="enclosureUrl" readonly class="text-xs">
<label class="px-1 text-xs text-gray-200 font-semibold">Episode URL from RSS feed</label>
</ui-text-input-with-label>
</div>
<div v-else class="py-4">
<p class="text-xs text-gray-300 font-semibold">Episode not linked to RSS feed episode</p>

View File

@@ -38,7 +38,8 @@ export default {
currentChapter: {
type: Object,
default: () => {}
}
},
playbackRate: Number
},
data() {
return {
@@ -63,6 +64,10 @@ export default {
}
},
computed: {
_playbackRate() {
if (!this.playbackRate || isNaN(this.playbackRate)) return 1
return this.playbackRate
},
currentChapterDuration() {
if (!this.currentChapter) return 0
return this.currentChapter.end - this.currentChapter.start
@@ -81,8 +86,8 @@ export default {
clickTrack(e) {
if (this.loading) return
var offsetX = e.offsetX
var perc = offsetX / this.trackWidth
const offsetX = e.offsetX
const perc = offsetX / this.trackWidth
const baseTime = this.useChapterTrack ? this.currentChapterStart : 0
const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration
const time = baseTime + perc * duration
@@ -111,7 +116,7 @@ export default {
this.updateReadyTrack()
},
updateReadyTrack() {
var widthReady = Math.round(this.trackWidth * this.percentReady)
const 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'
@@ -124,7 +129,7 @@ export default {
const time = this.useChapterTrack ? Math.max(0, this.currentTime - this.currentChapterStart) : this.currentTime
const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration
var ptWidth = Math.round((time / duration) * this.trackWidth)
const ptWidth = Math.round((time / duration) * this.trackWidth)
if (this.playedTrackWidth === ptWidth) {
return
}
@@ -133,7 +138,7 @@ export default {
},
setChapterTicks() {
this.chapterTicks = this.chapters.map((chap) => {
var perc = chap.start / this.duration
const perc = chap.start / this.duration
return {
title: chap.title,
left: perc * this.trackWidth
@@ -141,7 +146,7 @@ export default {
})
},
mousemoveTrack(e) {
var offsetX = e.offsetX
const offsetX = e.offsetX
const baseTime = this.useChapterTrack ? this.currentChapterStart : 0
const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration
@@ -167,7 +172,7 @@ export default {
this.$refs.hoverTimestampArrow.style.left = posLeft + 'px'
}
if (this.$refs.hoverTimestampText) {
var hoverText = this.$secondsToTimestamp(progressTime)
var hoverText = this.$secondsToTimestamp(progressTime / this._playbackRate)
var chapter = this.chapters.find((chapter) => chapter.start <= totalTime && totalTime < chapter.end)
if (chapter && chapter.title) {

View File

@@ -46,7 +46,7 @@
<player-playback-controls :loading="loading" :seek-loading="seekLoading" :playback-rate.sync="playbackRate" :paused="paused" :has-next-chapter="hasNextChapter" @prevChapter="prevChapter" @nextChapter="nextChapter" @jumpForward="jumpForward" @jumpBackward="jumpBackward" @setPlaybackRate="setPlaybackRate" @playPause="playPause" />
</div>
<player-track-bar ref="trackbar" :loading="loading" :chapters="chapters" :duration="duration" :current-chapter="currentChapter" @seek="seek" />
<player-track-bar ref="trackbar" :loading="loading" :chapters="chapters" :duration="duration" :current-chapter="currentChapter" :playback-rate="playbackRate" @seek="seek" />
<div class="flex">
<p ref="currentTimestamp" class="font-mono text-xxs sm:text-sm text-gray-100 pointer-events-auto">00:00:00</p>
@@ -59,7 +59,7 @@
<p class="font-mono text-xxs sm:text-sm text-gray-100 pointer-events-auto">{{ timeRemainingPretty }}</p>
</div>
<modals-chapters-modal v-model="showChaptersModal" :current-chapter="currentChapter" :chapters="chapters" @select="selectChapter" />
<modals-chapters-modal v-model="showChaptersModal" :current-chapter="currentChapter" :playback-rate="playbackRate" :chapters="chapters" @select="selectChapter" />
</div>
</template>
@@ -92,6 +92,11 @@ export default {
useChapterTrack: false
}
},
watch: {
playbackRate() {
this.updateTimestamp()
}
},
computed: {
sleepTimerRemainingString() {
var rounded = Math.round(this.sleepTimerRemaining)
@@ -213,18 +218,14 @@ export default {
}
},
increasePlaybackRate() {
var rates = [0.25, 0.5, 0.8, 1, 1.3, 1.5, 2, 2.5, 3]
var currentRateIndex = rates.findIndex((r) => r === this.playbackRate)
if (currentRateIndex >= rates.length - 1) return
this.playbackRate = rates[currentRateIndex + 1] || 1
this.playbackRateChanged(this.playbackRate)
if (this.playbackRate >= 10) return
this.playbackRate = Number((this.playbackRate + 0.1).toFixed(1))
this.setPlaybackRate(this.playbackRate)
},
decreasePlaybackRate() {
var rates = [0.25, 0.5, 0.8, 1, 1.3, 1.5, 2, 2.5, 3]
var currentRateIndex = rates.findIndex((r) => r === this.playbackRate)
if (currentRateIndex <= 0) return
this.playbackRate = rates[currentRateIndex - 1] || 1
this.playbackRateChanged(this.playbackRate)
if (this.playbackRate <= 0.5) return
this.playbackRate = Number((this.playbackRate - 0.1).toFixed(1))
this.setPlaybackRate(this.playbackRate)
},
setPlaybackRate(playbackRate) {
this.$emit('setPlaybackRate', playbackRate)
@@ -289,14 +290,13 @@ export default {
if (this.$refs.trackbar) this.$refs.trackbar.setPercentageReady(percentageReady)
},
updateTimestamp() {
var ts = this.$refs.currentTimestamp
const ts = this.$refs.currentTimestamp
if (!ts) {
console.error('No timestamp el')
return
}
const time = this.useChapterTrack ? Math.max(0, this.currentTime - this.currentChapterStart) : this.currentTime
var currTimeClean = this.$secondsToTimestamp(time)
ts.innerText = currTimeClean
ts.innerText = this.$secondsToTimestamp(time / this.playbackRate)
},
setBufferTime(bufferTime) {
if (this.$refs.trackbar) this.$refs.trackbar.setBufferTime(bufferTime)
@@ -312,7 +312,7 @@ export default {
this.useChapterTrack = this.chapters.length ? _useChapterTrack : false
if (this.$refs.trackbar) this.$refs.trackbar.setUseChapterTrack(this.useChapterTrack)
this.$emit('setPlaybackRate', this.playbackRate)
this.setPlaybackRate(this.playbackRate)
},
settingsUpdated(settings) {
if (settings.playbackRate && this.playbackRate !== settings.playbackRate) {

View File

@@ -3,11 +3,14 @@
<div class="absolute top-0 left-0 right-0 w-full h-36 bg-gradient-to-t from-transparent via-black-500 to-black-700 opacity-90 pointer-events-none" />
<div ref="content" class="relative text-white" :style="{ height: modalHeight, width: modalWidth }" v-click-outside="clickedOutside">
<div class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300">
<p class="text-lg mb-8 mt-2 px-1" v-html="message" />
<p class="text-lg mb-6 mt-2 px-1" v-html="message" />
<ui-checkbox v-if="checkboxLabel" v-model="checkboxValue" checkbox-bg="bg" :label="checkboxLabel" label-class="pl-2 text-base" class="mb-6 px-1" />
<div class="flex px-1 items-center">
<ui-btn v-if="isYesNo" color="primary" @click="nevermind">{{ $strings.ButtonCancel }}</ui-btn>
<div class="flex-grow" />
<ui-btn v-if="isYesNo" color="success" @click="confirm">{{ $strings.ButtonYes }}</ui-btn>
<ui-btn v-if="isYesNo" :color="yesButtonColor" @click="confirm">{{ yesButtonText }}</ui-btn>
<ui-btn v-else color="primary" @click="confirm">{{ $strings.ButtonOk }}</ui-btn>
</div>
</div>
@@ -21,7 +24,8 @@ export default {
data() {
return {
el: null,
content: null
content: null,
checkboxValue: false
}
},
watch: {
@@ -57,6 +61,18 @@ export default {
persistent() {
return !!this.confirmPromptOptions.persistent
},
checkboxLabel() {
return this.confirmPromptOptions.checkboxLabel
},
yesButtonText() {
return this.confirmPromptOptions.yesButtonText || this.$strings.ButtonYes
},
yesButtonColor() {
return this.confirmPromptOptions.yesButtonColor || 'success'
},
checkboxDefaultValue() {
return !!this.confirmPromptOptions.checkboxDefaultValue
},
isYesNo() {
return this.type === 'yesNo'
},
@@ -84,10 +100,11 @@ export default {
this.show = false
},
confirm() {
if (this.callback) this.callback(true)
if (this.callback) this.callback(true, this.checkboxValue)
this.show = false
},
setShow() {
this.checkboxValue = this.checkboxDefaultValue
this.$eventBus.$emit('showing-prompt', true)
document.body.appendChild(this.el)
setTimeout(() => {

View File

@@ -1,11 +1,11 @@
<template>
<div class="w-full h-full">
<div v-show="showPageMenu" v-click-outside="clickOutside" class="pagemenu absolute top-9 right-20 rounded-md overflow-y-auto bg-bg shadow-lg z-20 border border-gray-400 w-52">
<div v-for="(file, index) in pages" :key="file" class="w-full cursor-pointer hover:bg-black-200 px-2 py-1" :class="page === index ? 'bg-black-200' : ''" @click="setPage(index)">
<div v-show="showPageMenu" v-click-outside="clickOutside" class="pagemenu absolute top-9 left-8 rounded-md overflow-y-auto bg-bg shadow-lg z-20 border border-gray-400" :style="{ width: pageMenuWidth + 'px' }">
<div v-for="(file, index) in cleanedPageNames" :key="file" class="w-full cursor-pointer hover:bg-black-200 px-2 py-1" :class="page === index ? 'bg-black-200' : ''" @click="setPage(index + 1)">
<p class="text-sm truncate">{{ file }}</p>
</div>
</div>
<div v-show="showInfoMenu" v-click-outside="clickOutside" class="pagemenu absolute top-9 right-10 rounded-md overflow-y-auto bg-bg shadow-lg z-20 border border-gray-400 w-96">
<div v-show="showInfoMenu" v-click-outside="clickOutside" class="pagemenu absolute top-9 left-20 rounded-md overflow-y-auto bg-bg shadow-lg z-20 border border-gray-400 w-96">
<div v-for="key in comicMetadataKeys" :key="key" class="w-full px-2 py-1">
<p class="text-xs">
<strong>{{ key }}</strong
@@ -14,39 +14,38 @@
</div>
</div>
<div v-if="comicMetadata" class="absolute top-0 right-52 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" @mousedown.prevent @click.stop.prevent="showInfoMenu = !showInfoMenu">
<a v-if="pages && numPages" :href="mainImg" :download="pages[page - 1]" class="absolute top-0 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" :class="comicMetadata ? 'left-32' : 'left-20'">
<span class="material-icons text-xl">download</span>
</a>
<div v-if="comicMetadata" class="absolute top-0 left-20 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" @mousedown.prevent @click.stop.prevent="clickShowInfoMenu">
<span class="material-icons text-xl">more</span>
</div>
<div class="absolute top-0 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" style="right: 156px" @mousedown.prevent @click.stop.prevent="showPageMenu = !showPageMenu">
<div v-if="numPages" class="absolute top-0 left-8 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" @mousedown.prevent @click.stop.prevent="clickShowPageMenu">
<span class="material-icons text-xl">menu</span>
</div>
<div class="absolute top-0 right-20 bg-bg text-gray-100 border-b border-l border-r border-gray-400 rounded-b-md px-2 h-9 flex items-center text-center z-20">
<p class="font-mono">{{ page + 1 }} / {{ numPages }}</p>
<div v-if="numPages" class="absolute top-0 right-16 bg-bg text-gray-100 border-b border-l border-r border-gray-400 rounded-b-md px-2 h-9 flex items-center text-center z-20">
<p class="font-mono">{{ page }} / {{ numPages }}</p>
</div>
<div class="overflow-hidden m-auto comicwrapper relative">
<div v-show="canGoPrev" class="absolute top-0 left-0 h-full w-1/2 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="prev" @mousedown.prevent>
<div class="overflow-hidden w-full h-full relative">
<div v-show="canGoPrev" class="absolute top-0 left-0 h-full w-1/2 lg:w-1/3 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="prev" @mousedown.prevent>
<div class="flex items-center justify-center h-full w-1/2">
<span v-show="loadedFirstPage" class="material-icons text-5xl text-white cursor-pointer text-opacity-30 hover:text-opacity-90">arrow_back_ios</span>
</div>
</div>
<div v-show="canGoNext" class="absolute top-0 right-0 h-full w-1/2 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="next" @mousedown.prevent>
<div v-show="canGoNext" class="absolute top-0 right-0 h-full w-1/2 lg:w-1/3 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="next" @mousedown.prevent>
<div class="flex items-center justify-center h-full w-1/2 ml-auto">
<span v-show="loadedFirstPage" class="material-icons text-5xl text-white cursor-pointer text-opacity-30 hover:text-opacity-90">arrow_forward_ios</span>
</div>
</div>
<div class="h-full flex justify-center">
<img v-if="mainImg" :src="mainImg" class="object-contain comicimg" />
<img v-if="mainImg" :src="mainImg" class="object-contain h-full m-auto" />
</div>
<div v-show="loading" class="w-full h-full absolute top-0 left-0 flex items-center justify-center z-10">
<ui-loading-indicator />
</div>
</div>
<!-- <div v-show="loading" class="w-screen h-screen absolute top-0 left-0 bg-black bg-opacity-20 flex items-center justify-center">
<ui-loading-indicator />
</div> -->
</div>
</template>
@@ -61,7 +60,13 @@ Archive.init({
export default {
props: {
url: String
libraryItem: {
type: Object,
default: () => {}
},
playerOpen: Boolean,
keepProgress: Boolean,
fileId: String
},
data() {
return {
@@ -71,6 +76,7 @@ export default {
mainImg: null,
page: 0,
numPages: 0,
pageMenuWidth: 256,
showPageMenu: false,
showInfoMenu: false,
loadTimeout: null,
@@ -87,17 +93,79 @@ export default {
}
},
computed: {
userToken() {
return this.$store.getters['user/getToken']
},
libraryItemId() {
return this.libraryItem?.id
},
ebookUrl() {
if (this.fileId) {
return `/api/items/${this.libraryItemId}/ebook/${this.fileId}`
}
return `/api/items/${this.libraryItemId}/ebook`
},
comicMetadataKeys() {
return this.comicMetadata ? Object.keys(this.comicMetadata) : []
},
canGoNext() {
return this.page < this.numPages - 1
return this.page < this.numPages
},
canGoPrev() {
return this.page > 0
return this.page > 1
},
userMediaProgress() {
if (!this.libraryItemId) return
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId)
},
savedPage() {
if (!this.keepProgress) return 0
// Validate ebookLocation is a number
if (!this.userMediaProgress?.ebookLocation || isNaN(this.userMediaProgress.ebookLocation)) return 0
return Number(this.userMediaProgress.ebookLocation)
},
cleanedPageNames() {
return (
this.pages?.map((p) => {
if (p.length > 50) {
let firstHalf = p.slice(0, 22)
let lastHalf = p.slice(p.length - 23)
return `${firstHalf} ... ${lastHalf}`
}
return p
}) || []
)
}
},
methods: {
clickShowPageMenu() {
this.showInfoMenu = false
this.showPageMenu = !this.showPageMenu
},
clickShowInfoMenu() {
this.showPageMenu = false
this.showInfoMenu = !this.showInfoMenu
},
updateProgress() {
if (!this.keepProgress) return
if (!this.numPages) {
console.error('Num pages not loaded')
return
}
if (this.savedPage === this.page) {
return
}
const payload = {
ebookLocation: this.page,
ebookProgress: Math.max(0, Math.min(1, (Number(this.page) - 1) / Number(this.numPages)))
}
this.$axios.$patch(`/api/me/progress/${this.libraryItemId}`, payload).catch((error) => {
console.error('ComicReader.updateProgress failed:', error)
})
},
clickOutside() {
if (this.showPageMenu) this.showPageMenu = false
if (this.showInfoMenu) this.showInfoMenu = false
@@ -110,12 +178,15 @@ export default {
if (!this.canGoPrev) return
this.setPage(this.page - 1)
},
setPage(index) {
if (index < 0 || index > this.numPages - 1) {
setPage(page) {
if (page <= 0 || page > this.numPages) {
return
}
var filename = this.pages[index]
this.page = index
this.showPageMenu = false
this.showInfoMenu = false
const filename = this.pages[page - 1]
this.page = page
this.updateProgress()
return this.extractFile(filename)
},
setLoadTimeout() {
@@ -145,10 +216,11 @@ export default {
},
async extract() {
this.loading = true
console.log('Extracting', this.url)
var buff = await this.$axios.$get(this.url, {
responseType: 'blob'
var buff = await this.$axios.$get(this.ebookUrl, {
responseType: 'blob',
headers: {
Authorization: `Bearer ${this.userToken}`
}
})
const archive = await Archive.open(buff)
const originalFilesObject = await archive.getFilesObject()
@@ -164,9 +236,28 @@ export default {
this.numPages = this.pages.length
// Calculate page menu size
const largestFilename = this.cleanedPageNames
.map((p) => p)
.sort((a, b) => a.length - b.length)
.pop()
const pEl = document.createElement('p')
pEl.innerText = largestFilename
pEl.style.fontSize = '0.875rem'
pEl.style.opacity = 0
pEl.style.position = 'absolute'
document.body.appendChild(pEl)
const textWidth = pEl.getBoundingClientRect()?.width
if (textWidth) {
this.pageMenuWidth = textWidth + (16 + 5 + 2 + 5)
}
pEl.remove()
if (this.pages.length) {
this.loading = false
await this.setPage(0)
const startPage = this.savedPage > 0 && this.savedPage <= this.numPages ? this.savedPage : 1
await this.setPage(startPage)
this.loadedFirstPage = true
} else {
this.$toast.error('Unable to extract pages')
@@ -249,15 +340,6 @@ export default {
<style scoped>
.pagemenu {
max-height: calc(100vh - 60px);
}
.comicimg {
height: calc(100vh - 40px);
margin: auto;
}
.comicwrapper {
width: 100vw;
height: calc(100vh - 40px);
margin-top: 20px;
max-height: calc(100% - 48px);
}
</style>

View File

@@ -1,19 +1,15 @@
<template>
<div class="h-full w-full">
<div class="h-full flex items-center">
<div style="width: 100px; max-width: 100px" class="h-full flex items-center overflow-x-hidden justify-center">
<span v-show="hasPrev" class="material-icons text-white text-opacity-50 hover:text-opacity-80 cursor-pointer text-6xl" @mousedown.prevent @click="prev">chevron_left</span>
</div>
<div id="frame" class="w-full" style="height: 650px">
<div id="viewer" class="border border-gray-100 bg-white shadow-md"></div>
<div class="py-4 flex justify-center" style="height: 50px">
<p>{{ progress }}%</p>
</div>
</div>
<div style="width: 100px; max-width: 100px" class="h-full flex items-center justify-center overflow-x-hidden">
<span v-show="hasNext" class="material-icons text-white text-opacity-50 hover:text-opacity-80 cursor-pointer text-6xl" @mousedown.prevent @click="next">chevron_right</span>
<div id="epub-reader" class="h-full w-full">
<div class="h-full flex items-center justify-center">
<button type="button" aria-label="Previous page" class="w-24 max-w-24 h-full hidden sm:flex items-center overflow-x-hidden justify-center opacity-50 hover:opacity-100">
<span v-if="hasPrev" class="material-icons text-6xl" @mousedown.prevent @click="prev">chevron_left</span>
</button>
<div id="frame" class="w-full" style="height: 80%">
<div id="viewer"></div>
</div>
<button type="button" aria-label="Next page" class="w-24 max-w-24 h-full hidden sm:flex items-center justify-center overflow-x-hidden opacity-50 hover:opacity-100">
<span v-if="hasNext" class="material-icons text-6xl" @mousedown.prevent @click="next">chevron_right</span>
</button>
</div>
</div>
</template>
@@ -21,109 +17,326 @@
<script>
import ePub from 'epubjs'
/**
* @typedef {object} EpubReader
* @property {ePub.Book} book
* @property {ePub.Rendition} rendition
*/
export default {
props: {
url: String
libraryItem: {
type: Object,
default: () => {}
},
playerOpen: Boolean,
keepProgress: Boolean,
fileId: String
},
data() {
return {
windowWidth: 0,
windowHeight: 0,
/** @type {ePub.Book} */
book: null,
/** @type {ePub.Rendition} */
rendition: null,
chapters: [],
title: '',
author: '',
progress: 0,
hasNext: true,
hasPrev: false
ereaderSettings: {
theme: 'dark',
fontScale: 100,
lineSpacing: 115,
spread: 'auto'
}
}
},
computed: {},
methods: {
changedChapter() {
if (this.rendition) {
this.rendition.display(this.selectedChapter)
watch: {
playerOpen() {
this.resize()
}
},
computed: {
userToken() {
return this.$store.getters['user/getToken']
},
/** @returns {string} */
libraryItemId() {
return this.libraryItem?.id
},
hasPrev() {
return !this.rendition?.location?.atStart
},
hasNext() {
return !this.rendition?.location?.atEnd
},
/** @returns {Array<ePub.NavItem>} */
chapters() {
return this.book?.navigation?.toc || []
},
userMediaProgress() {
if (!this.libraryItemId) return
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId)
},
savedEbookLocation() {
if (!this.keepProgress) return null
if (!this.userMediaProgress?.ebookLocation) return null
// Validate ebookLocation is an epubcfi
if (!String(this.userMediaProgress.ebookLocation).startsWith('epubcfi')) return null
return this.userMediaProgress.ebookLocation
},
localStorageLocationsKey() {
return `ebookLocations-${this.libraryItemId}`
},
readerWidth() {
if (this.windowWidth < 640) return this.windowWidth
return this.windowWidth - 200
},
readerHeight() {
if (this.windowHeight < 400 || !this.playerOpen) return this.windowHeight
return this.windowHeight - 164
},
ebookUrl() {
if (this.fileId) {
return `/api/items/${this.libraryItemId}/ebook/${this.fileId}`
}
return `/api/items/${this.libraryItemId}/ebook`
},
themeRules() {
const isDark = this.ereaderSettings.theme === 'dark'
const fontColor = isDark ? '#fff' : '#000'
const backgroundColor = isDark ? 'rgb(35 35 35)' : 'rgb(255, 255, 255)'
const lineSpacing = this.ereaderSettings.lineSpacing / 100
const fontScale = this.ereaderSettings.fontScale / 100
return {
'*': {
color: `${fontColor}!important`,
'background-color': `${backgroundColor}!important`,
'line-height': lineSpacing * fontScale + 'rem!important'
},
a: {
color: `${fontColor}!important`
}
}
}
},
methods: {
updateSettings(settings) {
this.ereaderSettings = settings
if (!this.rendition) return
this.applyTheme()
const fontScale = settings.fontScale || 100
this.rendition.themes.fontSize(`${fontScale}%`)
this.rendition.spread(settings.spread || 'auto')
},
prev() {
if (this.rendition) {
this.rendition.prev()
}
return this.rendition?.prev()
},
next() {
if (this.rendition) {
this.rendition.next()
return this.rendition?.next()
},
goToChapter(href) {
return this.rendition?.display(href)
},
keyUp(e) {
const rtl = this.book.package.metadata.direction === 'rtl'
if ((e.keyCode || e.which) == 37) {
return rtl ? this.next() : this.prev()
} else if ((e.keyCode || e.which) == 39) {
return rtl ? this.prev() : this.next()
}
},
keyUp() {
if ((e.keyCode || e.which) == 37) {
this.prev()
} else if ((e.keyCode || e.which) == 39) {
this.next()
/**
* @param {object} payload
* @param {string} payload.ebookLocation - CFI of the current location
* @param {string} payload.ebookProgress - eBook Progress Percentage
*/
updateProgress(payload) {
if (!this.keepProgress) return
this.$axios.$patch(`/api/me/progress/${this.libraryItemId}`, payload).catch((error) => {
console.error('EpubReader.updateProgress failed:', error)
})
},
getAllEbookLocationData() {
const locations = []
let totalSize = 0 // Total in bytes
for (const key in localStorage) {
if (!localStorage.hasOwnProperty(key) || !key.startsWith('ebookLocations-')) {
continue
}
try {
const ebookLocations = JSON.parse(localStorage[key])
if (!ebookLocations.locations) throw new Error('Invalid locations object')
ebookLocations.key = key
ebookLocations.size = (localStorage[key].length + key.length) * 2
locations.push(ebookLocations)
totalSize += ebookLocations.size
} catch (error) {
console.error('Failed to parse ebook locations', key, error)
localStorage.removeItem(key)
}
}
// Sort by oldest lastAccessed first
locations.sort((a, b) => a.lastAccessed - b.lastAccessed)
return {
locations,
totalSize
}
},
/** @param {string} locationString */
checkSaveLocations(locationString) {
const maxSizeInBytes = 3000000 // Allow epub locations to take up to 3MB of space
const newLocationsSize = JSON.stringify({ lastAccessed: Date.now(), locations: locationString }).length * 2
// Too large overall
if (newLocationsSize > maxSizeInBytes) {
console.error('Epub locations are too large to store. Size =', newLocationsSize)
return
}
const ebookLocationsData = this.getAllEbookLocationData()
let availableSpace = maxSizeInBytes - ebookLocationsData.totalSize
// Remove epub locations until there is room for locations
while (availableSpace < newLocationsSize && ebookLocationsData.locations.length) {
const oldestLocation = ebookLocationsData.locations.shift()
console.log(`Removing cached locations for epub "${oldestLocation.key}" taking up ${oldestLocation.size} bytes`)
availableSpace += oldestLocation.size
localStorage.removeItem(oldestLocation.key)
}
console.log(`Cacheing epub locations with key "${this.localStorageLocationsKey}" taking up ${newLocationsSize} bytes`)
this.saveLocations(locationString)
},
/** @param {string} locationString */
saveLocations(locationString) {
localStorage.setItem(
this.localStorageLocationsKey,
JSON.stringify({
lastAccessed: Date.now(),
locations: locationString
})
)
},
loadLocations() {
const locationsObjString = localStorage.getItem(this.localStorageLocationsKey)
if (!locationsObjString) return null
const locationsObject = JSON.parse(locationsObjString)
// Remove invalid location objects
if (!locationsObject.locations) {
console.error('Invalid epub locations stored', this.localStorageLocationsKey)
localStorage.removeItem(this.localStorageLocationsKey)
return null
}
// Update lastAccessed
this.saveLocations(locationsObject.locations)
return locationsObject.locations
},
/** @param {string} location - CFI of the new location */
relocated(location) {
if (this.savedEbookLocation === location.start.cfi) {
return
}
if (location.end.percentage) {
this.updateProgress({
ebookLocation: location.start.cfi,
ebookProgress: location.end.percentage
})
} else {
this.updateProgress({
ebookLocation: location.start.cfi
})
}
},
initEpub() {
// var book = ePub(this.url, {
// requestHeaders: {
// Authorization: `Bearer ${this.userToken}`
// }
// })
var book = ePub(this.url)
this.book = book
/** @type {EpubReader} */
const reader = this
this.rendition = book.renderTo('viewer', {
width: window.innerWidth - 200,
height: 600,
ignoreClass: 'annotator-hl',
/** @type {ePub.Book} */
reader.book = new ePub(reader.ebookUrl, {
width: this.readerWidth,
height: this.readerHeight - 50,
openAs: 'epub',
requestHeaders: {
Authorization: `Bearer ${this.userToken}`
}
})
/** @type {ePub.Rendition} */
reader.rendition = reader.book.renderTo('viewer', {
width: this.readerWidth,
height: this.readerHeight * 0.8,
spread: 'auto',
snap: true,
manager: 'continuous',
spread: 'always'
flow: 'paginated'
})
var displayed = this.rendition.display()
book.ready
.then(() => {
console.log('Book ready')
return book.locations.generate(1600)
// load saved progress
reader.rendition.display(this.savedEbookLocation || reader.book.locations.start)
reader.rendition.on('rendered', () => {
this.applyTheme()
})
reader.book.ready.then(() => {
// set up event listeners
reader.rendition.on('relocated', reader.relocated)
reader.rendition.on('keydown', reader.keyUp)
reader.rendition.on('touchstart', (event) => {
this.$emit('touchstart', event)
})
.then((locations) => {
// console.log('Loaded locations', locations)
// Wait for book to be rendered to get current page
displayed.then(() => {
// Get the current CFI
var currentLocation = this.rendition.currentLocation()
if (!currentLocation.start) {
console.error('No Start', currentLocation)
} else {
var currentPage = book.locations.percentageFromCfi(currentLocation.start.cfi)
// console.log('current page', currentPage)
}
reader.rendition.on('touchend', (event) => {
this.$emit('touchend', event)
})
// load ebook cfi locations
const savedLocations = this.loadLocations()
if (savedLocations) {
reader.book.locations.load(savedLocations)
} else {
reader.book.locations.generate().then(() => {
this.checkSaveLocations(reader.book.locations.save())
})
})
book.loaded.navigation.then((toc) => {
var _chapters = []
toc.forEach((chapter) => {
_chapters.push(chapter)
})
this.chapters = _chapters
}
})
book.loaded.metadata.then((metadata) => {
this.author = metadata.creator
this.title = metadata.title
})
this.rendition.on('keyup', this.keyUp)
this.rendition.on('relocated', (location) => {
var percent = book.locations.percentageFromCfi(location.start.cfi)
this.progress = Math.floor(percent * 100)
this.hasNext = !location.atEnd
this.hasPrev = !location.atStart
},
resize() {
this.windowWidth = window.innerWidth
this.windowHeight = window.innerHeight
this.rendition?.resize(this.readerWidth, this.readerHeight * 0.8)
},
applyTheme() {
if (!this.rendition) return
this.rendition.getContents().forEach((c) => {
c.addStylesheetRules(this.themeRules)
})
}
},
mounted() {
this.windowWidth = window.innerWidth
this.windowHeight = window.innerHeight
window.addEventListener('resize', this.resize)
this.initEpub()
},
beforeDestroy() {
window.removeEventListener('resize', this.resize)
this.book?.destroy()
}
}
</script>

View File

@@ -1,88 +0,0 @@
<template>
<div class="h-full w-full">
<div id="viewer" class="border border-gray-100 bg-white text-black shadow-md h-screen overflow-y-auto p-4" v-html="pageHtml"></div>
</div>
</template>
<script>
export default {
props: {
url: String,
libraryItem: {
type: Object,
default: () => {}
}
},
data() {
return {
bookInfo: {},
page: 0,
numPages: 0,
pageHtml: '',
progress: 0
}
},
computed: {
libraryItemId() {
return this.libraryItem ? this.libraryItem.id : null
},
hasPrev() {
return this.page > 0
},
hasNext() {
return this.page < this.numPages - 1
}
},
methods: {
prev() {
if (!this.hasPrev) return
this.page--
this.loadPage()
},
next() {
if (!this.hasNext) return
this.page++
this.loadPage()
},
keyUp() {
if ((e.keyCode || e.which) == 37) {
this.prev()
} else if ((e.keyCode || e.which) == 39) {
this.next()
}
},
loadPage() {
this.$axios
.$get(`/api/ebooks/${this.libraryItemId}/page/${this.page}?dev=${this.$isDev ? 1 : 0}`)
.then((html) => {
this.pageHtml = html
})
.catch((error) => {
console.error('Failed to load page', error)
this.$toast.error('Failed to load page')
})
},
loadInfo() {
this.$axios
.$get(`/api/ebooks/${this.libraryItemId}/info?dev=${this.$isDev ? 1 : 0}`)
.then((bookInfo) => {
this.bookInfo = bookInfo
this.numPages = bookInfo.pages
this.page = 0
this.loadPage()
})
.catch((error) => {
console.error('Failed to load page', error)
this.$toast.error('Failed to load info')
})
},
initEpub() {
if (!this.libraryItemId) return
this.loadInfo()
}
},
mounted() {
this.initEpub()
}
}
</script>

View File

@@ -1,7 +1,7 @@
<template>
<div class="w-full h-full">
<div class="h-full max-h-full w-full">
<div class="ebook-viewer absolute overflow-y-scroll left-0 right-0 top-12 w-full max-w-4xl m-auto z-10 border border-black border-opacity-20 shadow-md bg-white">
<div class="ebook-viewer absolute overflow-y-scroll left-0 right-0 top-16 w-full max-w-4xl m-auto z-10 border border-black border-opacity-20 shadow-md bg-white">
<iframe title="html-viewer" width="100%"> Loading </iframe>
</div>
</div>
@@ -15,12 +15,30 @@ import defaultCss from '@/assets/ebooks/basic.js'
export default {
props: {
url: String
libraryItem: {
type: Object,
default: () => {}
},
playerOpen: Boolean,
fileId: String
},
data() {
return {}
},
computed: {},
computed: {
userToken() {
return this.$store.getters['user/getToken']
},
libraryItemId() {
return this.libraryItem?.id
},
ebookUrl() {
if (this.fileId) {
return `/api/items/${this.libraryItemId}/ebook/${this.fileId}`
}
return `/api/items/${this.libraryItemId}/ebook`
}
},
methods: {
addHtmlCss() {
let iframe = document.getElementsByTagName('iframe')[0]
@@ -78,8 +96,11 @@ export default {
},
async initMobi() {
// Fetch mobi file as blob
var buff = await this.$axios.$get(this.url, {
responseType: 'blob'
var buff = await this.$axios.$get(this.ebookUrl, {
responseType: 'blob',
headers: {
Authorization: `Bearer ${this.userToken}`
}
})
var reader = new FileReader()
reader.onload = async (event) => {

View File

@@ -11,15 +11,19 @@
</div>
</div>
<div class="absolute top-0 right-20 bg-bg text-gray-100 border-b border-l border-r border-gray-400 rounded-b-md px-2 h-9 flex items-center text-center">
<div class="absolute top-0 right-20 bg-bg text-gray-100 border-b border-l border-r border-gray-400 z-20 rounded-b-md px-2 h-9 hidden md:flex items-center text-center">
<p class="font-mono">{{ page }} / {{ numPages }}</p>
</div>
<div class="absolute top-0 right-40 bg-bg text-gray-100 border-b border-l border-r border-gray-400 z-20 rounded-b-md px-2 h-9 hidden md:flex items-center text-center">
<ui-icon-btn icon="zoom_out" :size="8" :disabled="!canScaleDown" borderless class="mr-px" @click="zoomOut" />
<ui-icon-btn icon="zoom_in" :size="8" :disabled="!canScaleUp" borderless class="ml-px" @click="zoomIn" />
</div>
<div :style="{ height: pdfHeight + 'px' }" class="overflow-hidden m-auto">
<div class="flex items-center justify-center">
<div :style="{ width: pdfWidth + 'px', height: pdfHeight + 'px' }" class="w-full h-full overflow-auto">
<div :style="{ width: pdfWidth + 'px', height: pdfHeight + 'px' }" class="overflow-auto">
<div v-if="loadedRatio > 0 && loadedRatio < 1" style="background-color: green; color: white; text-align: center" :style="{ width: loadedRatio * 100 + '%' }">{{ Math.floor(loadedRatio * 100) }}%</div>
<pdf ref="pdf" class="m-auto z-10 border border-black border-opacity-20 shadow-md" :src="url" :page="page" :rotate="rotate" @progress="loadedRatio = $event" @error="error" @num-pages="numPagesLoaded" @link-clicked="page = $event"></pdf>
<pdf ref="pdf" class="m-auto z-10 border border-black border-opacity-20 shadow-md" :src="pdfDocInitParams" :page="page" :rotate="rotate" @progress="progressEvt" @error="error" @num-pages="numPagesLoaded" @link-clicked="page = $event" @loaded="loadedEvt"></pdf>
</div>
</div>
</div>
@@ -30,17 +34,26 @@
</template>
<script>
import pdf from 'vue-pdf'
import pdf from '@teckel/vue-pdf'
export default {
components: {
pdf
},
props: {
url: String
libraryItem: {
type: Object,
default: () => {}
},
playerOpen: Boolean,
keepProgress: Boolean,
fileId: String
},
data() {
return {
windowWidth: 0,
windowHeight: 0,
scale: 1,
rotate: 0,
loadedRatio: 0,
page: 1,
@@ -48,35 +61,121 @@ export default {
}
},
computed: {
userToken() {
return this.$store.getters['user/getToken']
},
libraryItemId() {
return this.libraryItem?.id
},
fitToPageWidth() {
return this.pdfHeight * 0.6
},
pdfWidth() {
return this.pdfHeight * 0.6667
return this.fitToPageWidth * this.scale
},
pdfHeight() {
return window.innerHeight - 120
if (this.windowHeight < 400 || !this.playerOpen) return this.windowHeight - 120
return this.windowHeight - 284
},
maxScale() {
return Math.floor((this.windowWidth * 10) / this.fitToPageWidth) / 10
},
canGoNext() {
return this.page < this.numPages
},
canGoPrev() {
return this.page > 1
},
canScaleUp() {
return this.scale < this.maxScale
},
canScaleDown() {
return this.scale > 1
},
userMediaProgress() {
if (!this.libraryItemId) return
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId)
},
savedPage() {
if (!this.keepProgress) return 0
// Validate ebookLocation is a number
if (!this.userMediaProgress?.ebookLocation || isNaN(this.userMediaProgress.ebookLocation)) return 0
return Number(this.userMediaProgress.ebookLocation)
},
ebookUrl() {
if (this.fileId) {
return `/api/items/${this.libraryItemId}/ebook/${this.fileId}`
}
return `/api/items/${this.libraryItemId}/ebook`
},
pdfDocInitParams() {
return {
url: this.ebookUrl,
httpHeaders: {
Authorization: `Bearer ${this.userToken}`
}
}
}
},
methods: {
zoomIn() {
this.scale += 0.1
},
zoomOut() {
this.scale -= 0.1
},
updateProgress() {
if (!this.keepProgress) return
if (!this.numPages) {
console.error('Num pages not loaded')
return
}
const payload = {
ebookLocation: this.page,
ebookProgress: Math.max(0, Math.min(1, (Number(this.page) - 1) / Number(this.numPages)))
}
this.$axios.$patch(`/api/me/progress/${this.libraryItemId}`, payload).catch((error) => {
console.error('EpubReader.updateProgress failed:', error)
})
},
loadedEvt() {
if (this.savedPage > 0 && this.savedPage <= this.numPages) {
this.page = this.savedPage
}
},
progressEvt(progress) {
this.loadedRatio = progress
},
numPagesLoaded(e) {
this.numPages = e
},
prev() {
if (this.page <= 1) return
this.page--
this.updateProgress()
},
next() {
if (this.page >= this.numPages) return
this.page++
this.updateProgress()
},
error(err) {
console.error(err)
},
resize() {
this.windowWidth = window.innerWidth
this.windowHeight = window.innerHeight
}
},
mounted() {}
mounted() {
this.windowWidth = window.innerWidth
this.windowHeight = window.innerHeight
window.addEventListener('resize', this.resize)
},
beforeDestroy() {
window.removeEventListener('resize', this.resize)
}
}
</script>

View File

@@ -1,24 +1,113 @@
<template>
<div v-if="show" class="w-screen h-screen fixed top-0 left-0 z-60 bg-primary text-white">
<div v-if="show" id="reader" :data-theme="ereaderTheme" class="group absolute top-0 left-0 w-full z-60 data-[theme=dark]:bg-primary data-[theme=dark]:text-white data-[theme=light]:bg-white data-[theme=light]:text-black" :class="{ 'reader-player-open': !!streamLibraryItem }">
<div class="absolute top-4 left-4 z-20 flex items-center">
<button v-if="isEpub" @click="toggleToC" type="button" aria-label="Table of contents menu" class="inline-flex opacity-80 hover:opacity-100">
<span class="material-icons text-2xl">menu</span>
</button>
<button v-if="hasSettings" @click="openSettings" type="button" aria-label="Ereader settings" class="mx-4 inline-flex opacity-80 hover:opacity-100">
<span class="material-icons text-1.5xl">settings</span>
</button>
</div>
<div class="absolute top-4 left-1/2 transform -translate-x-1/2">
<h1 :data-type="ebookType" class="text-lg sm:text-xl md:text-2xl mb-1 data-[type=comic]:hidden" style="line-height: 1.15; font-weight: 100">
<span style="font-weight: 600">{{ abTitle }}</span>
<span v-if="abAuthor" class="hidden md:inline"> </span>
<span v-if="abAuthor" class="hidden md:inline">{{ abAuthor }}</span>
</h1>
</div>
<div class="absolute top-4 right-4 z-20">
<span class="material-icons cursor-pointer text-4xl" @click="close">close</span>
<button @click="close" type="button" aria-label="Close ereader" class="inline-flex opacity-80 hover:opacity-100">
<span class="material-icons text-2xl">close</span>
</button>
</div>
<div class="absolute top-4 left-4">
<h1 class="text-2xl mb-1">{{ abTitle }}</h1>
<p v-if="abAuthor">by {{ abAuthor }}</p>
<component v-if="componentName" ref="readerComponent" :is="componentName" :library-item="selectedLibraryItem" :player-open="!!streamLibraryItem" :keep-progress="keepProgress" :file-id="ebookFileId" @touchstart="touchstart" @touchend="touchend" @hook:mounted="readerMounted" />
<!-- TOC side nav -->
<div v-if="tocOpen" class="w-full h-full fixed inset-0 bg-black/20 z-20" @click.stop.prevent="toggleToC"></div>
<div v-if="isEpub" class="w-96 h-full max-h-full absolute top-0 left-0 shadow-xl transition-transform z-30 group-data-[theme=dark]:bg-primary group-data-[theme=dark]:text-white group-data-[theme=light]:bg-white group-data-[theme=light]:text-black" :class="tocOpen ? 'translate-x-0' : '-translate-x-96'" @click.stop.prevent="toggleToC">
<div class="p-4 h-full">
<div class="flex items-center mb-2">
<button @click.stop.prevent="toggleToC" type="button" aria-label="Close table of contents" class="inline-flex opacity-80 hover:opacity-100">
<span class="material-icons text-2xl">arrow_back</span>
</button>
<p class="text-lg font-semibold ml-2">{{ $strings.HeaderTableOfContents }}</p>
</div>
<div class="tocContent">
<ul>
<li v-for="chapter in chapters" :key="chapter.id" class="py-1">
<a :href="chapter.href" class="opacity-80 hover:opacity-100" @click.prevent="$refs.readerComponent.goToChapter(chapter.href)">{{ chapter.label }}</a>
<ul v-if="chapter.subitems.length">
<li v-for="subchapter in chapter.subitems" :key="subchapter.id" class="py-1 pl-4">
<a :href="subchapter.href" class="opacity-80 hover:opacity-100" @click.prevent="$refs.readerComponent.goToChapter(subchapter.href)">{{ subchapter.label }}</a>
</li>
</ul>
</li>
</ul>
</div>
</div>
</div>
<component v-if="componentName" ref="readerComponent" :is="componentName" :url="ebookUrl" :library-item="selectedLibraryItem" />
<div class="absolute bottom-2 left-2">{{ ebookType }}</div>
<!-- ereader settings modal -->
<modals-modal v-model="showSettings" name="ereader-settings-modal" :width="500" :height="'unset'" :processing="false">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-3/4 overflow-hidden">
<p class="text-xl md:text-3xl text-white truncate">{{ $strings.HeaderEreaderSettings }}</p>
</div>
</template>
<div class="px-2 py-4 md:p-8 w-full text-base rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-x-hidden overflow-y-auto" style="max-height: 80vh">
<div class="flex items-center mb-4">
<div class="w-40">
<p class="text-lg">{{ $strings.LabelTheme }}:</p>
</div>
<ui-toggle-btns v-model="ereaderSettings.theme" :items="themeItems" @input="settingsUpdated" />
</div>
<div class="flex items-center mb-4">
<div class="w-40">
<p class="text-lg">{{ $strings.LabelFontScale }}:</p>
</div>
<ui-range-input v-model="ereaderSettings.fontScale" :min="5" :max="300" :step="5" @input="settingsUpdated" />
</div>
<div class="flex items-center mb-4">
<div class="w-40">
<p class="text-lg">{{ $strings.LabelLineSpacing }}:</p>
</div>
<ui-range-input v-model="ereaderSettings.lineSpacing" :min="100" :max="300" :step="5" @input="settingsUpdated" />
</div>
<div class="flex items-center">
<div class="w-40">
<p class="text-lg">{{ $strings.LabelLayout }}:</p>
</div>
<ui-toggle-btns v-model="ereaderSettings.spread" :items="spreadItems" @input="settingsUpdated" />
</div>
</div>
</modals-modal>
</div>
</template>
<script>
export default {
data() {
return {}
return {
touchstartX: 0,
touchstartY: 0,
touchendX: 0,
touchendY: 0,
touchstartTime: 0,
touchIdentifier: null,
chapters: [],
tocOpen: false,
showSettings: false,
ereaderSettings: {
theme: 'dark',
fontScale: 100,
lineSpacing: 115,
spread: 'auto'
}
}
},
watch: {
show(newVal) {
@@ -36,14 +125,47 @@ export default {
this.$store.commit('setShowEReader', val)
}
},
ereaderTheme() {
if (this.isEpub) return this.ereaderSettings.theme
return 'dark'
},
spreadItems() {
return [
{
text: this.$strings.LabelLayoutSinglePage,
value: 'none'
},
{
text: this.$strings.LabelLayoutSplitPage,
value: 'auto'
}
]
},
themeItems() {
return [
{
text: this.$strings.LabelThemeDark,
value: 'dark'
},
{
text: this.$strings.LabelThemeLight,
value: 'light'
}
]
},
componentName() {
if (this.ebookType === 'epub' && this.$isDev) return 'readers-epub-reader2'
else if (this.ebookType === 'epub') return 'readers-epub-reader'
if (this.ebookType === 'epub') return 'readers-epub-reader'
else if (this.ebookType === 'mobi') return 'readers-mobi-reader'
else if (this.ebookType === 'pdf') return 'readers-pdf-reader'
else if (this.ebookType === 'comic') return 'readers-comic-reader'
return null
},
streamLibraryItem() {
return this.$store.state.streamLibraryItem
},
hasSettings() {
return this.isEpub
},
abTitle() {
return this.mediaMetadata.title
},
@@ -66,10 +188,18 @@ export default {
return this.selectedLibraryItem.folderId
},
ebookFile() {
// ebook file id is passed when reading a supplementary ebook
if (this.ebookFileId) {
return this.selectedLibraryItem.libraryFiles.find((lf) => lf.ino === this.ebookFileId)
}
return this.media.ebookFile
},
ebookFormat() {
if (!this.ebookFile) return null
// Use file extension for supplementary ebook
if (!this.ebookFile.ebookFormat) {
return this.ebookFile.metadata.ext.toLowerCase().slice(1)
}
return this.ebookFile.ebookFormat
},
ebookType() {
@@ -91,43 +221,119 @@ export default {
isComic() {
return this.ebookFormat == 'cbz' || this.ebookFormat == 'cbr'
},
ebookUrl() {
if (!this.ebookFile) return null
let filepath = ''
if (this.selectedLibraryItem.isFile) {
filepath = this.$encodeUriPath(this.ebookFile.metadata.filename)
} else {
const itemRelPath = this.selectedLibraryItem.relPath
if (itemRelPath.startsWith('/')) itemRelPath = itemRelPath.slice(1)
const relPath = this.ebookFile.metadata.relPath
if (relPath.startsWith('/')) relPath = relPath.slice(1)
filepath = this.$encodeUriPath(`${itemRelPath}/${relPath}`)
}
return `/ebook/${this.libraryId}/${this.folderId}/${filepath}`
},
userToken() {
return this.$store.getters['user/getToken']
},
keepProgress() {
return this.$store.state.ereaderKeepProgress
},
ebookFileId() {
return this.$store.state.ereaderFileId
},
isDarkTheme() {
return this.ereaderSettings.theme === 'dark'
}
},
methods: {
readerMounted() {
if (this.isEpub) {
this.loadEreaderSettings()
}
},
settingsUpdated() {
this.$refs.readerComponent?.updateSettings?.(this.ereaderSettings)
localStorage.setItem('ereaderSettings', JSON.stringify(this.ereaderSettings))
},
toggleToC() {
this.tocOpen = !this.tocOpen
this.chapters = this.$refs.readerComponent.chapters
},
openSettings() {
this.showSettings = true
},
hotkey(action) {
console.log('Reader hotkey', action)
if (!this.$refs.readerComponent) return
if (action === this.$hotkeys.EReader.NEXT_PAGE) {
if (this.$refs.readerComponent.next) this.$refs.readerComponent.next()
this.next()
} else if (action === this.$hotkeys.EReader.PREV_PAGE) {
if (this.$refs.readerComponent.prev) this.$refs.readerComponent.prev()
this.prev()
} else if (action === this.$hotkeys.EReader.CLOSE) {
this.close()
}
},
next() {
if (this.$refs.readerComponent?.next) this.$refs.readerComponent.next()
},
prev() {
if (this.$refs.readerComponent?.prev) this.$refs.readerComponent.prev()
},
handleGesture() {
// Touch must be less than 1s. Must be > 60px drag and X distance > Y distance
const touchTimeMs = Date.now() - this.touchstartTime
if (touchTimeMs >= 1000) {
console.log('Touch too long', touchTimeMs)
return
}
const touchDistanceX = Math.abs(this.touchendX - this.touchstartX)
const touchDistanceY = Math.abs(this.touchendY - this.touchstartY)
const touchDistance = Math.sqrt(Math.pow(this.touchstartX - this.touchendX, 2) + Math.pow(this.touchstartY - this.touchendY, 2))
if (touchDistance < 60) {
return
}
if (touchDistanceX < 60 || touchDistanceY > touchDistanceX) {
return
}
if (this.touchendX < this.touchstartX) {
this.next()
}
if (this.touchendX > this.touchstartX) {
this.prev()
}
},
touchstart(e) {
// Ignore rapid touch
if (this.touchstartTime && Date.now() - this.touchstartTime < 250) {
return
}
this.touchstartX = e.touches[0].screenX
this.touchstartY = e.touches[0].screenY
this.touchstartTime = Date.now()
this.touchIdentifier = e.touches[0].identifier
},
touchend(e) {
if (this.touchIdentifier !== e.changedTouches[0].identifier) {
return
}
this.touchendX = e.changedTouches[0].screenX
this.touchendY = e.changedTouches[0].screenY
this.handleGesture()
},
registerListeners() {
this.$eventBus.$on('reader-hotkey', this.hotkey)
document.body.addEventListener('touchstart', this.touchstart)
document.body.addEventListener('touchend', this.touchend)
},
unregisterListeners() {
this.$eventBus.$off('reader-hotkey', this.hotkey)
document.body.removeEventListener('touchstart', this.touchstart)
document.body.removeEventListener('touchend', this.touchend)
},
loadEreaderSettings() {
try {
const settings = localStorage.getItem('ereaderSettings')
if (settings) {
this.ereaderSettings = JSON.parse(settings)
this.settingsUpdated()
}
} catch (error) {
console.error('Failed to load ereader settings', error)
}
},
init() {
this.registerListeners()
@@ -147,8 +353,19 @@ export default {
</script>
<style>
/* @import url(@/assets/calibre/basic.css); */
.ebook-viewer {
height: calc(100% - 96px);
.tocContent {
height: calc(100% - 36px);
overflow-y: auto;
}
#reader {
height: 100%;
}
#reader.reader-player-open {
height: calc(100% - 164px);
}
@media (max-height: 400px) {
#reader.reader-player-open {
height: 100%;
}
}
</style>

View File

@@ -235,7 +235,6 @@ export default {
style: `transform:translate(${x}px,${y}px);background-color:${bgColor};outline:1px solid ${outlineColor};outline-offset:-1px;`
})
}
console.log('Data', this.data)
this.monthLabels = []
var lastMonth = null

View File

@@ -0,0 +1,115 @@
<template>
<tr>
<td class="text-center">
<p>{{ track.index }}</p>
</td>
<td class="font-sans">{{ showFullPath ? track.metadata.path : track.metadata.filename }}</td>
<td v-if="!showFullPath" class="hidden lg:table-cell">
{{ track.audioFile.codec || '' }}
</td>
<td v-if="!showFullPath" class="hidden xl:table-cell">
{{ $bytesPretty(track.audioFile.bitRate || 0, 0) }}
</td>
<td class="hidden md:table-cell">
{{ $bytesPretty(track.metadata.size) }}
</td>
<td class="hidden sm:table-cell">
{{ $secondsToTimestamp(track.duration) }}
</td>
<td v-if="contextMenuItems.length" class="text-center">
<ui-context-menu-dropdown :items="contextMenuItems" :menu-width="110" @action="contextMenuAction" />
</td>
</tr>
</template>
<script>
export default {
props: {
libraryItemId: String,
showFullPath: Boolean,
track: {
type: Object,
default: () => {}
}
},
data() {
return {}
},
computed: {
userToken() {
return this.$store.getters['user/getToken']
},
userCanDownload() {
return this.$store.getters['user/getUserCanDownload']
},
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
},
userIsAdmin() {
return this.$store.getters['user/getIsAdminOrUp']
},
contextMenuItems() {
const items = []
if (this.userCanDownload) {
items.push({
text: this.$strings.LabelDownload,
action: 'download'
})
}
if (this.userCanDelete) {
items.push({
text: this.$strings.ButtonDelete,
action: 'delete'
})
}
if (this.userIsAdmin) {
items.push({
text: this.$strings.LabelMoreInfo,
action: 'more'
})
}
return items
},
downloadUrl() {
return `${process.env.serverUrl}/api/items/${this.libraryItemId}/file/${this.track.audioFile.ino}/download?token=${this.userToken}`
}
},
methods: {
contextMenuAction({ action }) {
if (action === 'delete') {
this.deleteLibraryFile()
} else if (action === 'download') {
this.downloadLibraryFile()
} else if (action === 'more') {
this.$emit('showMore', this.track.audioFile)
}
},
deleteLibraryFile() {
const payload = {
message: this.$strings.MessageConfirmDeleteFile,
callback: (confirmed) => {
if (confirmed) {
this.$axios
.$delete(`/api/items/${this.libraryItemId}/file/${this.track.audioFile.ino}`)
.then(() => {
this.$toast.success('File deleted')
})
.catch((error) => {
console.error('Failed to delete file', error)
this.$toast.error('Failed to delete file')
})
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
downloadLibraryFile() {
this.$downloadFile(this.downloadUrl, this.track.metadata.filename)
}
},
mounted() {}
}
</script>

View File

@@ -21,14 +21,14 @@
<td class="hidden sm:table-cell font-mono md:text-sm text-xs">{{ $bytesPretty(backup.fileSize) }}</td>
<td>
<div class="w-full flex flex-row items-center justify-center">
<ui-btn v-if="backup.serverVersion" small color="primary" @click="applyBackup(backup)">{{ $strings.ButtonRestore }}</ui-btn>
<a v-if="backup.serverVersion" :href="`/metadata/${$encodeUriPath(backup.path)}?token=${userToken}`" class="mx-1 pt-1 hover:text-opacity-100 text-opacity-70 text-white" download><span class="material-icons text-xl">download</span></a>
<ui-btn v-if="backup.serverVersion && backup.key" small color="primary" @click="applyBackup(backup)">{{ $strings.ButtonRestore }}</ui-btn>
<ui-tooltip v-else text="This backup was created with an old version of audiobookshelf no longer supported" direction="bottom" class="mx-2 flex items-center">
<span class="material-icons-outlined text-2xl text-error">error_outline</span>
</ui-tooltip>
<span class="material-icons text-xl hover:text-error hover:text-opacity-100 text-opacity-70 text-white cursor-pointer mx-1" @click="deleteBackupClick(backup)">delete</span>
<button aria-label="Download Backup" class="inline-flex material-icons text-xl mx-1 mt-1 text-white/70 hover:text-white/100" @click.stop="downloadBackup(backup)">download</button>
<button aria-label="Delete Backup" class="inline-flex material-icons text-xl mx-1 text-white/70 hover:text-error" @click="deleteBackupClick(backup)">delete</button>
</div>
</td>
</tr>
@@ -80,6 +80,9 @@ export default {
}
},
methods: {
downloadBackup(backup) {
this.$downloadFile(`${process.env.serverUrl}/api/backups/${backup.id}/download?token=${this.userToken}`)
},
confirm() {
this.showConfirmApply = false
@@ -91,8 +94,9 @@ export default {
})
.catch((error) => {
this.isBackingUp = false
console.error('Failed', error)
this.$toast.error(this.$strings.ToastBackupRestoreFailed)
console.error('Failed to apply backup', error)
const errorMsg = error.response.data || this.$strings.ToastBackupRestoreFailed
this.$toast.error(errorMsg)
})
},
deleteBackupClick(backup) {

View File

@@ -0,0 +1,87 @@
<template>
<div class="w-full my-2">
<div class="w-full bg-primary px-4 md:px-6 py-2 flex items-center cursor-pointer" @click.stop="clickBar">
<p class="pr-2 md:pr-4">{{ $strings.HeaderEbookFiles }}</p>
<div class="h-5 md:h-7 w-5 md:w-7 rounded-full bg-white bg-opacity-10 flex items-center justify-center">
<span class="text-sm font-mono">{{ ebookFiles.length }}</span>
</div>
<div class="flex-grow" />
<ui-btn v-if="userIsAdmin" small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">{{ $strings.ButtonFullPath }}</ui-btn>
<div class="cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="showFiles ? 'transform rotate-180' : ''">
<span class="material-icons text-4xl">expand_more</span>
</div>
</div>
<transition name="slide">
<div class="w-full" v-show="showFiles">
<table class="text-sm tracksTable">
<tr>
<th class="text-left px-4">{{ $strings.LabelPath }}</th>
<th class="text-left w-24 min-w-24">{{ $strings.LabelSize }}</th>
<th class="text-left px-4 w-24">
{{ $strings.LabelRead }} <ui-tooltip :text="$strings.LabelReadEbookWithoutProgress" direction="top" class="inline-block"><span class="material-icons-outlined text-sm align-middle">info</span></ui-tooltip>
</th>
<th v-if="showMoreColumn" class="text-center w-16"></th>
</tr>
<template v-for="file in ebookFiles">
<tables-ebook-files-table-row :key="file.path" :libraryItemId="libraryItemId" :showFullPath="showFullPath" :file="file" @read="readEbook" />
</template>
</table>
</div>
</transition>
</div>
</template>
<script>
export default {
props: {
libraryItem: {
type: Object,
default: () => {}
}
},
data() {
return {
showFiles: false,
showFullPath: false
}
},
computed: {
libraryItemId() {
return this.libraryItem.id
},
userToken() {
return this.$store.getters['user/getToken']
},
userCanDownload() {
return this.$store.getters['user/getUserCanDownload']
},
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
userIsAdmin() {
return this.$store.getters['user/getIsAdminOrUp']
},
libraryIsAudiobooksOnly() {
return this.$store.getters['libraries/getLibraryIsAudiobooksOnly']
},
showMoreColumn() {
return this.userCanDelete || this.userCanDownload || (this.userCanUpdate && !this.libraryIsAudiobooksOnly)
},
ebookFiles() {
return (this.libraryItem.libraryFiles || []).filter((lf) => lf.fileType === 'ebook')
}
},
methods: {
readEbook(fileIno) {
this.$store.commit('showEReader', { libraryItem: this.libraryItem, keepProgress: false, fileId: fileIno })
},
clickBar() {
this.showFiles = !this.showFiles
}
},
mounted() {}
}
</script>

View File

@@ -0,0 +1,139 @@
<template>
<tr>
<td class="px-4">
{{ showFullPath ? file.metadata.path : file.metadata.relPath }} <ui-tooltip :text="$strings.LabelPrimaryEbook" class="inline-block"><span v-if="isPrimary" class="material-icons-outlined text-success align-text-bottom">check_circle</span></ui-tooltip>
</td>
<td>
{{ $bytesPretty(file.metadata.size) }}
</td>
<td class="text-xs">
<ui-icon-btn icon="auto_stories" outlined borderless icon-font-size="1.125rem" :size="8" @click="readEbook" />
</td>
<td v-if="contextMenuItems.length" class="text-center">
<ui-context-menu-dropdown :items="contextMenuItems" :menu-width="130" :processing="processing" @action="contextMenuAction" />
</td>
</tr>
</template>
<script>
export default {
props: {
libraryItemId: String,
showFullPath: Boolean,
file: {
type: Object,
default: () => {}
}
},
data() {
return {
processing: false
}
},
computed: {
userToken() {
return this.$store.getters['user/getToken']
},
userCanDownload() {
return this.$store.getters['user/getUserCanDownload']
},
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
userIsAdmin() {
return this.$store.getters['user/getIsAdminOrUp']
},
downloadUrl() {
return `${process.env.serverUrl}/api/items/${this.libraryItemId}/file/${this.file.ino}/download?token=${this.userToken}`
},
isPrimary() {
return !this.file.isSupplementary
},
libraryIsAudiobooksOnly() {
return this.$store.getters['libraries/getLibraryIsAudiobooksOnly']
},
contextMenuItems() {
const items = []
if (this.userCanUpdate && !this.libraryIsAudiobooksOnly) {
items.push({
text: this.isPrimary ? this.$strings.LabelSetEbookAsSupplementary : this.$strings.LabelSetEbookAsPrimary,
action: 'updateStatus'
})
}
if (this.userCanDownload) {
items.push({
text: this.$strings.LabelDownload,
action: 'download'
})
}
if (this.userCanDelete) {
items.push({
text: this.$strings.ButtonDelete,
action: 'delete'
})
}
return items
}
},
methods: {
readEbook() {
this.$emit('read', this.file.ino)
},
contextMenuAction({ action }) {
if (action === 'delete') {
this.deleteLibraryFile()
} else if (action === 'download') {
this.downloadLibraryFile()
} else if (action === 'updateStatus') {
this.updateEbookStatus()
}
},
updateEbookStatus() {
this.processing = true
this.$axios
.$patch(`/api/items/${this.libraryItemId}/ebook/${this.file.ino}/status`)
.then(() => {
this.$toast.success('Ebook updated')
})
.catch((error) => {
console.error('Failed to update ebook', error)
this.$toast.error('Failed to update ebook')
})
.finally(() => {
this.processing = false
})
},
deleteLibraryFile() {
const payload = {
message: this.$strings.MessageConfirmDeleteFile,
callback: (confirmed) => {
if (confirmed) {
this.processing = true
this.$axios
.$delete(`/api/items/${this.libraryItemId}/file/${this.file.ino}`)
.then(() => {
this.$toast.success('File deleted')
})
.catch((error) => {
console.error('Failed to delete file', error)
this.$toast.error('Failed to delete file')
})
.finally(() => {
this.processing = false
})
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
downloadLibraryFile() {
this.$downloadFile(this.downloadUrl, this.file.metadata.filename)
}
},
mounted() {}
}
</script>

View File

@@ -6,7 +6,7 @@
<span class="text-sm font-mono">{{ files.length }}</span>
</div>
<div class="flex-grow" />
<ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">{{ $strings.ButtonFullPath }}</ui-btn>
<ui-btn v-if="userIsAdmin" small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">{{ $strings.ButtonFullPath }}</ui-btn>
<div class="cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="showFiles ? 'transform rotate-180' : ''">
<span class="material-icons text-4xl">expand_more</span>
</div>
@@ -18,60 +18,78 @@
<th class="text-left px-4">{{ $strings.LabelPath }}</th>
<th class="text-left w-24 min-w-24">{{ $strings.LabelSize }}</th>
<th class="text-left px-4 w-24">{{ $strings.LabelType }}</th>
<th v-if="userCanDownload && !isMissing" class="text-center w-20">{{ $strings.LabelDownload }}</th>
<th v-if="userCanDelete || userCanDownload || (userIsAdmin && audioFiles.length && !inModal)" class="text-center w-16"></th>
</tr>
<template v-for="file in files">
<tr :key="file.path">
<td class="px-4">
{{ showFullPath ? file.metadata.path : file.metadata.relPath }}
</td>
<td class="font-mono">
{{ $bytesPretty(file.metadata.size) }}
</td>
<td class="text-xs">
<div class="flex items-center">
<p>{{ file.fileType }}</p>
</div>
</td>
<td v-if="userCanDownload && !isMissing" class="text-center">
<a :href="`${$config.routerBasePath}/s/item/${libraryItemId}/${$encodeUriPath(file.metadata.relPath).replace(/^\//, '')}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a>
</td>
</tr>
<template v-for="file in filesWithAudioFile">
<tables-library-files-table-row :key="file.path" :libraryItemId="libraryItemId" :showFullPath="showFullPath" :file="file" :inModal="inModal" @showMore="showMore" />
</template>
</table>
</div>
</transition>
<modals-audio-file-data-modal v-model="showAudioFileDataModal" :library-item-id="libraryItemId" :audio-file="selectedAudioFile" />
</div>
</template>
<script>
export default {
props: {
files: {
type: Array,
default: () => []
libraryItem: {
type: Object,
default: () => {}
},
libraryItemId: String,
isMissing: Boolean,
expanded: Boolean // start expanded
expanded: Boolean, // start expanded
inModal: Boolean
},
data() {
return {
showFiles: false,
showFullPath: false
showFullPath: false,
showAudioFileDataModal: false,
selectedAudioFile: null
}
},
computed: {
libraryItemId() {
return this.libraryItem.id
},
userToken() {
return this.$store.getters['user/getToken']
},
userCanDownload() {
return this.$store.getters['user/getUserCanDownload']
},
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
},
userIsAdmin() {
return this.$store.getters['user/getIsAdminOrUp']
},
files() {
return this.libraryItem.libraryFiles || []
},
audioFiles() {
if (this.libraryItem.mediaType === 'podcast') {
return this.libraryItem.media?.episodes.map((ep) => ep.audioFile) || []
}
return this.libraryItem.media?.audioFiles || []
},
filesWithAudioFile() {
return this.files.map((file) => {
if (file.fileType === 'audio') {
file.audioFile = this.audioFiles.find((af) => af.ino === file.ino)
}
return file
})
}
},
methods: {
clickBar() {
this.showFiles = !this.showFiles
},
showMore(audioFile) {
this.selectedAudioFile = audioFile
this.showAudioFileDataModal = true
}
},
mounted() {

View File

@@ -0,0 +1,110 @@
<template>
<tr>
<td class="px-4">
{{ showFullPath ? file.metadata.path : file.metadata.relPath }}
</td>
<td>
{{ $bytesPretty(file.metadata.size) }}
</td>
<td class="text-xs">
<div class="flex items-center">
<p>{{ file.fileType }}</p>
</div>
</td>
<td v-if="contextMenuItems.length" class="text-center">
<ui-context-menu-dropdown :items="contextMenuItems" :menu-width="110" @action="contextMenuAction" />
</td>
</tr>
</template>
<script>
export default {
props: {
libraryItemId: String,
showFullPath: Boolean,
file: {
type: Object,
default: () => {}
},
inModal: Boolean
},
data() {
return {}
},
computed: {
userToken() {
return this.$store.getters['user/getToken']
},
userCanDownload() {
return this.$store.getters['user/getUserCanDownload']
},
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
},
userIsAdmin() {
return this.$store.getters['user/getIsAdminOrUp']
},
downloadUrl() {
return `${process.env.serverUrl}/api/items/${this.libraryItemId}/file/${this.file.ino}/download?token=${this.userToken}`
},
contextMenuItems() {
const items = []
if (this.userCanDownload) {
items.push({
text: this.$strings.LabelDownload,
action: 'download'
})
}
if (this.userCanDelete) {
items.push({
text: this.$strings.ButtonDelete,
action: 'delete'
})
}
// Currently not showing this option in the Files tab modal
if (this.userIsAdmin && this.file.audioFile && !this.inModal) {
items.push({
text: this.$strings.LabelMoreInfo,
action: 'more'
})
}
return items
}
},
methods: {
contextMenuAction({ action }) {
if (action === 'delete') {
this.deleteLibraryFile()
} else if (action === 'download') {
this.downloadLibraryFile()
} else if (action === 'more') {
this.$emit('showMore', this.file.audioFile)
}
},
deleteLibraryFile() {
const payload = {
message: this.$strings.MessageConfirmDeleteFile,
callback: (confirmed) => {
if (confirmed) {
this.$axios
.$delete(`/api/items/${this.libraryItemId}/file/${this.file.ino}`)
.then(() => {
this.$toast.success('File deleted')
})
.catch((error) => {
console.error('Failed to delete file', error)
this.$toast.error('Failed to delete file')
})
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
downloadLibraryFile() {
this.$downloadFile(this.downloadUrl, this.file.metadata.filename)
}
},
mounted() {}
}
</script>

View File

@@ -70,7 +70,10 @@ export default {
methods: {
editItem(playlistItem) {
if (playlistItem.episode) {
this.$store.commit('globals/setSelectedEpisode', playlist.episode)
const episodeIds = this.items.map((pi) => pi.episodeId)
this.$store.commit('setEpisodeTableEpisodeIds', episodeIds)
this.$store.commit('setSelectedLibraryItem', playlistItem.libraryItem)
this.$store.commit('globals/setSelectedEpisode', playlistItem.episode)
this.$store.commit('globals/setShowEditPodcastEpisodeModal', true)
} else {
const itemIds = this.items.map((i) => i.libraryItemId)

View File

@@ -5,9 +5,8 @@
<div class="h-5 md:h-7 w-5 md:w-7 rounded-full bg-white bg-opacity-10 flex items-center justify-center">
<span class="text-sm font-mono">{{ tracks.length }}</span>
</div>
<!-- <span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ tracks.length }}</span> -->
<div class="flex-grow" />
<ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">{{ $strings.ButtonFullPath }}</ui-btn>
<ui-btn v-if="userIsAdmin" small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">{{ $strings.ButtonFullPath }}</ui-btn>
<nuxt-link v-if="userCanUpdate && !isFile" :to="`/audiobook/${libraryItemId}/edit`" class="mr-2 md:mr-4" @mousedown.prevent>
<ui-btn small color="primary">{{ $strings.ButtonManageTracks }}</ui-btn>
</nuxt-link>
@@ -21,41 +20,20 @@
<tr>
<th class="w-10">#</th>
<th class="text-left">{{ $strings.LabelFilename }}</th>
<th class="text-left w-20">{{ $strings.LabelSize }}</th>
<th class="text-left w-20">{{ $strings.LabelDuration }}</th>
<th v-if="userCanDownload" class="text-center w-20">{{ $strings.LabelDownload }}</th>
<th v-if="showExperimentalFeatures" class="text-center w-20">
<div class="flex items-center">
<p>Tone</p>
<ui-tooltip text="Experimental feature for testing Tone library metadata scan results. Results logged in browser console." class="ml-2 w-2" direction="left">
<span class="material-icons-outlined text-sm">information</span>
</ui-tooltip>
</div>
</th>
<th v-if="!showFullPath" class="text-left w-20 hidden lg:table-cell">{{ $strings.LabelCodec }}</th>
<th v-if="!showFullPath" class="text-left w-20 hidden xl:table-cell">{{ $strings.LabelBitrate }}</th>
<th class="text-left w-20 hidden md:table-cell">{{ $strings.LabelSize }}</th>
<th class="text-left w-20 hidden sm:table-cell">{{ $strings.LabelDuration }}</th>
<th class="text-center w-16"></th>
</tr>
<template v-for="track in tracks">
<tr :key="track.index">
<td class="text-center">
<p>{{ track.index }}</p>
</td>
<td class="font-sans">{{ showFullPath ? track.metadata.path : track.metadata.filename }}</td>
<td class="font-mono">
{{ $bytesPretty(track.metadata.size) }}
</td>
<td class="font-mono">
{{ $secondsToTimestamp(track.duration) }}
</td>
<td v-if="userCanDownload" class="text-center">
<a :href="`${$config.routerBasePath}/s/item/${libraryItemId}/${$encodeUriPath(track.metadata.relPath).replace(/^\//, '')}?token=${userToken}`" download><span class="material-icons icon-text pt-1">download</span></a>
</td>
<td v-if="showExperimentalFeatures" class="text-center">
<ui-icon-btn borderless :loading="toneProbing" icon="search" @click="toneProbe(track.index)" />
</td>
</tr>
<tables-audio-tracks-table-row :key="track.index" :track="track" :library-item-id="libraryItemId" :showFullPath="showFullPath" @showMore="showMore" />
</template>
</table>
</div>
</transition>
<modals-audio-file-data-modal v-model="showAudioFileDataModal" :library-item-id="libraryItemId" :audio-file="selectedAudioFile" />
</div>
</template>
@@ -77,47 +55,31 @@ export default {
return {
showTracks: false,
showFullPath: false,
toneProbing: false
selectedAudioFile: null,
showAudioFileDataModal: false
}
},
computed: {
userToken() {
return this.$store.getters['user/getToken']
},
userCanDownload() {
return this.$store.getters['user/getUserCanDownload']
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
},
userIsAdmin() {
return this.$store.getters['user/getIsAdminOrUp']
}
},
methods: {
clickBar() {
this.showTracks = !this.showTracks
},
toneProbe(index) {
this.toneProbing = true
this.$axios
.$post(`/api/items/${this.libraryItemId}/tone-scan/${index}`)
.then((data) => {
console.log('Tone probe data', data)
if (data.error) {
this.$toast.error('Tone probe error: ' + data.error)
} else {
this.$toast.success('Tone probe successful! Check browser console')
}
})
.catch((error) => {
console.error('Failed to tone probe', error)
this.$toast.error('Tone probe failed')
})
.finally(() => {
this.toneProbing = false
})
showMore(audioFile) {
this.selectedAudioFile = audioFile
this.showAudioFileDataModal = true
}
},
mounted() {}

View File

@@ -19,9 +19,13 @@
</td>
<td class="text-sm">{{ user.type }}</td>
<td class="hidden lg:table-cell">
<div v-if="usersOnline[user.id]">
<p v-if="usersOnline[user.id].session && usersOnline[user.id].session.libraryItem" class="truncate text-xs">Listening: {{ usersOnline[user.id].session.libraryItem.media.metadata.title || '' }}</p>
<p v-else-if="usersOnline[user.id].mostRecent && usersOnline[user.id].mostRecent.media" class="truncate text-xs">Last: {{ usersOnline[user.id].mostRecent.media.metadata.title }}</p>
<div v-if="usersOnline[user.id]?.session?.displayTitle">
<p class="truncate text-xs">Listening: {{ usersOnline[user.id].session.displayTitle || '' }}</p>
<p class="truncate text-xs text-gray-300">{{ getDeviceInfoString(usersOnline[user.id].session.deviceInfo) }}</p>
</div>
<div v-else-if="user.latestSession?.displayTitle">
<p class="truncate text-xs">Last: {{ user.latestSession.displayTitle || '' }}</p>
<p class="truncate text-xs text-gray-300">{{ getDeviceInfoString(user.latestSession.deviceInfo) }}</p>
</div>
</td>
<td class="text-xs font-mono hidden sm:table-cell">
@@ -83,6 +87,12 @@ export default {
}
},
methods: {
getDeviceInfoString(deviceInfo) {
if (!deviceInfo) return ''
if (deviceInfo.manufacturer && deviceInfo.model) return `${deviceInfo.manufacturer} ${deviceInfo.model}`
return `${deviceInfo.osName || 'Unknown'} ${deviceInfo.osVersion || ''} ${deviceInfo.browserName || ''}`
},
deleteUserClick(user) {
if (this.isDeletingUser) return
if (confirm(this.$getString('MessageRemoveUserWarning', [user.username]))) {
@@ -114,11 +124,12 @@ export default {
},
loadUsers() {
this.$axios
.$get('/api/users')
.$get('/api/users?include=latestSession')
.then((res) => {
this.users = res.users.sort((a, b) => {
return a.createdAt - b.createdAt
})
console.log('Loaded users', this.users)
})
.catch((error) => {
console.error('Failed', error)

View File

@@ -1,16 +1,18 @@
<template>
<div class="w-full px-1 md:px-2 py-2 overflow-hidden relative" @mouseover="mouseover" @mouseleave="mouseleave" :class="isHovering ? 'bg-white bg-opacity-5' : ''">
<div v-if="book" class="flex h-16 md:h-20">
<div v-if="book" class="flex h-18 md:h-[5.5rem]">
<div class="w-10 min-w-10 md:w-16 md:max-w-16 h-full">
<div class="flex h-full items-center justify-center">
<span class="material-icons drag-handle text-lg md:text-xl">menu</span>
</div>
</div>
<div class="h-full relative flex items-center" :style="{ width: coverWidth + 'px', minWidth: coverWidth + 'px', maxWidth: coverWidth + 'px' }">
<covers-book-cover :library-item="book" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<div class="absolute top-0 left-0 bg-black bg-opacity-50 flex items-center justify-center h-full w-full z-10" v-show="isHovering && showPlayBtn">
<div class="w-8 h-8 bg-white bg-opacity-20 rounded-full flex items-center justify-center hover:bg-opacity-40 cursor-pointer" @click="playClick">
<span class="material-icons text-2xl">play_arrow</span>
<div class="h-full flex items-center" :style="{ width: coverWidth + 'px', minWidth: coverWidth + 'px', maxWidth: coverWidth + 'px' }">
<div class="relative" :style="{ height: coverHeight + 'px', minHeight: coverHeight + 'px', maxHeight: coverHeight + 'px' }">
<covers-book-cover :library-item="book" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<div class="absolute top-0 left-0 flex items-center justify-center bg-black bg-opacity-50 h-full w-full z-10" v-show="isHovering && showPlayBtn">
<div class="w-8 h-8 bg-white bg-opacity-20 rounded-full flex items-center justify-center hover:bg-opacity-40 cursor-pointer" @click="playClick">
<span class="material-icons text-2xl">play_arrow</span>
</div>
</div>
</div>
</div>
@@ -19,9 +21,12 @@
<div class="truncate max-w-48 md:max-w-md">
<nuxt-link :to="`/item/${book.id}`" class="truncate hover:underline text-sm md:text-base">{{ bookTitle }}</nuxt-link>
</div>
<div class="truncate max-w-48 md:max-w-md text-xs md:text-sm text-gray-300">
<nuxt-link v-for="_series in seriesList" :key="_series.id" :to="`/library/${book.libraryId}/series/${_series.id}`" class="hover:underline font-sans text-gray-300"> {{ _series.text }}</nuxt-link>
</div>
<div class="truncate max-w-48 md:max-w-md text-xs md:text-sm text-gray-300">
<template v-for="(author, index) in bookAuthors">
<nuxt-link :key="author.id" :to="`/author/${author.id}`" class="truncate hover:underline">{{ author.name }}</nuxt-link
<nuxt-link :key="author.id" :to="`/author/${author.id}?library=${book.libraryId}`" class="truncate hover:underline">{{ author.name }}</nuxt-link
><span :key="author.id + '-comma'" v-if="index < bookAuthors.length - 1">,&nbsp;</span>
</template>
</div>
@@ -96,6 +101,19 @@ export default {
bookDuration() {
return this.$elapsedPretty(this.media.duration)
},
series() {
return this.mediaMetadata.series || []
},
seriesList() {
return this.series.map((se) => {
let text = se.name
if (se.sequence) text += ` #${se.sequence}`
return {
...se,
text
}
})
},
isMissing() {
return this.book.isMissing
},
@@ -117,6 +135,9 @@ export default {
coverSize() {
return this.$store.state.globals.isMobile ? 30 : 50
},
coverHeight() {
return this.coverSize * 1.6
},
coverWidth() {
if (this.bookCoverAspectRatio === 1) return this.coverSize * 1.6
return this.coverSize
@@ -167,7 +188,6 @@ export default {
.$patch(`/api/me/progress/${this.book.id}`, updatePayload)
.then(() => {
this.isProcessingReadUpdate = false
this.$toast.success(updatePayload.isFinished ? this.$strings.ToastItemMarkedAsFinishedSuccess : this.$strings.ToastItemMarkedAsNotFinishedSuccess)
})
.catch((error) => {
console.error('Failed', error)

View File

@@ -94,7 +94,7 @@ export default {
}
},
methods: {
contextMenuAction(action) {
contextMenuAction({ action }) {
this.showMobileMenu = false
if (action === 'edit') {
this.editClick()

View File

@@ -21,7 +21,7 @@
</div>
<div class="truncate max-w-48 md:max-w-md text-xs md:text-sm text-gray-300">
<template v-for="(author, index) in bookAuthors">
<nuxt-link :key="author.id" :to="`/author/${author.id}`" class="truncate hover:underline">{{ author.name }}</nuxt-link
<nuxt-link :key="author.id" :to="`/author/${author.id}?library=${libraryItem.libraryId}`" class="truncate hover:underline">{{ author.name }}</nuxt-link
><span :key="author.id + '-comma'" v-if="index < bookAuthors.length - 1">,&nbsp;</span>
</template>
<nuxt-link v-if="episode" :to="`/item/${libraryItem.id}`" class="truncate hover:underline">{{ mediaMetadata.title }}</nuxt-link>
@@ -198,7 +198,6 @@ export default {
.$patch(routepath, updatePayload)
.then(() => {
this.isProcessingReadUpdate = false
this.$toast.success(updatePayload.isFinished ? this.$strings.ToastItemMarkedAsFinishedSuccess : this.$strings.ToastItemMarkedAsNotFinishedSuccess)
})
.catch((error) => {
console.error('Failed', error)

View File

@@ -7,11 +7,11 @@
<widgets-podcast-type-indicator :type="episode.episodeType" />
</div>
<p class="text-sm text-gray-200 episode-subtitle mt-1.5 mb-0.5">{{ subtitle }}</p>
<p class="text-sm text-gray-200 episode-subtitle mt-1.5 mb-0.5" v-html="subtitle"></p>
<div class="flex justify-between pt-2 max-w-xl">
<p v-if="episode.season" class="text-sm text-gray-300">Season #{{ episode.season }}</p>
<p v-if="episode.episode" class="text-sm text-gray-300">Episode #{{ episode.episode }}</p>
<p v-if="episode.chapters?.length" class="text-sm text-gray-300">{{ episode.chapters.length }} Chapters</p>
<p v-if="publishedAt" class="text-sm text-gray-300">Published {{ $formatDate(publishedAt, dateFormat) }}</p>
</div>
@@ -21,10 +21,6 @@
<p class="pl-2 pr-1 text-sm font-semibold">{{ timeRemaining }}</p>
</button>
<!-- <button v-if="libraryItemIdStreaming && !isStreamingFromDifferentLibrary" class="h-8 w-8 flex justify-center items-center mx-2" :class="isQueued ? 'text-success' : ''" @click.stop="queueBtnClick">
<span class="material-icons-outlined">{{ isQueued ? 'playlist_add_check' : 'queue' }}</span>
</button> -->
<ui-tooltip v-if="libraryItemIdStreaming && !isStreamingFromDifferentLibrary" :text="isQueued ? $strings.MessageRemoveFromPlayerQueue : $strings.MessageAddToPlayerQueue" :class="isQueued ? 'text-success' : ''" direction="top">
<ui-icon-btn :icon="isQueued ? 'playlist_add_check' : 'playlist_play'" borderless @click="queueBtnClick" />
</ui-tooltip>
@@ -88,7 +84,7 @@ export default {
return this.episode.title || ''
},
subtitle() {
return this.episode.subtitle || ''
return this.episode.subtitle || this.description
},
description() {
return this.episode.description || ''
@@ -187,7 +183,6 @@ export default {
.$patch(`/api/me/progress/${this.libraryItemId}/${this.episode.id}`, updatePayload)
.then(() => {
this.isProcessingReadUpdate = false
this.$toast.success(updatePayload.isFinished ? this.$strings.ToastItemMarkedAsFinishedSuccess : this.$strings.ToastItemMarkedAsNotFinishedSuccess)
})
.catch((error) => {
console.error('Failed', error)

View File

@@ -1,22 +1,29 @@
<template>
<div class="w-full py-6">
<p class="text-lg mb-2 font-semibold md:hidden">{{ $strings.HeaderEpisodes }}</p>
<div class="flex items-center mb-4">
<p class="text-lg mb-0 font-semibold hidden md:block">{{ $strings.HeaderEpisodes }}</p>
<div class="flex flex-wrap flex-col md:flex-row md:items-center mb-4">
<div class="flex items-center flex-nowrap whitespace-nowrap mb-2 md:mb-0">
<p class="text-lg mb-0 font-semibold">{{ $strings.HeaderEpisodes }}</p>
<div class="inline-flex bg-white/5 px-1 mx-2 rounded-md text-sm text-gray-100">
<p v-if="episodesList.length === episodes.length">{{ episodes.length }}</p>
<p v-else>{{ episodesList.length }} / {{ episodes.length }}</p>
</div>
</div>
<div class="flex-grow hidden md:block" />
<template v-if="isSelectionMode">
<ui-tooltip :text="selectedIsFinished ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" direction="bottom">
<ui-read-icon-btn :disabled="processing" :is-read="selectedIsFinished" @click="toggleBatchFinished" class="mx-1.5" />
</ui-tooltip>
<ui-btn color="error" :disabled="processing" small class="h-9" @click="removeSelectedEpisodes">{{ $getString('MessageRemoveEpisodes', [selectedEpisodes.length]) }}</ui-btn>
<ui-btn :disabled="processing" small class="ml-2 h-9" @click="clearSelected">{{ $strings.ButtonCancel }}</ui-btn>
</template>
<template v-else>
<controls-filter-select v-model="filterKey" :items="filterItems" class="w-36 h-9 sm:ml-4" />
<controls-sort-select v-model="sortKey" :descending.sync="sortDesc" :items="sortItems" class="w-44 md:w-48 h-9 ml-1 sm:ml-4" />
<div class="flex-grow md:hidden" />
<ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" class="ml-1" @action="contextMenuAction" />
</template>
<div class="flex items-center">
<template v-if="isSelectionMode">
<ui-tooltip :text="selectedIsFinished ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" direction="bottom">
<ui-read-icon-btn :disabled="processing" :is-read="selectedIsFinished" @click="toggleBatchFinished" class="mx-1.5" />
</ui-tooltip>
<ui-btn color="error" :disabled="processing" small class="h-9" @click="removeSelectedEpisodes">{{ $getString('MessageRemoveEpisodes', [selectedEpisodes.length]) }}</ui-btn>
<ui-btn :disabled="processing" small class="ml-2 h-9" @click="clearSelected">{{ $strings.ButtonCancel }}</ui-btn>
</template>
<template v-else>
<controls-filter-select v-model="filterKey" :items="filterItems" class="w-36 h-9 md:ml-4" />
<controls-sort-select v-model="sortKey" :descending.sync="sortDesc" :items="sortItems" class="w-44 md:w-48 h-9 ml-1 sm:ml-4" />
<div class="flex-grow md:hidden" />
<ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" class="ml-1" @action="contextMenuAction" />
</template>
</div>
</div>
<p v-if="!episodes.length" class="py-4 text-center text-lg">{{ $strings.MessageNoEpisodes }}</p>
<div v-if="episodes.length" class="w-full py-3 mx-auto flex">
@@ -51,10 +58,9 @@ export default {
selectedEpisodes: [],
episodesToRemove: [],
processing: false,
quickMatchingEpisodes: false,
search: null,
searchTimeout: null,
searchText: null,
searchText: null
}
},
watch: {
@@ -71,6 +77,10 @@ export default {
{
text: 'Quick match all episodes',
action: 'quick-match-episodes'
},
{
text: this.allEpisodesFinished ? this.$strings.MessageMarkAllEpisodesNotFinished : this.$strings.MessageMarkAllEpisodesFinished,
action: 'batch-mark-as-finished'
}
]
},
@@ -139,26 +149,38 @@ export default {
return episodeProgress && !episodeProgress.isFinished
})
.sort((a, b) => {
if (this.sortDesc) {
return String(b[this.sortKey]).localeCompare(String(a[this.sortKey]), undefined, { numeric: true, sensitivity: 'base' })
let aValue = a[this.sortKey]
let bValue = b[this.sortKey]
// Sort episodes with no pub date as the oldest
if (this.sortKey === 'publishedAt') {
if (!aValue) aValue = Number.MAX_VALUE
if (!bValue) bValue = Number.MAX_VALUE
}
return String(a[this.sortKey]).localeCompare(String(b[this.sortKey]), undefined, { numeric: true, sensitivity: 'base' })
if (this.sortDesc) {
return String(bValue).localeCompare(String(aValue), undefined, { numeric: true, sensitivity: 'base' })
}
return String(aValue).localeCompare(String(bValue), undefined, { numeric: true, sensitivity: 'base' })
})
},
episodesList() {
return this.episodesSorted.filter((episode) => {
if (!this.searchText) return true
return (
(episode.title && episode.title.toLowerCase().includes(this.searchText)) ||
(episode.subtitle && episode.subtitle.toLowerCase().includes(this.searchText))
)
return episode.title?.toLowerCase().includes(this.searchText) || episode.subtitle?.toLowerCase().includes(this.searchText)
})
},
selectedIsFinished() {
// Find an item that is not finished, if none then all items finished
return !this.selectedEpisodes.find((episode) => {
var itemProgress = this.$store.getters['user/getUserMediaProgress'](this.libraryItem.id, episode.id)
return !itemProgress || !itemProgress.isFinished
return !this.selectedEpisodes.some((episode) => {
const itemProgress = this.$store.getters['user/getUserMediaProgress'](this.libraryItem.id, episode.id)
return !itemProgress?.isFinished
})
},
allEpisodesFinished() {
return !this.episodesSorted.some((episode) => {
const itemProgress = this.$store.getters['user/getUserMediaProgress'](this.libraryItem.id, episode.id)
return !itemProgress?.isFinished
})
},
dateFormat() {
@@ -179,19 +201,36 @@ export default {
this.searchText = this.search.toLowerCase().trim()
}, 500)
},
contextMenuAction(action) {
contextMenuAction({ action }) {
if (action === 'quick-match-episodes') {
if (this.quickMatchingEpisodes) return
if (this.processing) return
this.quickMatchAllEpisodes()
} else if (action === 'batch-mark-as-finished') {
if (this.processing) return
this.markAllEpisodesFinished()
}
},
markAllEpisodesFinished() {
const newIsFinished = !this.allEpisodesFinished
const payload = {
message: newIsFinished ? this.$strings.MessageConfirmMarkAllEpisodesFinished : this.$strings.MessageConfirmMarkAllEpisodesNotFinished,
callback: (confirmed) => {
if (confirmed) {
this.batchUpdateEpisodesFinished(this.episodesSorted, newIsFinished)
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
quickMatchAllEpisodes() {
if (!this.mediaMetadata.feedUrl) {
this.$toast.error(this.$strings.MessagePodcastHasNoRSSFeedForMatching)
return
}
this.quickMatchingEpisodes = true
this.processing = true
const payload = {
message: 'Quick matching episodes will overwrite details if a match is found. Only unmatched episodes will be updated. Are you sure?',
@@ -211,7 +250,7 @@ export default {
this.$toast.error('Failed to match episodes')
})
}
this.quickMatchingEpisodes = false
this.processing = false
},
type: 'yesNo'
}
@@ -235,17 +274,19 @@ export default {
this.$store.commit('addItemToQueue', queueItem)
},
toggleBatchFinished() {
this.batchUpdateEpisodesFinished(this.selectedEpisodes, !this.selectedIsFinished)
},
batchUpdateEpisodesFinished(episodes, newIsFinished) {
this.processing = true
var newIsFinished = !this.selectedIsFinished
var updateProgressPayloads = this.selectedEpisodes.map((episode) => {
const updateProgressPayloads = episodes.map((episode) => {
return {
libraryItemId: this.libraryItem.id,
episodeId: episode.id,
isFinished: newIsFinished
}
})
this.$axios
return this.$axios
.patch(`/api/me/progress/batch/update`, updateProgressPayloads)
.then(() => {
this.$toast.success(this.$strings.ToastBatchUpdateSuccess)

View File

@@ -73,7 +73,7 @@ export default {
}
</script>
<style>
<style scoped>
.btn::before {
content: '';
position: absolute;

View File

@@ -4,7 +4,7 @@
<input v-model="selected" :disabled="disabled" type="checkbox" class="opacity-0 absolute" :class="!disabled ? 'cursor-pointer' : ''" />
<svg v-if="selected" class="fill-current pointer-events-none" :class="svgClass" viewBox="0 0 20 20"><path d="M0 11l2-2 5 5L18 3l2 2L7 18z" /></svg>
</div>
<div v-if="label" class="select-none text-gray-100" :class="labelClassname">{{ label }}</div>
<div v-if="label" class="select-none" :class="[labelClassname, disabled ? 'text-gray-400' : 'text-gray-100']">{{ label }}</div>
</label>
</template>

View File

@@ -1,14 +1,37 @@
<template>
<div class="relative h-9 w-9" v-click-outside="clickOutsideObj">
<button type="button" :disabled="disabled" class="relative h-full w-full flex items-center justify-center shadow-sm pl-3 pr-3 text-left focus:outline-none cursor-pointer text-gray-100 hover:text-gray-200 rounded-full hover:bg-white/5" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
<span class="material-icons" :class="iconClass">more_vert</span>
</button>
<slot :disabled="disabled" :showMenu="showMenu" :clickShowMenu="clickShowMenu" :processing="processing">
<button v-if="!processing" type="button" :disabled="disabled" class="relative h-full w-full flex items-center justify-center shadow-sm pl-3 pr-3 text-left focus:outline-none cursor-pointer text-gray-100 hover:text-gray-200 rounded-full hover:bg-white/5" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
<span class="material-icons" :class="iconClass">more_vert</span>
</button>
<div v-else class="h-full w-full flex items-center justify-center">
<widgets-loading-spinner />
</div>
</slot>
<transition name="menu">
<div v-show="showMenu" class="absolute right-0 mt-1 z-10 bg-bg border border-black-200 shadow-lg max-h-56 w-48 rounded-md py-1 overflow-auto focus:outline-none sm:text-sm">
<div v-show="showMenu" ref="menuWrapper" class="absolute right-0 mt-1 z-10 bg-bg border border-black-200 shadow-lg rounded-md py-1 focus:outline-none sm:text-sm" :style="{ width: menuWidth + 'px' }">
<template v-for="(item, index) in items">
<div :key="index" class="flex items-center px-2 py-1.5 hover:bg-white hover:bg-opacity-5 text-white text-xs cursor-pointer" @click.stop="clickAction(item.action)">
<p>{{ item.text }}</p>
<template v-if="item.subitems">
<div :key="index" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-default" :class="{ 'bg-white/5': mouseoverItemIndex == index }" @mouseover="mouseoverItem(index)" @mouseleave="mouseleaveItem(index)" @click.stop>
<p>{{ item.text }}</p>
</div>
<div
v-if="mouseoverItemIndex === index"
:key="`subitems-${index}`"
@mouseover="mouseoverSubItemMenu(index)"
@mouseleave="mouseleaveSubItemMenu(index)"
class="absolute bg-bg border rounded-b-md border-black-200 shadow-lg z-50 -ml-px py-1"
:class="openSubMenuLeft ? 'rounded-l-md' : 'rounded-r-md'"
:style="{ left: submenuLeftPos + 'px', top: index * 28 + 'px', width: submenuWidth + 'px' }"
>
<div v-for="(subitem, subitemindex) in item.subitems" :key="`subitem-${subitemindex}`" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-pointer" @click.stop="clickAction(subitem.action, subitem.data)">
<p>{{ subitem.text }}</p>
</div>
</div>
</template>
<div v-else :key="index" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-pointer" @click.stop="clickAction(item.action)">
<p class="text-left">{{ item.text }}</p>
</div>
</template>
</div>
@@ -27,7 +50,12 @@ export default {
iconClass: {
type: String,
default: ''
}
},
menuWidth: {
type: Number,
default: 192
},
processing: Boolean
},
data() {
return {
@@ -36,22 +64,54 @@ export default {
events: ['mousedown'],
isActive: true
},
showMenu: false
submenuWidth: 144,
showMenu: false,
mouseoverItemIndex: null,
isOverSubItemMenu: false,
openSubMenuLeft: false
}
},
computed: {
submenuLeftPos() {
return this.openSubMenuLeft ? -(this.submenuWidth - 1) : this.menuWidth - 0.5
}
},
computed: {},
methods: {
mouseoverSubItemMenu(index) {
this.isOverSubItemMenu = true
},
mouseleaveSubItemMenu(index) {
setTimeout(() => {
if (this.isOverSubItemMenu && this.mouseoverItemIndex === index) this.mouseoverItemIndex = null
}, 1)
},
mouseoverItem(index) {
this.isOverSubItemMenu = false
this.mouseoverItemIndex = index
},
mouseleaveItem(index) {
setTimeout(() => {
if (this.isOverSubItemMenu) return
if (this.mouseoverItemIndex === index) this.mouseoverItemIndex = null
}, 1)
},
clickShowMenu() {
if (this.disabled) return
this.showMenu = !this.showMenu
this.$nextTick(() => {
const boundingRect = this.$refs.menuWrapper?.getBoundingClientRect()
if (boundingRect) {
this.openSubMenuLeft = window.innerWidth - boundingRect.x < this.menuWidth + this.submenuWidth + 5
}
})
},
clickedOutside() {
this.showMenu = false
},
clickAction(action) {
clickAction(action, data) {
if (this.disabled) return
this.showMenu = false
this.$emit('action', action)
this.$emit('action', { action, data })
}
},
mounted() {}

View File

@@ -1,6 +1,14 @@
<template>
<div v-if="currentLibrary" class="relative h-8 max-w-52 md:min-w-32" v-click-outside="clickOutsideObj">
<button type="button" :disabled="disabled" class="w-10 sm:w-full relative h-full border border-white border-opacity-10 hover:border-opacity-20 rounded shadow-sm px-2 text-left text-sm cursor-pointer bg-black bg-opacity-20 text-gray-400 hover:text-gray-200" aria-haspopup="listbox" :aria-expanded="showMenu" :aria-label="$strings.ButtonLibrary + ': ' + currentLibrary.name" @click.stop.prevent="clickShowMenu">
<button
type="button"
:disabled="disabled"
class="w-10 sm:w-full relative h-full border border-white border-opacity-10 hover:border-opacity-20 rounded shadow-sm px-2 text-left text-sm cursor-pointer bg-black bg-opacity-20 text-gray-400 hover:text-gray-200"
aria-haspopup="listbox"
:aria-expanded="showMenu"
:aria-label="$strings.ButtonLibrary + ': ' + currentLibrary.name"
@click.stop.prevent="clickShowMenu"
>
<div class="flex items-center justify-center sm:justify-start">
<ui-library-icon :icon="currentLibraryIcon" class="sm:mr-1.5" />
<span class="hidden sm:block truncate">{{ currentLibrary.name }}</span>
@@ -8,7 +16,7 @@
</button>
<transition name="menu">
<ul v-show="showMenu" class="absolute z-10 -mt-px min-w-48 w-full bg-primary border border-black-200 shadow-lg max-h-56 rounded-b-md py-1 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox">
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full min-w-48 bg-primary border border-black-200 shadow-lg rounded-b-md py-1 overflow-auto focus:outline-none sm:text-sm librariesDropdownMenu" tabindex="-1" role="listbox">
<template v-for="library in librariesFiltered">
<li :key="library.id" class="text-gray-400 hover:text-white relative py-2 cursor-pointer hover:bg-black-400" role="option" tabindex="0" @keydown.enter="selectLibrary(library)" @click="selectLibrary(library)">
<div class="flex items-center px-2">
@@ -93,4 +101,10 @@ export default {
},
mounted() {}
}
</script>
</script>
<style scoped>
.librariesDropdownMenu {
max-height: calc(100vh - 75px);
}
</style>

View File

@@ -1,61 +0,0 @@
<template>
<div class="relative" v-click-outside="clickOutside">
<button type="button" class="relative w-full bg-fg border border-gray-500 rounded shadow-sm pl-3 pr-10 py-2 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="showMenu = !showMenu">
<span class="flex items-center">
<span class="block truncate">{{ label }}</span>
</span>
<span class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<span class="material-icons text-2xl text-gray-100" aria-label="User Account" role="button">person</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 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox" aria-activedescendant="listbox-option-3">
<template v-for="item in items">
<nuxt-link :key="item.value" v-if="item.to" :to="item.to">
<li :key="item.value" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="clickedOption(item.value)">
<div class="flex items-center">
<span class="font-normal ml-3 block truncate font-sans">{{ item.text }}</span>
</div>
</li>
</nuxt-link>
<li v-else :key="item.value" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="clickedOption(item.value)">
<div class="flex items-center">
<span class="font-normal ml-3 block truncate font-sans">{{ item.text }}</span>
</div>
</li>
</template>
</ul>
</transition>
</div>
</template>
<script>
export default {
props: {
label: {
type: String,
default: 'Menu'
},
items: {
type: Array,
default: () => []
}
},
data() {
return {
showMenu: false
}
},
methods: {
clickOutside() {
this.showMenu = false
},
clickedOption(itemValue) {
this.$emit('action', itemValue)
this.showMenu = false
}
},
mounted() {}
}
</script>

View File

@@ -0,0 +1,86 @@
<template>
<div class="inline-flex">
<input v-model="input" type="range" :min="min" :max="max" :step="step" />
<p class="text-sm ml-2">{{ input }}%</p>
</div>
</template>
<script>
export default {
props: {
value: [String, Number],
min: Number,
max: Number,
step: Number
},
data() {
return {}
},
computed: {
input: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
}
},
methods: {},
mounted() {}
}
</script>
<style scoped>
input[type='range'] {
-webkit-appearance: none;
appearance: none;
background: transparent;
cursor: pointer;
}
input[type='range']:focus {
outline: none;
}
/* chromium */
input[type='range']::-webkit-slider-runnable-track {
background-color: rgb(0 0 0 / 0.25);
border-radius: 9999px;
height: 0.75rem;
}
input[type='range']::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
margin-top: -0.25rem;
border-radius: 9999px;
background-color: rgb(255 255 255 / 0.7);
height: 1.25rem;
width: 1.25rem;
}
input[type='range']:focus::-webkit-slider-thumb {
border: 1px solid #6b6b6b;
outline: 3px solid #6b6b6b;
outline-offset: 0.125rem;
}
/* firefox */
input[type='range']::-moz-range-track {
background-color: rgb(0 0 0 / 0.25);
border-radius: 9999px;
height: 0.75rem;
}
input[type='range']::-moz-range-thumb {
border: none;
border-radius: 9999px;
margin-top: -0.25rem;
background-color: rgb(255 255 255 / 0.7);
height: 1.25rem;
width: 1.25rem;
}
input[type='range']:focus::-moz-range-thumb {
border: 1px solid #6b6b6b;
outline: 3px solid #6b6b6b;
outline-offset: 0.125rem;
}
</style>

View File

@@ -1,6 +1,6 @@
<template>
<div ref="wrapper" class="relative">
<input :id="inputId" 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" />
<input :id="inputId" ref="input" v-model="inputValue" :type="actualType" :step="step" :min="min" :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>
@@ -32,7 +32,9 @@ export default {
noSpinner: Boolean,
textCenter: Boolean,
clearable: Boolean,
inputId: String
inputId: String,
step: [String, Number],
min: [String, Number]
},
data() {
return {

View File

@@ -1,7 +1,7 @@
<template>
<div class="w-full">
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</p>
<ui-textarea-input ref="input" v-model="inputValue" :disabled="disabled" :rows="rows" class="w-full" />
<ui-textarea-input ref="input" v-model="inputValue" :disabled="disabled" :readonly="readonly" :rows="rows" class="w-full" />
</div>
</template>
@@ -11,6 +11,7 @@ export default {
value: [String, Number],
label: String,
disabled: Boolean,
readonly: Boolean,
rows: {
type: Number,
default: 2

View File

@@ -0,0 +1,85 @@
<template>
<div class="inline-flex toggle-btn-wrapper shadow-md">
<button v-for="item in items" :key="item.value" type="button" class="toggle-btn outline-none relative border border-gray-600 px-4 py-1" :class="{ selected: item.value === value }" @click.stop="clickBtn(item.value)">
{{ item.text }}
</button>
</div>
</template>
<script>
export default {
props: {
value: String,
/**
* [{ "text", "", "value": "" }]
*/
items: {
type: Array,
default: Object
}
},
data() {
return {}
},
computed: {},
methods: {
clickBtn(value) {
this.$emit('input', value)
}
},
mounted() {}
}
</script>
<style scoped>
.toggle-btn-wrapper .toggle-btn:first-child {
border-top-left-radius: 0.375rem /* 6px */;
border-bottom-left-radius: 0.375rem /* 6px */;
}
.toggle-btn-wrapper .toggle-btn:last-child {
border-top-right-radius: 0.375rem /* 6px */;
border-bottom-right-radius: 0.375rem /* 6px */;
}
.toggle-btn-wrapper .toggle-btn:first-child::before {
border-top-left-radius: 0.375rem /* 6px */;
border-bottom-left-radius: 0.375rem /* 6px */;
}
.toggle-btn-wrapper .toggle-btn:last-child::before {
border-top-right-radius: 0.375rem /* 6px */;
border-bottom-right-radius: 0.375rem /* 6px */;
}
.toggle-btn-wrapper .toggle-btn:not(:first-child) {
margin-left: -1px;
}
.toggle-btn::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(255, 255, 255, 0);
transition: all 0.1s ease-in-out;
}
.toggle-btn:hover:not(:disabled)::before {
background-color: rgba(255, 255, 255, 0.1);
}
.toggle-btn:hover:not(:disabled) {
color: white;
}
.toggle-btn {
color: rgba(255, 255, 255, 0.75);
}
.toggle-btn.selected {
color: white;
}
.toggle-btn.selected::before {
background-color: rgba(255, 255, 255, 0.1);
}
button.toggle-btn:disabled::before {
background-color: rgba(0, 0, 0, 0.2);
}
</style>

View File

@@ -213,7 +213,9 @@ export default {
// Reload HTML content
this.$refs.trix.editor.loadHTML(newContent)
// Move cursor to end of new content updated
this.$refs.trix.editor.setSelectedRange(this.getContentEndPosition())
if (this.autofocus) {
this.$refs.trix.editor.setSelectedRange(this.getContentEndPosition())
}
},
getContentEndPosition() {
return this.$refs.trix.editor.getDocument().toString().length - 1

View File

@@ -0,0 +1,70 @@
<template>
<ui-tooltip :text="$strings.LabelAbridged" direction="top">
<svg xmlns="http://www.w3.org/2000/svg" width="12px" height="12px" viewBox="0 0 512 512" class="ml-1">
<path
fill="white"
d="M 89.00,40.12
C 89.00,40.12 127.00,40.12 127.00,40.12
127.00,40.12 198.00,40.12 198.00,40.12
198.00,40.12 416.00,40.12 416.00,40.12
446.58,40.05 472.95,66.42 473.00,97.00
473.00,97.00 473.00,303.00 473.00,303.00
473.00,303.00 473.00,418.00 473.00,418.00
472.65,447.55 445.06,472.95 416.00,473.00
416.00,473.00 210.00,473.00 210.00,473.00
210.00,473.00 95.00,473.00 95.00,473.00
65.45,472.65 40.05,445.06 40.00,416.00
40.00,416.00 40.00,136.00 40.00,136.00
40.00,136.00 40.00,109.00 40.00,109.00
40.00,109.00 40.00,96.00 40.00,96.00
40.07,81.58 46.89,67.14 57.01,57.01
61.17,52.86 64.86,50.13 70.00,47.31
77.25,43.33 81.02,42.18 89.00,40.12 Z
M 372.00,392.00
C 372.00,392.00 364.02,364.00 364.02,364.00
364.02,364.00 350.72,319.00 350.72,319.00
350.72,319.00 310.42,183.00 310.42,183.00
310.42,183.00 296.86,137.00 296.86,137.00
296.86,137.00 291.30,121.99 291.30,121.99
291.30,121.99 284.00,121.00 284.00,121.00
284.00,121.00 230.00,121.00 230.00,121.00
230.00,121.00 222.51,122.02 222.51,122.02
222.51,122.02 216.86,137.00 216.86,137.00
216.86,137.00 203.28,183.00 203.28,183.00
203.28,183.00 163.28,318.00 163.28,318.00
163.28,318.00 148.71,367.00 148.71,367.00
148.71,367.00 142.00,392.00 142.00,392.00
142.00,392.00 183.00,392.00 183.00,392.00
183.00,392.00 190.86,390.43 190.86,390.43
190.86,390.43 195.86,375.00 195.86,375.00
195.86,375.00 206.00,338.00 206.00,338.00
206.00,338.00 293.00,338.00 293.00,338.00
295.64,338.01 299.26,337.65 301.30,339.60
303.23,341.43 304.80,348.22 305.58,351.00
305.58,351.00 313.00,378.00 313.00,378.00
316.91,391.63 315.20,391.98 325.00,392.00
325.00,392.00 372.00,392.00 372.00,392.00 Z
M 254.00,170.00
C 254.00,170.00 256.00,170.00 256.00,170.00
256.00,170.00 263.12,197.00 263.12,197.00
263.12,197.00 282.88,268.00 282.88,268.00
282.88,268.00 290.00,296.00 290.00,296.00
290.00,296.00 219.00,296.00 219.00,296.00
219.00,296.00 230.58,253.00 230.58,253.00
230.58,253.00 254.00,170.00 254.00,170.00 Z"
/>
</svg>
</ui-tooltip>
</template>
<script>
export default {
props: {},
data() {
return {}
},
computed: {},
methods: {},
mounted() {}
}
</script>

View File

@@ -16,7 +16,7 @@
</div>
</div>
<tables-tracks-table :title="$strings.LabelStatsAudioTracks" :tracks="media.tracks" :is-file="isFile" :library-item-id="libraryItemId" class="mt-6" />
<tables-tracks-table :title="$strings.LabelStatsAudioTracks" :tracks="tracksWithAudioFile" :is-file="isFile" :library-item-id="libraryItemId" class="mt-6" />
</div>
</template>
@@ -34,6 +34,12 @@ export default {
return {}
},
computed: {
tracksWithAudioFile() {
return this.media.tracks.map((track) => {
track.audioFile = this.media.audioFiles.find((af) => af.metadata.path === track.metadata.path)
return track
})
},
missingPartChunks() {
if (this.missingParts === 1) return this.missingParts[0]
var chunks = []

View File

@@ -50,7 +50,7 @@
</div>
<div class="flex flex-wrap mt-2 -mx-1">
<div class="w-full md:w-1/2 px-1">
<div class="w-full md:w-1/4 px-1">
<ui-text-input-with-label ref="publisherInput" v-model="details.publisher" :label="$strings.LabelPublisher" />
</div>
<div class="w-1/2 md:w-1/4 px-1 mt-2 md:mt-0">
@@ -61,6 +61,11 @@
<ui-checkbox v-model="details.explicit" :label="$strings.LabelExplicit" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
</div>
</div>
<div class="flex-grow px-1 pt-6 mt-2 md:mt-0">
<div class="flex justify-center">
<ui-checkbox v-model="details.abridged" :label="$strings.LabelAbridged" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
</div>
</div>
</div>
</form>
</div>
@@ -89,7 +94,8 @@ export default {
isbn: null,
asin: null,
genres: [],
explicit: false
explicit: false,
abridged: false
},
newTags: []
}
@@ -271,6 +277,7 @@ export default {
this.details.isbn = this.mediaMetadata.isbn || null
this.details.asin = this.mediaMetadata.asin || null
this.details.explicit = !!this.mediaMetadata.explicit
this.details.abridged = !!this.mediaMetadata.abridged
this.newTags = [...(this.media.tags || [])]
},
submitForm() {

View File

@@ -1,6 +1,40 @@
<template>
<ui-tooltip v-if="explicit" :text="$strings.LabelExplicit" direction="top">
<span class="material-icons ml-1" style="font-size: 0.8rem">explicit</span>
<svg xmlns="http://www.w3.org/2000/svg" width="12px" height="12px" viewBox="0 0 512 512" class="ml-1">
<path
fill="white"
d="M 89.00,40.12
C 89.00,40.12 127.00,40.12 127.00,40.12
127.00,40.12 198.00,40.12 198.00,40.12
198.00,40.12 416.00,40.12 416.00,40.12
446.58,40.05 472.95,66.42 473.00,97.00
473.00,97.00 473.00,303.00 473.00,303.00
473.00,303.00 473.00,418.00 473.00,418.00
472.65,447.55 445.06,472.95 416.00,473.00
416.00,473.00 210.00,473.00 210.00,473.00
210.00,473.00 95.00,473.00 95.00,473.00
65.45,472.65 40.05,445.06 40.00,416.00
40.00,416.00 40.00,136.00 40.00,136.00
40.00,136.00 40.00,109.00 40.00,109.00
40.00,109.00 40.00,96.00 40.00,96.00
40.07,81.58 46.89,67.14 57.01,57.01
61.17,52.86 64.86,50.13 70.00,47.31
77.25,43.33 81.02,42.18 89.00,40.12 Z
M 337.00,121.00
C 337.00,121.00 175.00,121.00 175.00,121.00
175.00,121.00 175.00,392.00 175.00,392.00
175.00,392.00 337.00,392.00 337.00,392.00
337.00,392.00 337.00,349.00 337.00,349.00
337.00,349.00 226.00,349.00 226.00,349.00
226.00,349.00 226.00,274.00 226.00,274.00
226.00,274.00 332.00,274.00 332.00,274.00
332.00,274.00 332.00,232.00 332.00,232.00
332.00,232.00 226.00,232.00 226.00,232.00
226.00,232.00 226.00,164.00 226.00,164.00
226.00,164.00 337.00,164.00 337.00,164.00
337.00,164.00 337.00,121.00 337.00,121.00 Z"
/>
</svg>
</ui-tooltip>
</template>

View File

@@ -1,7 +1,17 @@
<template>
<div class="absolute w-36 bg-bg rounded-md border border-black-200 shadow-lg z-50" v-click-outside="clickOutsideObj" style="top: 0; left: 0">
<div ref="wrapper" class="absolute bg-bg rounded-md py-1 border border-black-200 shadow-lg z-50" v-click-outside="clickOutsideObj" :style="{ width: menuWidth + 'px' }" style="top: 0; left: 0">
<template v-for="(item, index) in items">
<div :key="index" class="flex items-center px-2 py-1.5 hover:bg-white hover:bg-opacity-5 text-white text-xs cursor-pointer" @click.stop="clickAction(item.func)">
<template v-if="item.subitems">
<div :key="index" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-default" :class="{ 'bg-white/5': mouseoverItemIndex == index }" @mouseover="mouseoverItem(index)" @mouseleave="mouseleaveItem(index)" @click.stop>
<p>{{ item.text }}</p>
</div>
<div v-if="mouseoverItemIndex === index" :key="`subitems-${index}`" @mouseover="mouseoverSubItemMenu(index)" @mouseleave="mouseleaveSubItemMenu(index)" class="absolute bg-bg rounded-b-md border border-black-200 py-1 shadow-lg z-50" :class="openSubMenuLeft ? 'rounded-l-md' : 'rounded-r-md'" :style="{ left: submenuLeftPos + 'px', top: index * 28 + 'px', width: submenuWidth + 'px' }">
<div v-for="(subitem, subitemindex) in item.subitems" :key="`subitem-${subitemindex}`" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-pointer" @click.stop="clickAction(subitem.func, subitem.data)">
<p>{{ subitem.text }}</p>
</div>
</div>
</template>
<div v-else :key="index" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-pointer" @mouseover="mouseoverItem(index)" @mouseleave="mouseleaveItem(index)" @click.stop="clickAction(item.func)">
<p>{{ item.text }}</p>
</div>
</template>
@@ -22,13 +32,43 @@ export default {
handler: this.clickedOutside,
events: ['mousedown'],
isActive: true
}
},
submenuWidth: 144,
menuWidth: 144,
mouseoverItemIndex: null,
isOverSubItemMenu: false,
openSubMenuLeft: false
}
},
computed: {
submenuLeftPos() {
return this.openSubMenuLeft ? -this.submenuWidth : this.menuWidth - 1.5
}
},
computed: {},
methods: {
clickAction(func) {
this.$emit('action', func)
mouseoverSubItemMenu(index) {
this.isOverSubItemMenu = true
},
mouseleaveSubItemMenu(index) {
setTimeout(() => {
if (this.isOverSubItemMenu && this.mouseoverItemIndex === index) this.mouseoverItemIndex = null
}, 1)
},
mouseoverItem(index) {
this.isOverSubItemMenu = false
this.mouseoverItemIndex = index
},
mouseleaveItem(index) {
setTimeout(() => {
if (this.isOverSubItemMenu) return
if (this.mouseoverItemIndex === index) this.mouseoverItemIndex = null
}, 1)
},
clickAction(func, data) {
this.$emit('action', {
func,
data
})
this.close()
},
clickedOutside(e) {
@@ -44,7 +84,14 @@ export default {
this.$el.parentNode.removeChild(this.$el)
}
},
mounted() {},
mounted() {
this.$nextTick(() => {
const boundingRect = this.$refs.wrapper?.getBoundingClientRect()
if (boundingRect) {
this.openSubMenuLeft = window.innerWidth - boundingRect.x < this.menuWidth + this.submenuWidth + 5
}
})
},
beforeDestroy() {}
}
</script>

View File

@@ -0,0 +1,100 @@
<template>
<div class="w-full">
<div class="flex items-center py-3">
<slot />
<div class="flex-grow" />
<button v-if="isScrollable" class="w-8 h-8 mx-1 flex items-center justify-center rounded-full" :class="canScrollLeft ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollLeft">
<span class="material-icons text-2xl">chevron_left</span>
</button>
<button v-if="isScrollable" class="w-8 h-8 mx-1 flex items-center justify-center rounded-full" :class="canScrollRight ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollRight">
<span class="material-icons text-2xl">chevron_right</span>
</button>
</div>
<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 in items">
<cards-narrator-card :key="item.name" :ref="`slider-item-${item.name}`" :narrator="item" :height="cardHeight" :width="cardWidth" class="relative mx-2" @hook:updated="setScrollVars" />
</template>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
items: {
type: Array,
default: () => []
},
height: {
type: Number,
default: 192
},
bookshelfView: {
type: Number,
default: 1
}
},
data() {
return {
isScrollable: false,
canScrollLeft: false,
canScrollRight: false,
clientWidth: 0
}
},
computed: {
cardHeight() {
return this.height
},
cardWidth() {
return this.cardHeight * 1.5
},
booksPerPage() {
return Math.floor(this.clientWidth / (this.cardWidth + 16))
}
},
methods: {
scrolled() {
this.setScrollVars()
},
scrollRight() {
if (!this.canScrollRight) return
const slider = this.$refs.slider
if (!slider) return
const scrollAmount = this.booksPerPage * this.cardWidth
const maxScrollLeft = slider.scrollWidth - slider.clientWidth
const newScrollLeft = Math.min(maxScrollLeft, slider.scrollLeft + scrollAmount)
slider.scrollLeft = newScrollLeft
},
scrollLeft() {
if (!this.canScrollLeft) return
const slider = this.$refs.slider
if (!slider) return
const scrollAmount = this.booksPerPage * this.cardWidth
const newScrollLeft = Math.max(0, slider.scrollLeft - scrollAmount)
slider.scrollLeft = newScrollLeft
},
setScrollVars() {
const slider = this.$refs.slider
if (!slider) return
const { scrollLeft, scrollWidth, clientWidth } = slider
const scrollPercent = (scrollLeft + clientWidth) / scrollWidth
this.clientWidth = clientWidth
this.isScrollable = scrollWidth > clientWidth
this.canScrollRight = scrollPercent < 1
this.canScrollLeft = scrollLeft > 0
}
},
updated() {
this.setScrollVars()
},
mounted() {},
beforeDestroy() {}
}
</script>

View File

@@ -1,19 +1,21 @@
<template>
<div v-if="tasksRunning" class="w-4 h-4 mx-3 relative" v-click-outside="clickOutsideObj">
<div v-if="tasksToShow.length" class="w-4 h-4 mx-3 relative" v-click-outside="clickOutsideObj">
<button type="button" :disabled="disabled" class="w-10 sm:w-full relative h-full cursor-pointer" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
<div class="flex h-full items-center justify-center">
<ui-tooltip text="Tasks running" direction="bottom" class="flex items-center">
<ui-tooltip v-if="tasksRunning" :text="$strings.LabelTasks" direction="bottom" class="flex items-center">
<widgets-loading-spinner />
</ui-tooltip>
<ui-tooltip v-else text="Activities" direction="bottom" class="flex items-center">
<span class="material-icons text-1.5xl" aria-label="Activities" role="button">notifications</span>
</ui-tooltip>
</div>
</button>
<transition name="menu">
<div class="sm:w-80 w-full relative">
<div v-show="showMenu" 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 globalTaskRunningMenu">
<div v-show="showMenu" class="absolute z-40 -mt-px w-40 sm:w-full bg-bg border border-black-200 shadow-lg rounded-md text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm globalTaskRunningMenu">
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<template v-if="tasksRunningOrFailed.length">
<p class="uppercase text-xs text-gray-400 my-1 px-1 font-semibold">{{ $strings.LabelTasks }}</p>
<template v-for="task in tasksRunningOrFailed">
<template v-if="tasksToShow.length">
<template v-for="task in tasksToShow">
<nuxt-link :key="task.id" v-if="actionLink(task)" :to="actionLink(task)">
<li class="text-gray-50 select-none relative hover:bg-black-400 py-1 cursor-pointer">
<cards-item-task-running-card :task="task" />
@@ -54,9 +56,10 @@ export default {
tasksRunning() {
return this.tasks.some((t) => !t.isFinished)
},
tasksRunningOrFailed() {
// return just the tasks that are running or failed in the last 1 minute
return this.tasks.filter((t) => !t.isFinished || (t.isFailed && t.finishedAt > new Date().getTime() - 1000 * 60)) || []
tasksToShow() {
// return just the tasks that are running or failed (or show success) in the last 1 minute
const tasks = this.tasks.filter((t) => !t.isFinished || ((t.isFailed || t.showSuccess) && t.finishedAt > new Date().getTime() - 1000 * 60)) || []
return tasks.sort((a, b) => b.startedAt - a.startedAt)
}
},
methods: {
@@ -73,6 +76,10 @@ export default {
return `/library/${task.data.libraryId}/podcast/download-queue`
case 'encode-m4b':
return `/audiobook/${task.data.libraryItemId}/manage?tool=m4b`
case 'embed-metadata':
return `/audiobook/${task.data.libraryItemId}/manage?tool=embed`
case 'scan-item':
return `/item/${task.data.libraryItemId}`
default:
return ''
}

View File

@@ -35,7 +35,9 @@ export default {
isSocketConnected: false,
isFirstSocketConnection: true,
socketConnectionToastId: null,
currentLang: null
currentLang: null,
multiSessionOtherSessionId: null, // Used for multiple sessions open warning toast
multiSessionCurrentSessionId: null // Used for multiple sessions open warning toast
}
},
watch: {
@@ -278,6 +280,13 @@ export default {
console.log('Task finished', task)
this.$store.commit('tasks/addUpdateTask', task)
},
metadataEmbedQueueUpdate(data) {
if (data.queued) {
this.$store.commit('tasks/addQueuedEmbedLId', data.libraryItemId)
} else {
this.$store.commit('tasks/removeQueuedEmbedLId', data.libraryItemId)
}
},
userUpdated(user) {
if (this.$store.state.user.user.id === user.id) {
this.$store.commit('user/setUser', user)
@@ -292,8 +301,30 @@ export default {
userStreamUpdate(user) {
this.$store.commit('users/updateUserOnline', user)
},
userSessionClosed(sessionId) {
// If this session or other session is closed then dismiss multiple sessions warning toast
if (sessionId === this.multiSessionOtherSessionId || this.multiSessionCurrentSessionId === sessionId) {
this.multiSessionOtherSessionId = null
this.multiSessionCurrentSessionId = null
this.$toast.dismiss('multiple-sessions')
}
if (this.$refs.streamContainer) this.$refs.streamContainer.sessionClosedEvent(sessionId)
},
userMediaProgressUpdate(payload) {
this.$store.commit('user/updateMediaProgress', payload)
if (payload.data) {
if (this.$store.getters['getIsMediaStreaming'](payload.data.libraryItemId, payload.data.episodeId) && this.$store.state.playbackSessionId !== payload.sessionId) {
this.multiSessionOtherSessionId = payload.sessionId
this.multiSessionCurrentSessionId = this.$store.state.playbackSessionId
console.log(`Media progress was updated from another session (${this.multiSessionOtherSessionId}) for currently open media. Device description=${payload.deviceDescription}. Current session id=${this.multiSessionCurrentSessionId}`)
if (this.$store.state.streamIsPlaying) {
this.$toast.update('multiple-sessions', { content: `Another session is open for this item on device ${payload.deviceDescription}`, options: { timeout: 20000, type: 'warning', pauseOnFocusLoss: false } }, true)
} else {
this.$eventBus.$emit('playback-time-update', payload.data.currentTime)
}
}
}
},
collectionAdded(collection) {
if (this.currentLibraryId !== collection.libraryId) return
@@ -349,6 +380,11 @@ export default {
adminMessageEvt(message) {
this.$toast.info(message)
},
ereaderDevicesUpdated(data) {
if (!data?.ereaderDevices) return
this.$store.commit('libraries/setEReaderDevices', data.ereaderDevices)
},
initializeSocket() {
this.socket = this.$nuxtSocket({
name: process.env.NODE_ENV === 'development' ? 'dev' : 'prod',
@@ -398,6 +434,7 @@ export default {
this.socket.on('user_online', this.userOnline)
this.socket.on('user_offline', this.userOffline)
this.socket.on('user_stream_update', this.userStreamUpdate)
this.socket.on('user_session_closed', this.userSessionClosed)
this.socket.on('user_item_progress_updated', this.userMediaProgressUpdate)
// Collection Listeners
@@ -418,6 +455,10 @@ export default {
// Task Listeners
this.socket.on('task_started', this.taskStarted)
this.socket.on('task_finished', this.taskFinished)
this.socket.on('metadata_embed_queue_update', this.metadataEmbedQueueUpdate)
// EReader Device Listeners
this.socket.on('ereader-devices-updated', this.ereaderDevicesUpdated)
this.socket.on('backup_applied', this.backupApplied)
@@ -450,9 +491,9 @@ export default {
}
},
checkActiveElementIsInput() {
var activeElement = document.activeElement
var inputs = ['input', 'select', 'button', 'textarea']
return activeElement && inputs.indexOf(activeElement.tagName.toLowerCase()) !== -1
const activeElement = document.activeElement
const inputs = ['input', 'select', 'button', 'textarea', 'trix-editor']
return activeElement && inputs.some((i) => i === activeElement.tagName.toLowerCase())
},
getHotkeyName(e) {
var keyCode = e.keyCode || e.which
@@ -519,24 +560,24 @@ export default {
.catch((err) => console.error(err))
},
initLocalStorage() {
// If experimental features set in local storage
var experimentalFeaturesSaved = localStorage.getItem('experimental')
if (experimentalFeaturesSaved === '1') {
this.$store.commit('setExperimentalFeatures', true)
}
// Queue auto play
var playerQueueAutoPlay = localStorage.getItem('playerQueueAutoPlay')
this.$store.commit('setPlayerQueueAutoPlay', playerQueueAutoPlay !== '0')
},
loadTasks() {
this.$axios
.$get('/api/tasks')
.$get('/api/tasks?include=queue')
.then((payload) => {
console.log('Fetched tasks', payload)
if (payload.tasks) {
this.$store.commit('tasks/setTasks', payload.tasks)
}
if (payload.queuedTaskData?.embedMetadata?.length) {
this.$store.commit(
'tasks/setQueuedEmbedLIds',
payload.queuedTaskData.embedMetadata.map((td) => td.libraryItemId)
)
}
})
.catch((error) => {
console.error('Failed to load tasks', error)
@@ -545,6 +586,7 @@ export default {
changeLanguage(code) {
console.log('Changed lang', code)
this.currentLang = code
document.documentElement.lang = code
}
},
beforeMount() {
@@ -569,6 +611,11 @@ export default {
this.$toast.error(this.$route.query.error)
this.$router.replace(this.$route.path)
}
// Set lang on HTML tag
if (this.$languageCodes?.current) {
document.documentElement.lang = this.$languageCodes.current
}
},
beforeDestroy() {
this.$eventBus.$off('change-lang', this.changeLanguage)

View File

@@ -27,11 +27,7 @@ module.exports = {
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ hid: 'description', name: 'description', content: '' }
],
script: [
{
src: (process.env.ROUTER_BASE_PATH || '') + '/libs/sortable.js'
}
],
script: [],
link: [
{ rel: 'icon', type: 'image/x-icon', href: (process.env.ROUTER_BASE_PATH || '') + '/favicon.ico' }
]
@@ -75,9 +71,8 @@ module.exports = {
],
proxy: {
'/dev/': { target: 'http://localhost:3333', pathRewrite: { '^/dev/': '' } },
'/ebook/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/' },
'/s/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' + process.env : '/' },
'/api/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/' },
'/dev/': { target: 'http://localhost:3333', pathRewrite: { '^/dev/': '' } }
},
io: {

146
client/package-lock.json generated
View File

@@ -1,16 +1,17 @@
{
"name": "audiobookshelf-client",
"version": "2.2.17",
"version": "2.3.2",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "audiobookshelf-client",
"version": "2.2.17",
"version": "2.3.2",
"license": "ISC",
"dependencies": {
"@nuxtjs/axios": "^5.13.6",
"@nuxtjs/proxy": "^2.1.0",
"@teckel/vue-pdf": "^4.3.5",
"core-js": "^3.16.0",
"cron-parser": "^4.7.1",
"date-fns": "^2.25.0",
@@ -21,7 +22,6 @@
"nuxt-socket-io": "^1.1.18",
"trix": "^1.3.1",
"v-click-outside": "^3.1.2",
"vue-pdf": "^4.2.0",
"vue-toastification": "^1.7.11",
"vuedraggable": "^2.24.3"
},
@@ -2983,6 +2983,43 @@
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz",
"integrity": "sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg=="
},
"node_modules/@teckel/vue-pdf": {
"version": "4.3.5",
"resolved": "https://registry.npmjs.org/@teckel/vue-pdf/-/vue-pdf-4.3.5.tgz",
"integrity": "sha512-g2DAbZMPbPc7NPFImOsU/e7rt7wfdmBkmFa2kPsB4x+k+Bs8yC5Icmq/VnTSEq/Y8bNvEY7i6+JoicGnlfQL7Q==",
"dependencies": {
"babel-plugin-syntax-dynamic-import": "^6.18.0",
"loader-utils": "^1.4.0",
"pdfjs-dist": "^2.5.207 <2.8.0",
"raw-loader": "^4.0.1",
"vue-resize-sensor": "^2.0.0",
"worker-loader": "^2.0.0"
}
},
"node_modules/@teckel/vue-pdf/node_modules/json5": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz",
"integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==",
"dependencies": {
"minimist": "^1.2.0"
},
"bin": {
"json5": "lib/cli.js"
}
},
"node_modules/@teckel/vue-pdf/node_modules/loader-utils": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.2.tgz",
"integrity": "sha512-I5d00Pd/jwMD2QCduo657+YM/6L3KZu++pmX9VFncxaxvHcru9jx1lBaFft+r4Mt2jK0Yhp41XlRAihzPxHNCg==",
"dependencies": {
"big.js": "^5.2.2",
"emojis-list": "^3.0.0",
"json5": "^1.0.1"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/@types/anymatch": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@types/anymatch/-/anymatch-3.0.0.tgz",
@@ -15921,43 +15958,6 @@
"resolved": "https://registry.npmjs.org/vue-no-ssr/-/vue-no-ssr-1.1.1.tgz",
"integrity": "sha512-ZMjqRpWabMPqPc7gIrG0Nw6vRf1+itwf0Itft7LbMXs2g3Zs/NFmevjZGN1x7K3Q95GmIjWbQZTVerxiBxI+0g=="
},
"node_modules/vue-pdf": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/vue-pdf/-/vue-pdf-4.3.0.tgz",
"integrity": "sha512-zd3lJj6CbtrawgaaDDciTDjkJMUKiLWtbEmBg5CvFn9Noe9oAO/GNy/fc5c59qGuFCJ14ibIV1baw4S07e5bSQ==",
"dependencies": {
"babel-plugin-syntax-dynamic-import": "^6.18.0",
"loader-utils": "^1.4.0",
"pdfjs-dist": "2.6.347",
"raw-loader": "^4.0.2",
"vue-resize-sensor": "^2.0.0",
"worker-loader": "^2.0.0"
}
},
"node_modules/vue-pdf/node_modules/json5": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz",
"integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==",
"dependencies": {
"minimist": "^1.2.0"
},
"bin": {
"json5": "lib/cli.js"
}
},
"node_modules/vue-pdf/node_modules/loader-utils": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.2.tgz",
"integrity": "sha512-I5d00Pd/jwMD2QCduo657+YM/6L3KZu++pmX9VFncxaxvHcru9jx1lBaFft+r4Mt2jK0Yhp41XlRAihzPxHNCg==",
"dependencies": {
"big.js": "^5.2.2",
"emojis-list": "^3.0.0",
"json5": "^1.0.1"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/vue-resize-sensor": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/vue-resize-sensor/-/vue-resize-sensor-2.0.0.tgz",
@@ -19591,6 +19591,39 @@
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz",
"integrity": "sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg=="
},
"@teckel/vue-pdf": {
"version": "4.3.5",
"resolved": "https://registry.npmjs.org/@teckel/vue-pdf/-/vue-pdf-4.3.5.tgz",
"integrity": "sha512-g2DAbZMPbPc7NPFImOsU/e7rt7wfdmBkmFa2kPsB4x+k+Bs8yC5Icmq/VnTSEq/Y8bNvEY7i6+JoicGnlfQL7Q==",
"requires": {
"babel-plugin-syntax-dynamic-import": "^6.18.0",
"loader-utils": "^1.4.0",
"pdfjs-dist": "^2.5.207 <2.8.0",
"raw-loader": "^4.0.1",
"vue-resize-sensor": "^2.0.0",
"worker-loader": "^2.0.0"
},
"dependencies": {
"json5": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz",
"integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==",
"requires": {
"minimist": "^1.2.0"
}
},
"loader-utils": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.2.tgz",
"integrity": "sha512-I5d00Pd/jwMD2QCduo657+YM/6L3KZu++pmX9VFncxaxvHcru9jx1lBaFft+r4Mt2jK0Yhp41XlRAihzPxHNCg==",
"requires": {
"big.js": "^5.2.2",
"emojis-list": "^3.0.0",
"json5": "^1.0.1"
}
}
}
},
"@types/anymatch": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@types/anymatch/-/anymatch-3.0.0.tgz",
@@ -29618,39 +29651,6 @@
"resolved": "https://registry.npmjs.org/vue-no-ssr/-/vue-no-ssr-1.1.1.tgz",
"integrity": "sha512-ZMjqRpWabMPqPc7gIrG0Nw6vRf1+itwf0Itft7LbMXs2g3Zs/NFmevjZGN1x7K3Q95GmIjWbQZTVerxiBxI+0g=="
},
"vue-pdf": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/vue-pdf/-/vue-pdf-4.3.0.tgz",
"integrity": "sha512-zd3lJj6CbtrawgaaDDciTDjkJMUKiLWtbEmBg5CvFn9Noe9oAO/GNy/fc5c59qGuFCJ14ibIV1baw4S07e5bSQ==",
"requires": {
"babel-plugin-syntax-dynamic-import": "^6.18.0",
"loader-utils": "^1.4.0",
"pdfjs-dist": "2.6.347",
"raw-loader": "^4.0.2",
"vue-resize-sensor": "^2.0.0",
"worker-loader": "^2.0.0"
},
"dependencies": {
"json5": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz",
"integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==",
"requires": {
"minimist": "^1.2.0"
}
},
"loader-utils": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.2.tgz",
"integrity": "sha512-I5d00Pd/jwMD2QCduo657+YM/6L3KZu++pmX9VFncxaxvHcru9jx1lBaFft+r4Mt2jK0Yhp41XlRAihzPxHNCg==",
"requires": {
"big.js": "^5.2.2",
"emojis-list": "^3.0.0",
"json5": "^1.0.1"
}
}
}
},
"vue-resize-sensor": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/vue-resize-sensor/-/vue-resize-sensor-2.0.0.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "audiobookshelf-client",
"version": "2.2.17",
"version": "2.3.2",
"description": "Self-hosted audiobook and podcast client",
"main": "index.js",
"scripts": {
@@ -15,6 +15,7 @@
"dependencies": {
"@nuxtjs/axios": "^5.13.6",
"@nuxtjs/proxy": "^2.1.0",
"@teckel/vue-pdf": "^4.3.5",
"core-js": "^3.16.0",
"cron-parser": "^4.7.1",
"date-fns": "^2.25.0",
@@ -25,7 +26,6 @@
"nuxt-socket-io": "^1.1.18",
"trix": "^1.3.1",
"v-click-outside": "^3.1.2",
"vue-pdf": "^4.2.0",
"vue-toastification": "^1.7.11",
"vuedraggable": "^2.24.3"
},

View File

@@ -21,13 +21,14 @@
<ui-checkbox v-model="showSecondInputs" checkbox-bg="primary" small label-class="text-sm text-gray-200 pl-1" label="Show seconds" class="mx-2" />
<div class="w-32 hidden lg:block" />
</div>
<div class="flex items-center mb-3 py-1">
<div class="flex items-center mb-3 py-1 -mx-1">
<div class="w-12 hidden lg:block" />
<ui-btn v-if="newChapters.length > 1" :color="showShiftTimes ? 'bg' : 'primary'" small @click="showShiftTimes = !showShiftTimes">{{ $strings.ButtonShiftTimes }}</ui-btn>
<ui-btn color="primary" small class="mx-2" @click="showFindChaptersModal = true">{{ $strings.ButtonLookup }}</ui-btn>
<ui-btn v-if="chapters.length" color="primary" small class="mx-1" @click.stop="removeAllChaptersClick">{{ $strings.ButtonRemoveAll }}</ui-btn>
<ui-btn v-if="newChapters.length > 1" :color="showShiftTimes ? 'bg' : 'primary'" class="mx-1" small @click="showShiftTimes = !showShiftTimes">{{ $strings.ButtonShiftTimes }}</ui-btn>
<ui-btn color="primary" small :class="{ 'mx-1': newChapters.length > 1 }" @click="showFindChaptersModal = true">{{ $strings.ButtonLookup }}</ui-btn>
<div class="flex-grow" />
<ui-btn v-if="hasChanges" small class="mx-2" @click.stop="resetChapters">{{ $strings.ButtonReset }}</ui-btn>
<ui-btn v-if="hasChanges" color="success" :disabled="!hasChanges" small @click="saveChapters">{{ $strings.ButtonSave }}</ui-btn>
<ui-btn v-if="hasChanges" small class="mx-1" @click.stop="resetChapters">{{ $strings.ButtonReset }}</ui-btn>
<ui-btn v-if="hasChanges" color="success" class="mx-1" :disabled="!hasChanges" small @click="saveChapters">{{ $strings.ButtonSave }}</ui-btn>
<div class="w-32 hidden lg:block" />
</div>
@@ -41,7 +42,7 @@
<ui-text-input v-model="shiftAmount" type="number" class="max-w-20" style="height: 30px" />
<ui-btn color="primary" class="mx-1" small @click="shiftChapterTimes">{{ $strings.ButtonAdd }}</ui-btn>
<div class="flex-grow" />
<span class="material-icons text-gray-200 hover:text-white cursor-pointer" @click="showShiftTimes = false">close</span>
<span class="material-icons text-gray-200 hover:text-white cursor-pointer" @click="showShiftTimes = false">expand_less</span>
</div>
<p class="text-xs py-1.5 text-gray-300 max-w-md">{{ $strings.NoteChapterEditorTimes }}</p>
</div>
@@ -111,17 +112,17 @@
<div class="flex text-xs uppercase text-gray-300 font-semibold mb-2">
<div class="flex-grow">{{ $strings.LabelFilename }}</div>
<div class="w-20">{{ $strings.LabelDuration }}</div>
<div class="w-20 text-center">{{ $strings.HeaderChapters }}</div>
<div class="w-20 hidden md:block text-center">{{ $strings.HeaderChapters }}</div>
</div>
<template v-for="track in audioTracks">
<div :key="track.ino" class="flex items-center py-2" :class="currentTrackIndex === track.index && isPlayingChapter ? 'bg-success bg-opacity-10' : ''">
<div class="flex-grow">
<div class="flex-grow max-w-[calc(100%-80px)] pr-2">
<p class="text-xs truncate max-w-sm">{{ track.metadata.filename }}</p>
</div>
<div class="w-20" style="min-width: 80px">
<p class="text-xs font-mono text-gray-200">{{ $secondsToTimestamp(Math.round(track.duration), false, true) }}</p>
</div>
<div class="w-20 flex justify-center" style="min-width: 80px">
<div class="w-20 hidden md:flex justify-center" style="min-width: 80px">
<span v-if="(track.chapters || []).length" class="material-icons text-success text-sm">check</span>
</div>
</div>
@@ -329,6 +330,7 @@ export default {
chap.start = Math.max(0, chap.start + amount)
}
}
this.checkChapters()
},
editItem() {
this.$store.commit('showEditModal', this.libraryItem)
@@ -587,6 +589,45 @@ export default {
]
}
this.checkChapters()
},
removeAllChaptersClick() {
const payload = {
message: this.$strings.MessageConfirmRemoveAllChapters,
callback: (confirmed) => {
if (confirmed) {
this.removeAllChapters()
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
removeAllChapters() {
this.saving = true
const payload = {
chapters: []
}
this.$axios
.$post(`/api/items/${this.libraryItem.id}/chapters`, payload)
.then((data) => {
if (data.updated) {
this.$toast.success('Chapters removed')
if (this.previousRoute) {
this.$router.push(this.previousRoute)
} else {
this.$router.push(`/item/${this.libraryItem.id}`)
}
} else {
this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary)
}
})
.catch((error) => {
console.error('Failed to remove chapters', error)
this.$toast.error('Failed to remove chapters')
})
.finally(() => {
this.saving = false
})
}
},
mounted() {

View File

@@ -62,14 +62,20 @@
<div class="w-full h-px bg-white bg-opacity-10 my-8" />
<div class="w-full max-w-4xl mx-auto">
<div v-if="isEmbedTool" class="w-full flex justify-end items-center mb-4">
<ui-checkbox v-if="!isFinished" v-model="shouldBackupAudioFiles" label="Backup audio files" medium checkbox-bg="bg" label-class="pl-2 text-base md:text-lg" @input="toggleBackupAudioFiles" />
<!-- queued alert -->
<widgets-alert v-if="isMetadataEmbedQueued" type="warning" class="mb-4">
<p class="text-lg">Audiobook is queued for metadata embed ({{ queuedEmbedLIds.length }} in queue)</p>
</widgets-alert>
<!-- metadata embed action buttons -->
<div v-else-if="isEmbedTool" class="w-full flex justify-end items-center mb-4">
<ui-checkbox v-if="!isTaskFinished" v-model="shouldBackupAudioFiles" :disabled="processing" label="Backup audio files" medium checkbox-bg="bg" label-class="pl-2 text-base md:text-lg" @input="toggleBackupAudioFiles" />
<div class="flex-grow" />
<ui-btn v-if="!isFinished" color="primary" :loading="processing" @click.stop="embedClick">{{ $strings.ButtonStartMetadataEmbed }}</ui-btn>
<ui-btn v-if="!isTaskFinished" color="primary" :loading="processing" @click.stop="embedClick">{{ $strings.ButtonStartMetadataEmbed }}</ui-btn>
<p v-else class="text-success text-lg font-semibold">{{ $strings.MessageEmbedFinished }}</p>
</div>
<!-- m4b embed action buttons -->
<div v-else class="w-full flex items-center mb-4">
<button :disabled="processing" class="text-sm uppercase text-gray-200 flex items-center pt-px pl-1 pr-2 hover:bg-white/5 rounded-md" @click="showEncodeOptions = !showEncodeOptions">
<span class="material-icons text-xl">{{ showEncodeOptions ? 'check_box' : 'check_box_outline_blank' }}</span> <span class="pl-1">Use Advanced Options</span>
@@ -83,11 +89,12 @@
<p v-else class="text-success text-lg font-semibold">{{ $strings.MessageM4BFinished }}</p>
</div>
<!-- advanced encoding options -->
<div v-if="isM4BTool" class="overflow-hidden">
<transition name="slide">
<div v-if="showEncodeOptions" class="mb-4 pb-4 border-b border-white/10">
<div class="flex flex-wrap -mx-2">
<ui-text-input-with-label ref="bitrateInput" v-model="encodingOptions.bitrate" :disabled="processing || isTaskFinished" :label="'Audio Bitrate (e.g. 64k)'" class="m-2 max-w-40" />
<ui-text-input-with-label ref="bitrateInput" v-model="encodingOptions.bitrate" :disabled="processing || isTaskFinished" :label="'Audio Bitrate (e.g. 128k)'" class="m-2 max-w-40" />
<ui-text-input-with-label ref="channelsInput" v-model="encodingOptions.channels" :disabled="processing || isTaskFinished" :label="'Audio Channels (1 or 2)'" class="m-2 max-w-40" />
<ui-text-input-with-label ref="codecInput" v-model="encodingOptions.codec" :disabled="processing || isTaskFinished" :label="'Audio Codec'" class="m-2 max-w-40" />
</div>
@@ -191,6 +198,7 @@ export default {
cnosole.error('No audio files')
return redirect('/?error=no audio files')
}
return {
libraryItem
}
@@ -200,14 +208,13 @@ export default {
processing: false,
audiofilesEncoding: {},
audiofilesFinished: {},
isFinished: false,
toneObject: null,
selectedTool: 'embed',
isCancelingEncode: false,
showEncodeOptions: false,
shouldBackupAudioFiles: true,
encodingOptions: {
bitrate: '64k',
bitrate: '128k',
channels: '2',
codec: 'aac'
}
@@ -272,11 +279,28 @@ export default {
isTaskFinished() {
return this.task && this.task.isFinished
},
tasks() {
return this.$store.getters['tasks/getTasksByLibraryItemId'](this.libraryItemId)
},
embedTask() {
return this.tasks.find((t) => t.action === 'embed-metadata')
},
encodeTask() {
return this.tasks.find((t) => t.action === 'encode-m4b')
},
task() {
return this.$store.getters['tasks/getTaskByLibraryItemId'](this.libraryItemId)
if (this.isEmbedTool) return this.embedTask
else if (this.isM4BTool) return this.encodeTask
return null
},
taskRunning() {
return this.task && !this.task.isFinished
},
queuedEmbedLIds() {
return this.$store.state.tasks.queuedEmbedLIds || []
},
isMetadataEmbedQueued() {
return this.queuedEmbedLIds.some((lid) => lid === this.libraryItemId)
}
},
methods: {
@@ -322,7 +346,7 @@ export default {
.catch((error) => {
var errorMsg = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
this.$toast.error(errorMsg)
this.processing = true
this.processing = false
})
},
embedClick() {
@@ -349,24 +373,6 @@ export default {
this.processing = false
})
},
audioMetadataStarted(data) {
console.log('audio metadata started', data)
if (data.libraryItemId !== this.libraryItemId) return
this.audiofilesFinished = {}
},
audioMetadataFinished(data) {
console.log('audio metadata finished', data)
if (data.libraryItemId !== this.libraryItemId) return
this.processing = false
this.audiofilesEncoding = {}
if (data.failed) {
this.$toast.error(data.error)
} else {
this.isFinished = true
this.$toast.success('Audio file metadata updated')
}
},
audiofileMetadataStarted(data) {
if (data.libraryItemId !== this.libraryItemId) return
this.$set(this.audiofilesEncoding, data.ino, true)
@@ -412,14 +418,10 @@ export default {
},
mounted() {
this.init()
this.$root.socket.on('audio_metadata_started', this.audioMetadataStarted)
this.$root.socket.on('audio_metadata_finished', this.audioMetadataFinished)
this.$root.socket.on('audiofile_metadata_started', this.audiofileMetadataStarted)
this.$root.socket.on('audiofile_metadata_finished', this.audiofileMetadataFinished)
},
beforeDestroy() {
this.$root.socket.off('audio_metadata_started', this.audioMetadataStarted)
this.$root.socket.off('audio_metadata_finished', this.audioMetadataFinished)
this.$root.socket.off('audiofile_metadata_started', this.audiofileMetadataStarted)
this.$root.socket.off('audiofile_metadata_finished', this.audiofileMetadataFinished)
}

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