Compare commits

...

476 Commits

Author SHA1 Message Date
advplyr
6e8547f0c8 Version bump 2.2.15 2023-02-11 16:30:06 -06:00
advplyr
96930d7ecc Fix:Scrollable config side nav and mobile ui 2023-02-11 16:19:04 -06:00
advplyr
23f2c8a251 Fix:Replacing item cover remove old covers case insensitive #1391 2023-02-11 15:56:18 -06:00
advplyr
c5372d1405 Fix:Series,Collection,Playlist title scaling #1440 2023-02-11 15:51:23 -06:00
advplyr
dcfbed5f30 Update:Add inode value to log #1447 2023-02-11 15:39:34 -06:00
advplyr
895ab8d18a Fix:Audio track hover timestamp bubble z-index 2023-02-11 15:29:39 -06:00
advplyr
8b5d05739f Fix:Adding new podcast when folder already exists #1462 2023-02-11 15:25:54 -06:00
advplyr
a8f6202302 Remove Gentium Book font, reduce appbar icon and title font size 2023-02-11 15:02:56 -06:00
advplyr
5d40fdf277 Merge pull request #1487 from Nab0y/master
FantLab.ru BookFinder
2023-02-11 14:29:38 -06:00
advplyr
f35c96e118 FantLab minor refactor 2023-02-11 14:25:25 -06:00
advplyr
8f8d6f81ab Fix:Upload API endpoint crashing on invalid request with no files #1473 2023-02-10 17:25:19 -06:00
advplyr
e195eec1c5 Fix:OPF parser supporting attributes on tags #1478 2023-02-10 17:22:23 -06:00
advplyr
33846e46fa Fix:Handle podcast RSS feeds with iso-8859-1 encoding #1489 2023-02-10 17:07:25 -06:00
advplyr
2ad03bcb9a Fix:Bad backup files and backing up playlists, feeds #1485 2023-02-10 15:33:42 -06:00
Dmitry
371cd3b2e5 Update server/providers/FantLab.js
Co-authored-by: advplyr <67830747+advplyr@users.noreply.github.com>
2023-02-09 23:09:44 +03:00
Dmitry Naboychenko
b9307143bd FantLab match provider fixes after code review 2023-02-08 22:32:27 +03:00
Dmitry
36e44e902a Merge branch 'advplyr:master' into master 2023-02-08 17:31:19 +03:00
advplyr
4529fc0124 Merge pull request #1484 from magnww/fix_oom_crash
Reduce memory usage when scanning large folders
2023-02-07 16:59:33 -06:00
gefan
ba07761de3 Revert "kill zombie processes to reduce memory usage"
This reverts commit 19e39f6321.
2023-02-07 12:33:33 +08:00
Dmitry
3b7ce69327 Merge branch 'advplyr:master' into master 2023-02-07 00:25:45 +03:00
Dmitry Naboychenko
cf927f61a0 Add FantLab.ru Book Finder 2023-02-07 00:25:18 +03:00
gefan
61c32d99e7 scan media files in batches 2023-02-07 00:18:57 +08:00
gefan
19e39f6321 kill zombie processes to reduce memory usage 2023-02-07 00:18:48 +08:00
advplyr
f9e6655359 Update:API endpoint for syncing multiple local sessions. New API endpoint to get current user. Deprecate /me/sync-local-progress endpoint 2023-02-05 16:52:17 -06:00
advplyr
debf0f495d Fix:OPML upload path separator #1476 2023-02-04 13:34:50 -06:00
advplyr
3383ec2046 Add logs to playback session manager 2023-02-04 13:23:13 -06:00
advplyr
b957e1a36b Update:API endpoints for library and library item scan updated to POST requests 2023-02-03 17:50:42 -06:00
advplyr
c93f17051a Fix:Event sent when changing languages to rehydrate page 2023-02-03 14:50:48 -06:00
advplyr
5983f0262f Merge pull request #1472 from Nab0y/master
Russian localization
2023-02-03 14:49:14 -06:00
Dmitry
96a8e74d38 Merge branch 'advplyr:master' into master 2023-02-03 23:09:09 +03:00
Dmitry Naboychenko
6f3d488c3d Add russian localization 2023-02-03 23:08:38 +03:00
advplyr
74dcc4f9e4 Merge pull request #1470 from tomazed/patch-localization
Patch localization Item Metadata Utils Header
2023-02-03 04:27:41 -06:00
Tomazed
8c4d3b93c8 revert formatting 2023-02-03 11:25:02 +01:00
Tomazed
c411cf04cc ItemMetaDataUtils Header localized 2023-02-03 11:16:45 +01:00
advplyr
d1b25da408 Merge pull request #1469 from yuuzhan/adding-tags-to-metadata.abs
Adding tags to metadata.abs
2023-02-02 17:19:30 -06:00
advplyr
08f765fa51 Update parsing and using tags from abmetadata file 2023-02-02 17:13:22 -06:00
advplyr
337cf90c4b Add debug logs to playback sessions 2023-02-02 16:24:34 -06:00
advplyr
17b930e13d Merge pull request #1463 from Machou/patch-1
Update fr.json
2023-02-02 16:17:23 -06:00
yuuzhan
639b600570 Updated parseAndCheckForUpdates to pass in LibraryItem instead of Metadata Object 2023-02-02 12:47:12 -05:00
yuuzhan
7a751b8f91 Updated function parseAndCheckForUpdates to pass Library Item rather then just the metadata object 2023-02-02 12:46:22 -05:00
yuuzhan
68621e0c07 Update abmetadataGenerator.js 2023-02-02 12:43:48 -05:00
advplyr
5abb02e93a Version bump 2.2.14 2023-02-01 16:17:36 -06:00
advplyr
573079c5a1 Fix:Downgrade to axios 0.27.2 for pkg #1466 2023-02-01 15:58:58 -06:00
advplyr
5bde320ac7 Update:Remove X-Powered-By express response headers 2023-02-01 14:34:01 -06:00
Machou
5f63d97e59 Update fr.json 2023-02-01 03:40:41 +01:00
Machou
bf2fe3faea Update fr.json
fr_FR update
fr_FR reworked
fr_FR cleaned
2023-02-01 03:37:53 +01:00
advplyr
ea60f19e7a Version bump 2.2.13 2023-01-31 16:35:49 -06:00
advplyr
cefc75a4ed Fix:Edit library modal blur inputs on submit #1427 2023-01-31 16:04:30 -06:00
advplyr
d8753aafb9 Fix:Series collapse series filter out empty sequences #1450 2023-01-31 15:53:04 -06:00
advplyr
ba5ad228cc Merge pull request #1456 from jramer/master
Fixes m4b chapters only taken from first file.
2023-01-30 17:53:48 -06:00
advplyr
0203f9cc1b Update server/objects/mediaTypes/Book.js 2023-01-30 17:50:50 -06:00
advplyr
4770be5a39 Update server/objects/mediaTypes/Book.js 2023-01-30 17:50:45 -06:00
advplyr
1bac395bed Merge pull request #1453 from lkiesow/bookshelf-view
One Default Bookshelf View
2023-01-30 17:29:44 -06:00
advplyr
e818f270cd Merge pull request #1457 from tomazed/LocalizedDateFns
Localized date fns
2023-01-30 08:44:06 -06:00
advplyr
c4e2726622 Update client/plugins/i18n.js 2023-01-30 08:42:56 -06:00
Tomazed
74d8a09f31 Merge branch 'master' into LocalizedDateFns 2023-01-30 15:22:59 +01:00
Tomazed
618338165e Removed unused strings from translations 2023-01-30 15:17:42 +01:00
Tomazed
db494001a2 Import all locales from date-fns/locale 2023-01-30 15:17:04 +01:00
Tomazed
a67213fb7b Simplified languageCodeMap maintainability 2023-01-30 15:09:11 +01:00
Joakim Ramer
5d96b2cc6e Logs correctly and simplifies for single audio file. 2023-01-30 12:56:22 +01:00
Joakim Ramer
72d0b097ab Reverts change of file ending 2023-01-30 12:48:50 +01:00
Joakim Ramer
36d2957fb4 Adds check for duplicated chapter data 2023-01-30 12:46:41 +01:00
Tomazed
b5de517aad Generate days from date-fns locale 2023-01-30 01:49:30 +01:00
Tomazed
41db0e3bb1 Use Date-Fns locale to generate month and day labels 2023-01-30 01:25:35 +01:00
Tomazed
e8d582269b localized date-fns library 2023-01-30 00:52:28 +01:00
Joakim Ramer
80ef8ee890 Fixes m4b chapters only taken from first file. 2023-01-30 00:42:02 +01:00
Lars Kiesow
a65859f575 One Default Bookshelf View
This patch updates the default value of the home page bookshelf view so
that it is identical to the default library view. Having different
styles by default seems odd. I picked the non-wooden view as default
based on the recent discussion on Matrx/Discord that that one looks more
modern.
2023-01-28 23:17:31 +01:00
advplyr
5724887785 Merge pull request #1451 from springsunx/patch-1
Update zh-cn.json
2023-01-28 15:04:21 -06:00
advplyr
8908aa7a82 Fix:Podcast RSS feeds update on new/updated episodes #1435 2023-01-28 14:58:10 -06:00
advplyr
f83dd29213 Update:syncLocalMediaProgress API response payload 2023-01-28 14:46:01 -06:00
SunX
99d90778f4 Update zh-cn.json 2023-01-28 20:36:16 +08:00
advplyr
49279430fc Add:Recommended book home page shelf 2023-01-27 17:59:06 -06:00
advplyr
030c20b12e Merge pull request #1438 from tomazed/LocalizedStatsPage
Localized stats page
2023-01-24 17:30:32 -06:00
Tomazed
5e943ef152 Fix Localized Days in Your Stats page 2023-01-24 19:19:42 +01:00
Tomazed
4ae057f626 Fix Localized Months displaying in heatmap 2023-01-24 18:03:32 +01:00
advplyr
9ebe4b55dd Merge pull request #1431 from lkiesow/x-accel
Implement X-Accel Redirect
2023-01-23 17:27:23 -06:00
advplyr
2f7403adec Merge pull request #1432 from jmt-gh/issue_1410
Align "Series Name" and "Sequence" inputs on Series Editor modal
2023-01-23 17:19:50 -06:00
jmt-gh
2777b496ad change the label to be a label instead of a p 2023-01-22 20:44:39 -08:00
advplyr
f7a3dbf209 Fix:Embed metadata tool embeds cover image 2023-01-22 18:02:57 -06:00
advplyr
d900093976 Fix:Make m4b embed cover image 2023-01-22 18:00:35 -06:00
Lars Kiesow
08250e266e Implement X-Accel Redirect
This patch implements [X-Accel](https://www.nginx.com/resources/wiki/start/topics/examples/x-accel/)
redirect headers as an optional way for offloading static file delivery
from Express to Nginx, which is far better optimized for static file
delivery.

This provides a really easy to configure way for getting a huge
performance boost over delivering all files through Audiobookshelf.

How it works
------------

The way this works is basically that Audiobookshelf gets an HTTP request
for delivering a static file (let's say an audiobook). It will first
check the user is authorized and then convert the API path to a local
file path.

Now, instead of reading and delivering the file, Audiobookshelf will
return just the HTTP header with an additional `X-Accel-Redirect`
pointing to the file location on the file syste.

This header is picked up by Nginx which will then deliver the file.

Configuration
-------------

The configuration for this is very simple. You need to run Nginx as
reverse proxy and it must have access to your Audiobookshelf data
folder.

You then configure Audiobookshelf to use X-Accel by setting
`USE_X_ACCEL=/protected`. The path is the internal redirect path used by
Nginx.

In the Nginx configuration you then configure this location and map it
to the storage area to serve like this:

```
location /protected/ {
  internal;
  alias /;
}
```

That's all.

Impact
------

I just did a very simple performance test, downloading a 1170620819
bytes large audiobook file from another machine on the same network
like this, using `time -p` to measure how log the process took:

```sh
URL='https://url to audiobook…'

for i in `seq 1 50`
do
  echo "$i"
  curl -s -o /dev/null "${URL}"
done
```

This sequential test with 50 iterations and without x-accel resulted in:

```
real 413.42
user 197.11
sys 82.04
```

That is an average download speed of about 1080 MBit/s.

With X-Accel enabled, serving the files through Nginx, the same test
yielded the following results:

```
real 200.37
user 86.95
sys 29.79
```

That is an average download speed of about 2229 MBit/s, more than
doubling the previous speed.

I have also run the same test with 4 parallel processes and 25 downloads
each. Without x-accel, that test resulted in:

```
real 364.89
user 273.09
sys 112.75
```

That is an average speed of about 2448 MBit/s.

With X-Accel enabled, the parallel test also shows a significant
speedup:

```
real 167.19
user 195.62
sys 78.61
```

That is an average speed of about 5342 MBit/s.

While doing that, I also peaked at the system load which was a bit lower
when using X-Accel. Even though the system was delivering far more data.
But I just looked at the `load1` values and did not build a proper test
for that. That means, I cant provide any definitive data.

Supported Media
---------------

The current implementation works for audio files and book covers. There
are other media files which would benefit from this mechanism like feed
covers or author pictures.

But that's something for a future developer ;-)
2023-01-23 00:02:27 +01:00
advplyr
da2d1455d7 Merge pull request #1420 from Bostrolicious/master
Fix HTTP links not working in podcast show notes.
2023-01-22 08:07:55 -06:00
advplyr
b6c6c4c939 Merge pull request #1423 from lkiesow/prod-opts
Ensure default are similar in different environments
2023-01-22 08:05:30 -06:00
advplyr
22179d82b8 Merge branch 'master' into prod-opts 2023-01-22 08:05:25 -06:00
advplyr
343ce312f1 Merge pull request #1422 from tomazed/translation-fr
Update fr.json Months and Days
2023-01-22 08:03:44 -06:00
advplyr
10677d6fb0 Merge pull request #1421 from lkiesow/fingerprinting
Reduce Fingerprinting
2023-01-22 08:03:17 -06:00
advplyr
49a8aead9b Merge pull request #1425 from lkiesow/undefined-uid-gid
Skip AUDIOBOOKSHELF_UID/GID if undefined
2023-01-22 08:02:53 -06:00
Lars Kiesow
274b0e48be Skip AUDIOBOOKSHELF_UID/GID if undefined
This patch slightly changes the behavior of the `AUDIOBOOKSHELF_UID` and
`AUDIOBOOKSHELF_GID` options. Instead of defining a default user and
group, trying to modify files and silently failing if the filesystem
mode cannot be changed, this patch will just skip the entire process in
the first place.

If these options are defined, Audiobookshelf should behave exactly as
before. If they are not defined, Audiobookshelf will now cause fewer
file modifications (or less failures when trying to modify files).

If this patch gets applied, it should probably be highlighted in the
release notes. This usually shouldn't cause problems for migrations
since the Docker guides explicitly configure the options and the
package installations do not seem to use this at all, but there is still
a change that it will and users should be aware of that.

If a problem arises, users can easily fix the problem by either setting
the permissions once manually to the audiobookshelf user or by simply
defining the `AUDIOBOOKSHELF_UID/GID` options.
2023-01-22 12:30:36 +01:00
Lars Kiesow
4d8ffc5d99 Ensure default are similar in different environments
This patch tries to make sure that defaults and options work in the same
way regardless of users using Docker or a package deployment.

- Commit 10295b0 updated `Host` to be empty by default to better support
  IPv6. This fixes a missed occurrence of the old default.
- This makes sure the `SOURCE` environment variable is honored when
  installing the packages. The `--source` option will still take
  precedence though.
2023-01-22 00:53:10 +01:00
Tomazed
4f3029e5b2 Update fr.json Months and Days 2023-01-22 00:39:23 +01:00
Lars Kiesow
a1b49f5fcf Reduce Fingerprinting
As DieselTech#6997 pointed out in Matrix, it is a good idea to reduce
fingerprinting by removing the `X-Powered-By` response header as pointed
out by the Express security best practices:

See http://expressjs.com/en/advanced/best-practice-security.html#reduce-fingerprinting
2023-01-21 23:18:06 +01:00
Martin Boström
89d497a305 Fix mailto links not working in podcast show notes. 2023-01-21 22:46:38 +01:00
Martin Boström
9e095a4bc1 Fix HTTP links not working in podcast show notes. 2023-01-21 21:51:05 +01:00
advplyr
024d052a7b Update:Show total duration of episodes on podcast page 2023-01-19 17:55:40 -06:00
advplyr
c312979aec Update:Heatmap day of week translations 2023-01-18 18:04:03 -06:00
advplyr
773e621944 Merge pull request #1404 from burghy86/patch-8
update it
2023-01-18 17:50:42 -06:00
advplyr
ed4f33b565 Merge pull request #1364 from lkiesow/update-server-libs
Update Server Libraries
2023-01-18 17:36:20 -06:00
advplyr
f8a0852dfc Merge pull request #1403 from k9withabone/fix-banner
Fix banner view box
2023-01-18 17:35:28 -06:00
advplyr
6dec750d3e Fix:Close open playback session on server when local playback session syncing from mobile 2023-01-15 15:00:18 -06:00
burghy86
3c98a5fb24 update
and add a month and day a abbreviated for annual table
2023-01-14 23:37:07 +01:00
advplyr
702ee3d350 Merge pull request #1398 from lkiesow/dont-list-book-twice
Don't list book twice in continue series
2023-01-13 14:02:53 -06:00
advplyr
fcc2f3650b Merge pull request #1400 from springsunx/master
Update zh-cn.json
2023-01-13 13:54:46 -06:00
Paul Nettleton
e4ad622c01 Fix banner view box 2023-01-13 02:51:20 -06:00
SunX
458403eec9 Update zh-cn.json 2023-01-13 10:53:05 +08:00
Lars Kiesow
aaede2752c Don't list book twice in continue series
Sometimes, a book belongs to more than one series. If you listen to and
finish such a book, Audiobookshelf will list the next book in “Continue
Series” twice, right next to each other. That is not helpful.

This patch fixes the problem by not adding books to the list if they are
already in the list.
2023-01-13 00:50:04 +01:00
advplyr
39d8c2cf04 Merge pull request #1385 from Hallo951/master
Update de.json
2023-01-11 04:51:52 -06:00
advplyr
dd5c940d36 Merge pull request #1388 from lkiesow/continue-listening
Show next book only if previous book is finished
2023-01-10 16:39:59 -06:00
advplyr
277f024bbc Merge pull request #1390 from lkiesow/button-no-submit
Toggle switch shouldn't submit form
2023-01-10 16:32:14 -06:00
Lars Kiesow
59ad1e5e36 Toggle switch shouldn't submit form
This patch fixes the problem that toggling one of the options in the
user account dialog will automatically submit the form.

The problem got introduced as a combination of the recent accessibility
fixes where some elements got turned into HTML button elements to make
them keyboard accessible. Doing that, I did not realize that the default
type of a button is `submit` [1]. This causes no problems at most places,
but will cause problem within a form (e.g. the user account settings)
where toggling an option is now identical to clicking submit.

This patch fixes the issue by setting the `type` attribute to `button`.
Not only for the toggle switch, but also for a few other elements which
have been recently converted to buttons.

[1] https://www.w3.org/TR/2011/WD-html5-20110525/the-button-element.html#attr-button-type
2023-01-10 22:58:20 +01:00
Lars Kiesow
02c4b21d3f Show next book only if previous book is finished
This patch changes the books displayed in “Continue Series”, avoiding
books if another book from the series is played back right now. This
prevents Audiobookshelf suggesting books to which users will not listen
to because they are still listening to the last one.

Once a book is finished, the next book in the series will pop still be
suggested to the user.

This fixes  #1382
2023-01-10 21:50:33 +01:00
Hallo951
33ae5445be Update 2023-01-10 10:06:57 +01:00
advplyr
5ed06871b6 Merge pull request #1381 from tomazed/translation-fr
Update fr.json => ButtonEdit
2023-01-09 18:18:41 -06:00
Tomazed
e98eb8f1eb Update fr.json => ButtonEdit 2023-01-09 22:31:24 +01:00
advplyr
ebedaeb3b0 Version bump 2.2.12 2023-01-08 10:48:25 -06:00
advplyr
62aec63d1d Fix:Backups to not backup temp db files 2023-01-08 09:59:24 -06:00
advplyr
3c25e87e8d Update:Cleanup audio player 2023-01-08 09:38:37 -06:00
advplyr
08d16ce7c2 Silence remove invalid sessions debug log 2023-01-08 09:15:11 -06:00
advplyr
2cb3808326 Fix:Loading backups catching failed backups 2023-01-08 09:11:55 -06:00
advplyr
bdb6f0c0aa Update:Sync session API endpoint to not respond with a payload 2023-01-07 17:33:05 -06:00
advplyr
5255bf13cc Update:Libraries table using context menu instead of hover buttons. Cleanup mobile view #1342 2023-01-07 17:14:55 -06:00
advplyr
3588e1e8d3 Update:Handle badly formatted series sequence from Audible #1339 2023-01-07 16:33:20 -06:00
advplyr
8fa8360e99 Update:Manual match tab prefer using ASIN with audible providers #1352 2023-01-07 16:22:59 -06:00
advplyr
b305cfd268 Update max playback speed to 10x 2023-01-07 16:18:52 -06:00
advplyr
ff10287d05 Fix:Force AAC when transcoding ALAC audio file streams #1372 2023-01-07 15:58:57 -06:00
advplyr
7a7708403f Update:Item metadata utils tag and genre loading indicator visible in viewport #1346 2023-01-07 15:44:59 -06:00
advplyr
ddabd0ee75 Update:Library folder browser to not save folders from request #1371 2023-01-07 15:31:51 -06:00
advplyr
5a26704c32 Add:Option to disable backup of audio files in embed metadata tool #1370 2023-01-07 15:16:52 -06:00
advplyr
7ccf36a896 Merge pull request #1374 from lkiesow/gentium-book-basic
Update Gentium Book Basic Font
2023-01-07 13:35:06 -06:00
Lars Kiesow
e9a84dd7dd Update Gentium Book Basic Font
This patch updates the Gentium Book Basic font file [1]. While I
couldn't get any client to use the previous file, it doesn't seem to be
a problem with this file and now the text is being rendered correctly.

[1] https://gwfh.mranftl.com/fonts/gentium-book-basic?subsets=latin
2023-01-07 20:25:11 +01:00
advplyr
b00510855e Fix:Gentium Book Basic font 2023-01-07 13:06:44 -06:00
advplyr
2cd9079692 Add MusicBrainz provider 2023-01-07 13:05:33 -06:00
advplyr
3e4b1652fc Fix disc/track metadata mapping 2023-01-06 17:39:15 -06:00
advplyr
878330b4fb Fix filePathToPOSIX used in scan, updates for music track page 2023-01-06 17:10:55 -06:00
advplyr
9a85ad1f6b Fix:Check if Windows before cleaning file path for POSIX separators #1254 2023-01-05 17:45:27 -06:00
advplyr
f76f9c7f84 Merge pull request #1367 from lkiesow/log-source
Add Source to Logging
2023-01-05 16:51:37 -06:00
advplyr
3426832f2b Fix for windows, update regex to only include line number, move to end of log 2023-01-05 16:44:34 -06:00
Lars Kiesow
10fd51498c Add Source to Logging
The Audiobookshelf logs sometimes contain information about the source
of the log statement, but sometimes they don't This really depends on
developers adding these information to the log messages.

But even then, the information is usually just a hint about the module
logging this, like `[Db]` or [Watcher]`, and finding the exact line can
be hard.

This patch automatically adds the source of the log statement to the
logs. This means if someone calls `Logger.info(…)` in line `22` of
`foo.js`, the log statement will contain this file and line:

```
[2023-01-05 19:04:12[ (LogManager.js:85:18) DEBUG: Daily Log file found 2023-01-05.txt
[2023-01-05 19:04:12] (LogManager.js:59:12)  INFO: [LogManager] Init current daily log filename: 2023-01-05.txt
```

This should make it much easier to identify the code where the log
statement originated from.

Long-term, this also means that we can probably remove the manually set
identifiers contained in the log messages, like the `[LogManager]` in
the example above.
2023-01-05 19:13:31 +01:00
advplyr
49c581ed35 Add:Podcast option to quick match all unmatched episodes 2023-01-04 18:13:46 -06:00
Lars Kiesow
f095d89980 Update Server Libraries
This patch updates the server libraries to their latest state.
2023-01-05 00:46:23 +01:00
advplyr
1609f1a499 Add:Global library search also searches on podcast episode titles #1363 2023-01-04 17:43:15 -06:00
advplyr
88bd51e2da Fix:Update authors in different order #1361 2023-01-04 17:21:25 -06:00
advplyr
74388fe0b9 Fix:Series sequence parsed from metadata.abs allow non-numerical characters #1128 #1360 2023-01-04 15:55:02 -06:00
advplyr
7f5356100d Bookshelf updates for music tracks 2023-01-03 18:00:01 -06:00
advplyr
84d2d00a30 Merge pull request #1353 from tomazed/translation-fr
Update fr.json
2023-01-03 15:37:30 -06:00
Tomazed
31dddfbb60 Update fr.json 2023-01-03 10:53:27 +01:00
advplyr
d6da161b13 Music albums grouping and page 2023-01-02 18:02:04 -06:00
advplyr
9de7be1cb4 Update scanner, music meta tags and fix issue with force update 2023-01-02 16:35:39 -06:00
advplyr
5410aae8fc Remove old scanner setting from ServerSettings 2023-01-02 12:07:26 -06:00
advplyr
86bf6bfc62 Remove scannerMaxThreads from ServerSettings 2023-01-02 12:05:58 -06:00
advplyr
0807146aab Cleanup scanner 2023-01-02 12:05:07 -06:00
advplyr
591d8a8ab1 Add:OPF file pulls ASIN and subtitle #1330 2023-01-02 10:47:13 -06:00
advplyr
b1d4e28027 Merge pull request #1350 from lkiesow/settings-menu
Fix Hidden Settings Menu
2023-01-02 10:40:24 -06:00
advplyr
44363f05ac Start of new epub reader 2023-01-01 18:09:00 -06:00
Lars Kiesow
452af43916 Fix Hidden Settings Menu
This patch fixes several problems of the settings menu related to
display on mobile devices or small(ish) windows:

- The `isMobileLandscape` is now calculated correctly. Previously, this
  was set to `true` if a device was in portrait mode.

- Showing the button to collapse the settings menu and making the menu
  collapsible now use the same mechanism. Previously, it could happen
  that the menu was opened and not fixed, but no button to close it
  again was shown.

- The icons fore opening and closing the settings menu are now both
  arrows, indicating that their functionality is reversed.

- The button to open the menu now always has the string “Settings”,
  instead of using the name of the current page. The current page hader
  is listed below that anyway and this is the action component to open
  the settings menu after all.

This fixes #1334
2023-01-01 19:49:43 +01:00
advplyr
70ba2f7850 Add:RSS feed for series & cleanup empty series from db #1265 2022-12-31 16:58:19 -06:00
advplyr
a364fe5031 Merge RSS feed modals into a universal one 2022-12-31 15:26:37 -06:00
advplyr
ca6765c8e7 Add translations for series #1166 2022-12-31 15:04:37 -06:00
advplyr
6bfa281dc5 Update:Series page toolbar add context menu and confirm dialog for marking series as finished 2022-12-31 14:56:18 -06:00
advplyr
d8ee61bfab Update:Personalized API endpoint include query string to add rssFeed to entities 2022-12-31 14:31:38 -06:00
advplyr
c6763dee2d Remove invalid RSS feeds on init and remove feeds when associated entity is removed 2022-12-31 14:08:34 -06:00
advplyr
0e6b0d3eff Update:Remove RSS feeds from login response payload and include feeds from library items request 2022-12-31 10:59:12 -06:00
advplyr
8bbfee334c Update:Show RSS feed icon on collection card & update API endpoint for fetching collections 2022-12-31 10:33:38 -06:00
advplyr
f806e4cce3 Merge pull request #1343 from lkiesow/a11y-main-settings
Accessibility Improvements for Main Settings
2022-12-31 09:30:48 -06:00
advplyr
209ba308bd Merge branch 'master' into a11y-main-settings 2022-12-31 08:43:26 -06:00
advplyr
4cd9088a66 Add translations for aria labels #1166 2022-12-30 16:27:21 -06:00
advplyr
ac5e2e5c73 Merge pull request #1341 from lkiesow/a11y-user-settings
Fix keyboard navigation in user settings
2022-12-30 16:26:07 -06:00
Lars Kiesow
f1329d2847 Accessibility Improvements for Main Settings
This patch fixes some accessibility problems on the main settings page.
Most notably, it makes sure that the different options have labels which
are picked up by screen readers.

As a more generic addition, this also makes sure that the dropdown
component will always have a proper label constructed, explaining what
the dropdown is for and what its current value is.
2022-12-30 19:14:04 +01:00
advplyr
27faefc64d Merge pull request #1338 from naleo/master
Fix incorrect series and seriespart tag codes, they were swapped
2022-12-29 18:05:41 -06:00
advplyr
0fa7e61dc1 Merge pull request #1336 from lkiesow/user-settings-screenreader
Make User Settings Accessible via Screen Reader
2022-12-29 18:03:40 -06:00
advplyr
5a3f14ae51 Remove extra space from label 2022-12-29 18:03:05 -06:00
Lars Kiesow
4e61185136 Fix keyboard navigation in user settings
This patch makes sure that the option in the user settings are
accessible via keyboard navigation and that the labels, if users use a
screen reader, actually make sense.

This patch introduces new strings which need to be translated. Although
I did already provide a German translation.
2022-12-29 21:36:42 +01:00
Naleo
6ee06d5dae Fix incorrent series and seriespart tag codes, they were swapped 2022-12-29 08:41:46 -10:00
Lars Kiesow
2c344a0bc0 Make User Settings Accessible via Screen Reader
This patch should fix most of the problems for users trying to access
the user settings via screen reader. It makes sure user interface
elements can be reached via keyboard and provides proper labels, roles
and values so you not only can interact with elements but also know what
you are actually changing.

While not focused on other views, this should also already fix a number
of accessibility issues with other settings pages.
2022-12-29 05:00:40 +01:00
advplyr
315c83e4c3 RSS feed for collection to update when any item in the collection is updated #606 2022-12-28 18:08:03 -06:00
advplyr
9e4bc582cb Merge pull request #1335 from lkiesow/keyboard-navigation-libraries
Fix keyboard navigation in library selection
2022-12-28 17:18:35 -06:00
Lars Kiesow
fc6aa1f91f Fix keyboard navigation in library selection
This patch fixes the keyboard navigation in the library selection of the
main app bar. Without this patch, no options are selectable via keyboard
and selecting an option and hitting return has no effect.
2022-12-29 00:09:22 +01:00
advplyr
d4bea34423 Merge pull request #1333 from lkiesow/keynoard-navigation-border
Highlight items when navigating via keyboard
2022-12-28 17:01:17 -06:00
advplyr
a551a2d288 Merge pull request #1332 from lkiesow/home-img-alt
Text description of home link
2022-12-28 16:40:21 -06:00
Lars Kiesow
4b0c59b174 Highlight items when navigating via keyboard
This patch highlights items in the app bar if a user uses the keyboard
to navigate in audiobookshelf. This ensures that users actually know
which item they have selected.

This also modifies the text for the library selector, so that users
which are using a screen reader understand that it is a selector for
libraries and not only a button related to the current library.
2022-12-28 22:59:27 +01:00
Lars Kiesow
a0840d2a08 Text description of home link
This patch adds the missing alt attribute to the image linking the home
page of audiobookshelf. This allows screen readers to explain to users
where this link leads to.
2022-12-28 22:55:11 +01:00
advplyr
308ccf470f Add:Open RSS feed for collection #606 #1265 2022-12-27 18:03:31 -06:00
advplyr
4021b6eca1 Merge pull request #1320 from lkiesow/undefined-default
Fix undefined string assignment
2022-12-27 15:37:31 -06:00
advplyr
061695f922 Add:API endpoint for opening RSS feed for collection #606 #1265 2022-12-26 17:48:39 -06:00
advplyr
e803dcd325 Update:RSS feed API routes 2022-12-26 16:58:36 -06:00
Lars Kiesow
128796bd36 Fix undefined string assignment
Assigning something to `process.env.profile`, Node stringifies the value. This
means that assigning `undefined` to an environment variable in Node will result
in it holding the string `undefined`.

This means, for example, that `module.exports.FFPROBE_PATH || 'ffprobe'` in
`server/libs/nodeFfprobe/index.js` will actually result in the string
`undefined`.

This patch fixes several such assignments in the `index.js`, potentially
causing problems in the development mode.
2022-12-26 23:55:14 +01:00
advplyr
775dedc338 Cleanup and remove more vars 2022-12-26 16:08:53 -06:00
advplyr
45c9038954 Fix:Manually updating author image path & realtime update author image #1317 2022-12-26 15:45:42 -06:00
advplyr
8acf962864 Update:Remove relImagePath from Author entity 2022-12-26 15:29:45 -06:00
advplyr
c3fc38639e Merge pull request #1297 from Eschguy/master
Add Caddyfile example to readme
2022-12-25 16:24:57 -06:00
Austin Eschweiler
b60b75c8da Update readme.md 2022-12-25 11:32:12 -06:00
advplyr
0f7edec73b Add note in readme about subfolder 2022-12-24 13:32:45 -06:00
advplyr
321277826f Readme updates 2022-12-24 11:32:40 -06:00
advplyr
6e752af2c0 Update readme with new docs 2022-12-24 11:29:58 -06:00
advplyr
0717ae39db Fix music fine file with inode 2022-12-24 11:12:39 -06:00
advplyr
7bc5902ea8 Merge pull request #1312 from Machou/master
Update fr.json
2022-12-24 06:58:04 -06:00
Machou
a28e1ed5e0 Update fr.json 2022-12-24 07:49:22 +01:00
advplyr
43d9e129a6 Merge pull request #1310 from k9withabone/socket-fixes
Socket fixes
2022-12-23 07:47:32 -06:00
advplyr
b516019ddd Merge branch 'socket-fixes' of https://github.com/k9withabone/audiobookshelf into socket-fixes 2022-12-23 07:34:15 -06:00
advplyr
e4c20d677c Update server/controllers/SeriesController.js
Co-authored-by: Paul Nettleton <paulnett7@hotmail.com>
2022-12-23 07:33:33 -06:00
advplyr
33e183b802 Merge branch 'master' into socket-fixes 2022-12-23 07:27:14 -06:00
advplyr
b884f8fe11 Laying the groundwork for music media type #964 2022-12-22 16:38:55 -06:00
Paul Nettleton
2cba83f1dd Server socket event fixes 2022-12-22 16:26:11 -06:00
Paul Nettleton
a9ee9031c3 Add rss feed minified 2022-12-22 16:04:42 -06:00
advplyr
c3717f6979 Merge pull request #1306 from burghy86/patch-7
Update it.json
2022-12-21 07:22:43 -06:00
advplyr
657d4dd705 Update:Trim whitespace from audio file meta tag values #1305 2022-12-21 07:13:28 -06:00
burghy86
17356ffd79 Update it.json
fix
2022-12-21 10:46:21 +01:00
advplyr
c4be75b5bd Fix:Backups cron scheduler modal #1304 2022-12-20 12:35:31 -06:00
advplyr
57422d0759 Fix:PWA manifest add PNG icon for desktop browsers #1300 2022-12-20 11:57:52 -06:00
advplyr
d2454201b4 Merge pull request #1302 from Hallo951/master
Update de.json
2022-12-20 09:43:43 -06:00
Hallo951
3a92a69693 Update de.json
- minor fixes
- Item translated with medium or media
2022-12-20 09:36:50 +01:00
Austin Eschweiler
d733c9ccc6 Add Caddyfile example to readme
An example Caddyfile based on what I use
2022-12-19 18:10:55 -06:00
advplyr
3e15e09c07 Fix:Get libraries endpoint #1296 2022-12-19 17:46:32 -06:00
advplyr
0592a41d4f Version bump 2.2.11 2022-12-19 17:16:58 -06:00
advplyr
c32e33f804 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-12-19 17:16:48 -06:00
advplyr
616ffb8f79 Add:M4b tool configurable options bitrate/channels/codec #1029 #1257 2022-12-19 17:13:04 -06:00
advplyr
bc771a3a44 Delete DownloadManager.js 2022-12-19 16:20:18 -06:00
advplyr
539d1a2d4f Merge pull request #1294 from tomazed/translation-fr
Update fr.json regarding new Metadata strings
2022-12-19 16:16:56 -06:00
advplyr
4d8cea0bb4 Merge pull request #1293 from springsunx/patch-1
Update zh-cn.json
2022-12-19 16:16:37 -06:00
advplyr
8b46262e93 Merge pull request #1292 from Hallo951/master
Update de.json
2022-12-19 16:16:17 -06:00
advplyr
eb9a077520 Fix scroll listener for multi select inputs 2022-12-19 16:10:45 -06:00
advplyr
3d3a224402 Fix:Edit modal dropdown menus hidden #1295 2022-12-19 15:32:17 -06:00
advplyr
e1397a6dda Update:Author cover image API endpoint to get raw cover image #1291 2022-12-19 15:06:43 -06:00
advplyr
8f49aae979 Fix:Adding podcast and filename sanitize func #1290 2022-12-19 15:02:31 -06:00
Tomazed
c0a13f01d4 Update fr.json regarding new Metadata strings 2022-12-19 16:39:46 +01:00
SunX
efcebc616c Update zh-cn.json 2022-12-19 22:08:54 +08:00
Hallo951
902867c3bc Update de.json 2022-12-19 09:02:17 +01:00
advplyr
b7abd372e4 Version bump 2.2.10 2022-12-18 18:38:00 -06:00
advplyr
147ffc0210 Fix:Cover size widget behind home page arrow #1288 2022-12-18 18:37:03 -06:00
advplyr
1b2ccb6cee Fix:Series inner input behind details modal #1289 2022-12-18 18:35:05 -06:00
advplyr
c58a6b9047 Version bump 2.2.9 2022-12-18 15:50:47 -06:00
advplyr
b787fb18f3 Merge pull request #1251 from lkiesow/PermissionsStartOnly
No PermissionsStartOnly=true
2022-12-18 15:50:10 -06:00
advplyr
17cce9c914 Merge pull request #1287 from lkiesow/subpath-detection
Fix Sub-path Detection
2022-12-18 15:48:28 -06:00
Lars Kiesow
90299e348c Fix Sub-path Detection
If the scanner detects new files with a path containing part of the name
of an already existing library item, the new item will incorrectly be
detected as being a parent directory of the already existing item and
the import will be aborted.

You can follow these steps to reproduce the issue:

```
❯ mkdir audiobooks/author/

❯ mv title\ 10 audiobooks/author
[2022-12-18 22:14:12] DEBUG: [Watcher] File Added /home/lars/dev/audiobookshelf/audiobooks/author/title 10/dictaphone.mp3
[2022-12-18 22:14:16] DEBUG: [DB] Library Items inserted 1

❯ mv title\ 1 audiobooks/author
[2022-12-18 22:15:03] DEBUG: [Watcher] File Added /home/lars/dev/audiobookshelf/audiobooks/author/title 1/dictaphone.mp3
[2022-12-18 22:15:07]  WARN: [Scanner] Files were modified in a parent directory of a library item "title 10" - ignoring
```

Since `'title 10'.startsWith('title 1')` is `true`, the current code
makes this false assumption.

This patch fixes the issue by requiring a path separator to be part of
the matching path. This should ensure that only true parent directories
are detected.

This patch requires audiobookshelf to always use Unix file separators.
But that shouldn't be a problem since audiobookshelf always seems to use
these kinds of separators. Even on Windows.
2022-12-18 22:23:50 +01:00
advplyr
fe25a1bc54 Update item metadata pages sort 2022-12-18 15:16:32 -06:00
advplyr
edbe1851b5 Add translation strings for item metadata utils #1166 2022-12-18 15:11:48 -06:00
advplyr
ad6c5a4f00 Merge pull request #1286 from tomazed/translation-fr
Update fr.json with new strings from d7cc8a0
2022-12-18 14:54:08 -06:00
advplyr
4971787482 Add:Manage genres #1163 2022-12-18 14:52:53 -06:00
Tomazed
56d2ec9c22 Update fr.json with new strings from d7cc8a052a 2022-12-18 21:37:47 +01:00
advplyr
106ddc9541 Fix scan log path #1285 2022-12-18 14:26:15 -06:00
advplyr
4d93e39fa9 Add:Item metadata utils config page for managing tags #1163 2022-12-18 14:17:52 -06:00
advplyr
54b41b15c2 Merge pull request #1282 from lkiesow/google-books-https
Use HTTPS for Google Books Images
2022-12-17 17:59:44 -06:00
advplyr
54ca42a903 Update:Bookshelf view title sign width 2022-12-17 17:50:16 -06:00
advplyr
d7cc8a052a New translation strings for collections/playlist #1166 2022-12-17 17:47:35 -06:00
advplyr
5165f11460 Add:Create playlist from a collection #1226 2022-12-17 17:31:19 -06:00
Lars Kiesow
b47ce4fb24 Use HTTPS for Google Books Images
The API for Google Books will return HTTP image URLs when matiching any
books using it as a search provider. In a secure environment, this
causes browser warnings.

All Google image links support HTTPS and we can safely switch to HTTOS
to avoid these warnings.
2022-12-18 00:18:11 +01:00
advplyr
9b1f7f566f Fix:On bookshelf view show series name placard on shelf #1239 2022-12-17 16:36:41 -06:00
advplyr
10295b000a Update:Remove HOST default to allow for ipv6 #1256 2022-12-17 15:55:53 -06:00
advplyr
c06d734d5e Update:Persist series sort/filter options #1272 2022-12-17 15:10:25 -06:00
advplyr
49a69193d8 Comments where user settings needs to be removed 2022-12-17 14:52:10 -06:00
advplyr
7852804a9c Update:Remove call to server for user settings, user settings stored locally 2022-12-17 14:50:01 -06:00
advplyr
415dda37a4 Update:Match tab persist selected details to use #1276 2022-12-17 10:27:27 -06:00
advplyr
179d339afd Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-12-16 17:58:42 -06:00
advplyr
858c1a7353 Update:Series inner input modal update button Save to Submit #1277 2022-12-16 17:57:46 -06:00
advplyr
0b42b81558 Update:Author modal Submit button to Save #1280 2022-12-16 17:54:00 -06:00
advplyr
f9678dec2f Merge pull request #1275 from tomazed/translation-fr
Update fr.json for batch update
2022-12-15 17:58:17 -06:00
advplyr
82642b295c Merge pull request #1271 from tomazed/localization-update
Missing Localization in Appbar.vue
2022-12-15 17:57:52 -06:00
advplyr
ba3d84a924 Update client/components/app/Appbar.vue 2022-12-15 17:57:42 -06:00
advplyr
96e2f934a3 Merge pull request #1270 from Hallo951/master
Update de.json
2022-12-15 17:56:53 -06:00
advplyr
a68ade2b3d Update:Select largest cover image from Google Books provider #1244 2022-12-15 17:54:02 -06:00
advplyr
4fcdeda447 Add:Book library filter for missing cover image #1243 2022-12-15 17:46:27 -06:00
advplyr
dc03835742 Update:Trim whitespace from chapter titles in chapter editor #1248 2022-12-15 17:40:34 -06:00
advplyr
50430e6b27 Update:Audiobook RSS feed track episode pub dates #1253 2022-12-15 17:36:29 -06:00
advplyr
d130dd6d5e Fix:Setting file ownership for /config and /metadata/logs #584 2022-12-15 17:30:45 -06:00
advplyr
793cc989de Fix:Overflowing edit library folders #1266 2022-12-15 16:51:37 -06:00
Tomazed
27d8c4d67c Update fr.json for batch update 2022-12-15 23:19:46 +01:00
Tomazed
48f493a9f5 Missing Localization in Appbar.vue 2022-12-15 17:50:13 +01:00
Hallo951
04992ee3fb Update de.json 2022-12-15 16:36:28 +01:00
advplyr
4d8e2a1279 Update:Max filename to 255 bytes in utf-16 #1261 2022-12-13 17:46:18 -06:00
advplyr
2af7b6b6f1 Add translation strings for batch update page #1166 2022-12-13 16:59:46 -06:00
advplyr
e59351566d Add:Batch append details #848 2022-12-13 16:28:05 -06:00
advplyr
05d10b73c3 Merge pull request #1231 from k9withabone/server/respond-with-objects
Server respond with objects
2022-12-12 17:53:57 -06:00
advplyr
41e192c6a5 Update more vars 2022-12-12 17:52:20 -06:00
advplyr
ea42ab7624 Update get all users route 2022-12-12 17:48:57 -06:00
advplyr
2d9035d90b Update get tags route and revert podcast/books search route 2022-12-12 17:45:51 -06:00
advplyr
0ae853c119 Update library items batch get route 2022-12-12 17:36:53 -06:00
advplyr
3c0fdff7b4 Update libraries reorder and get all authors routes 2022-12-12 17:33:59 -06:00
advplyr
eede2bbd46 Update for filesystem and libraries api update and revert personalized shelves route 2022-12-12 17:29:56 -06:00
advplyr
5c31687a0f Merge branch 'master' into server/respond-with-objects 2022-12-12 17:20:14 -06:00
advplyr
6b654d3c2d Update:Starting session for finished item sets the user start time back to 0 2022-12-12 17:18:56 -06:00
Lars Kiesow
91cbe45839 No PermissionsStartOnly=true
This patch removes `PermissionsStartOnly=true` from the systemd unit
file used for packaging. This shouldn't be necessary for any commands
run by the unit.
2022-12-06 00:52:23 +01:00
advplyr
7883d4a97f Merge pull request #1249 from lkiesow/tooltips
Add Missing Tooltips
2022-12-05 17:13:14 -06:00
advplyr
9f4547cff8 Update client/components/app/Appbar.vue 2022-12-05 17:13:03 -06:00
advplyr
a98106593d Update client/components/app/Appbar.vue 2022-12-05 17:12:58 -06:00
advplyr
c625b3f08c Update client/components/app/Appbar.vue 2022-12-05 17:12:53 -06:00
advplyr
9e7f09c21b Merge pull request #1245 from burghy86/patch-6
Update it.json
2022-12-05 17:03:19 -06:00
Lars Kiesow
616caecdf1 Add Missing Tooltips
This patch adds a few more missing tooltips to the user interface.
2022-12-05 23:16:27 +01:00
burghy86
cee19c5128 Update it.json
fix and add
2022-12-05 16:50:16 +01:00
advplyr
67db41a525 Update:Get item cover API endpoint to allow for returning the raw cover image 2022-12-04 16:23:15 -06:00
advplyr
3ea3e55d17 Fix:Typo in library settings 2022-12-03 17:50:54 -06:00
advplyr
4959a28485 Update:Playlists cover size 2022-12-03 15:44:53 -06:00
advplyr
6d2482a98e Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-12-01 18:08:57 -06:00
advplyr
4b23b842bb Version bump 2.2.8 2022-12-01 18:08:52 -06:00
advplyr
07bebc8808 Merge pull request #1238 from Hallo951/master
Update german language
2022-12-01 18:06:04 -06:00
advplyr
027d7f7a5b Fix:Chapter editor show save button when applying lookup data #1237 2022-12-01 17:42:02 -06:00
advplyr
6baa0fa047 Fix:Multi-select library items using shift key #1236 2022-12-01 17:39:23 -06:00
advplyr
8425fac543 Update PWA workbox 2022-12-01 17:26:22 -06:00
Hallo951
7b2ac7b9e9 Update german language 2022-12-01 11:27:06 +01:00
advplyr
bf071be247 Version bump 2.2.7 2022-11-30 17:43:13 -06:00
advplyr
6c05a0af8a Merge pull request #1234 from tomazed/master
[Update] client/strings/fr.json
2022-11-30 17:33:59 -06:00
advplyr
0e292c64c4 Update:Only emit library socket events to users with access to lib 2022-11-30 17:32:59 -06:00
advplyr
725f8eecdb Fix:Batch selecting ebooks showing play button in appbar #1235 2022-11-30 17:09:00 -06:00
Tomazed
521a673094 [Update] client/strings/fr.json 2022-11-30 23:20:29 +01:00
advplyr
d917f0e37d Fix:Ebook reader for ebooks in root folder #1232 2022-11-30 16:15:25 -06:00
advplyr
7ed5b1744f Var cleanup 2022-11-29 18:03:50 -06:00
Paul Nettleton
c9ab2a242d Update MiscController.js to respond with objects
Changes:
- `getAllTags` (GET /api/tags)
2022-11-29 12:26:59 -06:00
Paul Nettleton
13532cba14 Update SearchController.js to respond with objects
Changes:
- `findCovers` (GET /api/search/covers)
- `findBooks` (GET /api/search/books)
- `findPodcasts` (GET /api/search/podcast)
2022-11-29 12:23:02 -06:00
Paul Nettleton
3fb2bd3362 Update SeriesController.js to respond with objects
Changes:
- `search` (GET /api/series/search)
2022-11-29 12:08:40 -06:00
Paul Nettleton
e80c3a1c5a Update AuthorController.js to respond with objects
Changes:
- `search` (GET /api/authors/search)
2022-11-29 12:04:45 -06:00
Paul Nettleton
e04d26307e Update FileSystemController.js to respond with objects
Changes:
- `getPaths` (GET /api/filesystem)
2022-11-29 11:55:22 -06:00
Paul Nettleton
b8f74e1c98 Update CollectionController.js to respond with objects
Changes:
- `findAll` (GET /api/collections)
2022-11-29 11:48:21 -06:00
Paul Nettleton
0851050392 Update UserController.js to respond with objects
Changes:
- `findAll` (GET /api/users)
2022-11-29 11:43:39 -06:00
Paul Nettleton
b84882d9d1 Update LibraryItemController.js to respond with objects
Changes:
- `batchGet` (POST /api/items/batch/get)
2022-11-29 11:37:45 -06:00
Paul Nettleton
cd37a7618e Update LibraryController.js to respond with objects
Changes:
- `findAll` (GET /api/libraries)
- `getLibraryUserPersonalizedOptimal` (GET /api/libraries/<ID>/personalized)
- `getAuthors` (GET /api/libraries/<ID>/authors)
- `reorder` (POST /api/libraries/order)
2022-11-29 11:30:25 -06:00
advplyr
64a7cfac3b Merge pull request #1230 from springsunx/patch-1
Update zh-cn.json
2022-11-29 07:57:28 -06:00
SunX
1ee7ba54f8 Update zh-cn.json 2022-11-29 21:45:15 +08:00
advplyr
6bb18f8800 Fix:Purge cache buttons on config page for mobile screens #1228 2022-11-28 17:55:52 -06:00
advplyr
b26b854963 Translation strings for other langs #1166 2022-11-28 17:52:36 -06:00
advplyr
7d58361ced Update:Chapter editor add reset button, cleanup ui, add translation strings #1166 2022-11-28 17:49:58 -06:00
advplyr
a3723f3d06 Update:New translation strings for chapter editor #1166 2022-11-28 17:00:06 -06:00
advplyr
78d1cd0cfb Add:Chapter editor button to set chapters using audio tracks #1229 2022-11-28 16:55:13 -06:00
advplyr
d41366a417 Fix:Playlist API endpoint permissions 2022-11-28 16:29:04 -06:00
advplyr
a2347150a2 Fix:PWA manifest start_url 2022-11-28 16:26:26 -06:00
advplyr
d33f23dede Merge pull request #1225 from springsunx/patch-1
Update zh-cn.json
2022-11-28 09:23:22 -06:00
SunX
cfca2be1b2 Update zh-cn.json 2022-11-28 18:14:08 +08:00
advplyr
73f07c1392 Update:More translation strings for library filters #1166 2022-11-27 17:55:25 -06:00
advplyr
4541e9ddc3 Fix:Library filters when using other language #1166 2022-11-27 17:54:40 -06:00
advplyr
972271a1a9 Add:Library filter for single & multi-track audiobooks #1213 2022-11-27 17:42:02 -06:00
advplyr
e97d92a8ac Fix:Copy to clipboard 2022-11-27 17:10:06 -06:00
advplyr
9a73e352d1 Version bump 2.2.6 2022-11-27 16:07:26 -06:00
advplyr
08f09f81fa Fix:Updating authors image 2022-11-27 15:35:47 -06:00
advplyr
c72609013c Update:i18n strings for playlists 2022-11-27 15:14:28 -06:00
advplyr
29a6434fdc Playlist and collections cleanup 2022-11-27 15:12:55 -06:00
advplyr
eb2ea9950a Remove playlists for user when removing user 2022-11-27 14:54:17 -06:00
advplyr
e307ded192 Remove item from playlist when removing item, update PlaylistController socket events to emit to playlist userId 2022-11-27 14:49:21 -06:00
advplyr
2d6c997b38 Cleanup collections add/create modal 2022-11-27 14:39:29 -06:00
advplyr
232a80a848 Fix playlist cover for single item playlsit 2022-11-27 14:35:55 -06:00
advplyr
083f8faa46 Update:Fetch library API to return numUserPlaylists, only display playlists in siderail if user has playlists 2022-11-27 14:34:27 -06:00
advplyr
0fcf978ffe Add /playlist/:id to dynamic routes 2022-11-27 14:23:28 -06:00
advplyr
c1360267c6 Fix:Collection page set library on refresh 2022-11-27 14:22:46 -06:00
advplyr
084bea6b15 Update:Collections i18n strings 2022-11-27 14:19:22 -06:00
advplyr
2032dd88ba Hide add to playlist buttons for ebooks 2022-11-27 14:16:22 -06:00
advplyr
b11b1be432 Cleanup playlist cover component 2022-11-27 14:13:31 -06:00
advplyr
b743b34fab Update playlist cover to square of 4 items 2022-11-27 14:11:17 -06:00
advplyr
950d10091d Add playlists to bookshelf item context menu 2022-11-27 13:44:54 -06:00
advplyr
af0e02b9a2 Update lazybookshelf playlist socket events 2022-11-27 13:38:08 -06:00
advplyr
1332147c4a Update playlist icons 2022-11-27 13:34:50 -06:00
advplyr
f07cb1e7a3 Fix player UI icon buttons from pr #1187 2022-11-27 12:36:10 -06:00
advplyr
53dbdd115f Update:Playlists for podcasts 2022-11-27 12:33:38 -06:00
advplyr
a217ed5574 Update:Handle edit playlist item 2022-11-27 12:17:58 -06:00
advplyr
531f947754 Update:Remove playlist if all items are removed 2022-11-27 12:04:49 -06:00
advplyr
c957e9483e Update:Playlist edit modal 2022-11-27 11:53:48 -06:00
advplyr
623a706555 Update:Create playlist items table 2022-11-26 17:58:52 -06:00
advplyr
7e171576e0 Update:Add libraries playlists API endpoint, add lazy playlists card 2022-11-26 17:24:46 -06:00
advplyr
0979b3e03d Update:Playlist cover & json expanded 2022-11-26 16:45:54 -06:00
advplyr
1131bfa751 Update:Creating user playlists modal 2022-11-26 16:25:14 -06:00
advplyr
f9b87b94bf Add:Playlist API endpoints 2022-11-26 15:14:45 -06:00
advplyr
59ed2ec87f Merge branch 'master' into playlists 2022-11-26 12:53:30 -06:00
advplyr
7b0b79e3a1 Merge pull request #1221 from jmt-gh/settings_page_component
Implement a Settings Content component
2022-11-26 11:07:09 -06:00
advplyr
53f73e1201 Settings content updates 2022-11-26 11:08:09 -06:00
jmt-gh
c62a1dfff0 Convert Backup page to use new settings component
This commit moves the Backup page to use the new settings component.

As part of this, I had to do a little bit of modification for some of
the strings, as I cleaned up the way the strings are laid out on the
page.
2022-11-25 21:12:23 -08:00
jmt-gh
61f8055493 convert notifications page to use new settings component 2022-11-25 21:12:12 -08:00
jmt-gh
000d7fd249 convert library stats page to use new settings component 2022-11-25 21:11:45 -08:00
jmt-gh
087de03a1f convert log page to use new settings component 2022-11-25 21:11:32 -08:00
jmt-gh
a3ca6159fb convert stats page to use new settings component 2022-11-25 21:11:17 -08:00
jmt-gh
5de6ee136a Convert Users settings page to use new component
This commit moves the Users settings page to use the new Settings
Content component. Similar to the Libraries page, this one is already a
component. I mimiced the behavior of the existing libraries component to
get the same functionality needed here
2022-11-25 21:10:05 -08:00
jmt-gh
d5a19f2b42 Convert Library settings page to use new component
This commit moves the library settings page over to use the new
component. This page is 1 of 2 that actually has a component for itself,
so it was mostly just modifying that existing component and wrapping it
2022-11-25 21:08:54 -08:00
jmt-gh
e3ec5dd506 convert sessions page to use settings content component 2022-11-25 21:08:10 -08:00
jmt-gh
762748225d convert settings page to use settings content component 2022-11-25 21:07:41 -08:00
jmt-gh
4db34e0c56 Add new settings content component
This commit adds a new settings conent component. This is a container
component for the settings pages, so that they all get the same
formatting by default. It handles the header text, description text, and
any "add new" plus button as needed.
2022-11-25 21:06:18 -08:00
advplyr
fb078d05bc Merge pull request #1218 from jmt-gh/fix_notifications_ui
Update notification settings UI to match other pages
2022-11-25 14:01:00 -06:00
jmt-gh
f59edffa43 update notification settings to match other pages 2022-11-25 11:04:11 -08:00
advplyr
7aa0ddb71f Merge branch 'master' into playlists 2022-11-25 08:09:46 -06:00
advplyr
a0a6256c7a Merge pull request #1212 from Weldawadyathink/master
Improve title naming for single file audiobooks when opening RSS feed
2022-11-25 06:21:55 -06:00
advplyr
df7e331605 Update server/objects/FeedEpisode.js 2022-11-25 06:21:50 -06:00
Spenser Bushey
8c23704e17 Merge branch 'advplyr:master' into master 2022-11-24 23:12:55 -08:00
Spenser Bushey
12abb1731c Single file audiobook rss feed naming logic moved to FeedEpisode.js 2022-11-24 23:10:20 -08:00
advplyr
180293ebc1 Update:Cleanup socket usage & add func for emitting events to admin users 2022-11-24 16:35:26 -06:00
advplyr
e2af33e136 Update:Refactor socket connection management into SocketAuthority 2022-11-24 15:53:58 -06:00
advplyr
42e68edc65 Fix:Users table activity & cleanup 2022-11-24 14:44:09 -06:00
advplyr
47e732c213 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-11-24 13:51:53 -06:00
advplyr
77a86d92f4 Update:Socket event for getting online users & test event for messaging all online users 2022-11-24 13:51:41 -06:00
advplyr
64a8a046c1 Update:Backups API endpoints, add get all backups route, update socket init event payload 2022-11-24 13:14:29 -06:00
Spenser Bushey
1f02cbddd3 Merge branch 'advplyr:master' into master 2022-11-23 22:37:02 -08:00
Spenser Bushey
5e7bca02b3 RSS feeds for single file audiobooks now use book title 2022-11-23 22:36:07 -08:00
advplyr
097f9549b1 Merge pull request #1211 from lkiesow/started-at
Fix startedAt in progress API
2022-11-23 17:37:40 -06:00
Lars Kiesow
45434b16e0 Fix startedAt in progress API
If no progress had been set before, setting `startedAt` did not work and
it would always been set to `finishedAt` or `Date.now()`. Settings this
if any progress had already been recorded did work.

This fixes the problem so that setting `startedAt` it properly works in
both cases.
2022-11-24 00:16:20 +01:00
advplyr
6af5ac2be1 Merge pull request #1208 from konradorlinski/polish-translation
Add more polish translation
2022-11-23 16:08:20 -06:00
advplyr
34ff7efa27 Merge pull request #1205 from lkiesow/api-start-end-date
Allow specifying start and end of progress via API
2022-11-23 16:07:53 -06:00
ko
8f4391003f Add more polish translation 2022-11-23 18:29:35 +01:00
advplyr
ecefb30f3d Merge pull request #1206 from lkiesow/400-bad-request
Respond with bad request to unvalid request data
2022-11-23 07:27:18 -06:00
Lars Kiesow
a8162b57ba Respond with bad request to unvalid request data
This patch updates the batch progress update endpoint to respond with a
`400 Bad Request` instead of a `500 Internal Server Error` if a user
sends an invalid request with no body. This is a user error after all.

```
❯ curl -i -X PATCH \
  'http://127.0.0.1:3333/api/me/progress/batch/update' \
  -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5Q_MoRptP0oI' \
  -H 'Content-Type: application/json'
HTTP/1.1 400 Bad Request
…

Missing request payload
```
2022-11-23 02:15:36 +01:00
Lars Kiesow
b0edac4234 Allow specifying start and end of progress via API
This patch is a minor extension to the update progress and batch update
progress API and allows you to specify `finishedAt` and `startedAt` when
updating a progress.

If not specified, both values are still automatically set to the current
time. If just `finishedAt` is specified, `startedAt` is set to the same
value.

Example API request:

```
❯ curl -i -X PATCH \
  'http://127.0.0.1:3333/api/me/progress/li_ywupqxw5d22adcadpa' \
  -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiJyb290IiwidXNlcm5hbWUiOiJyb290IiwiaWF0IjoxNjY4OTYxNjAxfQ._NbilCoFy_hfoqy7uvbV4E_0X6qgLYapQ_MoRptP0oI' \
  -H 'Content-Type: application/json' \
  --data-raw '{"isFinished":true, "finishedAt": 1668556852000, "startedAt": 1668056852000}'
```
2022-11-23 01:32:52 +01:00
advplyr
98c4045a71 Setting up Playlist model 2022-11-22 18:08:11 -06:00
advplyr
24e90e2ead Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-11-22 16:58:05 -06:00
advplyr
145e0217b6 Update:Media session show next/prev track buttons #1201 2022-11-22 16:57:18 -06:00
advplyr
e5925fb1b6 Create config.yml 2022-11-22 16:27:59 -06:00
advplyr
9e416d02bd Merge pull request #1200 from springsunx/patch-1
Update _id.vue
2022-11-22 04:33:56 -06:00
SunX
82b7068130 Update _id.vue 2022-11-22 12:51:37 +08:00
advplyr
579ee36857 Merge pull request #1197 from Hallo951/patch-1
Update de.json
2022-11-21 10:08:57 -06:00
advplyr
4f2d7a519d Update client/strings/de.json 2022-11-21 10:08:51 -06:00
advplyr
a3642d92c5 Update client/strings/de.json 2022-11-21 10:08:45 -06:00
advplyr
224f36164f Update client/strings/de.json 2022-11-21 10:08:39 -06:00
Hallo951
638c220ae8 Update de.json 2022-11-21 16:27:41 +01:00
advplyr
51070b3e7b Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-11-21 07:52:38 -06:00
advplyr
0aa2723063 Update:API status codes and update server settings response payload 2022-11-21 07:52:33 -06:00
advplyr
1af66c8e8b Merge pull request #1196 from tomazed/master
Update fr.json
2022-11-21 07:18:51 -06:00
advplyr
7df8795d52 Fix:Icon sizes 2022-11-21 07:18:10 -06:00
Tomazed
a0e9ae7092 Merge branch 'advplyr:master' into master 2022-11-21 13:54:48 +01:00
Tomazed
0f0d8e317a Update fr.json 2022-11-21 13:54:05 +01:00
advplyr
3d5ca7d5c4 Merge pull request #1192 from lkiesow/update-edit-author-modal
Update Author Modal on Changes
2022-11-21 06:43:32 -06:00
advplyr
e33104fa2b Merge pull request #1193 from lkiesow/no-feed-no-error
No feed log level
2022-11-21 06:40:38 -06:00
advplyr
a2f1723642 Update log level for RSS feed requests 2022-11-21 06:39:32 -06:00
advplyr
93357cf280 Merge pull request #1195 from springsunx/patch-1
Update zh-cn.json
2022-11-21 06:28:53 -06:00
advplyr
767427c787 Merge pull request #1194 from burghy86/patch-5
Update it.json
2022-11-21 06:27:52 -06:00
SunX
9377631896 Update zh-cn.json 2022-11-21 19:01:34 +08:00
burghy86
d08af094b8 Update it.json
fix and add new string
2022-11-21 11:40:40 +01:00
Lars Kiesow
c307b1e6fb No feed log level
This patch drops the log level for logging requests to non-existing
feeds from error to debug. The reasoning behind this is that this is a
client error and a client error is returned and can be handled by the
client.

Otherwise anyone can easily spam the logs with error messages by just
requesting non-existing feeds.
2022-11-21 01:54:25 +01:00
Lars Kiesow
d387d5b758 Update Author Modal on Changes
If you are on the home page and open the edit author modal, you can
automatically update all data by clicking “Quick Match” or you can
remove a set image by clicking 🗑.

Both options will update the actual data, but not the data in the open
modal. This means that, for example, a picture is still shown in the
modal after deleting it. That's confusing.

This patch fixes the bug and makes sure the modal is updated if the data
is updated.
2022-11-21 01:48:19 +01:00
advplyr
c285dd666d Fix:Australian audible TLD #1191 2022-11-20 17:17:25 -06:00
advplyr
b37b382ea7 Update:More translation strings #1103 #1166 2022-11-20 17:11:51 -06:00
advplyr
a2cd755ffa Merge pull request #1188 from lkiesow/a11y-tooltip
Make Tooltips Accessible
2022-11-20 16:47:27 -06:00
advplyr
340aedfe13 Merge pull request #1187 from lkiesow/tooltip
Add a few tooltips
2022-11-20 16:44:02 -06:00
advplyr
6fafa7a75e Update other string files with new strings #1103 2022-11-20 16:45:11 -06:00
advplyr
03df5aaf42 Update client/strings/en-us.json 2022-11-20 16:41:51 -06:00
advplyr
6d84db08a8 Update client/strings/en-us.json 2022-11-20 16:41:47 -06:00
advplyr
1a5e0d2a5e Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-11-20 16:12:38 -06:00
advplyr
70d887bada Update:API status codes and default provider for findCovers route 2022-11-20 16:12:30 -06:00
Lars Kiesow
ee0ac00f80 Make Tooltips Accessible
When using accessibility tools like screen magnifiers, dynamic screen
content can be quite problematic. In particular content, which only
appears if you interact with elements somewhere else on the screen. That
is the case, for example, with the current implementation of tooltips
used by audiobookshelf.

This patch provides a slight adjustment, keeping the tooltips open if
you hover over them. This allows users to have better access to the
content.
2022-11-20 20:02:31 +01:00
Lars Kiesow
fdfb07ff2c Add a few tooltips
Starting to use audiobookshelf, the function of some buttons weren't
very clear to me and while some buttons have tooltips, others have not.

This patch adds some additional tooltips to the user interface,
further explaining some of the functionality.
2022-11-20 18:50:34 +01:00
advplyr
b648155170 Merge pull request #1185 from springsunx/patch-1
Update zh-cn.json
2022-11-20 08:01:25 -06:00
SunX
59dc5299b1 Update zh-cn.json 2022-11-20 21:30:55 +08:00
advplyr
357a63a4d9 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2022-11-19 17:57:34 -06:00
advplyr
94912c7542 Update:Enable PWA workbox #354 2022-11-19 17:57:26 -06:00
advplyr
fae182b328 Merge pull request #1182 from tomazed/master
Update fr.json from new strings in  advplyr/audiobookshelf@d24ed98bcd
2022-11-19 15:41:36 -06:00
Tomazed
9ba2f3e33a Merge branch 'advplyr:master' into master 2022-11-19 22:40:29 +01:00
advplyr
5ca2bc5d64 Version bump v2.2.5 2022-11-19 14:50:57 -06:00
Tomazed
442687b198 Fix: Incorrect translation in fr.json 2022-11-19 21:49:27 +01:00
Tomazed
7e400d3e9c Update fr.json from new strings in advplyr/audiobookshelf@d24ed98bcd 2022-11-19 21:48:17 +01:00
advplyr
e3ba739db5 Update:Encode & embed metadata API endpoints, separate cache & search endpoints into controllers 2022-11-19 13:28:06 -06:00
advplyr
cd92a22f4d Update:Account button icon size 2022-11-19 12:09:05 -06:00
advplyr
d24ed98bcd Update:Add translation strings for other languages #1103 #1166 2022-11-19 11:44:54 -06:00
advplyr
bcd224f534 Update:Add translation strings for bookshelf #1103 #1166 2022-11-19 11:44:08 -06:00
advplyr
1a93103e50 Update:Remove limit for batch editing #1170 2022-11-19 11:27:08 -06:00
advplyr
45ccf9d4be Update:Hide bookshelf toolbar inputs when batch selecting 2022-11-19 11:24:21 -06:00
advplyr
052a8307b3 Merge pull request #1181 from burghy86/patch-4
Update it.json
2022-11-19 11:10:40 -06:00
burghy86
a0c0b9ea76 Update it.json
fix with the new it.json file
2022-11-19 17:47:52 +01:00
advplyr
7485cf1a26 Add:Batch select audiobook play button, item page mobile screen size cleanup 2022-11-19 10:20:10 -06:00
advplyr
8931702f1b Update:Filter submenu translations & new translation string #1103 #1166 2022-11-19 09:17:41 -06:00
advplyr
00fae3eb16 Merge pull request #1180 from tomazed/master
Update fr.json
2022-11-19 09:06:50 -06:00
advplyr
003e8e17be Merge pull request #1177 from springsunx/patch-1
Update zh-cn.json
2022-11-19 09:06:37 -06:00
Tomazed
edd9443d51 Update fr.json 2022-11-19 15:59:38 +01:00
SunX
b93a4c6792 Update zh-cn.json 2022-11-19 18:24:57 +08:00
advplyr
30cf144090 Merge pull request #1176 from tomazed/master
Fix: French translations regarding issue #1166
2022-11-18 17:27:39 -06:00
advplyr
f17abef20a Update:Add more translation strings for sort/filter menus #1103 #1166 2022-11-18 16:59:11 -06:00
Tomazed
937438800e Fix: French translations regarding issue #1166 2022-11-18 23:38:25 +01:00
advplyr
892fb6410c Update:Add client ip address in server log for failed auth attempts #1172 2022-11-17 18:04:11 -06:00
advplyr
7008267e42 Fix:Mobile series books sortBy filter #1152 2022-11-17 17:09:27 -06:00
advplyr
2e5e02472c Update:Playback session sync local status codes 2022-11-17 17:00:37 -06:00
advplyr
f9d37228cf Merge pull request #1171 from springsunx/patch-1
Update zh-cn.json
2022-11-17 15:48:01 -06:00
SunX
f48d52a489 Update zh-cn.json 2022-11-17 10:23:13 +08:00
advplyr
7d8c8fa5bb Update:Navigation for mobile screen include authors page and podcast latest page 2022-11-16 17:23:18 -06:00
advplyr
96a739e22d Fix:Removing all sessions from last page of sessions table #1168 2022-11-16 16:28:46 -06:00
advplyr
c3ec036009 Update:New strings for translation #1103 #1166 2022-11-16 16:11:06 -06:00
advplyr
c7794e00f6 Update:Author image from cache API status codes 2022-11-16 15:32:32 -06:00
advplyr
3316394f5c Add:Button on series books page to re-add series to continue listening #1159 2022-11-15 17:20:57 -06:00
advplyr
c5d66989a6 Update:Bookshelf toolbar for series page on mobile 2022-11-15 17:05:03 -06:00
advplyr
e6b886a511 Merge pull request #1156 from tomazed/master
Add French Translation
2022-11-15 16:49:57 -06:00
Tomazed
9bdfb05ea6 Fixed typo in ButtonRemoveSeriesFromContinueSeries 2022-11-15 10:08:23 +01:00
Tomazed
52d02b32f7 update French translation with "LabelStatsOverallDays" and "LabelStatsOverallHours" 2022-11-15 09:55:56 +01:00
Tomazed
adff5a7705 Merge branch 'advplyr:master' into master 2022-11-15 09:51:06 +01:00
advplyr
60fb4090ff Merge pull request #1155 from springsunx/patch-1
Update zh-cn.json
2022-11-14 18:04:34 -06:00
SunX
dd28be0113 Update zh-cn.json 2022-11-15 08:02:26 +08:00
advplyr
5a60bb8267 Update:Stats translation for Overall Days/Hours 2022-11-14 17:55:45 -06:00
Tomazed
2749b710e6 Add French Translation 2022-11-14 17:45:26 +01:00
SunX
55ddcde631 Update zh-cn.json
fix translation errors
2022-11-14 20:45:00 +08:00
advplyr
4d2bcfd167 Fix:Revert calculating total entities 2022-11-13 16:46:43 -06:00
273 changed files with 16625 additions and 7474 deletions

8
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: Discord
url: https://discord.gg/pJsjuNCKRq
about: Ask questions, get help troubleshooting, and join the Abs community here.
- name: Matrix
url: https://matrix.to/#/#audiobookshelf:matrix.org
about: Ask questions, get help troubleshooting, and join the Abs community here.

View File

@@ -11,7 +11,6 @@ ExecReload=/bin/kill -HUP $MAINPID
Restart=always
User=audiobookshelf
Group=audiobookshelf
PermissionsStartOnly=true
[Install]
WantedBy=multi-user.target

View File

@@ -26,7 +26,7 @@
-webkit-font-smoothing: antialiased;
}
.material-icons:not(.text-xs):not(.text-sm):not(.text-md):not(.text-base):not(.text-lg):not(.text-xl):not(.text-2xl):not(.text-3xl):not(.text-4xl):not(.text-5xl):not(.text-6xl):not(.text-7xl):not(.text-8xl) {
.material-icons:not([class*="text-"]) {
font-size: 1.5rem;
}
@@ -44,30 +44,10 @@
-webkit-font-smoothing: antialiased;
}
.material-icons-outlined:not(.text-xs):not(.text-sm):not(.text-md):not(.text-base):not(.text-lg):not(.text-xl):not(.text-2xl):not(.text-3xl):not(.text-4xl):not(.text-5xl):not(.text-6xl):not(.text-7xl):not(.text-8xl) {
.material-icons-outlined:not([class*="text-"]) {
font-size: 1.5rem;
}
@font-face {
font-family: 'Gentium Book Basic';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(~static/fonts/GentiumBookBasic.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Gentium Book Basic';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(~static/fonts/GentiumBookBasic.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Source Sans Pro';

View File

@@ -20,42 +20,67 @@
transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
}
.slide-enter-to, .slide-leave {
.slide-enter-to,
.slide-leave {
max-height: 600px;
overflow: hidden;
}
.slide-enter, .slide-leave-to {
.slide-enter,
.slide-leave-to {
overflow: hidden;
max-height: 0;
}
.menu-enter, .menu-leave-active {
.menu-enter,
.menu-leave-active {
transform: translateY(-15px);
}
.menu-enter-active {
transition: all 0.2s;
}
.menu-leave-active {
transition: all 0.1s;
}
.menu-enter,
.menu-leave-active {
opacity: 0;
}
.menux-enter, .menux-leave-active {
.menux-enter,
.menux-leave-active {
transform: translateX(15px);
}
.menux-enter-active {
transition: all 0.2s;
}
.menux-leave-active {
transition: all 0.1s;
}
.menux-enter,
.menux-leave-active {
opacity: 0;
}
.list-complete-item {
transition: all 0.8s ease;
}
.list-complete-enter-from,
.list-complete-leave-to {
opacity: 0;
transform: translateY(30px);
}
.list-complete-leave-active {
position: absolute;
}

View File

@@ -1,13 +1,13 @@
<template>
<div class="w-full h-16 bg-primary relative">
<div id="appbar" class="absolute top-0 bottom-0 left-0 w-full h-full px-2 md:px-6 py-1 z-50">
<div id="appbar" class="absolute top-0 bottom-0 left-0 w-full h-full px-2 md:px-6 py-1 z-60">
<div class="flex h-full items-center">
<nuxt-link to="/">
<img src="~static/icon.svg" class="w-8 min-w-8 h-8 mr-2 sm:w-12 sm:min-w-12 sm:h-12 sm:mr-4" />
<img src="~static/icon.svg" :alt="$strings.ButtonHome" class="w-8 min-w-8 h-8 mr-2 sm:w-10 sm:min-w-10 sm:h-10 sm:mr-4" />
</nuxt-link>
<nuxt-link to="/">
<h1 class="text-2xl font-book 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 <span v-if="showExperimentalFeatures" class="material-icons text-lg text-warning pr-1">logo_dev</span></h1>
</nuxt-link>
<ui-libraries-dropdown class="mr-2" />
@@ -18,55 +18,65 @@
<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-warning text-opacity-50"> cast </span>
<span class="material-icons-outlined text-2xl text-warning text-opacity-50"> cast </span>
</ui-tooltip>
<div v-if="isChromecastInitialized" class="w-6 min-w-6 h-6 ml-2 mr-1 sm:mx-2 cursor-pointer">
<google-cast-launcher></google-cast-launcher>
</div>
<nuxt-link v-if="currentLibrary" to="/config/stats" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 hidden sm:flex items-center justify-center mx-1">
<span class="material-icons" aria-label="User Stats" role="button">equalizer</span>
<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>
</ui-tooltip>
</nuxt-link>
<nuxt-link v-if="userCanUpload && currentLibrary" to="/upload" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
<span class="material-icons" aria-label="Upload Media" role="button">upload</span>
<nuxt-link v-if="userCanUpload && currentLibrary" to="/upload" class="hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
<ui-tooltip :text="$strings.ButtonUpload" direction="bottom" class="flex items-center">
<span class="material-icons text-2xl" aria-label="Upload Media" role="button">upload</span>
</ui-tooltip>
</nuxt-link>
<nuxt-link v-if="userIsAdminOrUp" to="/config" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
<span class="material-icons" aria-label="System Settings" role="button">settings</span>
<nuxt-link v-if="userIsAdminOrUp" to="/config" class="hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
<ui-tooltip :text="$strings.HeaderSettings" direction="bottom" class="flex items-center">
<span class="material-icons text-2xl" aria-label="System Settings" role="button">settings</span>
</ui-tooltip>
</nuxt-link>
<nuxt-link to="/account" class="relative w-9 h-9 md:w-32 bg-fg border border-gray-500 rounded shadow-sm ml-1.5 sm:ml-3 md:ml-5 md:pl-3 md:pr-10 py-2 text-left focus:outline-none sm:text-sm cursor-pointer hover:bg-bg hover:bg-opacity-40" aria-haspopup="listbox" aria-expanded="true">
<nuxt-link to="/account" class="relative w-9 h-9 md:w-32 bg-fg border border-gray-500 rounded shadow-sm ml-1.5 sm:ml-3 md:ml-5 md:pl-3 md:pr-10 py-2 text-left sm:text-sm cursor-pointer hover:bg-bg hover:bg-opacity-40" aria-haspopup="listbox" aria-expanded="true">
<span class="items-center hidden md:flex">
<span class="block truncate">{{ username }}</span>
</span>
<span class="h-full md:ml-3 md:absolute inset-y-0 md:right-0 flex items-center justify-center md:pr-2 pointer-events-none">
<span class="material-icons text-gray-100">person</span>
<span class="material-icons text-xl text-gray-100">person</span>
</span>
</nuxt-link>
</div>
<div v-show="numLibraryItemsSelected" class="absolute top-0 left-0 w-full h-full px-4 bg-primary flex items-center">
<h1 class="text-2xl px-4">{{ $getString('MessageItemsSelected', [numLibraryItemsSelected]) }}</h1>
<div v-show="numMediaItemsSelected" class="absolute top-0 left-0 w-full h-full px-4 bg-primary flex items-center">
<h1 class="text-lg md:text-2xl px-4">{{ $getString('MessageItemsSelected', [numMediaItemsSelected]) }}</h1>
<div class="flex-grow" />
<ui-tooltip v-if="userIsAdminOrUp && !isPodcastLibrary" :text="$strings.ButtonQuickMatch" direction="bottom">
<ui-btn v-if="!isPodcastLibrary && selectedMediaItemsArePlayable" color="success" :padding-x="4" small class="flex items-center h-9 mr-2" @click="playSelectedItems">
<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="!isPodcastLibrary" :text="selectedIsFinished ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" direction="bottom">
<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>
<ui-tooltip v-if="userCanUpdate && !isPodcastLibrary" :text="$strings.LabelAddToCollection" direction="bottom">
<ui-tooltip v-if="userCanUpdate && isBookLibrary" :text="$strings.LabelAddToCollection" direction="bottom">
<ui-icon-btn :disabled="processingBatch" icon="collections_bookmark" @click="batchAddToCollectionClick" class="mx-1.5" />
</ui-tooltip>
<template v-if="userCanUpdate && numLibraryItemsSelected < 50">
<ui-tooltip text="Edit" direction="bottom">
<ui-icon-btn v-show="!processingBatchDelete" icon="edit" bg-color="warning" class="mx-1.5" @click="batchEditClick" />
<template v-if="userCanUpdate">
<ui-tooltip :text="$strings.LabelEdit" direction="bottom">
<ui-icon-btn :disabled="processingBatch" icon="edit" bg-color="warning" class="mx-1.5" @click="batchEditClick" />
</ui-tooltip>
</template>
<ui-tooltip v-if="userCanDelete" :text="$strings.ButtonRemove" direction="bottom">
<ui-icon-btn :disabled="processingBatchDelete" icon="delete" bg-color="error" class="mx-1.5" @click="batchDeleteClick" />
<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="processingBatchDelete ? 'text-gray-400' : ''" @click="cancelSelectionMode">close</span>
<span class="material-icons text-4xl px-4 hover:text-gray-100 cursor-pointer" :class="processingBatch ? 'text-gray-400' : ''" @click="cancelSelectionMode">close</span>
</ui-tooltip>
</div>
</div>
@@ -77,9 +87,7 @@
export default {
data() {
return {
processingBatchDelete: false,
totalEntities: 0,
isAllSelected: false
totalEntities: 0
}
},
computed: {
@@ -95,6 +103,9 @@ export default {
isPodcastLibrary() {
return this.libraryMediaType === 'podcast'
},
isBookLibrary() {
return this.libraryMediaType === 'book'
},
isHome() {
return this.$route.name === 'library-library'
},
@@ -107,11 +118,14 @@ export default {
username() {
return this.user ? this.user.username : 'err'
},
numLibraryItemsSelected() {
return this.selectedLibraryItems.length
numMediaItemsSelected() {
return this.selectedMediaItems.length
},
selectedLibraryItems() {
return this.$store.state.selectedLibraryItems
selectedMediaItems() {
return this.$store.state.globals.selectedMediaItems
},
selectedMediaItemsArePlayable() {
return !this.selectedMediaItems.some((i) => !i.hasTracks)
},
userMediaProgress() {
return this.$store.state.user.user.mediaProgress || []
@@ -127,8 +141,8 @@ export default {
},
selectedIsFinished() {
// Find an item that is not finished, if none then all items finished
return !this.selectedLibraryItems.find((libraryItemId) => {
var itemProgress = this.userMediaProgress.find((lip) => lip.libraryItemId === libraryItemId)
return !this.selectedMediaItems.find((item) => {
const itemProgress = this.userMediaProgress.find((lip) => lip.libraryItemId === item.id)
return !itemProgress || !itemProgress.isFinished
})
},
@@ -149,18 +163,61 @@ export default {
}
},
methods: {
cancelSelectionMode() {
if (this.processingBatchDelete) return
this.$store.commit('setSelectedLibraryItems', [])
async playSelectedItems() {
this.$store.commit('setProcessingBatch', true)
const libraryItemIds = this.selectedMediaItems.map((i) => i.id)
const libraryItems = await this.$axios
.$post(`/api/items/batch/get`, { libraryItemIds })
.then((res) => res.libraryItems)
.catch((error) => {
const errorMsg = error.response.data || 'Failed to get items'
console.error(errorMsg, error)
this.$toast.error(errorMsg)
return []
})
if (!libraryItems.length) {
this.$store.commit('setProcessingBatch', false)
return
}
const queueItems = []
libraryItems.forEach((item) => {
let subtitle = ''
if (item.mediaType === 'book') subtitle = item.media.metadata.authors.map((au) => au.name).join(', ')
else if (item.mediaType === 'music') subtitle = item.media.metadata.artists.join(', ')
queueItems.push({
libraryItemId: item.id,
libraryId: item.libraryId,
episodeId: null,
title: item.media.metadata.title,
subtitle,
caption: '',
duration: item.media.duration || null,
coverPath: item.media.coverPath || null
})
})
this.$eventBus.$emit('play-item', {
libraryItemId: queueItems[0].libraryItemId,
queueItems
})
this.$store.commit('setProcessingBatch', false)
this.$store.commit('globals/resetSelectedMediaItems', [])
this.$eventBus.$emit('bookshelf_clear_selection')
},
cancelSelectionMode() {
if (this.processingBatch) return
this.$store.commit('globals/resetSelectedMediaItems', [])
this.$eventBus.$emit('bookshelf_clear_selection')
this.isAllSelected = false
},
toggleBatchRead() {
this.$store.commit('setProcessingBatch', true)
var newIsFinished = !this.selectedIsFinished
var updateProgressPayloads = this.selectedLibraryItems.map((lid) => {
const newIsFinished = !this.selectedIsFinished
const updateProgressPayloads = this.selectedMediaItems.map((item) => {
return {
libraryItemId: lid,
libraryItemId: item.id,
isFinished: newIsFinished
}
})
@@ -170,7 +227,7 @@ export default {
.then(() => {
this.$toast.success('Batch update success!')
this.$store.commit('setProcessingBatch', false)
this.$store.commit('setSelectedLibraryItems', [])
this.$store.commit('globals/resetSelectedMediaItems', [])
this.$eventBus.$emit('bookshelf_clear_selection')
})
.catch((error) => {
@@ -180,26 +237,23 @@ export default {
})
},
batchDeleteClick() {
var audiobookText = this.numLibraryItemsSelected > 1 ? `these ${this.numLibraryItemsSelected} items` : 'this item'
var confirmMsg = `Are you sure you want to remove ${audiobookText}?\n\n*Does not delete your files, only removes the items from Audiobookshelf`
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.processingBatchDelete = true
this.$store.commit('setProcessingBatch', true)
this.$axios
.$post(`/api/items/batch/delete`, {
libraryItemIds: this.selectedLibraryItems
libraryItemIds: this.selectedMediaItems.map((i) => i.id)
})
.then(() => {
this.$toast.success('Batch delete success!')
this.processingBatchDelete = false
this.$store.commit('setProcessingBatch', false)
this.$store.commit('setSelectedLibraryItems', [])
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.processingBatchDelete = false
this.$store.commit('setProcessingBatch', false)
})
}

View File

@@ -1,17 +1,17 @@
<template>
<div id="bookshelf" ref="wrapper" class="w-full max-w-full h-full overflow-y-scroll relative">
<!-- Cover size widget -->
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-30" />
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-50" />
<div v-if="loaded && !shelves.length && !search" class="w-full flex flex-col items-center justify-center py-12">
<p class="text-center text-2xl font-book mb-4 py-4">{{ libraryName }} Library is empty!</p>
<p class="text-center text-2xl mb-4 py-4">{{ libraryName }} Library is empty!</p>
<div v-if="userIsAdminOrUp" class="flex">
<ui-btn to="/config" color="primary" class="w-52 mr-2">Configure Scanner</ui-btn>
<ui-btn color="success" class="w-52" @click="scan">Scan Library</ui-btn>
</div>
</div>
<div v-else-if="loaded && !shelves.length && search" class="w-full h-40 flex items-center justify-center">
<p class="text-center text-xl font-book py-4">No results for query</p>
<p class="text-center text-xl py-4">No results for query</p>
</div>
<!-- Alternate plain view -->
<div v-else-if="isAlternativeBookshelfView" class="w-full mb-24">
@@ -89,8 +89,8 @@ export default {
var baseSize = this.isCoverSquareAspectRatio ? 192 : 120
return this.bookCoverWidth / baseSize
},
selectedLibraryItems() {
return this.$store.state.selectedLibraryItems || []
selectedMediaItems() {
return this.$store.state.globals.selectedMediaItems || []
}
},
methods: {
@@ -100,15 +100,15 @@ export default {
const indexOf = shelf.shelfStartIndex + entityShelfIndex
const lastLastItemIndexSelected = this.lastItemIndexSelected
if (!this.selectedLibraryItems.includes(entity.id)) {
if (!this.selectedMediaItems.some((i) => i.id === entity.id)) {
this.lastItemIndexSelected = indexOf
} else {
this.lastItemIndexSelected = -1
}
if (shiftKey && lastLastItemIndexSelected >= 0) {
var loopStart = indexOf
var loopEnd = lastLastItemIndexSelected
let loopStart = indexOf
let loopEnd = lastLastItemIndexSelected
if (indexOf > lastLastItemIndexSelected) {
loopStart = lastLastItemIndexSelected
loopEnd = indexOf
@@ -117,12 +117,12 @@ export default {
const flattenedEntitiesArray = []
this.shelves.map((s) => flattenedEntitiesArray.push(...s.entities))
var isSelecting = false
let isSelecting = false
// If any items in this range is not selected then select all otherwise unselect all
for (let i = loopStart; i <= loopEnd; i++) {
const thisEntity = flattenedEntitiesArray[i]
if (thisEntity) {
if (!this.selectedLibraryItems.includes(thisEntity.id)) {
if (!this.selectedMediaItems.some((i) => i.id === thisEntity.id)) {
isSelecting = true
break
}
@@ -133,13 +133,23 @@ export default {
for (let i = loopStart; i <= loopEnd; i++) {
const thisEntity = flattenedEntitiesArray[i]
if (thisEntity) {
this.$store.commit('setLibraryItemSelected', { libraryItemId: thisEntity.id, selected: isSelecting })
const mediaItem = {
id: thisEntity.id,
mediaType: thisEntity.mediaType,
hasTracks: thisEntity.mediaType === 'podcast' || thisEntity.media.audioFile || thisEntity.media.numTracks || (thisEntity.media.tracks && thisEntity.media.tracks.length)
}
this.$store.commit('globals/setMediaItemSelected', { item: mediaItem, selected: isSelecting })
} else {
console.error('Invalid entity index', i)
}
}
} else {
this.$store.commit('toggleLibraryItemSelected', entity.id)
const mediaItem = {
id: entity.id,
mediaType: entity.mediaType,
hasTracks: entity.mediaType === 'podcast' || entity.media.audioFile || entity.media.numTracks || (entity.media.tracks && entity.media.tracks.length)
}
this.$store.commit('globals/toggleMediaItemSelected', mediaItem)
}
this.$nextTick(() => {
@@ -157,8 +167,8 @@ export default {
this.loaded = true
},
async fetchCategories() {
var categories = await this.$axios
.$get(`/api/libraries/${this.currentLibraryId}/personalized`)
const categories = await this.$axios
.$get(`/api/libraries/${this.currentLibraryId}/personalized?include=rssfeed`)
.then((data) => {
return data
})
@@ -395,8 +405,6 @@ export default {
}
},
removeListeners() {
this.$store.commit('user/removeSettingsListener', 'bookshelf')
if (this.$root.socket) {
this.$root.socket.off('user_updated', this.userUpdated)
this.$root.socket.off('author_updated', this.authorUpdated)

View File

@@ -44,7 +44,7 @@
</div>
</div>
<div class="absolute text-center categoryPlacard font-book transform z-30 bottom-px left-4 md:left-8 w-44 rounded-md" style="height: 22px">
<div class="absolute text-center categoryPlacard transform z-30 bottom-px left-4 md:left-8 w-44 rounded-md" style="height: 22px">
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border">
<p class="transform text-sm">{{ $strings[shelf.labelStringKey] }}</p>
</div>
@@ -98,7 +98,7 @@ export default {
return this.$store.state.libraries.currentLibraryId
},
isSelectionMode() {
return this.$store.getters['getNumLibraryItemsSelected'] > 0
return this.$store.getters['globals/getIsBatchSelectingMediaItems']
}
},
methods: {
@@ -119,14 +119,14 @@ export default {
this.$store.commit('globals/setShowEditPodcastEpisodeModal', true)
},
updateSelectionMode(val) {
var selectedLibraryItems = this.$store.state.selectedLibraryItems
const selectedMediaItems = this.$store.state.globals.selectedMediaItems
if (this.shelf.type === 'book' || this.shelf.type === 'podcast') {
this.shelf.entities.forEach((ent) => {
var component = this.$refs[`shelf-book-${ent.id}`]
if (!component || !component.length) return
component = component[0]
component.setSelectionMode(val)
component.selected = selectedLibraryItems.includes(ent.id)
component.selected = selectedMediaItems.some((i) => i.id === ent.id)
})
} else if (this.shelf.type === 'episode') {
this.shelf.entities.forEach((ent) => {
@@ -134,7 +134,7 @@ export default {
if (!component || !component.length) return
component = component[0]
component.setSelectionMode(val)
component.selected = selectedLibraryItems.includes(ent.id)
component.selected = selectedMediaItems.some((i) => i.id === ent.id)
})
}
},

View File

@@ -2,63 +2,86 @@
<div class="w-full h-20 md:h-10 relative">
<div class="flex md:hidden h-10 items-center">
<nuxt-link :to="`/library/${currentLibraryId}`" class="flex-grow h-full flex justify-center items-center" :class="isHomePage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
<p class="text-sm">{{ $strings.ButtonHome }}</p>
<p v-if="isHomePage || isPodcastLibrary" class="text-sm">{{ $strings.ButtonHome }}</p>
<svg v-else xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
</nuxt-link>
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf`" class="flex-grow h-full flex justify-center items-center" :class="isLibraryPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
<p class="text-sm">{{ $strings.ButtonLibrary }}</p>
<p v-if="isLibraryPage || isPodcastLibrary" class="text-sm">{{ $strings.ButtonLibrary }}</p>
<svg v-else xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
</nuxt-link>
<nuxt-link v-if="!isPodcastLibrary" :to="`/library/${currentLibraryId}/bookshelf/series`" class="flex-grow h-full flex justify-center items-center" :class="isSeriesPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
<p class="text-sm">{{ $strings.ButtonSeries }}</p>
<nuxt-link v-if="isPodcastLibrary" :to="`/library/${currentLibraryId}/podcast/latest`" class="flex-grow h-full flex justify-center items-center" :class="isPodcastLatestPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
<p class="text-sm">{{ $strings.ButtonLatest }}</p>
</nuxt-link>
<nuxt-link v-if="!isPodcastLibrary" :to="`/library/${currentLibraryId}/bookshelf/collections`" class="flex-grow h-full flex justify-center items-center" :class="isCollectionsPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
<p class="text-sm">{{ $strings.ButtonCollections }}</p>
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/series`" class="flex-grow h-full flex justify-center items-center" :class="isSeriesPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
<p v-if="isSeriesPage" class="text-sm">{{ $strings.ButtonSeries }}</p>
<svg v-else xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2" />
</svg>
</nuxt-link>
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/collections`" class="flex-grow h-full flex justify-center items-center" :class="isCollectionsPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
<p v-if="isCollectionsPage" class="text-sm">{{ $strings.ButtonCollections }}</p>
<span v-else class="material-icons-outlined text-lg">collections_bookmark</span>
</nuxt-link>
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/authors`" class="flex-grow h-full flex justify-center items-center" :class="isAuthorsPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
<p v-if="isAuthorsPage" class="text-sm">{{ $strings.ButtonAuthors }}</p>
<svg v-else class="w-5 h-5" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M12,5.5A3.5,3.5 0 0,1 15.5,9A3.5,3.5 0 0,1 12,12.5A3.5,3.5 0 0,1 8.5,9A3.5,3.5 0 0,1 12,5.5M5,8C5.56,8 6.08,8.15 6.53,8.42C6.38,9.85 6.8,11.27 7.66,12.38C7.16,13.34 6.16,14 5,14A3,3 0 0,1 2,11A3,3 0 0,1 5,8M19,8A3,3 0 0,1 22,11A3,3 0 0,1 19,14C17.84,14 16.84,13.34 16.34,12.38C17.2,11.27 17.62,9.85 17.47,8.42C17.92,8.15 18.44,8 19,8M5.5,18.25C5.5,16.18 8.41,14.5 12,14.5C15.59,14.5 18.5,16.18 18.5,18.25V20H5.5V18.25M0,20V18.5C0,17.11 1.89,15.94 4.45,15.6C3.86,16.28 3.5,17.22 3.5,18.25V20H0M24,20H20.5V18.25C20.5,17.22 20.14,16.28 19.55,15.6C22.11,15.94 24,17.11 24,18.5V20Z"
/>
</svg>
</nuxt-link>
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/search`" class="flex-grow h-full flex justify-center items-center" :class="isPodcastSearchPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
<p class="text-sm">{{ $strings.ButtonSearch }}</p>
</nuxt-link>
</div>
<div id="toolbar" class="absolute top-10 md:top-0 left-0 w-full h-10 md:h-full z-30 flex items-center justify-end md:justify-start px-2 md:px-8">
<template v-if="page !== 'search' && page !== 'podcast-search' && page !== 'recent-episodes' && !isHome">
<p v-if="!selectedSeries" class="font-book hidden md:block">{{ numShowing }} {{ entityName }}</p>
<div v-else class="items-center hidden md:flex w-full">
<p class="pl-2 font-book text-lg">
{{ seriesName }}
</p>
<div class="w-6 h-6 rounded-full bg-black bg-opacity-30 flex items-center justify-center ml-3">
<span class="font-mono">{{ numShowing }}</span>
</div>
<div class="flex-grow" />
<ui-checkbox v-model="settings.collapseBookSeries" :label="$strings.LabelCollapseSeries" checkbox-bg="bg" check-color="white" small class="mr-2" @input="updateCollapseBookSeries" />
<ui-btn color="primary" small :loading="processingSeries" class="flex items-center ml-1 sm:ml-4" @click="markSeriesFinished">
<div class="h-5 w-5">
<svg v-if="isSeriesFinished" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="rgb(63, 181, 68)">
<path d="M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-9 15l-5-5 1.41-1.41L10 13.17l7.59-7.59L19 7l-9 9z" />
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-7 19.6l-7-4.66V3h14v12.93l-7 4.67zm-2.01-7.42l-2.58-2.59L6 12l4 4 8-8-1.42-1.42z" />
</svg>
</div>
<span class="pl-2"> {{ $strings.LabelMarkSeries }} {{ isSeriesFinished ? $strings.LabelNotFinished : $strings.LabelFinished }}</span>
</ui-btn>
<div id="toolbar" class="absolute top-10 md:top-0 left-0 w-full h-10 md:h-full z-40 flex items-center justify-end md:justify-start px-2 md:px-8">
<!-- Series books page -->
<template v-if="selectedSeries">
<p class="pl-2 text-base md:text-lg">
{{ seriesName }}
</p>
<div class="w-6 h-6 rounded-full bg-black bg-opacity-30 flex items-center justify-center ml-3">
<span class="font-mono">{{ numShowing }}</span>
</div>
<div class="flex-grow" />
<ui-checkbox v-if="!isBatchSelecting" v-model="settings.collapseBookSeries" :label="$strings.LabelCollapseSeries" checkbox-bg="bg" check-color="white" small class="mr-2" @input="updateCollapseBookSeries" />
<!-- RSS feed -->
<ui-tooltip v-if="seriesRssFeed" :text="$strings.LabelOpenRSSFeed" direction="top">
<ui-icon-btn icon="rss_feed" class="mx-0.5" :size="7" icon-font-size="1.2rem" bg-color="success" outlined @click="showOpenSeriesRSSFeed" />
</ui-tooltip>
<ui-context-menu-dropdown v-if="!isBatchSelecting && seriesContextMenuItems.length" :items="seriesContextMenuItems" class="mx-px" @action="seriesContextMenuAction" />
</template>
<!-- library & collections page -->
<template v-else-if="page !== 'search' && page !== 'podcast-search' && page !== 'recent-episodes' && !isHome">
<p class="hidden md:block">{{ numShowing }} {{ entityName }}</p>
<div class="flex-grow hidden sm:inline-block" />
<ui-checkbox v-if="isLibraryPage && !isPodcastLibrary" v-model="settings.collapseSeries" :label="$strings.LabelCollapseSeries" checkbox-bg="bg" check-color="white" small class="mr-2" @input="updateCollapseSeries" />
<controls-library-filter-select v-if="isLibraryPage" v-model="settings.filterBy" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateFilter" />
<controls-library-sort-select v-if="isLibraryPage" v-model="settings.orderBy" :descending.sync="settings.orderDesc" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateOrder" />
<controls-library-filter-select v-if="isSeriesPage" v-model="seriesFilterBy" is-series class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateSeriesFilter" />
<controls-sort-select v-if="isSeriesPage" v-model="seriesSortBy" :descending.sync="seriesSortDesc" :items="seriesSortItems" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateSeriesSort" />
<ui-checkbox v-if="isLibraryPage && isBookLibrary && !isBatchSelecting" v-model="settings.collapseSeries" :label="$strings.LabelCollapseSeries" checkbox-bg="bg" check-color="white" small class="mr-2" @input="updateCollapseSeries" />
<controls-library-filter-select v-if="isLibraryPage && !isBatchSelecting" v-model="settings.filterBy" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateFilter" />
<controls-library-sort-select v-if="isLibraryPage && !isBatchSelecting" v-model="settings.orderBy" :descending.sync="settings.orderDesc" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateOrder" />
<controls-library-filter-select v-if="isSeriesPage && !isBatchSelecting" v-model="settings.seriesFilterBy" is-series class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateSeriesFilter" />
<controls-sort-select v-if="isSeriesPage && !isBatchSelecting" v-model="settings.seriesSortBy" :descending.sync="settings.seriesSortDesc" :items="seriesSortItems" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateSeriesSort" />
<ui-btn v-if="isIssuesFilter && userCanDelete" :loading="processingIssues" color="error" small class="ml-4" @click="removeAllIssues">{{ $strings.ButtonRemoveAll }} {{ numShowing }} {{ entityName }}</ui-btn>
<ui-btn v-if="isIssuesFilter && userCanDelete && !isBatchSelecting" :loading="processingIssues" color="error" small class="ml-4" @click="removeAllIssues">{{ $strings.ButtonRemoveAll }} {{ numShowing }} {{ entityName }}</ui-btn>
</template>
<!-- search page -->
<template v-else-if="page === 'search'">
<div class="flex-grow" />
<p>{{ $strings.MessageSearchResultsFor }} "{{ searchQuery }}"</p>
<div class="flex-grow" />
</template>
<!-- authors page -->
<template v-else-if="page === 'authors'">
<div class="flex-grow" />
<ui-btn v-if="userCanUpdate && authors && authors.length" :loading="processingAuthors" color="primary" small @click="matchAllAuthors">{{ $strings.ButtonMatchAllAuthors }}</ui-btn>
<ui-btn v-if="userCanUpdate && authors && authors.length && !isBatchSelecting" :loading="processingAuthors" color="primary" small @click="matchAllAuthors">{{ $strings.ButtonMatchAllAuthors }}</ui-btn>
</template>
</div>
</div>
@@ -86,28 +109,56 @@ export default {
totalEntities: 0,
processingSeries: false,
processingIssues: false,
processingAuthors: false,
seriesSortItems: [
{
text: 'Name',
value: 'name'
},
{
text: 'Number of Books',
value: 'numBooks'
},
{
text: 'Date Added',
value: 'addedAt'
},
{
text: 'Total Duration',
value: 'totalDuration'
}
]
processingAuthors: false
}
},
computed: {
seriesContextMenuItems() {
if (!this.selectedSeries) return []
const items = [
{
text: this.isSeriesFinished ? this.$strings.MessageMarkAsNotFinished : this.$strings.MessageMarkAsFinished,
action: 'mark-series-finished'
}
]
if (this.userIsAdminOrUp || this.selectedSeries.rssFeed) {
items.push({
text: this.$strings.LabelOpenRSSFeed,
action: 'open-rss-feed'
})
}
if (this.isSeriesRemovedFromContinueListening) {
items.push({
text: 'Re-Add Series to Continue Listening',
action: 're-add-to-continue-listening'
})
}
return items
},
seriesSortItems() {
return [
{
text: this.$strings.LabelName,
value: 'name'
},
{
text: this.$strings.LabelNumberOfBooks,
value: 'numBooks'
},
{
text: this.$strings.LabelAddedAt,
value: 'addedAt'
},
{
text: this.$strings.LabelTotalDuration,
value: 'totalDuration'
}
]
},
userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
},
@@ -123,9 +174,15 @@ export default {
currentLibraryMediaType() {
return this.$store.getters['libraries/getCurrentLibraryMediaType']
},
isBookLibrary() {
return this.currentLibraryMediaType === 'book'
},
isPodcastLibrary() {
return this.currentLibraryMediaType === 'podcast'
},
isMusicLibrary() {
return this.currentLibraryMediaType === 'music'
},
isLibraryPage() {
return this.page === ''
},
@@ -135,67 +192,112 @@ export default {
isCollectionsPage() {
return this.page === 'collections'
},
isPlaylistsPage() {
return this.page === 'playlists'
},
isHomePage() {
return this.$route.name === 'library-library'
},
isPodcastSearchPage() {
return this.$route.name === 'library-library-podcast-search'
},
isPodcastLatestPage() {
return this.$route.name === 'library-library-podcast-latest'
},
isAuthorsPage() {
return this.$route.name === 'library-library-authors'
},
isAlbumsPage() {
return this.page === 'albums'
},
numShowing() {
return this.totalEntities
},
entityName() {
if (this.isAlbumsPage) return 'Albums'
if (this.isMusicLibrary) return 'Tracks'
if (this.isPodcastLibrary) return this.$strings.LabelPodcasts
if (!this.page) return this.$strings.LabelBooks
if (this.isSeriesPage) return this.$strings.LabelSeries
if (this.isCollectionsPage) return this.$strings.LabelCollections
if (this.isPlaylistsPage) return this.$strings.LabelPlaylists
return ''
},
seriesId() {
return this.selectedSeries ? this.selectedSeries.id : null
},
seriesName() {
return this.selectedSeries ? this.selectedSeries.name : null
},
seriesProgress() {
return this.selectedSeries ? this.selectedSeries.progress : null
},
seriesRssFeed() {
return this.selectedSeries ? this.selectedSeries.rssFeed : null
},
seriesLibraryItemIds() {
if (!this.seriesProgress) return []
return this.seriesProgress.libraryItemIds || []
},
isBatchSelecting() {
return this.$store.getters['globals/getIsBatchSelectingMediaItems']
},
isSeriesFinished() {
return this.seriesProgress && !!this.seriesProgress.isFinished
},
isSeriesRemovedFromContinueListening() {
if (!this.seriesId) return false
return this.$store.getters['user/getIsSeriesRemovedFromContinueListening'](this.seriesId)
},
filterBy() {
return this.$store.getters['user/getUserSetting']('filterBy')
},
isIssuesFilter() {
return this.filterBy === 'issues' && this.$route.query.filter === 'issues'
},
seriesSortBy: {
get() {
return this.$store.state.libraries.seriesSortBy
},
set(val) {
this.$store.commit('libraries/setSeriesSortBy', val)
}
},
seriesSortDesc: {
get() {
return this.$store.state.libraries.seriesSortDesc
},
set(val) {
this.$store.commit('libraries/setSeriesSortDesc', val)
}
},
seriesFilterBy: {
get() {
return this.$store.state.libraries.seriesFilterBy
},
set(val) {
this.$store.commit('libraries/setSeriesFilterBy', val)
}
}
},
methods: {
seriesContextMenuAction(action) {
if (action === 'open-rss-feed') {
this.showOpenSeriesRSSFeed()
} else if (action === 're-add-to-continue-listening') {
if (this.processingSeries) {
console.warn('Already processing series')
return
}
this.reAddSeriesToContinueListening()
} else if (action === 'mark-series-finished') {
if (this.processingSeries) {
console.warn('Already processing series')
return
}
this.markSeriesFinished()
}
},
showOpenSeriesRSSFeed() {
this.$store.commit('globals/setRSSFeedOpenCloseModal', {
id: this.selectedSeries.id,
name: this.selectedSeries.name,
type: 'series',
feed: this.selectedSeries.rssFeed
})
},
reAddSeriesToContinueListening() {
this.processingSeries = true
this.$axios
.$get(`/api/me/series/${this.seriesId}/readd-to-continue-listening`)
.then(() => {
this.$toast.success('Series re-added to continue listening')
})
.catch((error) => {
console.error('Failed to re-add series to continue listening', error)
this.$toast.error('Failed to re-add series to continue listening')
})
.finally(() => {
this.processingSeries = false
})
},
async matchAllAuthors() {
this.processingAuthors = true
@@ -233,38 +335,50 @@ export default {
.then(() => {
this.$toast.success('Removed library items with issues')
this.$router.push(`/library/${this.currentLibraryId}/bookshelf`)
this.processingIssues = false
this.$store.dispatch('libraries/fetch', this.currentLibraryId)
})
.catch((error) => {
console.error('Failed to remove library items with issues', error)
this.$toast.error('Failed to remove library items with issues')
})
.finally(() => {
this.processingIssues = false
})
}
},
markSeriesFinished() {
var newIsFinished = !this.isSeriesFinished
this.processingSeries = true
var updateProgressPayloads = this.seriesLibraryItemIds.map((lid) => {
return {
libraryItemId: lid,
isFinished: newIsFinished
}
})
console.log('Progress payloads', updateProgressPayloads)
this.$axios
.patch(`/api/me/progress/batch/update`, updateProgressPayloads)
.then(() => {
this.$toast.success('Series update success')
this.selectedSeries.progress.isFinished = newIsFinished
this.processingSeries = false
})
.catch((error) => {
this.$toast.error('Series update failed')
console.error('Failed to batch update read/not read', error)
this.processingSeries = false
})
const newIsFinished = !this.isSeriesFinished
const payload = {
message: newIsFinished ? this.$strings.MessageConfirmMarkSeriesFinished : this.$strings.MessageConfirmMarkSeriesNotFinished,
callback: (confirmed) => {
if (confirmed) {
this.processingSeries = true
const updateProgressPayloads = this.seriesLibraryItemIds.map((lid) => {
return {
libraryItemId: lid,
isFinished: newIsFinished
}
})
console.log('Progress payloads', updateProgressPayloads)
this.$axios
.patch(`/api/me/progress/batch/update`, updateProgressPayloads)
.then(() => {
this.$toast.success(this.$strings.ToastSeriesUpdateSuccess)
this.selectedSeries.progress.isFinished = newIsFinished
})
.catch((error) => {
this.$toast.error(this.$strings.ToastSeriesUpdateFailed)
console.error('Failed to batch update read/not read', error)
})
.finally(() => {
this.processingSeries = false
})
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
updateOrder() {
this.saveSettings()
@@ -273,10 +387,10 @@ export default {
this.saveSettings()
},
updateSeriesSort() {
this.$eventBus.$emit('series-sort-updated')
this.saveSettings()
},
updateSeriesFilter() {
this.$eventBus.$emit('series-sort-updated')
this.saveSettings()
},
updateCollapseSeries() {
this.saveSettings()
@@ -297,16 +411,32 @@ export default {
},
setBookshelfTotalEntities(totalEntities) {
this.totalEntities = totalEntities
},
rssFeedOpen(data) {
if (data.entityId === this.seriesId) {
console.log('RSS Feed Opened', data)
this.selectedSeries.rssFeed = data
}
},
rssFeedClosed(data) {
if (data.entityId === this.seriesId) {
console.log('RSS Feed Closed', data)
this.selectedSeries.rssFeed = null
}
}
},
mounted() {
this.init()
this.$store.commit('user/addSettingsListener', { id: 'bookshelftoolbar', meth: this.settingsUpdated })
this.$eventBus.$on('user-settings', this.settingsUpdated)
this.$eventBus.$on('bookshelf-total-entities', this.setBookshelfTotalEntities)
this.$root.socket.on('rss_feed_open', this.rssFeedOpen)
this.$root.socket.on('rss_feed_closed', this.rssFeedClosed)
},
beforeDestroy() {
this.$store.commit('user/removeSettingsListener', 'bookshelftoolbar')
this.$eventBus.$off('user-settings', this.settingsUpdated)
this.$eventBus.$off('bookshelf-total-entities', this.setBookshelfTotalEntities)
this.$root.socket.off('rss_feed_open', this.rssFeedOpen)
this.$root.socket.off('rss_feed_closed', this.rssFeedClosed)
}
}
</script>

View File

@@ -1,15 +1,19 @@
<template>
<div class="w-44 fixed left-0 top-16 h-full bg-bg bg-opacity-100 md:bg-opacity-70 shadow-lg border-r border-white border-opacity-5 py-3 transform transition-transform" :class="wrapperClass" v-click-outside="clickOutside">
<div class="md:hidden flex items-center justify-end pb-2 px-4 mb-1" @click="closeDrawer">
<span class="material-icons text-2xl">arrow_back</span>
<div>
<div class="w-44 fixed left-0 top-16 bg-bg bg-opacity-100 md:bg-opacity-70 shadow-lg border-r border-white border-opacity-5 py-3 transform transition-transform mb-12 overflow-y-auto" :class="wrapperClass + ' ' + (streamLibraryItem ? 'h-[calc(100%-270px)]' : 'h-[calc(100%-110px)]')" v-click-outside="clickOutside">
<div v-show="isMobilePortrait" class="flex items-center justify-end pb-2 px-4 mb-1" @click="closeDrawer">
<span class="material-icons text-2xl">arrow_back</span>
</div>
<nuxt-link v-for="route in configRoutes" :key="route.id" :to="route.path" class="w-full px-4 h-12 border-b border-primary border-opacity-30 flex items-center cursor-pointer relative" :class="routeName === route.id ? 'bg-primary bg-opacity-70' : 'hover:bg-primary hover:bg-opacity-30'">
<p>{{ route.title }}</p>
<div v-show="routeName === route.iod" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<modals-changelog-view-modal v-model="showChangelogModal" :changelog="currentVersionChangelog" :currentVersion="$config.version" />
</div>
<nuxt-link v-for="route in configRoutes" :key="route.id" :to="route.path" class="w-full px-4 h-12 border-b border-primary border-opacity-30 flex items-center cursor-pointer relative" :class="routeName === route.id ? 'bg-primary bg-opacity-70' : 'hover:bg-primary hover:bg-opacity-30'">
<p>{{ route.title }}</p>
<div v-show="routeName === route.iod" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<div class="w-full h-12 px-4 border-t border-black border-opacity-20 absolute left-0 flex flex-col justify-center" :style="{ bottom: streamLibraryItem && isMobileLandscape ? '300px' : '65px' }">
<div class="w-44 h-12 px-4 border-t bg-bg border-black border-opacity-20 fixed left-0 flex flex-col justify-center" :class="wrapperClass" :style="{ bottom: streamLibraryItem ? '160px' : '0px' }">
<div class="flex justify-between">
<p class="underline font-mono text-sm" @click="clickChangelog">v{{ $config.version }}</p>
@@ -17,8 +21,6 @@
</div>
<a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-xs">Latest: {{ latestVersion }}</a>
</div>
<modals-changelog-view-modal v-model="showChangelogModal" :changelog="currentVersionChangelog" :currentVersion="$config.version" />
</div>
</template>
@@ -87,6 +89,11 @@ export default {
id: 'config-notifications',
title: this.$strings.HeaderNotifications,
path: '/config/notifications'
},
{
id: 'config-item-metadata-utils',
title: this.$strings.HeaderItemMetadataUtils,
path: '/config/item-metadata-utils'
}
]
@@ -109,7 +116,7 @@ export default {
var classes = []
if (this.drawerOpen) classes.push('translate-x-0')
else classes.push('-translate-x-44')
if (this.isMobile) classes.push('z-50')
if (this.isMobilePortrait) classes.push('z-50')
else classes.push('z-40')
return classes.join(' ')
},
@@ -119,9 +126,11 @@ export default {
isMobileLandscape() {
return this.$store.state.globals.isMobileLandscape
},
isMobilePortrait() {
return this.$store.state.globals.isMobilePortrait
},
drawerOpen() {
if (this.isMobile) return this.isOpen
return true
return !this.isMobilePortrait || this.isOpen
},
routeName() {
return this.$route.name

View File

@@ -6,22 +6,22 @@
</div>
</template>
<div v-if="initialized && !totalShelves && !hasFilter && entityName === 'books'" class="w-full flex flex-col items-center justify-center py-12">
<p class="text-center text-2xl font-book mb-4 py-4">{{ libraryName }} Library is empty!</p>
<div v-if="initialized && !totalShelves && !hasFilter && entityName === 'items'" class="w-full flex flex-col items-center justify-center py-12">
<p class="text-center text-2xl mb-4 py-4">{{ $getString('MessageXLibraryIsEmpty', [libraryName]) }}</p>
<div v-if="userIsAdminOrUp" class="flex">
<ui-btn to="/config" color="primary" class="w-52 mr-2">Configure Scanner</ui-btn>
<ui-btn color="success" class="w-52" @click="scan">Scan Library</ui-btn>
<ui-btn to="/config" color="primary" class="w-52 mr-2">{{ $strings.ButtonConfigureScanner }}</ui-btn>
<ui-btn color="success" class="w-52" @click="scan">{{ $strings.ButtonScanLibrary }}</ui-btn>
</div>
</div>
<div v-else-if="!totalShelves && initialized" class="w-full py-16">
<p class="text-xl text-center">{{ emptyMessage }}</p>
<!-- Clear filter only available on Library bookshelf -->
<div v-if="entityName === 'books'" class="flex justify-center mt-2">
<ui-btn v-if="hasFilter" color="primary" @click="clearFilter">Clear Filter</ui-btn>
<div v-if="entityName === 'items'" class="flex justify-center mt-2">
<ui-btn v-if="hasFilter" color="primary" @click="clearFilter">{{ $strings.ButtonClearFilter }}</ui-btn>
</div>
</div>
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-30" />
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-50" />
</div>
</template>
@@ -81,31 +81,35 @@ export default {
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
},
libraryMediaType() {
return this.$store.getters['libraries/getCurrentLibraryMediaType']
},
isPodcast() {
return this.$store.getters['libraries/getCurrentLibraryMediaType'] == 'podcast'
return this.libraryMediaType === 'podcast'
},
emptyMessage() {
if (this.page === 'series') return 'You have no series'
if (this.page === 'collections') return "You haven't made any collections yet"
if (this.page === 'series') return this.$strings.MessageBookshelfNoSeries
if (this.page === 'collections') return this.$strings.MessageBookshelfNoCollections
if (this.page === 'playlists') return this.$strings.MessageNoUserPlaylists
if (this.hasFilter) {
if (this.filterName === 'Issues') return 'No Issues'
else if (this.filterName === 'Feed-open') return 'No RSS feeds are open'
return `No Results for filter "${this.filterName}: ${this.filterValue}"`
if (this.filterName === 'Issues') return this.$strings.MessageNoIssues
else if (this.filterName === 'Feed-open') return this.$strings.MessageBookshelfNoRSSFeeds
return this.$getString('MessageBookshelfNoResultsForFilter', [this.filterName, this.filterValue])
}
return 'No results'
return this.$strings.MessageNoResults
},
entityName() {
if (!this.page) return 'books'
if (!this.page) return 'items'
return this.page
},
seriesSortBy() {
return this.$store.state.libraries.seriesSortBy
return this.$store.getters['user/getUserSetting']('seriesSortBy')
},
seriesSortDesc() {
return this.$store.state.libraries.seriesSortDesc
return this.$store.getters['user/getUserSetting']('seriesSortDesc')
},
seriesFilterBy() {
return this.$store.state.libraries.seriesFilterBy
return this.$store.getters['user/getUserSetting']('seriesFilterBy')
},
orderBy() {
return this.$store.getters['user/getUserSetting']('orderBy')
@@ -157,16 +161,13 @@ export default {
libraryName() {
return this.$store.getters['libraries/getCurrentLibraryName']
},
isEntityBook() {
return this.entityName === 'series-books' || this.entityName === 'books'
},
bookWidth() {
var coverSize = this.$store.getters['user/getUserSetting']('bookshelfCoverSize')
if (this.isCoverSquareAspectRatio) return coverSize * 1.6
const coverSize = this.$store.getters['user/getUserSetting']('bookshelfCoverSize')
if (this.isCoverSquareAspectRatio || this.entityName === 'playlists') return coverSize * 1.6
return coverSize
},
bookHeight() {
if (this.isCoverSquareAspectRatio) return this.bookWidth
if (this.isCoverSquareAspectRatio || this.entityName === 'playlists') return this.bookWidth
return this.bookWidth * 1.6
},
shelfPadding() {
@@ -191,7 +192,8 @@ export default {
},
shelfHeight() {
if (this.isAlternativeBookshelfView) {
var extraTitleSpace = this.isEntityBook ? 80 : 40
const isItemEntity = this.entityName === 'series-books' || this.entityName === 'items'
const extraTitleSpace = isItemEntity ? 80 : this.entityName === 'albums' ? 60 : 40
return this.entityHeight + extraTitleSpace * this.sizeMultiplier
}
return this.entityHeight + 40
@@ -200,11 +202,11 @@ export default {
// Includes margin
return this.entityWidth + 24
},
selectedLibraryItems() {
return this.$store.state.selectedLibraryItems || []
selectedMediaItems() {
return this.$store.state.globals.selectedMediaItems || []
},
sizeMultiplier() {
var baseSize = this.isCoverSquareAspectRatio ? 192 : 120
const baseSize = this.isCoverSquareAspectRatio ? 192 : 120
return this.entityWidth / baseSize
}
},
@@ -213,12 +215,14 @@ export default {
this.$store.dispatch('user/updateUserSettings', { filterBy: 'all' })
},
editEntity(entity) {
if (this.entityName === 'books' || this.entityName === 'series-books') {
var bookIds = this.entities.map((e) => e.id)
if (this.entityName === 'items' || this.entityName === 'series-books') {
const bookIds = this.entities.map((e) => e.id)
this.$store.commit('setBookshelfBookIds', bookIds)
this.$store.commit('showEditModal', entity)
} else if (this.entityName === 'collections') {
this.$store.commit('globals/setEditCollection', entity)
} else if (this.entityName === 'playlists') {
this.$store.commit('globals/setEditPlaylist', entity)
}
},
clearSelectedEntities() {
@@ -226,29 +230,29 @@ export default {
this.isSelectionMode = false
},
selectEntity(entity, shiftKey) {
if (this.entityName === 'books' || this.entityName === 'series-books') {
var indexOf = this.entities.findIndex((ent) => ent && ent.id === entity.id)
if (this.entityName === 'items' || this.entityName === 'series-books') {
const indexOf = this.entities.findIndex((ent) => ent && ent.id === entity.id)
const lastLastItemIndexSelected = this.lastItemIndexSelected
if (!this.selectedLibraryItems.includes(entity.id)) {
if (!this.selectedMediaItems.some((i) => i.id === entity.id)) {
this.lastItemIndexSelected = indexOf
} else {
this.lastItemIndexSelected = -1
}
if (shiftKey && lastLastItemIndexSelected >= 0) {
var loopStart = indexOf
var loopEnd = lastLastItemIndexSelected
let loopStart = indexOf
let loopEnd = lastLastItemIndexSelected
if (indexOf > lastLastItemIndexSelected) {
loopStart = lastLastItemIndexSelected
loopEnd = indexOf
}
var isSelecting = false
let isSelecting = false
// If any items in this range is not selected then select all otherwise unselect all
for (let i = loopStart; i <= loopEnd; i++) {
const thisEntity = this.entities[i]
if (thisEntity && !thisEntity.collapsedSeries) {
if (!this.selectedLibraryItems.includes(thisEntity.id)) {
if (!this.selectedMediaItems.some((i) => i.id === thisEntity.id)) {
isSelecting = true
break
}
@@ -266,16 +270,27 @@ export default {
const entityComponentRef = this.entityComponentRefs[i]
if (thisEntity && entityComponentRef) {
entityComponentRef.selected = isSelecting
this.$store.commit('setLibraryItemSelected', { libraryItemId: thisEntity.id, selected: isSelecting })
const mediaItem = {
id: thisEntity.id,
mediaType: thisEntity.mediaType,
hasTracks: thisEntity.mediaType === 'podcast' || thisEntity.media.audioFile || thisEntity.media.numTracks || (thisEntity.media.tracks && thisEntity.media.tracks.length)
}
this.$store.commit('globals/setMediaItemSelected', { item: mediaItem, selected: isSelecting })
} else {
console.error('Invalid entity index', i)
}
}
} else {
this.$store.commit('toggleLibraryItemSelected', entity.id)
const mediaItem = {
id: entity.id,
mediaType: entity.mediaType,
hasTracks: entity.mediaType === 'podcast' || entity.media.audioFile || entity.media.numTracks || (entity.media.tracks && entity.media.tracks.length)
}
this.$store.commit('globals/toggleMediaItemSelected', mediaItem)
}
var newIsSelectionMode = !!this.selectedLibraryItems.length
const newIsSelectionMode = !!this.selectedMediaItems.length
if (this.isSelectionMode !== newIsSelectionMode) {
this.isSelectionMode = newIsSelectionMode
this.updateBookSelectionMode(newIsSelectionMode)
@@ -293,7 +308,7 @@ export default {
}
},
async fetchEntites(page = 0) {
var startIndex = page * this.booksPerFetch
const startIndex = page * this.booksPerFetch
this.isFetchingEntities = true
@@ -301,11 +316,11 @@ export default {
this.currentSFQueryString = this.buildSearchParams()
}
var entityPath = this.entityName === 'books' || this.entityName === 'series-books' ? `items` : this.entityName
var sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : ''
var fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1`
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`
var payload = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/${entityPath}${fullQueryString}`).catch((error) => {
const payload = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/${entityPath}${fullQueryString}`).catch((error) => {
console.error('failed to fetch books', error)
return null
})
@@ -325,7 +340,7 @@ export default {
}
for (let i = 0; i < payload.results.length; i++) {
var index = i + startIndex
const index = i + startIndex
this.entities[index] = payload.results[i]
if (this.entityComponentRefs[index]) {
this.entityComponentRefs[index].setEntity(this.entities[index])
@@ -483,7 +498,7 @@ export default {
}
},
settingsUpdated(settings) {
var wasUpdated = this.checkUpdateSearchParams()
const wasUpdated = this.checkUpdateSearchParams()
if (wasUpdated) {
this.resetEntities()
} else if (settings.bookshelfCoverSize !== this.currentBookWidth) {
@@ -502,7 +517,7 @@ export default {
},
libraryItemUpdated(libraryItem) {
console.log('Item updated', libraryItem)
if (this.entityName === 'books' || this.entityName === 'series-books') {
if (this.entityName === 'items' || this.entityName === 'series-books') {
var indexOf = this.entities.findIndex((ent) => ent && ent.id === libraryItem.id)
if (indexOf >= 0) {
this.entities[indexOf] = libraryItem
@@ -513,7 +528,7 @@ export default {
}
},
libraryItemRemoved(libraryItem) {
if (this.entityName === 'books' || this.entityName === 'series-books') {
if (this.entityName === 'items' || this.entityName === 'series-books') {
var indexOf = this.entities.findIndex((ent) => ent && ent.id === libraryItem.id)
if (indexOf >= 0) {
this.entities = this.entities.filter((ent) => ent.id !== libraryItem.id)
@@ -560,6 +575,33 @@ export default {
this.executeRebuild()
}
},
playlistAdded(playlist) {
if (this.entityName !== 'playlists') return
console.log(`[LazyBookshelf] playlistAdded ${playlist.id}`, playlist)
this.resetEntities()
},
playlistUpdated(playlist) {
if (this.entityName !== 'playlists') return
console.log(`[LazyBookshelf] playlistUpdated ${playlist.id}`, playlist)
var indexOf = this.entities.findIndex((ent) => ent && ent.id === playlist.id)
if (indexOf >= 0) {
this.entities[indexOf] = playlist
if (this.entityComponentRefs[indexOf]) {
this.entityComponentRefs[indexOf].setEntity(playlist)
}
}
},
playlistRemoved(playlist) {
if (this.entityName !== 'playlists') return
console.log(`[LazyBookshelf] playlistRemoved ${playlist.id}`, playlist)
var indexOf = this.entities.findIndex((ent) => ent && ent.id === playlist.id)
if (indexOf >= 0) {
this.entities = this.entities.filter((ent) => ent.id !== playlist.id)
this.totalEntities--
this.$eventBus.$emit('bookshelf-total-entities', this.totalEntities)
this.executeRebuild()
}
},
initSizeData(_bookshelf) {
var bookshelf = _bookshelf || document.getElementById('bookshelf')
if (!bookshelf) {
@@ -625,11 +667,9 @@ export default {
}
})
this.$eventBus.$on('series-sort-updated', this.seriesSortUpdated)
this.$eventBus.$on('bookshelf_clear_selection', this.clearSelectedEntities)
this.$eventBus.$on('socket_init', this.socketInit)
this.$store.commit('user/addSettingsListener', { id: 'lazy-bookshelf', meth: this.settingsUpdated })
this.$eventBus.$on('user-settings', this.settingsUpdated)
if (this.$root.socket) {
this.$root.socket.on('item_updated', this.libraryItemUpdated)
@@ -640,6 +680,9 @@ export default {
this.$root.socket.on('collection_added', this.collectionAdded)
this.$root.socket.on('collection_updated', this.collectionUpdated)
this.$root.socket.on('collection_removed', this.collectionRemoved)
this.$root.socket.on('playlist_added', this.playlistAdded)
this.$root.socket.on('playlist_updated', this.playlistUpdated)
this.$root.socket.on('playlist_removed', this.playlistRemoved)
} else {
console.error('Bookshelf - Socket not initialized')
}
@@ -651,11 +694,9 @@ export default {
bookshelf.removeEventListener('scroll', this.scroll)
}
this.$eventBus.$off('series-sort-updated', this.seriesSortUpdated)
this.$eventBus.$off('bookshelf_clear_selection', this.clearSelectedEntities)
this.$eventBus.$off('socket_init', this.socketInit)
this.$store.commit('user/removeSettingsListener', 'lazy-bookshelf')
this.$eventBus.$off('user-settings', this.settingsUpdated)
if (this.$root.socket) {
this.$root.socket.off('item_updated', this.libraryItemUpdated)
@@ -666,6 +707,9 @@ export default {
this.$root.socket.off('collection_added', this.collectionAdded)
this.$root.socket.off('collection_updated', this.collectionUpdated)
this.$root.socket.off('collection_removed', this.collectionRemoved)
this.$root.socket.off('playlist_added', this.playlistAdded)
this.$root.socket.off('playlist_updated', this.playlistUpdated)
this.$root.socket.off('playlist_removed', this.playlistRemoved)
} else {
console.error('Bookshelf - Socket not initialized')
}

View File

@@ -0,0 +1,48 @@
<template>
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8">
<div class="flex items-center mb-2">
<h1 class="text-xl">{{ headerText }}</h1>
<div v-if="showAddButton" class="mx-2 w-7 h-7 flex items-center justify-center rounded-full cursor-pointer hover:bg-white hover:bg-opacity-10 text-center" @click="clicked">
<button type="button" class="material-icons" :aria-label="$strings.ButtonAdd + ': ' + headerText" style="font-size: 1.4rem">add</button>
</div>
</div>
<p v-if="description" id="settings-description" class="mb-6 text-gray-200" v-html="description" />
<slot></slot>
</div>
</template>
<script>
export default {
props: {
headerText: String,
description: String,
note: String,
showAddButton: Boolean
},
methods: {
clicked() {
this.$emit('clicked')
}
}
}
</script>
<style>
#settings-description a {
color: rgb(96 165 250);
}
#settings-description a:hover {
color: rgb(147 197 253);
text-decoration-line: underline;
}
#settings-description code {
font-size: 0.875rem;
border-radius: 6px;
background-color: rgb(82, 82, 82);
color: white;
padding: 2px 4px;
}
</style>

View File

@@ -1,6 +1,5 @@
<template>
<!-- <div class="w-20 bg-bg h-full relative box-shadow-side z-40" style="min-width: 80px"> -->
<div class="w-20 bg-bg h-full fixed left-0 box-shadow-side z-40" style="min-width: 80px" :style="{ top: offsetTop + 'px' }">
<div class="w-20 bg-bg h-full fixed left-0 box-shadow-side z-50" style="min-width: 80px" :style="{ top: offsetTop + 'px' }">
<!-- ugly little workaround to cover up the shadow overlapping the bookshelf toolbar -->
<div v-if="isShowingBookshelfToolbar" class="absolute top-0 -right-4 w-4 bg-bg h-10 pointer-events-none" />
@@ -9,15 +8,15 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
<p class="font-book pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonHome }}</p>
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonHome }}</p>
<div v-show="homePage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link v-if="isPodcastLibrary" :to="`/library/${currentLibraryId}/podcast/latest`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPodcastLatestPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<span class="material-icons">format_list_bulleted</span>
<span class="material-icons text-2xl">format_list_bulleted</span>
<p class="font-book pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonLatest }}</p>
<p class="pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonLatest }}</p>
<div v-show="isPodcastLatestPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
@@ -27,30 +26,30 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
<p class="font-book pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonLibrary }}</p>
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonLibrary }}</p>
<div v-show="showLibrary" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link v-if="!isPodcastLibrary" :to="`/library/${currentLibraryId}/bookshelf/series`" 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="isSeriesPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/series`" 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="isSeriesPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2" />
</svg>
<p class="font-book pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonSeries }}</p>
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonSeries }}</p>
<div v-show="isSeriesPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link v-if="!isPodcastLibrary" :to="`/library/${currentLibraryId}/bookshelf/collections`" 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="paramId === 'collections' ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<span class="material-icons-outlined">collections_bookmark</span>
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/collections`" 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="paramId === 'collections' ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<span class="material-icons-outlined text-2xl">collections_bookmark</span>
<p class="font-book pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonCollections }}</p>
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonCollections }}</p>
<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="!isPodcastLibrary" :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'">
<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
fill="currentColor"
@@ -58,7 +57,7 @@
/>
</svg>
<p class="font-book pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonAuthors }}</p>
<p class="pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonAuthors }}</p>
<div v-show="isAuthorsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
@@ -66,15 +65,31 @@
<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>
<p class="font-book pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonSearch }}</p>
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonSearch }}</p>
<div v-show="isPodcastSearchPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link v-if="isMusicLibrary" :to="`/library/${currentLibraryId}/bookshelf/albums`" 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="isMusicAlbumsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<span class="material-icons-outlined text-xl">album</span>
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">Albums</p>
<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="numIssues" :to="`/library/${currentLibraryId}/bookshelf?filter=issues`" 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-opacity-40 cursor-pointer relative" :class="showingIssues ? 'bg-error bg-opacity-40' : ' bg-error bg-opacity-20'">
<span class="material-icons text-2xl">warning</span>
<p class="font-book pt-1.5 text-center leading-4" style="font-size: 1rem">{{ $strings.ButtonIssues }}</p>
<p class="pt-1.5 text-center leading-4" style="font-size: 1rem">{{ $strings.ButtonIssues }}</p>
<div v-show="showingIssues" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
<div class="absolute top-1 right-1 w-4 h-4 rounded-full bg-white bg-opacity-30 flex items-center justify-center">
@@ -125,15 +140,24 @@ export default {
currentLibraryMediaType() {
return this.$store.getters['libraries/getCurrentLibraryMediaType']
},
isBookLibrary() {
return this.currentLibraryMediaType === 'book'
},
isPodcastLibrary() {
return this.currentLibraryMediaType === 'podcast'
},
isMusicLibrary() {
return this.currentLibraryMediaType === 'music'
},
isPodcastSearchPage() {
return this.$route.name === 'library-library-podcast-search'
},
isPodcastLatestPage() {
return this.$route.name === 'library-library-podcast-latest'
},
isMusicAlbumsPage() {
return this.paramId === 'albums'
},
homePage() {
return this.$route.name === 'library-library'
},
@@ -143,6 +167,9 @@ export default {
isAuthorsPage() {
return this.$route.name === 'library-library-authors'
},
isPlaylistsPage() {
return this.paramId === 'playlists'
},
libraryBookshelfPage() {
return this.$route.name === 'library-library-bookshelf-id'
},
@@ -173,6 +200,9 @@ export default {
},
streamLibraryItem() {
return this.$store.state.streamLibraryItem
},
showPlaylists() {
return this.$store.state.libraries.numUserPlaylists > 0
}
},
methods: {

View File

@@ -1,10 +1,10 @@
<template>
<div v-if="streamLibraryItem" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-48 sm:h-44 md:h-40 z-40 bg-primary px-4 pb-1 md:pb-4 pt-2">
<div v-if="streamLibraryItem" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-48 sm:h-44 md:h-40 z-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-1 sm:left-4 cursor-pointer" :style="{ top: bookCoverPosTop + 'px' }">
<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' : 'pl-20 sm:pl-24'">
<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">
{{ title }}
@@ -12,6 +12,7 @@
<div v-if="!playerHandler.isVideo" class="text-gray-400 flex items-center">
<span class="material-icons text-sm">person</span>
<p v-if="podcastAuthor" class="pl-1 sm:pl-1.5 text-xs sm:text-base">{{ podcastAuthor }}</p>
<p v-else-if="musicArtists" class="pl-1 sm:pl-1.5 text-xs sm:text-base">{{ musicArtists }}</p>
<p v-else-if="authors.length" class="pl-1 sm:pl-1.5 text-xs sm:text-base">
<nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">,&nbsp;</span></nuxt-link>
</p>
@@ -24,7 +25,9 @@
</div>
</div>
<div class="flex-grow" />
<span class="material-icons sm:px-2 py-1 md:p-4 cursor-pointer text-xl sm:text-2xl" @click="closePlayer">close</span>
<ui-tooltip direction="top" :text="$strings.LabelClosePlayer">
<span class="material-icons sm:px-2 py-1 md:p-4 cursor-pointer text-xl sm:text-2xl" @click="closePlayer">close</span>
</ui-tooltip>
</div>
<player-ui
ref="audioPlayer"
@@ -83,12 +86,15 @@ export default {
coverAspectRatio() {
return this.$store.getters['libraries/getBookCoverAspectRatio']
},
bookCoverWidth() {
return 88
isSquareCover() {
return this.coverAspectRatio === 1
},
bookCoverPosTop() {
if (this.coverAspectRatio == 1) return -10
return -64
isMobile() {
return this.$store.state.globals.isMobile
},
bookCoverWidth() {
if (this.isMobile) return 64 / this.coverAspectRatio
return 77 / this.coverAspectRatio
},
cover() {
if (this.media.coverPath) return this.media.coverPath
@@ -120,6 +126,9 @@ export default {
isPodcast() {
return this.streamLibraryItem ? this.streamLibraryItem.mediaType === 'podcast' : false
},
isMusic() {
return this.streamLibraryItem ? this.streamLibraryItem.mediaType === 'music' : false
},
mediaMetadata() {
return this.media.metadata || {}
},
@@ -143,6 +152,10 @@ export default {
if (!this.isPodcast) return null
return this.mediaMetadata.author || 'Unknown'
},
musicArtists() {
if (!this.isMusic) return null
return this.mediaMetadata.artists.join(', ')
},
playerQueueItems() {
return this.$store.state.playerQueueItems || []
}
@@ -297,6 +310,16 @@ export default {
this.playerHandler.seek(e.seekTime)
}
},
mediaSessionPreviousTrack() {
if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.prevChapter()
}
},
mediaSessionNextTrack() {
if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.nextChapter()
}
},
updateMediaSessionPlaybackState() {
if ('mediaSession' in navigator) {
navigator.mediaSession.playbackState = this.isPlaying ? 'playing' : 'paused'
@@ -330,20 +353,23 @@ export default {
navigator.mediaSession.setActionHandler('seekbackward', this.mediaSessionSeekBackward)
navigator.mediaSession.setActionHandler('seekforward', this.mediaSessionSeekForward)
navigator.mediaSession.setActionHandler('seekto', this.mediaSessionSeekTo)
// navigator.mediaSession.setActionHandler('previoustrack')
// navigator.mediaSession.setActionHandler('nexttrack')
navigator.mediaSession.setActionHandler('previoustrack', this.mediaSessionPreviousTrack)
const hasNextChapter = this.$refs.audioPlayer && this.$refs.audioPlayer.hasNextChapter
navigator.mediaSession.setActionHandler('nexttrack', hasNextChapter ? this.mediaSessionNextTrack : null)
} else {
console.warn('Media session not available')
}
},
streamProgress(data) {
if (!data.numSegments) return
var chunks = data.chunks
console.log(`[StreamContainer] Stream Progress ${data.percent}`)
if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.setChunksReady(chunks, data.numSegments)
} else {
console.error('No Audio Ref')
if (this.playerHandler.isPlayingLocalItem && this.playerHandler.currentStreamId === data.stream) {
if (!data.numSegments) return
var chunks = data.chunks
console.log(`[StreamContainer] Stream Progress ${data.percent}`)
if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.setChunksReady(chunks, data.numSegments)
} else {
console.error('No Audio Ref')
}
}
},
sessionOpen(session) {
@@ -365,7 +391,7 @@ export default {
}
},
streamReady() {
console.log(`[STREAM-CONTAINER] Stream Ready`)
console.log(`[StreamContainer] Stream Ready`)
if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.setStreamReady()
} else {
@@ -392,8 +418,8 @@ export default {
}
},
async playLibraryItem(payload) {
var libraryItemId = payload.libraryItemId
var episodeId = payload.episodeId || null
const libraryItemId = payload.libraryItemId
const episodeId = payload.episodeId || null
if (this.playerHandler.libraryItemId == libraryItemId && this.playerHandler.episodeId == episodeId) {
if (payload.startTime !== null && !isNaN(payload.startTime)) {
@@ -404,11 +430,12 @@ export default {
return
}
var libraryItem = await this.$axios.$get(`/api/items/${libraryItemId}?expanded=1`).catch((error) => {
const libraryItem = await this.$axios.$get(`/api/items/${libraryItemId}?expanded=1`).catch((error) => {
console.error('Failed to fetch full item', error)
return null
})
if (!libraryItem) return
this.$store.commit('setMediaPlaying', {
libraryItem,
episodeId,

View File

@@ -13,10 +13,14 @@
<!-- Search icon btn -->
<div v-show="!searching && isHovering && userCanUpdate" class="absolute top-0 left-0 p-2 cursor-pointer hover:text-white text-gray-200 transform hover:scale-125 duration-150" @click.prevent.stop="searchAuthor">
<span class="material-icons text-lg">search</span>
<ui-tooltip :text="$strings.ButtonQuickMatch" direction="bottom">
<span class="material-icons text-lg">search</span>
</ui-tooltip>
</div>
<div v-show="isHovering && !searching && userCanUpdate" class="absolute top-0 right-0 p-2 cursor-pointer hover:text-white text-gray-200 transform hover:scale-125 duration-150" @click.prevent.stop="$emit('edit', author)">
<span class="material-icons text-lg">edit</span>
<ui-tooltip :text="$strings.LabelEdit" direction="bottom">
<span class="material-icons text-lg">edit</span>
</ui-tooltip>
</div>
<!-- Loading spinner -->

View File

View File

@@ -6,7 +6,7 @@
<covers-group-cover ref="groupcover" :id="groupEncode" :name="groupName" :type="groupType" :book-items="bookItems" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<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 z-30" :class="isHovering ? '' : 'opacity-0'" :style="{ padding: `${sizeMultiplier}rem` }">
<p class="font-book" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">{{ groupName }}</p>
<p :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">{{ groupName }}</p>
</div>
<div class="absolute z-10 top-1.5 right-1.5 rounded-md leading-3 text-sm p-1 font-semibold text-white flex items-center justify-center" style="background-color: #cd9d49dd">{{ bookItems.length }}</div>

View File

@@ -10,7 +10,7 @@
<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'" class="m-0 p-0 truncate text-xs" 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>
</div>
</template>
@@ -67,6 +67,7 @@ export default {
// 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 === 'authors') return `by ${html}`
if (this.matchKey === 'isbn') return `<p class="truncate">ISBN: ${html}</p>`

View File

@@ -0,0 +1,114 @@
<template>
<div ref="card" :id="`album-card-${index}`" :style="{ width: width + 'px', height: height + 'px' }" class="absolute top-0 left-0 rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
<div class="w-full h-full bg-primary relative rounded overflow-hidden">
<covers-preview-cover ref="cover" :src="coverSrc" :width="width" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</div>
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md text-center" :style="{ width: Math.min(200, width) + 'px' }">
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
</div>
</div>
<div v-else class="absolute z-30 left-0 right-0 mx-auto -bottom-8 h-8 py-1 rounded-md text-center">
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
<p class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ artist || '&nbsp;' }}</p>
</div>
</div>
</template>
<script>
export default {
props: {
index: Number,
width: Number,
height: Number,
bookCoverAspectRatio: Number,
bookshelfView: {
type: Number,
default: 0
},
albumMount: {
type: Object,
default: () => null
}
},
data() {
return {
album: null,
isSelectionMode: false,
selected: false,
isHovering: false
}
},
computed: {
coverSrc() {
const config = this.$config || this.$nuxt.$config
if (!this.album || !this.album.libraryItemId) return `${config.routerBasePath}/book_placeholder.jpg`
return this.store.getters['globals/getLibraryItemCoverSrcById'](this.album.libraryItemId)
},
labelFontSize() {
if (this.width < 160) return 0.75
return 0.875
},
sizeMultiplier() {
const baseSize = this.bookCoverAspectRatio === 1 ? 192 : 120
return this.width / baseSize
},
title() {
return this.album ? this.album.title : ''
},
artist() {
return this.album ? this.album.artist : ''
},
store() {
return this.$store || this.$nuxt.$store
},
currentLibraryId() {
return this.store.state.libraries.currentLibraryId
},
isAlternativeBookshelfView() {
const constants = this.$constants || this.$nuxt.$constants
return this.bookshelfView == constants.BookshelfView.DETAIL
}
},
methods: {
setEntity(album) {
this.album = album
},
setSelectionMode(val) {
this.isSelectionMode = val
},
mouseover() {
this.isHovering = true
},
mouseleave() {
this.isHovering = false
},
clickCard() {
if (!this.album) return
// const router = this.$router || this.$nuxt.$router
// router.push(`/album/${this.$encode(this.title)}`)
},
clickEdit() {
this.$emit('edit', this.album)
},
destroy() {
// destroy the vue listeners, etc
this.$destroy()
// remove the element from the DOM
if (this.$el && this.$el.parentNode) {
this.$el.parentNode.removeChild(this.$el)
} else if (this.$el && this.$el.remove) {
this.$el.remove()
}
}
},
mounted() {
if (this.albumMount) {
this.setEntity(this.albumMount)
}
}
}
</script>

View File

@@ -23,7 +23,7 @@
<div class="w-full h-full absolute top-0 left-0 rounded overflow-hidden z-10">
<div v-show="libraryItem && !imageReady" class="absolute top-0 left-0 w-full h-full flex items-center justify-center" :style="{ padding: sizeMultiplier * 0.5 + 'rem' }">
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }" class="font-book text-gray-300 text-center">{{ title }}</p>
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }" class="text-gray-300 text-center">{{ title }}</p>
</div>
<!-- Cover Image -->
@@ -32,13 +32,13 @@
<!-- Placeholder Cover Title & Author -->
<div v-if="!hasCover" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'rem' }">
<div>
<p class="text-center font-book" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'rem' }">
<p class="text-center" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'rem' }">
{{ titleCleaned }}
</p>
</div>
</div>
<div v-if="!hasCover" class="absolute left-0 right-0 w-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'rem', bottom: authorBottom + 'rem' }">
<p class="text-center font-book" style="color: rgb(247 223 187); opacity: 0.75" :style="{ fontSize: authorFontSize + 'rem' }">{{ authorCleaned }}</p>
<p class="text-center" style="color: rgb(247 223 187); opacity: 0.75" :style="{ fontSize: authorFontSize + 'rem' }">{{ authorCleaned }}</p>
</div>
</div>
@@ -70,7 +70,7 @@
</div>
<!-- More Menu Icon -->
<div ref="moreIcon" v-show="!isSelectionMode" 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">
<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>
@@ -190,6 +190,9 @@ export default {
isPodcast() {
return this.mediaType === 'podcast'
},
isMusic() {
return this.mediaType === 'music'
},
placeholderUrl() {
const config = this.$config || this.$nuxt.$config
return `${config.routerBasePath}/book_placeholder.jpg`
@@ -257,7 +260,7 @@ export default {
return this.bookCoverAspectRatio === 1
},
sizeMultiplier() {
var baseSize = this.squareAspectRatio ? 192 : 120
const baseSize = this.squareAspectRatio ? 192 : 120
return this.width / baseSize
},
title() {
@@ -273,6 +276,10 @@ export default {
authorLF() {
return this.mediaMetadata.authorNameLF
},
artist() {
const artists = this.mediaMetadata.artists || []
return artists.join(', ')
},
displayTitle() {
if (this.recentEpisode) return this.recentEpisode.title
const ignorePrefix = this.orderBy === 'media.metadata.title' && this.sortingIgnorePrefix
@@ -282,6 +289,7 @@ export default {
displayLineTwo() {
if (this.recentEpisode) return this.title
if (this.isPodcast) return this.author
if (this.isMusic) return this.artist
if (this.collapsedSeries) return ''
if (this.isAuthorBookshelfView) {
return this.mediaMetadata.publishedYear || ''
@@ -305,6 +313,7 @@ export default {
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId, this.recentEpisode.id)
},
userProgress() {
if (this.isMusic) return null
if (this.episodeProgress) return this.episodeProgress
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId)
},
@@ -341,7 +350,7 @@ export default {
return !this.isSelectionMode && !this.showPlayButton && this.hasEbook && (this.showExperimentalFeatures || this.enableEReader)
},
showPlayButton() {
return !this.isSelectionMode && !this.isMissing && !this.isInvalid && !this.isStreaming && (this.numTracks || this.recentEpisode)
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)
@@ -366,7 +375,7 @@ export default {
if (this.isPodcast) return 'Podcast has no episodes'
return 'Item has no audio tracks & ebook'
}
var txt = ''
let txt = ''
if (this.numMissingParts) {
txt += `${this.numMissingParts} missing parts.`
}
@@ -377,7 +386,7 @@ export default {
return txt || 'Unknown Error'
},
overlayWrapperClasslist() {
var classes = []
const classes = []
if (this.isSelectionMode) classes.push('bg-opacity-60')
else classes.push('bg-opacity-40')
if (this.selected) {
@@ -401,6 +410,8 @@ export default {
return this.store.getters['user/getIsAdminOrUp']
},
moreMenuItems() {
if (this.isMusic) return []
if (this.recentEpisode) {
const items = [
{
@@ -410,6 +421,10 @@ export default {
{
func: 'toggleFinished',
text: this.itemIsFinished ? this.$strings.MessageMarkAsNotFinished : this.$strings.MessageMarkAsFinished
},
{
func: 'openPlaylists',
text: this.$strings.LabelAddToPlaylist
}
]
if (this.continueListeningShelf) {
@@ -434,7 +449,7 @@ export default {
return items
}
var items = []
let items = []
if (!this.isPodcast) {
items = [
{
@@ -448,6 +463,12 @@ export default {
text: this.$strings.LabelAddToCollection
})
}
if (this.numTracks) {
items.push({
func: 'openPlaylists',
text: this.$strings.LabelAddToPlaylist
})
}
}
if (this.userCanUpdate) {
items.push({
@@ -524,11 +545,11 @@ export default {
return this.author
},
isAlternativeBookshelfView() {
var constants = this.$constants || this.$nuxt.$constants
const constants = this.$constants || this.$nuxt.$constants
return this.bookshelfView === constants.BookshelfView.DETAIL
},
isAuthorBookshelfView() {
var constants = this.$constants || this.$nuxt.$constants
const constants = this.$constants || this.$nuxt.$constants
return this.bookshelfView === constants.BookshelfView.AUTHOR
},
titleDisplayBottomOffset() {
@@ -538,7 +559,7 @@ export default {
},
rssFeed() {
if (this.booksInSeries) return null
return this.store.getters['feeds/getFeedForItem'](this.libraryItemId)
return this._libraryItem.rssFeed || null
}
},
methods: {
@@ -641,7 +662,7 @@ export default {
const axios = this.$axios || this.$nuxt.$axios
this.processing = true
axios
.$get(`/api/items/${this.libraryItemId}/scan`)
.$post(`/api/items/${this.libraryItemId}/scan`)
.then((data) => {
var result = data.result
if (!result) {
@@ -739,6 +760,10 @@ export default {
this.store.commit('setSelectedLibraryItem', this.libraryItem)
this.store.commit('globals/setShowCollectionsModal', true)
},
openPlaylists() {
this.store.commit('globals/setSelectedPlaylistItems', [{ libraryItem: this.libraryItem, episode: this.recentEpisode }])
this.store.commit('globals/setShowPlaylistsModal', true)
},
createMoreMenu() {
if (!this.$refs.moreIcon) return
@@ -795,7 +820,6 @@ export default {
return null
})
if (!libraryItem) return
console.log('Got library itemn', libraryItem)
this.store.commit('showEReader', libraryItem)
},
selectBtnClick(evt) {

View File

@@ -9,13 +9,16 @@
<span class="material-icons text-xl text-white text-opacity-75 hover:text-opacity-100">edit</span>
</div>
</div>
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(160, width) + 'px' }">
<span v-if="!isHovering && rssFeed" class="absolute z-10 material-icons text-success" :style="{ top: 0.5 * sizeMultiplier + 'rem', left: 0.5 * sizeMultiplier + 'rem', fontSize: 1.5 * sizeMultiplier + 'rem' }">rss_feed</span>
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md text-center" :style="{ width: Math.min(200, width) + 'px' }">
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
</div>
</div>
<div v-else class="absolute z-30 left-0 right-0 mx-auto -bottom-8 h-8 py-1 rounded-md text-center">
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
<p class="truncate" :style="{ fontSize: labelFontSize * sizeMultiplier + 'rem' }">{{ title }}</p>
</div>
</div>
</template>
@@ -72,6 +75,9 @@ export default {
},
userCanUpdate() {
return this.store.getters['user/getUserCanUpdate']
},
rssFeed() {
return this.collection ? this.collection.rssFeed : null
}
},
methods: {

View File

@@ -0,0 +1,115 @@
<template>
<div ref="card" :id="`playlist-card-${index}`" :style="{ width: width + 'px', height: height + 'px' }" class="absolute top-0 left-0 rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
<div class="w-full h-full bg-primary relative rounded overflow-hidden">
<covers-playlist-cover ref="cover" :items="items" :width="width" :height="height" />
</div>
<div v-show="isHovering && userCanUpdate" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-40 pointer-events-none">
<div class="absolute pointer-events-auto" :style="{ top: 0.5 * sizeMultiplier + 'rem', right: 0.5 * sizeMultiplier + 'rem' }" @click.stop.prevent="clickEdit">
<span class="material-icons text-xl text-white text-opacity-75 hover:text-opacity-100">edit</span>
</div>
</div>
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md text-center" :style="{ width: Math.min(200, width) + 'px' }">
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
</div>
</div>
<div v-else class="absolute z-30 left-0 right-0 mx-auto -bottom-8 h-8 py-1 rounded-md text-center">
<p class="truncate" :style="{ fontSize: labelFontSize * sizeMultiplier + 'rem' }">{{ title }}</p>
</div>
</div>
</template>
<script>
export default {
props: {
index: Number,
width: Number,
height: Number,
bookCoverAspectRatio: Number,
bookshelfView: {
type: Number,
default: 0
},
playlistMount: {
type: Object,
default: () => null
}
},
data() {
return {
playlist: null,
isSelectionMode: false,
selected: false,
isHovering: false
}
},
computed: {
labelFontSize() {
if (this.width < 160) return 0.75
return 0.875
},
sizeMultiplier() {
if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6)
return this.width / 120
},
title() {
return this.playlist ? this.playlist.name : ''
},
items() {
return this.playlist ? this.playlist.items || [] : []
},
store() {
return this.$store || this.$nuxt.$store
},
currentLibraryId() {
return this.store.state.libraries.currentLibraryId
},
isAlternativeBookshelfView() {
const constants = this.$constants || this.$nuxt.$constants
return this.bookshelfView == constants.BookshelfView.DETAIL
},
userCanUpdate() {
return this.store.getters['user/getUserCanUpdate']
}
},
methods: {
setEntity(playlist) {
this.playlist = playlist
},
setSelectionMode(val) {
this.isSelectionMode = val
},
mouseover() {
this.isHovering = true
},
mouseleave() {
this.isHovering = false
},
clickCard() {
if (!this.playlist) return
var router = this.$router || this.$nuxt.$router
router.push(`/playlist/${this.playlist.id}`)
},
clickEdit() {
this.$emit('edit', this.playlist)
},
destroy() {
// destroy the vue listeners, etc
this.$destroy()
// remove the element from the DOM
if (this.$el && this.$el.parentNode) {
this.$el.parentNode.removeChild(this.$el)
} else if (this.$el && this.$el.remove) {
this.$el.remove()
}
}
},
mounted() {
if (this.playlistMount) {
this.setEntity(this.playlistMount)
}
}
}
</script>

View File

@@ -1,5 +1,5 @@
<template>
<div ref="card" :id="`series-card-${index}`" :style="{ width: width + 'px', height: height + 'px' }" class="rounded-sm z-10 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
<div ref="card" :id="`series-card-${index}`" :style="{ width: width + 'px', height: height + 'px' }" class="rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
<div class="w-full h-full bg-primary relative rounded overflow-hidden z-0">
<covers-group-cover v-if="series" ref="cover" :id="seriesId" :name="displayTitle" :book-items="books" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" />
@@ -10,16 +10,18 @@
<div v-if="isSeriesFinished" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b bg-success w-full" />
<div v-if="hasValidCovers" class="bg-black bg-opacity-60 absolute top-0 left-0 w-full h-full flex items-center justify-center text-center transition-opacity" :class="isHovering ? '' : 'opacity-0'" :style="{ padding: `${sizeMultiplier}rem` }">
<p class="font-book" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">{{ displayTitle }}</p>
<p :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">{{ displayTitle }}</p>
</div>
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(160, width) + 'px' }">
<span v-if="!isHovering && rssFeed" class="absolute z-10 material-icons text-success" :style="{ top: 0.5 * sizeMultiplier + 'rem', left: 0.5 * sizeMultiplier + 'rem', fontSize: 1.5 * sizeMultiplier + 'rem' }">rss_feed</span>
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-10 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md text-center" :style="{ width: Math.min(200, width) + 'px' }">
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ displayTitle }}</p>
</div>
</div>
<div v-else class="absolute z-30 left-0 right-0 mx-auto -bottom-8 h-8 py-1 rounded-md text-center">
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ displayTitle }}</p>
<p class="truncate" :style="{ fontSize: labelFontSize * sizeMultiplier + 'rem' }">{{ displayTitle }}</p>
<p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
</div>
</div>
@@ -125,6 +127,9 @@ export default {
isAlternativeBookshelfView() {
const constants = this.$constants || this.$nuxt.$constants
return this.bookshelfView == constants.BookshelfView.DETAIL
},
rssFeed() {
return this.series ? this.series.rssFeed : null
}
},
methods: {

View File

@@ -54,7 +54,7 @@ export default {
},
folderPath() {
if (!this.libraryFolderPath) return ''
return `${this.libraryFolderPath}\\${this.$sanitizeFilename(this.title)}`
return `${this.libraryFolderPath}/${this.$sanitizeFilename(this.title)}`
},
detailsWidth() {
return this.width - 85

View File

@@ -22,7 +22,7 @@
<span class="font-normal ml-3 block truncate text-sm md:text-base">{{ 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">arrow_right</span>
<span class="material-icons text-2xl">arrow_right</span>
</div>
</li>
</template>
@@ -30,7 +30,7 @@
<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">
<div class="absolute left-1 top-0 bottom-0 h-full flex items-center">
<span class="material-icons">arrow_left</span>
<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>
@@ -41,9 +41,9 @@
<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'))">
<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">No Series</span>
<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">
@@ -67,120 +67,7 @@ export default {
data() {
return {
showMenu: false,
sublist: null,
seriesItems: [
{
text: 'All',
value: 'all'
},
{
text: 'Genre',
value: 'genres',
sublist: true
},
{
text: 'Tag',
value: 'tags',
sublist: true
},
{
text: 'Authors',
value: 'authors',
sublist: true
},
{
text: 'Narrator',
value: 'narrators',
sublist: true
},
{
text: 'Language',
value: 'languages',
sublist: true
},
{
text: 'Series Progress',
value: 'progress',
sublist: true
}
],
bookItems: [
{
text: 'All',
value: 'all'
},
{
text: 'Genre',
value: 'genres',
sublist: true
},
{
text: 'Tag',
value: 'tags',
sublist: true
},
{
text: 'Series',
value: 'series',
sublist: true
},
{
text: 'Authors',
value: 'authors',
sublist: true
},
{
text: 'Narrator',
value: 'narrators',
sublist: true
},
{
text: 'Language',
value: 'languages',
sublist: true
},
{
text: 'Progress',
value: 'progress',
sublist: true
},
{
text: 'Missing',
value: 'missing',
sublist: true
},
{
text: 'Issues',
value: 'issues',
sublist: false
},
{
text: 'RSS Feed Open',
value: 'feed-open',
sublist: false
}
],
podcastItems: [
{
text: 'All',
value: 'all'
},
{
text: 'Genre',
value: 'genres',
sublist: true
},
{
text: 'Tag',
value: 'tags',
sublist: true
},
{
text: 'Issues',
value: 'issues',
sublist: false
}
]
sublist: null
}
},
watch: {
@@ -200,12 +87,166 @@ export default {
this.$emit('input', val)
}
},
libraryMediaType() {
return this.$store.getters['libraries/getCurrentLibraryMediaType']
},
isPodcast() {
return this.$store.getters['libraries/getCurrentLibraryMediaType'] == 'podcast'
return this.libraryMediaType === 'podcast'
},
isMusic() {
return this.libraryMediaType === 'music'
},
seriesItems() {
return [
{
text: this.$strings.LabelAll,
value: 'all'
},
{
text: this.$strings.LabelGenre,
value: 'genres',
sublist: true
},
{
text: this.$strings.LabelTag,
value: 'tags',
sublist: true
},
{
text: this.$strings.LabelAuthor,
value: 'authors',
sublist: true
},
{
text: this.$strings.LabelNarrator,
value: 'narrators',
sublist: true
},
{
text: this.$strings.LabelLanguage,
value: 'languages',
sublist: true
},
{
text: this.$strings.LabelSeriesProgress,
value: 'progress',
sublist: true
}
]
},
bookItems() {
return [
{
text: this.$strings.LabelAll,
value: 'all'
},
{
text: this.$strings.LabelGenre,
value: 'genres',
sublist: true
},
{
text: this.$strings.LabelTag,
value: 'tags',
sublist: true
},
{
text: this.$strings.LabelSeries,
value: 'series',
sublist: true
},
{
text: this.$strings.LabelAuthor,
value: 'authors',
sublist: true
},
{
text: this.$strings.LabelNarrator,
value: 'narrators',
sublist: true
},
{
text: this.$strings.LabelLanguage,
value: 'languages',
sublist: true
},
{
text: this.$strings.LabelProgress,
value: 'progress',
sublist: true
},
{
text: this.$strings.LabelMissing,
value: 'missing',
sublist: true
},
{
text: this.$strings.LabelTracks,
value: 'tracks',
sublist: true
},
{
text: this.$strings.ButtonIssues,
value: 'issues',
sublist: false
},
{
text: this.$strings.LabelRSSFeedOpen,
value: 'feed-open',
sublist: false
}
]
},
podcastItems() {
return [
{
text: this.$strings.LabelAll,
value: 'all'
},
{
text: this.$strings.LabelGenre,
value: 'genres',
sublist: true
},
{
text: this.$strings.LabelTag,
value: 'tags',
sublist: true
},
{
text: this.$strings.ButtonIssues,
value: 'issues',
sublist: false
}
]
},
musicItems() {
return [
{
text: this.$strings.LabelAll,
value: 'all'
},
{
text: this.$strings.LabelGenre,
value: 'genres',
sublist: true
},
{
text: this.$strings.LabelTag,
value: 'tags',
sublist: true
},
{
text: this.$strings.ButtonIssues,
value: 'issues',
sublist: false
}
]
},
selectItems() {
if (this.isSeries) return this.seriesItems
if (this.isPodcast) return this.podcastItems
if (this.isMusic) return this.musicItems
return this.bookItems
},
selectedItemSublist() {
@@ -257,10 +298,92 @@ export default {
return this.filterData.languages || []
},
progress() {
return ['Finished', 'In Progress', 'Not Started', 'Not Finished']
return [
{
id: 'finished',
name: this.$strings.LabelFinished
},
{
id: 'in-progress',
name: this.$strings.LabelInProgress
},
{
id: 'not-started',
name: this.$strings.LabelNotStarted
},
{
id: 'not-finished',
name: this.$strings.LabelNotFinished
}
]
},
tracks() {
return [
{
id: 'single',
name: this.$strings.LabelTracksSingleTrack
},
{
id: 'multi',
name: this.$strings.LabelTracksMultiTrack
}
]
},
missing() {
return ['ASIN', 'ISBN', 'Subtitle', 'Author', 'Publish Year', 'Series', 'Description', 'Genres', 'Tags', 'Narrator', 'Publisher', 'Language']
return [
{
id: 'asin',
name: 'ASIN'
},
{
id: 'isbn',
name: 'ISBN'
},
{
id: 'subtitle',
name: this.$strings.LabelSubtitle
},
{
id: 'authors',
name: this.$strings.LabelAuthor
},
{
id: 'publishedYear',
name: this.$strings.LabelPublishYear
},
{
id: 'series',
name: this.$strings.LabelSeries
},
{
id: 'description',
name: this.$strings.LabelDescription
},
{
id: 'genres',
name: this.$strings.LabelGenres
},
{
id: 'tags',
name: this.$strings.LabelTags
},
{
id: 'narrators',
name: this.$strings.LabelNarrator
},
{
id: 'publisher',
name: this.$strings.LabelPublisher
},
{
id: 'language',
name: this.$strings.LabelLanguage
},
{
id: 'cover',
name: this.$strings.LabelCover
}
]
},
sublistItems() {
return (this[this.sublist] || []).map((item) => {

View File

@@ -50,37 +50,43 @@ export default {
this.$emit('update:descending', val)
}
},
libraryMediaType() {
return this.$store.getters['libraries/getCurrentLibraryMediaType']
},
isPodcast() {
return this.$store.getters['libraries/getCurrentLibraryMediaType'] == 'podcast'
return this.libraryMediaType === 'podcast'
},
isMusic() {
return this.libraryMediaType === 'music'
},
podcastItems() {
return [
{
text: 'Title',
text: this.$strings.LabelTitle,
value: 'media.metadata.title'
},
{
text: 'Author',
text: this.$strings.LabelAuthor,
value: 'media.metadata.author'
},
{
text: 'Added At',
text: this.$strings.LabelAddedAt,
value: 'addedAt'
},
{
text: 'Size',
text: this.$strings.LabelSize,
value: 'size'
},
{
text: '# of Episodes',
text: this.$strings.LabelNumberOfEpisodes,
value: 'media.numTracks'
},
{
text: 'File Birthtime',
text: this.$strings.LabelFileBirthtime,
value: 'birthtimeMs'
},
{
text: 'File Modified',
text: this.$strings.LabelFileModified,
value: 'mtimeMs'
}
]
@@ -92,35 +98,35 @@ export default {
value: 'media.metadata.title'
},
{
text: 'Author (First Last)',
text: this.$strings.LabelAuthorFirstLast,
value: 'media.metadata.authorName'
},
{
text: 'Author (Last, First)',
text: this.$strings.LabelAuthorLastFirst,
value: 'media.metadata.authorNameLF'
},
{
text: 'Published Year',
text: this.$strings.LabelPublishYear,
value: 'media.metadata.publishedYear'
},
{
text: 'Added At',
text: this.$strings.LabelAddedAt,
value: 'addedAt'
},
{
text: 'Size',
text: this.$strings.LabelSize,
value: 'size'
},
{
text: 'Duration',
text: this.$strings.LabelDuration,
value: 'media.duration'
},
{
text: 'File Birthtime',
text: this.$strings.LabelFileBirthtime,
value: 'birthtimeMs'
},
{
text: 'File Modified',
text: this.$strings.LabelFileModified,
value: 'mtimeMs'
}
]
@@ -129,15 +135,45 @@ export default {
return [
...this.bookItems,
{
text: 'Sequence',
text: this.$strings.LabelSequence,
value: 'sequence'
}
]
},
musicItems() {
return [
{
text: this.$strings.LabelTitle,
value: 'media.metadata.title'
},
{
text: this.$strings.LabelAddedAt,
value: 'addedAt'
},
{
text: this.$strings.LabelSize,
value: 'size'
},
{
text: this.$strings.LabelDuration,
value: 'media.duration'
},
{
text: this.$strings.LabelFileBirthtime,
value: 'birthtimeMs'
},
{
text: this.$strings.LabelFileModified,
value: 'mtimeMs'
}
]
},
selectItems() {
let items = null
if (this.isPodcast) {
items = this.podcastItems
} else if (this.isMusic) {
items = this.musicItems
} else if (this.$store.getters['user/getUserSetting']('filterBy').startsWith('series.')) {
items = this.seriesItems
} else {

View File

@@ -40,7 +40,7 @@ export default {
showMenu: false,
currentPlaybackRate: 0,
MIN_SPEED: 0.5,
MAX_SPEED: 3,
MAX_SPEED: 10,
menuLeft: -92,
arrowLeft: 0
}

View File

@@ -7,7 +7,7 @@
<img v-if="libraryItem" ref="cover" :src="fullCoverUrl" loading="lazy" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0 z-10 duration-300 transition-opacity" :style="{ opacity: imageReady ? '1' : '0' }" :class="showCoverBg ? 'object-contain' : 'object-fill'" />
<div v-show="loading && libraryItem" class="absolute top-0 left-0 h-full w-full flex items-center justify-center">
<p class="font-book text-center" :style="{ fontSize: 0.75 * sizeMultiplier + 'rem' }">{{ title }}</p>
<p class="text-center" :style="{ fontSize: 0.75 * sizeMultiplier + 'rem' }">{{ title }}</p>
<div class="absolute top-2 right-2">
<widgets-loading-spinner />
</div>
@@ -17,17 +17,17 @@
<div v-if="imageFailed" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-red-100" :style="{ padding: placeholderCoverPadding + 'rem' }">
<div class="w-full h-full border-2 border-error flex flex-col items-center justify-center">
<img src="/Logo.png" loading="lazy" class="mb-2" :style="{ height: 64 * sizeMultiplier + 'px' }" />
<p class="text-center font-book text-error" :style="{ fontSize: titleFontSize + 'rem' }">Invalid Cover</p>
<p class="text-center text-error" :style="{ fontSize: titleFontSize + 'rem' }">Invalid Cover</p>
</div>
</div>
<div v-if="!hasCover" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex items-center justify-center z-10" :style="{ padding: placeholderCoverPadding + 'rem' }">
<div>
<p class="text-center font-book" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'rem' }">{{ titleCleaned }}</p>
<p class="text-center" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'rem' }">{{ titleCleaned }}</p>
</div>
</div>
<div v-if="!hasCover" class="absolute left-0 right-0 w-full flex items-center justify-center z-10" :style="{ padding: placeholderCoverPadding + 'rem', bottom: authorBottom + 'rem' }">
<p class="text-center font-book" style="color: rgb(247 223 187); opacity: 0.75" :style="{ fontSize: authorFontSize + 'rem' }">{{ authorCleaned }}</p>
<p class="text-center" style="color: rgb(247 223 187); opacity: 0.75" :style="{ fontSize: authorFontSize + 'rem' }">{{ authorCleaned }}</p>
</div>
</div>
</template>

View File

@@ -19,7 +19,7 @@
<div v-else class="relative w-full h-full flex items-center justify-center p-2 bg-primary rounded-sm">
<div class="absolute top-0 left-0 w-full h-full bg-gray-400 bg-opacity-5" />
<p class="font-book text-white text-opacity-60 text-center" :style="{ fontSize: Math.min(1, sizeMultiplier) + 'rem' }">Empty Collection</p>
<p class="text-white text-opacity-60 text-center" :style="{ fontSize: Math.min(1, sizeMultiplier) + 'rem' }">Empty Collection</p>
</div>
</div>
</template>

View File

@@ -138,7 +138,7 @@ export default {
var innerP = document.createElement('p')
innerP.textContent = this.name
innerP.className = 'text-sm font-book text-white'
innerP.className = 'text-sm text-white'
imgdiv.appendChild(innerP)
return imgdiv

View File

@@ -0,0 +1,51 @@
<template>
<div class="relative rounded-sm overflow-hidden" :style="{ width: width + 'px', height: height + 'px' }">
<div v-if="items.length" class="flex flex-wrap justify-center h-full relative bg-primary bg-opacity-95 rounded-sm">
<div class="absolute top-0 left-0 w-full h-full bg-gray-400 bg-opacity-5" />
<covers-book-cover v-for="(li, index) in libraryItemCovers" :key="index" :library-item="li" :width="itemCoverWidth" :book-cover-aspect-ratio="1" />
</div>
<div v-else class="relative w-full h-full flex items-center justify-center p-2 bg-primary rounded-sm">
<div class="absolute top-0 left-0 w-full h-full bg-gray-400 bg-opacity-5" />
</div>
</div>
</template>
<script>
export default {
props: {
items: {
type: Array,
default: () => []
},
width: Number,
height: Number
},
data() {
return {}
},
computed: {
sizeMultiplier() {
return this.width / (120 * 1.6 * 2)
},
itemCoverWidth() {
if (this.libraryItemCovers.length === 1) return this.width
return this.width / 2
},
libraryItemCovers() {
if (!this.items.length) return []
if (this.items.length === 1) return [this.items[0].libraryItem]
const covers = []
for (let i = 0; i < 4; i++) {
let index = i % this.items.length
if (this.items.length === 2 && i >= 2) index = (i + 1) % 2 // for playlists with 2 items show covers in checker pattern
covers.push(this.items[index].libraryItem)
}
return covers
}
},
methods: {},
mounted() {}
}
</script>

View File

@@ -14,7 +14,7 @@
<div v-if="imageFailed" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-red-100" :style="{ padding: placeholderCoverPadding + 'rem' }">
<div class="w-full h-full border-2 border-error flex flex-col items-center justify-center">
<img src="/Logo.png" class="mb-2" :style="{ height: 64 * sizeMultiplier + 'px' }" />
<p class="text-center font-book text-error" :style="{ fontSize: sizeMultiplier + 'rem' }">Invalid Cover</p>
<p class="text-center text-error" :style="{ fontSize: sizeMultiplier + 'rem' }">Invalid Cover</p>
</div>
</div>
@@ -65,7 +65,8 @@ export default {
return `${this.naturalWidth}x${this.naturalHeight}px`
},
placeholderUrl() {
return `${this.$config.routerBasePath}/book_placeholder.jpg`
const config = this.$config || this.$nuxt.$config
return `${config.routerBasePath}/book_placeholder.jpg`
}
},
methods: {

View File

@@ -2,7 +2,7 @@
<modals-modal ref="modal" v-model="show" name="account" :width="800" :height="'unset'" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
<p class="text-3xl text-white truncate">{{ title }}</p>
</div>
</template>
<form @submit.prevent="submitForm">
@@ -22,8 +22,8 @@
</div>
<div class="flex-grow" />
<div class="flex items-center pt-4 px-2">
<p class="px-3 font-semibold" :class="isEditingRoot ? 'text-gray-300' : ''">{{ $strings.LabelEnable }}</p>
<ui-toggle-switch v-model="newUser.isActive" :disabled="isEditingRoot" />
<p class="px-3 font-semibold" id="user-enabled-toggle" :class="isEditingRoot ? 'text-gray-300' : ''">{{ $strings.LabelEnable }}</p>
<ui-toggle-switch labeledBy="user-enabled-toggle" v-model="newUser.isActive" :disabled="isEditingRoot" />
</div>
</div>
@@ -31,55 +31,55 @@
<p class="text-lg mb-2 font-semibold">{{ $strings.HeaderPermissions }}</p>
<div class="flex items-center my-2 max-w-md">
<div class="w-1/2">
<p>{{ $strings.LabelPermissionsDownload }}</p>
<p id="download-permissions-toggle">{{ $strings.LabelPermissionsDownload }}</p>
</div>
<div class="w-1/2">
<ui-toggle-switch v-model="newUser.permissions.download" />
<ui-toggle-switch labeledBy="download-permissions-toggle" v-model="newUser.permissions.download" />
</div>
</div>
<div class="flex items-center my-2 max-w-md">
<div class="w-1/2">
<p>{{ $strings.LabelPermissionsUpdate }}</p>
<p id="update-permissions-toggle">{{ $strings.LabelPermissionsUpdate }}</p>
</div>
<div class="w-1/2">
<ui-toggle-switch v-model="newUser.permissions.update" />
<ui-toggle-switch labeledBy="update-permissions-toggle" v-model="newUser.permissions.update" />
</div>
</div>
<div class="flex items-center my-2 max-w-md">
<div class="w-1/2">
<p>{{ $strings.LabelPermissionsDelete }}</p>
<p id="delete-permissions-toggle">{{ $strings.LabelPermissionsDelete }}</p>
</div>
<div class="w-1/2">
<ui-toggle-switch v-model="newUser.permissions.delete" />
<ui-toggle-switch labeledBy="delete-permissions-toggle" v-model="newUser.permissions.delete" />
</div>
</div>
<div class="flex items-center my-2 max-w-md">
<div class="w-1/2">
<p>{{ $strings.LabelPermissionsUpload }}</p>
<p id="upload-permissions-toggle">{{ $strings.LabelPermissionsUpload }}</p>
</div>
<div class="w-1/2">
<ui-toggle-switch v-model="newUser.permissions.upload" />
<ui-toggle-switch labeledBy="upload-permissions-toggle" v-model="newUser.permissions.upload" />
</div>
</div>
<div class="flex items-center my-2 max-w-md">
<div class="w-1/2">
<p>{{ $strings.LabelPermissionsAccessExplicitContent }}</p>
<p id="explicit-content-permissions-toggle">{{ $strings.LabelPermissionsAccessExplicitContent }}</p>
</div>
<div class="w-1/2">
<ui-toggle-switch v-model="newUser.permissions.accessExplicitContent" />
<ui-toggle-switch labeledBy="explicit-content-permissions-toggle" v-model="newUser.permissions.accessExplicitContent" />
</div>
</div>
<div class="flex items-center my-2 max-w-md">
<div class="w-1/2">
<p>{{ $strings.LabelPermissionsAccessAllLibraries }}</p>
<p id="access-all-libs--permissions-toggle">{{ $strings.LabelPermissionsAccessAllLibraries }}</p>
</div>
<div class="w-1/2">
<ui-toggle-switch v-model="newUser.permissions.accessAllLibraries" @input="accessAllLibrariesToggled" />
<ui-toggle-switch labeledBy="access-all-libs--permissions-toggle" v-model="newUser.permissions.accessAllLibraries" @input="accessAllLibrariesToggled" />
</div>
</div>
@@ -201,8 +201,8 @@ export default {
this.loadingTags = true
this.$axios
.$get(`/api/tags`)
.then((tags) => {
this.tags = tags
.then((res) => {
this.tags = res.tags
this.loadingTags = false
})
.catch((error) => {

View File

@@ -2,7 +2,7 @@
<modals-modal v-model="show" name="backup-scheduler" :width="700" :height="'unset'" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ $strings.HeaderSetBackupSchedule }}</p>
<p class="text-3xl text-white truncate">{{ $strings.HeaderSetBackupSchedule }}</p>
</div>
</template>
<div v-if="show && newCronExpression" 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">

View File

@@ -2,7 +2,7 @@
<modals-modal v-model="show" name="batchQuickMatch" :processing="processing" :width="500" :height="'unset'">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
<p class="text-3xl text-white truncate">{{ title }}</p>
</div>
</template>
@@ -19,7 +19,7 @@
<ui-tooltip :text="$strings.LabelUpdateCoverHelp">
<p class="pl-4">
{{ $strings.LabelUpdateCover }}
<span class="material-icons icon-text text-sm">info_outlined</span>
<span class="material-icons icon-text">info_outlined</span>
</p>
</ui-tooltip>
</div>
@@ -28,7 +28,7 @@
<ui-tooltip :text="$strings.LabelUpdateDetailsHelp">
<p class="pl-4">
{{ $strings.LabelUpdateDetails }}
<span class="material-icons icon-text text-sm">info_outlined</span>
<span class="material-icons icon-text">info_outlined</span>
</p>
</ui-tooltip>
</div>
@@ -82,7 +82,7 @@ export default {
return this.$store.state.globals.showBatchQuickMatchModal
},
selectedBookIds() {
return this.$store.state.selectedLibraryItems || []
return (this.$store.state.globals.selectedMediaItems || []).map((i) => i.id)
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId

View File

@@ -2,7 +2,7 @@
<modals-modal v-model="show" name="bookmarks" :width="500" :height="'unset'">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ $strings.LabelYourBookmarks }}</p>
<p class="text-3xl text-white truncate">{{ $strings.LabelYourBookmarks }}</p>
</div>
</template>
<div ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
@@ -24,7 +24,7 @@
<div class="flex-grow px-2">
<ui-text-input v-model="newBookmarkTitle" placeholder="Note" class="w-full" />
</div>
<ui-btn type="submit" color="success" :padding-x="4" class="h-10"><span class="material-icons -mt-px">add</span></ui-btn>
<ui-btn type="submit" color="success" :padding-x="4" class="h-10"><span class="material-icons text-2xl -mt-px">add</span></ui-btn>
</div>
</form>
</div>

View File

@@ -1,5 +1,5 @@
<template>
<div ref="wrapper" class="hidden absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 rounded-lg items-center justify-center" style="z-index: 51" @click="clickClose">
<div ref="wrapper" class="hidden absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 rounded-lg items-center justify-center" style="z-index: 61" @click="clickClose">
<div class="absolute top-3 right-3 md:top-5 md:right-5 h-8 w-8 md:h-12 md:w-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300">
<span class="material-icons text-2xl md:text-4xl">close</span>
</div>
@@ -15,7 +15,7 @@
</div>
</div>
<div class="flex justify-end mt-2 p-1">
<ui-btn type="submit">{{ $strings.ButtonSave }}</ui-btn>
<ui-btn type="submit">{{ $strings.ButtonSubmit }}</ui-btn>
</div>
</div>
</form>

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="font-book text-3xl text-white truncate">{{ $strings.HeaderSession }} {{ _session.id }}</p>
<p class="text-3xl 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">

View File

@@ -39,7 +39,7 @@ export default {
},
zIndex: {
type: Number,
default: 50
default: 60
},
bgOpacity: {
type: Number,

View File

@@ -2,7 +2,7 @@
<modals-modal v-model="show" name="sleep-timer" :width="350" :height="'unset'">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden pointer-events-none">
<p class="font-book text-3xl text-white truncate pointer-events-none">{{ $strings.HeaderSleepTimer }}</p>
<p class="text-3xl text-white truncate pointer-events-none">{{ $strings.HeaderSleepTimer }}</p>
</div>
</template>

View File

@@ -2,7 +2,7 @@
<modals-modal v-model="show" name="edit-author" :width="800" :height="'unset'" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
<p class="text-3xl text-white truncate">{{ title }}</p>
</div>
</template>
<div class="p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">
@@ -35,7 +35,7 @@
<div class="flex pt-2 px-2">
<ui-btn type="button" @click="searchAuthor">{{ $strings.ButtonQuickMatch }}</ui-btn>
<div class="flex-grow" />
<ui-btn type="submit">{{ $strings.ButtonSubmit }}</ui-btn>
<ui-btn type="submit">{{ $strings.ButtonSave }}</ui-btn>
</div>
</div>
</div>
@@ -109,7 +109,8 @@ export default {
this.processing = true
var result = await this.$axios.$patch(`/api/authors/${this.authorId}`, updatePayload).catch((error) => {
console.error('Failed', error)
this.$toast.error(this.$strings.ToastAuthorUpdateFailed)
const errorMsg = error.response ? error.response.data : null
this.$toast.error(errorMsg || this.$strings.ToastAuthorUpdateFailed)
return null
})
if (result) {
@@ -125,8 +126,7 @@ export default {
},
async removeCover() {
var updatePayload = {
imagePath: null,
relImagePath: null
imagePath: null
}
this.processing = true
var result = await this.$axios.$patch(`/api/authors/${this.authorId}`, updatePayload).catch((error) => {
@@ -136,6 +136,7 @@ export default {
})
if (result && result.updated) {
this.$toast.success(this.$strings.ToastAuthorImageRemoveSuccess)
this.$store.commit('globals/showEditAuthorModal', result.author)
}
this.processing = false
},
@@ -157,8 +158,10 @@ export default {
if (!response) {
this.$toast.error('Author not found')
} else if (response.updated) {
if (response.author.imagePath) this.$toast.success(this.$strings.ToastAuthorUpdateSuccess)
else this.$toast.success(this.$strings.ToastAuthorUpdateSuccessNoImageFound)
if (response.author.imagePath) {
this.$toast.success(this.$strings.ToastAuthorUpdateSuccess)
this.$store.commit('globals/showEditAuthorModal', response.author)
} else this.$toast.success(this.$strings.ToastAuthorUpdateSuccessNoImageFound)
} else {
this.$toast.info('No updates were made for Author')
}

View File

@@ -12,7 +12,7 @@
<div class="flex-grow pr-2">
<ui-text-input v-model="newBookmarkTitle" placeholder="Note" class="w-full" />
</div>
<ui-btn type="submit" color="success" :padding-x="4" class="h-10"><span class="material-icons -mt-px">forward</span></ui-btn>
<ui-btn type="submit" color="success" :padding-x="4" class="h-10"><span class="material-icons text-2xl -mt-px">forward</span></ui-btn>
<div class="pl-2 flex items-center">
<span class="material-icons text-3xl text-white text-opacity-70 hover:text-opacity-95 cursor-pointer" @click.stop.prevent="cancelEditing">close</span>
</div>

View File

@@ -2,7 +2,7 @@
<modals-modal v-model="show" name="changelog" :width="800" :height="'unset'">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">Changelog</p>
<p class="text-3xl text-white truncate">Changelog</p>
</div>
</template>
<div class="px-8 py-6 w-full rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-y-scroll" style="max-height: 80vh">

View File

@@ -2,7 +2,7 @@
<modals-modal v-model="show" name="collections" :processing="processing" :width="500" :height="'unset'">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden pointer-events-none">
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
<p class="text-3xl text-white truncate">{{ title }}</p>
</div>
</template>
@@ -15,7 +15,7 @@
<div class="w-full overflow-y-auto overflow-x-hidden max-h-96">
<transition-group name="list-complete" tag="div">
<template v-for="collection in sortedCollections">
<modals-collections-user-collection-item :key="collection.id" :collection="collection" :book-cover-aspect-ratio="bookCoverAspectRatio" class="list-complete-item" @add="addToCollection" @remove="removeFromCollection" @close="show = false" />
<modals-collections-collection-item :key="collection.id" :collection="collection" :book-cover-aspect-ratio="bookCoverAspectRatio" class="list-complete-item" @add="addToCollection" @remove="removeFromCollection" @close="show = false" />
</template>
</transition-group>
</div>
@@ -104,7 +104,7 @@ export default {
return this.$store.state.globals.showBatchCollectionModal
},
selectedBookIds() {
return this.$store.state.selectedLibraryItems || []
return (this.$store.state.globals.selectedMediaItems || []).map((i) => i.id)
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
@@ -112,23 +112,21 @@ export default {
},
methods: {
loadCollections() {
if (!this.collections.length) {
this.processing = true
this.$axios
.$get(`/api/libraries/${this.currentLibraryId}/collections`)
.then((data) => {
if (data.results) {
this.$store.commit('libraries/setCollections', data.results || [])
}
})
.catch((error) => {
console.error('Failed to get collections', error)
this.$toast.error('Failed to load collections')
})
.finally(() => {
this.processing = false
})
}
this.processing = true
this.$axios
.$get(`/api/libraries/${this.currentLibraryId}/collections`)
.then((data) => {
if (data.results) {
this.$store.commit('libraries/setCollections', data.results || [])
}
})
.catch((error) => {
console.error('Failed to get collections', error)
this.$toast.error('Failed to load collections')
})
.finally(() => {
this.processing = false
})
},
removeFromCollection(collection) {
if (!this.selectedLibraryItemId && !this.selectedBookIds.length) return
@@ -231,19 +229,3 @@ export default {
mounted() {}
}
</script>
<style>
.list-complete-item {
transition: all 0.8s ease;
}
.list-complete-enter-from,
.list-complete-leave-to {
opacity: 0;
transform: translateY(30px);
}
.list-complete-leave-active {
position: absolute;
}
</style>

View File

@@ -0,0 +1,58 @@
<template>
<div class="flex items-center px-4 py-2 justify-start relative hover:bg-bg" @mouseover="mouseover" @mouseleave="mouseleave">
<div v-if="isBookIncluded" class="absolute top-0 left-0 h-full w-1 bg-success z-10" />
<div class="w-20 max-w-20 text-center">
<covers-collection-cover :book-items="books" :width="80" :height="40 * bookCoverAspectRatio" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</div>
<div class="flex-grow overflow-hidden px-2">
<nuxt-link :to="`/collection/${collection.id}`" class="pl-2 pr-2 truncate hover:underline cursor-pointer" @click.native="clickNuxtLink">{{ collection.name }}</nuxt-link>
</div>
<div class="h-full flex items-center justify-end transform" :class="isHovering ? 'transition-transform translate-0 w-16' : 'translate-x-40 w-0'">
<ui-btn v-if="!isBookIncluded" color="success" :padding-x="3" small class="h-9" @click.stop="clickAdd"><span class="material-icons text-2xl pt-px">add</span></ui-btn>
<ui-btn v-else color="error" :padding-x="3" class="h-9" small @click.stop="clickRem"><span class="material-icons text-2xl pt-px">remove</span></ui-btn>
</div>
</div>
</template>
<script>
export default {
props: {
collection: {
type: Object,
default: () => {}
},
bookCoverAspectRatio: Number
},
data() {
return {
isHovering: false
}
},
computed: {
isBookIncluded() {
return !!this.collection.isBookIncluded
},
books() {
return this.collection.books || []
}
},
methods: {
clickNuxtLink() {
this.$emit('close')
},
mouseover() {
this.isHovering = true
},
mouseleave() {
this.isHovering = false
},
clickAdd() {
this.$emit('add', this.collection)
},
clickRem() {
this.$emit('remove', this.collection)
}
},
mounted() {}
}
</script>

View File

@@ -2,7 +2,7 @@
<modals-modal v-model="show" name="edit-collection" :width="700" :height="'unset'" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ $strings.HeaderCollection }}</p>
<p class="text-3xl text-white truncate">{{ $strings.HeaderCollection }}</p>
</div>
</template>
<div class="p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">

View File

@@ -1,95 +0,0 @@
<template>
<div class="flex items-center px-4 py-2 justify-start relative hover:bg-bg" :class="wrapperClass" @mouseover="mouseover" @mouseleave="mouseleave">
<div v-if="isBookIncluded" class="absolute top-0 left-0 h-full w-1 bg-success z-10" />
<!-- <span class="material-icons" :class="highlight ? 'text-success' : 'text-white text-opacity-80'">{{ highlight ? 'bookmark' : 'bookmark_border' }}</span> -->
<div class="w-20 max-w-20 text-center">
<!-- <img src="/Logo.png" /> -->
<covers-collection-cover :book-items="books" :width="80" :height="40 * bookCoverAspectRatio" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</div>
<div class="flex-grow overflow-hidden px-2">
<!-- <template v-if="isEditing">
<form @submit.prevent="submitUpdate">
<div class="flex items-center">
<div class="flex-grow pr-2">
<ui-text-input v-model="newBookmarkTitle" placeholder="Note" class="w-full" />
</div>
<ui-btn type="submit" color="success" :padding-x="4" class="h-10"><span class="material-icons -mt-px">forward</span></ui-btn>
<div class="pl-2 flex items-center">
<span class="material-icons text-3xl text-white text-opacity-70 hover:text-opacity-95 cursor-pointer" @click.stop.prevent="cancelEditing">close</span>
</div>
</div>
</form>
</template> -->
<nuxt-link :to="`/collection/${collection.id}`" class="pl-2 pr-2 truncate hover:underline cursor-pointer" @click.native="clickNuxtLink">{{ collection.name }}</nuxt-link>
</div>
<div v-if="!isEditing" class="h-full flex items-center justify-end transform" :class="isHovering ? 'transition-transform translate-0 w-16' : 'translate-x-40 w-0'">
<ui-btn v-if="!isBookIncluded" color="success" :padding-x="3" small class="h-9" @click.stop="clickAdd"><span class="material-icons pt-px">add</span></ui-btn>
<ui-btn v-else color="error" :padding-x="3" class="h-9" small @click.stop="clickRem"><span class="material-icons pt-px">remove</span></ui-btn>
<!-- <span class="material-icons text-xl mr-2 text-gray-200 hover:text-yellow-400" @click.stop="editClick">edit</span>
<span class="material-icons text-xl text-gray-200 hover:text-error cursor-pointer" @click.stop="deleteClick">delete</span> -->
</div>
</div>
</template>
<script>
export default {
props: {
collection: {
type: Object,
default: () => {}
},
highlight: Boolean,
bookCoverAspectRatio: Number
},
data() {
return {
isHovering: false,
isEditing: false
}
},
computed: {
isBookIncluded() {
return !!this.collection.isBookIncluded
},
wrapperClass() {
var classes = []
if (this.highlight) classes.push('bg-bg bg-opacity-60')
if (!this.isEditing) classes.push('cursor-pointer')
return classes.join(' ')
},
books() {
return this.collection.books || []
}
},
methods: {
clickNuxtLink() {
this.$emit('close')
},
mouseover() {
if (this.isEditing) return
this.isHovering = true
},
mouseleave() {
this.isHovering = false
},
clickAdd() {
this.$emit('add', this.collection)
},
clickRem() {
this.$emit('remove', this.collection)
},
deleteClick() {
if (this.isEditing) return
this.$emit('delete', this.collection)
},
editClick() {
this.isEditing = true
this.isHovering = false
},
cancelEditing() {
this.isEditing = false
}
},
mounted() {}
}
</script>

View File

@@ -2,12 +2,12 @@
<modals-modal v-model="show" name="edit-book" :width="800" :height="height" :processing="processing" :content-margin-top="marginTop">
<template #outer>
<div class="absolute top-0 left-0 p-4 landscape:px-4 landscape:py-2 md:portrait:p-5 lg:p-5 w-2/3 overflow-hidden pointer-events-none">
<p class="font-book text-xl md:portrait:text-3xl md:landscape:text-lg lg:text-3xl text-white truncate pointer-events-none">{{ title }}</p>
<p class="text-xl md:portrait:text-3xl md:landscape:text-lg lg:text-3xl text-white truncate pointer-events-none">{{ title }}</p>
</div>
</template>
<div class="absolute -top-10 left-0 z-10 w-full flex">
<template v-for="tab in availableTabs">
<div :key="tab.id" class="w-28 rounded-t-lg flex items-center justify-center mr-0.5 sm:mr-1 cursor-pointer hover:bg-bg font-book border-t border-l border-r border-black-300 tab text-xs sm:text-base" :class="selectedTab === tab.id ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab(tab.id)">{{ tab.title }}</div>
<div :key="tab.id" class="w-28 rounded-t-lg flex items-center justify-center mr-0.5 sm:mr-1 cursor-pointer hover:bg-bg border-t border-l border-r border-black-300 tab text-xs sm:text-base" :class="selectedTab === tab.id ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab(tab.id)">{{ tab.title }}</div>
</template>
</div>

View File

@@ -7,7 +7,9 @@
<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" />
<div class="p-1 absolute top-1 right-1 text-red-500 rounded-full w-8 h-8 cursor-pointer hover:text-red-400 shadow-sm" @click="removeCover">
<span class="material-icons">delete</span>
<ui-tooltip direction="top" :text="$strings.LabelRemoveCover">
<span class="material-icons text-2xl">delete</span>
</ui-tooltip>
</div>
</div>
</div>
@@ -16,7 +18,7 @@
<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 inline-block md:!hidden">upload</span></ui-file-input
><span class="material-icons text-2xl inline-block md:!hidden">upload</span></ui-file-input
>
</div>
<form @submit.prevent="submitForm" class="flex flex-grow">
@@ -301,11 +303,14 @@ export default {
this.persistProvider()
this.isProcessing = true
var searchQuery = this.getSearchQuery()
var results = await this.$axios.$get(`/api/search/covers?${searchQuery}`).catch((error) => {
console.error('Failed', error)
return []
})
const searchQuery = this.getSearchQuery()
const results = await this.$axios
.$get(`/api/search/covers?${searchQuery}`)
.then((res) => res.results)
.catch((error) => {
console.error('Failed', error)
return []
})
this.coversFound = results
this.isProcessing = false
this.hasSearched = true

View File

@@ -129,7 +129,7 @@ export default {
rescan() {
this.rescanning = true
this.$axios
.$get(`/api/items/${this.libraryItemId}/scan`)
.$post(`/api/items/${this.libraryItemId}/scan`)
.then((data) => {
this.rescanning = false
var result = data.result

View File

@@ -19,7 +19,7 @@
</div>
<div v-if="!episodes.length" class="flex my-4 text-center justify-center text-xl">{{ $strings.MessageNoEpisodes }}</div>
<table v-else class="text-sm tracksTable">
<tr class="font-book">
<tr>
<th class="text-left">Sort #</th>
<th class="text-left whitespace-nowrap">{{ $strings.LabelEpisode }}</th>
<th class="text-left">{{ $strings.EpisodeTitle }}</th>
@@ -33,7 +33,7 @@
<td class="text-left">
<p class="px-4">{{ episode.episode }}</p>
</td>
<td class="font-book">
<td>
{{ episode.title }}
</td>
<td class="font-mono text-center">

View File

@@ -306,13 +306,13 @@ export default {
this.runSearch()
},
async runSearch() {
var searchQuery = this.getSearchQuery()
const searchQuery = this.getSearchQuery()
if (this.lastSearch === searchQuery) return
this.searchResults = []
this.isProcessing = true
this.lastSearch = searchQuery
var searchEntity = this.isPodcast ? 'podcast' : 'books'
var results = await this.$axios.$get(`/api/search/${searchEntity}?${searchQuery}`, { timeout: 20000 }).catch((error) => {
const searchEntity = this.isPodcast ? 'podcast' : 'books'
let results = await this.$axios.$get(`/api/search/${searchEntity}?${searchQuery}`, { timeout: 20000 }).catch((error) => {
console.error('Failed', error)
return []
})
@@ -335,8 +335,7 @@ export default {
this.isProcessing = false
this.hasSearched = true
},
init() {
this.clearSelectedMatch()
initSelectedMatchUsage() {
this.selectedMatchUsage = {
title: true,
subtitle: true,
@@ -360,6 +359,27 @@ export default {
releaseDate: true
}
// Load saved selected match from local storage
try {
let savedSelectedMatchUsage = localStorage.getItem('selectedMatchUsage')
if (!savedSelectedMatchUsage) return
savedSelectedMatchUsage = JSON.parse(savedSelectedMatchUsage)
for (const key in savedSelectedMatchUsage) {
if (this.selectedMatchUsage[key] !== undefined) {
this.selectedMatchUsage[key] = !!savedSelectedMatchUsage[key]
}
}
} catch (error) {
console.error('Failed to load saved selectedMatchUsage', error)
}
this.checkboxToggled()
},
init() {
this.clearSelectedMatch()
this.initSelectedMatchUsage()
if (this.libraryItem.id !== this.libraryItemId) {
this.searchResults = []
this.hasSearched = false
@@ -376,6 +396,12 @@ export default {
if (this.isPodcast) this.provider = 'itunes'
else this.provider = localStorage.getItem('book-provider') || 'google'
// Prefer using ASIN if set and using audible provider
if (this.provider.startsWith('audible') && this.libraryItem.media.metadata.asin) {
this.searchTitle = this.libraryItem.media.metadata.asin
this.searchAuthor = ''
}
if (this.searchTitle) {
this.submitSearch()
}
@@ -465,11 +491,14 @@ export default {
console.log('Match payload', updatePayload)
this.isProcessing = true
// Persist in local storage
localStorage.setItem('selectedMatchUsage', JSON.stringify(this.selectedMatchUsage))
if (updatePayload.metadata.cover) {
var coverPayload = {
const coverPayload = {
url: updatePayload.metadata.cover
}
var success = await this.$axios.$post(`/api/items/${this.libraryItemId}/cover`, coverPayload).catch((error) => {
const success = await this.$axios.$post(`/api/items/${this.libraryItemId}/cover`, coverPayload).catch((error) => {
console.error('Failed to update', error)
return false
})
@@ -483,8 +512,8 @@ export default {
}
if (Object.keys(updatePayload).length) {
var mediaUpdatePayload = updatePayload
var updateResult = await this.$axios.$patch(`/api/items/${this.libraryItemId}/media`, mediaUpdatePayload).catch((error) => {
const mediaUpdatePayload = updatePayload
const updateResult = await this.$axios.$patch(`/api/items/${this.libraryItemId}/media`, mediaUpdatePayload).catch((error) => {
console.error('Failed to update', error)
return false
})
@@ -502,6 +531,7 @@ export default {
} else {
this.clearSelectedMatch()
}
this.isProcessing = false
},
clearSelectedMatch() {

View File

@@ -15,7 +15,7 @@
<ui-tooltip text="Value of 0 sets no max limit. After a new episode is auto-downloaded this will delete the oldest episode if you have more than X episodes. <br>This will only delete 1 episode per new download.">
<p class="pl-4 text-base">
Max episodes to keep
<span class="material-icons icon-text text-sm">info_outlined</span>
<span class="material-icons icon-text">info_outlined</span>
</p>
</ui-tooltip>
</div>
@@ -24,7 +24,7 @@
<ui-tooltip text="Value of 0 sets no max limit. When checking for new episodes this is the max number of episodes that will be downloaded.">
<p class="pl-4 text-base">
Max new episodes to download per check
<span class="material-icons icon-text text-sm">info_outlined</span>
<span class="material-icons icon-text">info_outlined</span>
</p>
</ui-tooltip>
</div>

View File

@@ -6,8 +6,8 @@
<div v-if="showM4bDownload" class="w-full border border-black-200 p-4 my-8">
<div class="flex flex-wrap items-center">
<div>
<p class="text-lg">Make M4B Audiobook File</p>
<p class="max-w-sm text-sm pt-2 text-gray-300">Generate a .M4B audiobook file with embedded metadata, cover image, and chapters.</p>
<p class="text-lg">{{ $strings.LabelToolsMakeM4b }}</p>
<p class="max-w-sm text-sm pt-2 text-gray-300">{{ $strings.LabelToolsMakeM4bDescription }}</p>
</div>
<div class="flex-grow" />
<div>
@@ -23,12 +23,12 @@
<div v-if="showMp3Split && showExperimentalFeatures" class="w-full border border-black-200 p-4 my-8">
<div class="flex items-center">
<div>
<p class="text-lg">Split M4B to MP3's</p>
<p class="max-w-sm text-sm pt-2 text-gray-300">Generate multiple MP3's split by chapters with embedded metadata, cover image, and chapters.</p>
<p class="text-lg">{{ $strings.LabelToolsSplitM4b }}</p>
<p class="max-w-sm text-sm pt-2 text-gray-300">{{ $strings.LabelToolsSplitM4bDescription }}</p>
</div>
<div class="flex-grow" />
<div>
<ui-btn :disabled="true">Not yet implemented</ui-btn>
<ui-btn :disabled="true">{{ $strings.MessageNotYetImplemented }}</ui-btn>
</div>
</div>
</div>
@@ -37,8 +37,8 @@
<div v-if="mediaTracks.length" class="w-full border border-black-200 p-4 my-8">
<div class="flex items-center">
<div>
<p class="text-lg">Embed Metadata</p>
<p class="max-w-sm text-sm pt-2 text-gray-300">Embed metadata into audio files including cover image and chapters.</p>
<p class="text-lg">{{ $strings.LabelToolsEmbedMetadata }}</p>
<p class="max-w-sm text-sm pt-2 text-gray-300">{{ $strings.LabelToolsEmbedMetadataDescription }}</p>
</div>
<div class="flex-grow" />
<div>

View File

@@ -1,12 +1,12 @@
<template>
<div class="w-full h-full px-1 md:px-4 py-2 mb-4">
<div v-if="!showDirectoryPicker" class="w-full h-full py-4">
<div class="flex flex-wrap md:flex-nowrap -mx-1">
<div class="w-full h-full md:px-4 py-2 mb-4">
<div v-if="!showDirectoryPicker" class="w-full h-full md:py-4">
<div class="flex flex-wrap md:flex-nowrap -mx-1 mb-2">
<div class="w-2/5 md:w-72 px-1 py-1 md:py-0">
<ui-dropdown v-model="mediaType" :items="mediaTypes" :label="$strings.LabelMediaType" :disabled="!isNew" small @input="changedMediaType" />
</div>
<div class="w-full md:flex-grow px-1 py-1 md:py-0">
<ui-text-input-with-label v-model="name" :label="$strings.LabelLibraryName" @blur="nameBlurred" />
<ui-text-input-with-label ref="nameInput" v-model="name" :label="$strings.LabelLibraryName" @blur="nameBlurred" />
</div>
<div class="w-1/5 md:w-18 px-1 py-1 md:py-0">
<ui-media-icon-picker v-model="icon" :label="$strings.LabelIcon" @input="iconChanged" />
@@ -16,16 +16,16 @@
</div>
</div>
<div class="w-full py-4">
<div class="folders-container overflow-y-auto w-full py-2 mb-2">
<p class="px-1 text-sm font-semibold">{{ $strings.LabelFolders }}</p>
<div v-for="(folder, index) in folders" :key="index" class="w-full flex items-center py-1 px-2">
<span class="material-icons bg-opacity-50 mr-2 text-yellow-200" style="font-size: 1.2rem">folder</span>
<ui-editable-text v-model="folder.fullPath" readonly type="text" class="w-full" />
<span v-show="folders.length > 1" class="material-icons ml-2 cursor-pointer hover:text-error" @click="removeFolder(folder)">close</span>
<ui-editable-text ref="folderInput" v-model="folder.fullPath" readonly type="text" class="w-full" />
<span v-show="folders.length > 1" class="material-icons text-2xl ml-2 cursor-pointer hover:text-error" @click="removeFolder(folder)">close</span>
</div>
<div class="flex py-1 px-2 items-center w-full">
<span class="material-icons bg-opacity-50 mr-2 text-yellow-200" style="font-size: 1.2rem">folder</span>
<ui-editable-text v-model="newFolderPath" :placeholder="$strings.PlaceholderNewFolderPath" type="text" class="w-full" @blur="newFolderInputBlurred" />
<ui-editable-text ref="newFolderInput" v-model="newFolderPath" :placeholder="$strings.PlaceholderNewFolderPath" type="text" class="w-full" @blur="newFolderInputBlurred" />
</div>
<ui-btn class="w-full mt-2" color="primary" @click="browseForFolder">{{ $strings.ButtonBrowseForFolder }}</ui-btn>
@@ -67,6 +67,10 @@ export default {
value: 'podcast',
text: this.$strings.LabelPodcasts
}
// {
// value: 'music',
// text: 'Music'
// }
]
},
folderPaths() {
@@ -78,6 +82,19 @@ export default {
}
},
methods: {
checkBlurExpressionInput() {
if (this.$refs.nameInput) {
this.$refs.nameInput.blur()
}
if (this.$refs.folderInput && this.$refs.folderInput.length) {
this.$refs.folderInput.forEach((input) => {
if (input.blur) input.blur()
})
}
if (this.$refs.newFolderInput) {
this.$refs.newFolderInput.blur()
}
},
browseForFolder() {
this.showDirectoryPicker = true
},
@@ -140,3 +157,14 @@ export default {
}
}
</script>
<style>
.folders-container {
max-height: calc(80vh - 192px);
}
@media (max-device-width: 768px) {
.folders-container {
max-height: calc(80vh - 292px);
}
}
</style>

View File

@@ -2,16 +2,16 @@
<modals-modal v-model="show" name="edit-library" :width="700" :height="'unset'" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-xl md:text-3xl text-white truncate">{{ title }}</p>
<p class="text-xl md:text-3xl text-white truncate">{{ title }}</p>
</div>
</template>
<div class="absolute -top-10 left-0 z-10 w-full flex">
<template v-for="tab in tabs">
<div :key="tab.id" class="w-28 rounded-t-lg flex items-center justify-center mr-1 cursor-pointer hover:bg-bg font-book border-t border-l border-r border-black-300 tab text-xs sm:text-base" :class="selectedTab === tab.id ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab(tab.id)">{{ tab.title }}</div>
<div :key="tab.id" class="w-28 rounded-t-lg flex items-center justify-center mr-1 cursor-pointer hover:bg-bg border-t border-l border-r border-black-300 tab text-xs sm:text-base" :class="selectedTab === tab.id ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab(tab.id)">{{ tab.title }}</div>
</template>
</div>
<div class="px-2 md:px-4 w-full text-sm pt-6 pb-20 rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">
<div class="px-2 md:px-4 w-full text-sm pt-2 md:pt-6 pb-20 rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">
<component v-if="libraryCopy && show" ref="tabComponent" :is="tabName" :is-new="!library" :library="libraryCopy" :processing.sync="processing" @update="updateLibrary" @close="show = false" />
<div class="absolute bottom-0 left-0 w-full px-4 py-4 border-t border-white border-opacity-10">
@@ -144,8 +144,6 @@ export default {
return true
},
submit() {
if (!this.validate()) return
// If custom expression input is focused then unfocus it instead of submitting
if (this.$refs.tabComponent && this.$refs.tabComponent.checkBlurExpressionInput) {
if (this.$refs.tabComponent.checkBlurExpressionInput()) {
@@ -153,6 +151,8 @@ export default {
}
}
if (!this.validate()) return
if (this.library) {
this.submitUpdateLibrary()
} else {

View File

@@ -2,7 +2,7 @@
<modals-modal ref="modal" v-model="show" name="notification-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="font-book text-3xl text-white truncate">{{ title }}</p>
<p class="text-3xl text-white truncate">{{ title }}</p>
</div>
</template>
<form @submit.prevent="submitForm">

View File

@@ -10,10 +10,10 @@
<p v-if="isOpenInPlayer" class="text-sm text-right text-gray-400">{{ $strings.ButtonPlaying }}</p>
<div v-else-if="isHovering" class="flex items-center justify-end -mx-1">
<button class="outline-none mx-1 flex items-center" @click.stop="playClick">
<span class="material-icons text-success">play_arrow</span>
<span class="material-icons text-2xl text-success">play_arrow</span>
</button>
<button class="outline-none mx-1 flex items-center" @click.stop="removeClick">
<span class="material-icons text-error">close</span>
<span class="material-icons text-2xl text-error">close</span>
</button>
</div>
<p v-else class="text-gray-400 text-sm text-right">{{ durationPretty }}</p>

View File

@@ -2,7 +2,7 @@
<modals-modal v-model="show" name="queue-items" :width="800" :height="'unset'">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ $strings.HeaderPlayerQueue }}</p>
<p class="text-3xl text-white truncate">{{ $strings.HeaderPlayerQueue }}</p>
</div>
</template>
<div ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden py-4" style="max-height: 80vh">

View File

@@ -0,0 +1,191 @@
<template>
<modals-modal v-model="show" name="playlists" :processing="processing" :width="500" :height="'unset'">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden pointer-events-none">
<p class="text-3xl text-white truncate">{{ title }}</p>
</div>
</template>
<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="show" class="w-full h-full">
<div class="py-4 px-4">
<h1 v-if="!isBatch" class="text-2xl">{{ $strings.LabelAddToPlaylist }}</h1>
<h1 v-else class="text-2xl">{{ $getString('LabelAddToPlaylistBatch', [selectedPlaylistItems.length]) }}</h1>
</div>
<div class="w-full overflow-y-auto overflow-x-hidden max-h-96">
<transition-group name="list-complete" tag="div">
<template v-for="playlist in sortedPlaylists">
<modals-playlists-user-playlist-item :key="playlist.id" :playlist="playlist" class="list-complete-item" @add="addToPlaylist" @remove="removeFromPlaylist" @close="show = false" />
</template>
</transition-group>
</div>
<div v-if="!playlists.length" class="flex h-32 items-center justify-center">
<p class="text-xl">{{ $strings.MessageNoUserPlaylists }}</p>
</div>
<div class="w-full h-px bg-white bg-opacity-10" />
<form @submit.prevent="submitCreatePlaylist">
<div class="flex px-4 py-2 items-center text-center border-b border-white border-opacity-10 text-white text-opacity-80">
<div class="flex-grow px-2">
<ui-text-input v-model="newPlaylistName" :placeholder="$strings.PlaceholderNewPlaylist" class="w-full" />
</div>
<ui-btn type="submit" color="success" :padding-x="4" class="h-10">{{ $strings.ButtonCreate }}</ui-btn>
</div>
</form>
</div>
</div>
</modals-modal>
</template>
<script>
export default {
data() {
return {
newPlaylistName: '',
processing: false
}
},
watch: {
show(newVal) {
if (newVal) {
this.loadPlaylists()
this.newPlaylistName = ''
} else {
this.$store.commit('globals/setSelectedPlaylistItems', null)
}
}
},
computed: {
show: {
get() {
return this.$store.state.globals.showPlaylistsModal
},
set(val) {
this.$store.commit('globals/setShowPlaylistsModal', val)
}
},
title() {
if (!this.selectedPlaylistItems.length) return ''
if (this.isBatch) {
return this.$getString('MessageItemsSelected', [this.selectedPlaylistItems.length])
}
const selectedPlaylistItem = this.selectedPlaylistItems[0]
if (selectedPlaylistItem.episode) {
return selectedPlaylistItem.episode.title
}
return selectedPlaylistItem.libraryItem.media.metadata.title || ''
},
playlists() {
return this.$store.state.libraries.userPlaylists || []
},
sortedPlaylists() {
return this.playlists
.map((playlist) => {
const includesItem = !this.selectedPlaylistItems.some((item) => !this.checkIsItemInPlaylist(playlist, item))
return {
isItemIncluded: includesItem,
...playlist
}
})
.sort((a, b) => (a.isItemIncluded ? -1 : 1))
},
isBatch() {
return this.selectedPlaylistItems.length > 1
},
selectedPlaylistItems() {
return this.$store.state.globals.selectedPlaylistItems || []
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
}
},
methods: {
checkIsItemInPlaylist(playlist, item) {
if (item.episode) {
return playlist.items.some((i) => i.libraryItemId === item.libraryItem.id && i.episodeId === item.episode.id)
}
return playlist.items.some((i) => i.libraryItemId === item.libraryItem.id)
},
loadPlaylists() {
this.processing = true
this.$axios
.$get(`/api/libraries/${this.currentLibraryId}/playlists`)
.then((data) => {
this.$store.commit('libraries/setUserPlaylists', data.results || [])
})
.catch((error) => {
console.error('Failed to get playlists', error)
this.$toast.error('Failed to load user playlists')
})
.finally(() => {
this.processing = false
})
},
removeFromPlaylist(playlist) {
if (!this.selectedPlaylistItems.length) return
this.processing = true
const itemObjects = this.selectedPlaylistItems.map((pi) => ({ libraryItemId: pi.libraryItem.id, episodeId: pi.episode ? pi.episode.id : null }))
this.$axios
.$post(`/api/playlists/${playlist.id}/batch/remove`, { items: itemObjects })
.then((updatedPlaylist) => {
console.log(`Items removed from playlist`, updatedPlaylist)
this.$toast.success('Playlist item(s) removed')
this.processing = false
})
.catch((error) => {
console.error('Failed to remove items from playlist', error)
this.$toast.error('Failed to remove playlist item(s)')
this.processing = false
})
},
addToPlaylist(playlist) {
if (!this.selectedPlaylistItems.length) return
this.processing = true
const itemObjects = this.selectedPlaylistItems.map((pi) => ({ libraryItemId: pi.libraryItem.id, episodeId: pi.episode ? pi.episode.id : null }))
this.$axios
.$post(`/api/playlists/${playlist.id}/batch/add`, { items: itemObjects })
.then((updatedPlaylist) => {
console.log(`Items added to playlist`, updatedPlaylist)
this.$toast.success('Items added to playlist')
this.processing = false
})
.catch((error) => {
console.error('Failed to add items to playlist', error)
this.$toast.error('Failed to add items to playlist')
this.processing = false
})
},
submitCreatePlaylist() {
if (!this.newPlaylistName || !this.selectedPlaylistItems.length) {
return
}
this.processing = true
const itemObjects = this.selectedPlaylistItems.map((pi) => ({ libraryItemId: pi.libraryItem.id, episodeId: pi.episode ? pi.episode.id : null }))
const newPlaylist = {
items: itemObjects,
libraryId: this.currentLibraryId,
name: this.newPlaylistName
}
this.$axios
.$post('/api/playlists', newPlaylist)
.then((data) => {
console.log('New playlist created', data)
this.$toast.success(`Playlist "${data.name}" created`)
this.processing = false
this.newPlaylistName = ''
})
.catch((error) => {
console.error('Failed to create playlist', error)
var errMsg = error.response ? error.response.data || '' : ''
this.$toast.error(`Failed to create playlist: ${errMsg}`)
this.processing = false
})
}
},
mounted() {}
}
</script>

View File

@@ -0,0 +1,125 @@
<template>
<modals-modal v-model="show" name="edit-playlist" :width="700" :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">{{ $strings.HeaderPlaylist }}</p>
</div>
</template>
<div class="p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">
<form @submit.prevent="submitForm">
<div class="flex">
<div>
<covers-playlist-cover :items="items" :width="200" :height="200" />
</div>
<div class="flex-grow px-4">
<ui-text-input-with-label v-model="newPlaylistName" :label="$strings.LabelName" class="mb-2" />
<ui-textarea-with-label v-model="newPlaylistDescription" :label="$strings.LabelDescription" />
</div>
</div>
<div class="absolute bottom-0 left-0 right-0 w-full py-2 px-4 flex">
<ui-btn v-if="userCanDelete" small color="error" type="button" @click.stop="removeClick">{{ $strings.ButtonRemove }}</ui-btn>
<div class="flex-grow" />
<ui-btn color="success" type="submit">{{ $strings.ButtonSave }}</ui-btn>
</div>
</form>
</div>
</modals-modal>
</template>
<script>
export default {
data() {
return {
processing: false,
newPlaylistName: null,
newPlaylistDescription: null,
showImageUploader: false
}
},
watch: {
show: {
handler(newVal) {
if (newVal) {
this.init()
}
}
}
},
computed: {
show: {
get() {
return this.$store.state.globals.showEditPlaylistModal
},
set(val) {
this.$store.commit('globals/setShowEditPlaylistModal', val)
}
},
playlist() {
return this.$store.state.globals.selectedPlaylist || {}
},
playlistName() {
return this.playlist.name
},
items() {
return this.playlist.items || []
},
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
}
},
methods: {
init() {
this.newPlaylistName = this.playlistName
this.newPlaylistDescription = this.playlist.description || ''
},
removeClick() {
if (confirm(this.$getString('MessageConfirmRemovePlaylist', [this.playlistName]))) {
this.processing = true
this.$axios
.$delete(`/api/playlists/${this.playlist.id}`)
.then(() => {
this.processing = false
this.show = false
this.$toast.success(this.$strings.ToastPlaylistRemoveSuccess)
})
.catch((error) => {
console.error('Failed to remove playlist', error)
this.processing = false
this.$toast.error(this.$strings.ToastPlaylistRemoveFailed)
})
}
},
submitForm() {
if (this.newPlaylistName === this.playlistName && this.newPlaylistDescription === this.playlist.description) {
return
}
if (!this.newPlaylistName) {
return this.$toast.error('Playlist must have a name')
}
this.processing = true
var playlistUpdate = {
name: this.newPlaylistName,
description: this.newPlaylistDescription || null
}
this.$axios
.$patch(`/api/playlists/${this.playlist.id}`, playlistUpdate)
.then((playlist) => {
console.log('Playlist Updated', playlist)
this.processing = false
this.show = false
this.$toast.success(this.$strings.ToastPlaylistUpdateSuccess)
})
.catch((error) => {
console.error('Failed to update playlist', error)
this.processing = false
this.$toast.error(this.$strings.ToastPlaylistUpdateFailed)
})
}
},
mounted() {},
beforeDestroy() {}
}
</script>

View File

@@ -0,0 +1,57 @@
<template>
<div class="flex items-center px-4 py-2 justify-start relative hover:bg-bg" @mouseover="mouseover" @mouseleave="mouseleave">
<div v-if="isItemIncluded" class="absolute top-0 left-0 h-full w-1 bg-success z-10" />
<div class="w-16 max-w-16 text-center">
<covers-playlist-cover :items="items" :width="64" :height="64" />
</div>
<div class="flex-grow overflow-hidden px-2">
<nuxt-link :to="`/playlist/${playlist.id}`" class="pl-2 pr-2 truncate hover:underline cursor-pointer" @click.native="clickNuxtLink">{{ playlist.name }}</nuxt-link>
</div>
<div class="h-full flex items-center justify-end transform" :class="isHovering ? 'transition-transform translate-0 w-16' : 'translate-x-40 w-0'">
<ui-btn v-if="!isItemIncluded" color="success" :padding-x="3" small class="h-9" @click.stop="clickAdd"><span class="material-icons text-2xl pt-px">add</span></ui-btn>
<ui-btn v-else color="error" :padding-x="3" class="h-9" small @click.stop="clickRem"><span class="material-icons text-2xl pt-px">remove</span></ui-btn>
</div>
</div>
</template>
<script>
export default {
props: {
playlist: {
type: Object,
default: () => {}
}
},
data() {
return {
isHovering: false
}
},
computed: {
isItemIncluded() {
return !!this.playlist.isItemIncluded
},
items() {
return this.playlist.items || []
}
},
methods: {
clickNuxtLink() {
this.$emit('close')
},
mouseover() {
this.isHovering = true
},
mouseleave() {
this.isHovering = false
},
clickAdd() {
this.$emit('add', this.playlist)
},
clickRem() {
this.$emit('remove', this.playlist)
}
},
mounted() {}
}
</script>

View File

@@ -2,12 +2,12 @@
<modals-modal v-model="show" name="podcast-episode-edit-modal" :width="800" :height="'unset'" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
<p class="text-3xl text-white truncate">{{ title }}</p>
</div>
</template>
<div class="absolute -top-10 left-0 z-10 w-full flex">
<template v-for="tab in tabs">
<div :key="tab.id" class="w-28 rounded-t-lg flex items-center justify-center mr-0.5 sm:mr-1 cursor-pointer hover:bg-bg font-book border-t border-l border-r border-black-300 tab text-xs sm:text-base" :class="selectedTab === tab.id ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab(tab.id)">{{ tab.title }}</div>
<div :key="tab.id" class="w-28 rounded-t-lg flex items-center justify-center mr-0.5 sm:mr-1 cursor-pointer hover:bg-bg border-t border-l border-r border-black-300 tab text-xs sm:text-base" :class="selectedTab === tab.id ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab(tab.id)">{{ tab.title }}</div>
</template>
</div>

View File

@@ -2,7 +2,7 @@
<modals-modal v-model="show" name="podcast-episodes-modal" :width="1200" :height="'unset'" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
<p class="text-3xl text-white truncate">{{ title }}</p>
</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">

View File

@@ -2,7 +2,7 @@
<modals-modal v-model="show" name="new-podcast-modal" :width="1000" :height="'unset'" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-3/4 overflow-hidden">
<p class="font-book text-xl md:text-3xl text-white truncate">{{ title }}</p>
<p class="text-xl md:text-3xl text-white truncate">{{ title }}</p>
</div>
</template>
<div ref="wrapper" id="podcast-wrapper" class="p-2 md:p-8 w-full text-sm py-2 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-x-hidden overflow-y-auto" style="max-height: 80vh">

View File

@@ -2,7 +2,7 @@
<modals-modal v-model="show" name="opml-feeds-modal" :width="1000" :height="'unset'" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
<p class="text-3xl text-white truncate">{{ title }}</p>
</div>
</template>
<div ref="wrapper" class="p-4 w-full text-sm py-2 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden">
@@ -97,7 +97,7 @@ export default {
},
methods: {
toFeedMetadata(feed) {
var metadata = feed.metadata
const metadata = feed.metadata
return {
title: metadata.title,
author: metadata.author,
@@ -122,9 +122,9 @@ export default {
},
async submit() {
this.processing = true
var newFeedPayloads = this.feedMetadata.map((metadata) => {
const newFeedPayloads = this.feedMetadata.map((metadata) => {
return {
path: `${this.selectedFolderPath}\\${this.$sanitizeFilename(metadata.title)}`,
path: `${this.selectedFolderPath}/${this.$sanitizeFilename(metadata.title)}`,
folderId: this.selectedFolderId,
libraryId: this.currentLibrary.id,
media: {

View File

@@ -2,7 +2,7 @@
<modals-modal v-model="show" name="podcast-episode-remove-modal" :width="500" :height="'unset'" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
<p class="text-3xl text-white truncate">{{ title }}</p>
</div>
</template>
<div ref="wrapper" class="px-8 py-6 w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden">

View File

@@ -2,7 +2,7 @@
<modals-modal v-model="show" name="podcast-episode-view-modal" :width="800" :height="'unset'" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ $strings.LabelEpisode }}</p>
<p class="text-3xl text-white truncate">{{ $strings.LabelEpisode }}</p>
</div>
</template>
<div ref="wrapper" class="p-4 w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-y-auto" style="max-height: 80vh">

View File

@@ -2,17 +2,17 @@
<modals-modal v-model="show" name="rss-feed-modal" :width="600" :height="'unset'" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
<p class="text-3xl text-white truncate">{{ title }}</p>
</div>
</template>
<div ref="wrapper" class="px-8 py-6 w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden">
<div v-if="currentFeedUrl" class="w-full">
<div v-if="currentFeed" class="w-full">
<p class="text-lg font-semibold mb-4">{{ $strings.HeaderRSSFeedIsOpen }}</p>
<div class="w-full relative">
<ui-text-input v-model="currentFeedUrl" readonly />
<ui-text-input v-model="currentFeed.feedUrl" readonly />
<span class="material-icons absolute right-2 bottom-2 p-0.5 text-base transition-transform duration-100 text-gray-300 hover:text-white transform hover:scale-125 cursor-pointer" @click="copyToClipboard(currentFeedUrl)">content_copy</span>
<span class="material-icons absolute right-2 bottom-2 p-0.5 text-base transition-transform duration-100 text-gray-300 hover:text-white transform hover:scale-125 cursor-pointer" @click="copyToClipboard(currentFeed.feedUrl)">content_copy</span>
</div>
</div>
<div v-else class="w-full">
@@ -28,7 +28,7 @@
</div>
<div v-show="userIsAdminOrUp" class="flex items-center pt-6">
<div class="flex-grow" />
<ui-btn v-if="currentFeedUrl" color="error" small @click="closeFeed">{{ $strings.ButtonCloseFeed }}</ui-btn>
<ui-btn v-if="currentFeed" color="error" small @click="closeFeed">{{ $strings.ButtonCloseFeed }}</ui-btn>
<ui-btn v-else color="success" small @click="openFeed">{{ $strings.ButtonOpenFeed }}</ui-btn>
</div>
</div>
@@ -37,19 +37,11 @@
<script>
export default {
props: {
value: Boolean,
libraryItem: {
type: Object,
default: () => null
},
feedUrl: String
},
data() {
return {
processing: false,
newFeedSlug: null,
currentFeedUrl: null
currentFeed: null
}
},
watch: {
@@ -65,23 +57,29 @@ export default {
computed: {
show: {
get() {
return this.value
return this.$store.state.globals.showRSSFeedOpenCloseModal
},
set(val) {
this.$emit('input', val)
this.$store.commit('globals/setShowRSSFeedOpenCloseModal', val)
}
},
libraryItemId() {
return this.libraryItem.id
rssFeedEntity() {
return this.$store.state.globals.rssFeedEntity || {}
},
media() {
return this.libraryItem.media || {}
entityId() {
return this.rssFeedEntity.id
},
mediaMetadata() {
return this.media.metadata || {}
entityType() {
return this.rssFeedEntity.type
},
entityFeed() {
return this.rssFeedEntity.feed
},
hasEpisodesWithoutPubDate() {
return !!this.rssFeedEntity.hasEpisodesWithoutPubDate
},
title() {
return this.mediaMetadata.title
return this.rssFeedEntity.name
},
userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
@@ -91,12 +89,6 @@ export default {
},
isHttp() {
return window.origin.startsWith('http://')
},
episodes() {
return this.media.episodes || []
},
hasEpisodesWithoutPubDate() {
return this.episodes.some((ep) => !ep.pubDate)
}
},
methods: {
@@ -106,7 +98,7 @@ export default {
return
}
var sanitized = this.$sanitizeSlug(this.newFeedSlug)
const sanitized = this.$sanitizeSlug(this.newFeedSlug)
if (this.newFeedSlug !== sanitized) {
this.newFeedSlug = sanitized
this.$toast.warning('Slug had to be modified - Run again')
@@ -121,19 +113,15 @@ export default {
console.log('Payload', payload)
this.$axios
.$post(`/api/items/${this.libraryItemId}/open-feed`, payload)
.$post(`/api/feeds/${this.entityType}/${this.entityId}/open`, payload)
.then((data) => {
if (data.success) {
console.log('Opened RSS Feed', data)
this.currentFeedUrl = data.feedUrl
} else {
const errorMsg = data.error || 'Unknown error'
this.$toast.error(errorMsg)
}
console.log('Opened RSS Feed', data)
this.currentFeed = data.feed
})
.catch((error) => {
console.error('Failed to open RSS Feed', error)
this.$toast.error()
const errorMsg = error.response ? error.response.data : null
this.$toast.error(errorMsg || 'Failed to open RSS Feed')
})
},
copyToClipboard(str) {
@@ -142,22 +130,23 @@ export default {
closeFeed() {
this.processing = true
this.$axios
.$post(`/api/items/${this.libraryItem.id}/close-feed`)
.$post(`/api/feeds/${this.currentFeed.id}/close`)
.then(() => {
this.$toast.success(this.$strings.ToastRSSFeedCloseSuccess)
this.show = false
this.processing = false
})
.catch((error) => {
console.error('Failed to close RSS feed', error)
this.processing = false
this.$toast.error(this.$strings.ToastRSSFeedCloseFailed)
})
.finally(() => {
this.processing = false
})
},
init() {
if (!this.libraryItem) return
this.newFeedSlug = this.libraryItem.id
this.currentFeedUrl = this.feedUrl
if (!this.entityId) return
this.newFeedSlug = this.entityId
this.currentFeed = this.entityFeed
}
},
mounted() {}

View File

@@ -9,7 +9,7 @@
<span class="material-icons text-2xl sm:text-3xl">replay_10</span>
</div>
<div class="cursor-pointer p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-4 md:mx-8" :class="seekLoading ? 'animate-spin' : ''" @mousedown.prevent @mouseup.prevent @click.stop="playPause">
<span class="material-icons">{{ seekLoading ? 'autorenew' : paused ? 'play_arrow' : 'pause' }}</span>
<span class="material-icons text-2xl">{{ seekLoading ? 'autorenew' : paused ? 'play_arrow' : 'pause' }}</span>
</div>
<div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpForward">
<span class="material-icons text-2xl sm:text-3xl">forward_10</span>

View File

@@ -15,7 +15,7 @@
</div>
<!-- Hover timestamp -->
<div ref="hoverTimestamp" class="absolute -top-8 left-0 bg-white text-black rounded-full opacity-0 pointer-events-none">
<div ref="hoverTimestamp" class="absolute -top-8 left-0 bg-white text-black rounded-full opacity-0 pointer-events-none z-10">
<p ref="hoverTimestampText" class="text-xs font-mono text-center px-2 py-0.5 truncate whitespace-nowrap">00:00</p>
</div>
<div ref="hoverTimestampArrow" class="absolute -top-3 left-0 bg-white text-black rounded-full opacity-0 pointer-events-none">
@@ -83,9 +83,9 @@ export default {
var offsetX = e.offsetX
var perc = offsetX / this.trackWidth
const baseTime = this.useChapterTrack ? this.currentChapterStart : 0;
const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration;
const time = baseTime + (perc * duration);
const baseTime = this.useChapterTrack ? this.currentChapterStart : 0
const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration
const time = baseTime + perc * duration
if (isNaN(time) || time === null) {
console.error('Invalid time', perc, time)
return
@@ -143,10 +143,10 @@ export default {
mousemoveTrack(e) {
var offsetX = e.offsetX
const baseTime = this.useChapterTrack ? this.currentChapterStart : 0;
const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration;
const progressTime = (offsetX / this.trackWidth) * duration;
const totalTime = baseTime + progressTime;
const baseTime = this.useChapterTrack ? this.currentChapterStart : 0
const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration
const progressTime = (offsetX / this.trackWidth) * duration
const totalTime = baseTime + progressTime
if (this.$refs.hoverTimestamp) {
var width = this.$refs.hoverTimestamp.clientWidth

View File

@@ -4,27 +4,37 @@
<div class="absolute -top-10 md:top-0 right-0 lg:right-2 flex items-center h-full">
<!-- <span class="material-icons text-2xl cursor-pointer" @click="toggleFullscreen(true)">expand_less</span> -->
<controls-volume-control ref="volumeControl" v-model="volume" @input="setVolume" class="mx-2 hidden md:block" />
<ui-tooltip direction="top" :text="$strings.LabelVolume">
<controls-volume-control ref="volumeControl" v-model="volume" @input="setVolume" class="mx-2 hidden md:block" />
</ui-tooltip>
<div class="cursor-pointer text-gray-300 hover:text-white mx-1 lg:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showSleepTimer')">
<span v-if="!sleepTimerSet" class="material-icons text-2xl sm:text-2.5xl">snooze</span>
<div v-else class="flex items-center">
<span class="material-icons text-lg text-warning">snooze</span>
<p class="text-xl text-warning font-mono font-semibold text-center px-0.5 pb-0.5" style="min-width: 30px">{{ sleepTimerRemainingString }}</p>
<ui-tooltip direction="top" :text="$strings.LabelSleepTimer">
<div class="cursor-pointer text-gray-300 hover:text-white mx-1 lg:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showSleepTimer')">
<span v-if="!sleepTimerSet" class="material-icons text-2xl">snooze</span>
<div v-else class="flex items-center">
<span class="material-icons text-lg text-warning">snooze</span>
<p class="text-xl text-warning font-mono font-semibold text-center px-0.5 pb-0.5" style="min-width: 30px">{{ sleepTimerRemainingString }}</p>
</div>
</div>
</div>
</ui-tooltip>
<div v-if="!isPodcast" class="cursor-pointer text-gray-300 hover:text-white mx-1 lg:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showBookmarks')">
<span class="material-icons text-2xl sm:text-2.5xl">{{ bookmarks.length ? 'bookmarks' : 'bookmark_border' }}</span>
</div>
<ui-tooltip v-if="!isPodcast" direction="top" :text="$strings.LabelViewBookmarks">
<div class="cursor-pointer text-gray-300 hover:text-white mx-1 lg:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showBookmarks')">
<span class="material-icons text-2xl">{{ bookmarks.length ? 'bookmarks' : 'bookmark_border' }}</span>
</div>
</ui-tooltip>
<div v-if="chapters.length" class="cursor-pointer text-gray-300 hover:text-white mx-1 lg:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="showChapters">
<span class="material-icons text-2xl sm:text-3xl">format_list_bulleted</span>
</div>
<ui-tooltip v-if="chapters.length" direction="top" :text="$strings.LabelViewChapters">
<div class="cursor-pointer text-gray-300 hover:text-white mx-1 lg:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="showChapters">
<span class="material-icons text-2xl">format_list_bulleted</span>
</div>
</ui-tooltip>
<button v-if="playerQueueItems.length" class="outline-none text-gray-300 mx-1 lg:mx-2 hover:text-white" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showPlayerQueueItems')">
<span class="material-icons text-2xl sm:text-3xl">queue_music</span>
</button>
<ui-tooltip v-if="playerQueueItems.length" direction="top" :text="$strings.LabelViewQueue">
<button class="outline-none text-gray-300 mx-1 lg:mx-2 hover:text-white" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showPlayerQueueItems')">
<span class="material-icons text-2.5xl sm:text-3xl">playlist_play</span>
</button>
</ui-tooltip>
<ui-tooltip v-if="chapters.length" direction="top" :text="useChapterTrack ? $strings.LabelUseFullTrack : $strings.LabelUseChapterTrack">
<div class="cursor-pointer text-gray-300 mx-1 lg:mx-2 hover:text-white" @mousedown.prevent @mouseup.prevent @click.stop="setUseChapterTrack">
@@ -224,13 +234,10 @@ export default {
this.showChaptersModal = false
},
setUseChapterTrack() {
var useChapterTrack = !this.useChapterTrack
this.useChapterTrack = useChapterTrack
if (this.$refs.trackbar) this.$refs.trackbar.setUseChapterTrack(useChapterTrack)
this.useChapterTrack = !this.useChapterTrack
if (this.$refs.trackbar) this.$refs.trackbar.setUseChapterTrack(this.useChapterTrack)
this.$store.dispatch('user/updateUserSettings', { useChapterTrack }).catch((err) => {
console.error('Failed to update settings', err)
})
this.$store.dispatch('user/updateUserSettings', { useChapterTrack: this.useChapterTrack })
this.updateTimestamp()
},
checkUpdateChapterTrack() {
@@ -301,7 +308,7 @@ export default {
init() {
this.playbackRate = this.$store.getters['user/getUserSetting']('playbackRate') || 1
var _useChapterTrack = this.$store.getters['user/getUserSetting']('useChapterTrack') || false
const _useChapterTrack = this.$store.getters['user/getUserSetting']('useChapterTrack') || false
this.useChapterTrack = this.chapters.length ? _useChapterTrack : false
if (this.$refs.trackbar) this.$refs.trackbar.setUseChapterTrack(this.useChapterTrack)
@@ -335,13 +342,14 @@ export default {
}
},
mounted() {
this.$store.commit('user/addSettingsListener', { id: 'audioplayer', meth: this.settingsUpdated })
this.init()
this.$eventBus.$on('player-hotkey', this.hotkey)
this.$eventBus.$on('user-settings', this.settingsUpdated)
this.init()
},
beforeDestroy() {
this.$store.commit('user/removeSettingsListener', 'audioplayer')
this.$eventBus.$off('player-hotkey', this.hotkey)
this.$eventBus.$off('user-settings', this.settingsUpdated)
}
}
</script>

View File

@@ -0,0 +1,88 @@
<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,15 +1,15 @@
<template>
<div v-if="show" class="w-screen h-screen fixed top-0 left-0 z-50 bg-primary text-white">
<div v-if="show" class="w-screen h-screen fixed top-0 left-0 z-60 bg-primary text-white">
<div class="absolute top-4 right-4 z-20">
<span class="material-icons cursor-pointer text-4xl" @click="close">close</span>
</div>
<div class="absolute top-4 left-4 font-book">
<div class="absolute top-4 left-4">
<h1 class="text-2xl mb-1">{{ abTitle }}</h1>
<p v-if="abAuthor">by {{ abAuthor }}</p>
</div>
<component v-if="componentName" ref="readerComponent" :is="componentName" :url="ebookUrl" />
<component v-if="componentName" ref="readerComponent" :is="componentName" :url="ebookUrl" :library-item="selectedLibraryItem" />
<div class="absolute bottom-2 left-2">{{ ebookType }}</div>
</div>
@@ -37,7 +37,8 @@ export default {
}
},
componentName() {
if (this.ebookType === 'epub') return 'readers-epub-reader'
if (this.ebookType === 'epub' && this.$isDev) return 'readers-epub-reader2'
else 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'
@@ -92,13 +93,18 @@ export default {
},
ebookUrl() {
if (!this.ebookFile) return null
var itemRelPath = this.selectedLibraryItem.relPath
if (itemRelPath.startsWith('/')) itemRelPath = itemRelPath.slice(1)
var relPath = this.ebookFile.metadata.relPath
if (relPath.startsWith('/')) relPath = relPath.slice(1)
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)
const relRelPath = this.$encodeUriPath(`${itemRelPath}/${relPath}`)
return `/ebook/${this.libraryId}/${this.folderId}/${relRelPath}`
filepath = this.$encodeUriPath(`${itemRelPath}/${relPath}`)
}
return `/ebook/${this.libraryId}/${this.folderId}/${filepath}`
},
userToken() {
return this.$store.getters['user/getToken']

View File

@@ -1,6 +1,6 @@
<template>
<div class="w-96 my-6 mx-auto">
<h1 class="text-2xl mb-4 font-book">{{ $strings.HeaderStatsMinutesListeningChart }}</h1>
<h1 class="text-2xl mb-4">{{ $strings.HeaderStatsMinutesListeningChart }}</h1>
<div class="relative w-96 h-72">
<div class="absolute top-0 left-0">
<template v-for="lbl in yAxisLabels">
@@ -27,7 +27,7 @@
<div class="absolute -bottom-2 left-0 flex ml-6">
<template v-for="dayObj in last7Days">
<div :key="dayObj.date" :style="{ width: daySpacing + daySpacing / 14 + 'px' }">
<p class="text-sm font-book">{{ dayObj.dayOfWeek.slice(0, 3) }}</p>
<p class="text-sm">{{ dayObj.dayOfWeekAbbr }}</p>
</div>
</template>
</div>
@@ -108,6 +108,7 @@ export default {
var _date = this.$addDaysToToday(i * -1)
days.push({
dayOfWeek: this.$formatJsDate(_date, 'EEEE'),
dayOfWeekAbbr: this.$formatJsDate(_date, 'EEE'),
date: this.$formatJsDate(_date, 'yyyy-MM-dd')
})
}
@@ -218,4 +219,4 @@ export default {
},
mounted() {}
}
</script>
</script>

View File

@@ -68,7 +68,7 @@ export default {
dayLabels() {
return [
{
label: 'Mon',
label: this.$formatJsDate(new Date(2023, 0, 2), 'EEE'),
style: {
transform: `translate(${-25}px, ${13}px)`,
lineHeight: '10px',
@@ -76,7 +76,7 @@ export default {
}
},
{
label: 'Wed',
label: this.$formatJsDate(new Date(2023, 0, 4), 'EEE'),
style: {
transform: `translate(${-25}px, ${13 * 3}px)`,
lineHeight: '10px',
@@ -84,7 +84,7 @@ export default {
}
},
{
label: 'Fri',
label: this.$formatJsDate(new Date(2023, 0, 6), 'EEE'),
style: {
transform: `translate(${-25}px, ${13 * 5}px)`,
lineHeight: '10px',
@@ -270,4 +270,4 @@ export default {
},
beforeDestroy() {}
}
</script>
</script>

View File

@@ -6,7 +6,7 @@
</svg>
<div class="px-2">
<p class="text-4xl md:text-5xl font-bold">{{ totalItems }}</p>
<p class="font-book text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelStatsItemsInLibrary }}</p>
<p class="text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelStatsItemsInLibrary }}</p>
</div>
</div>
@@ -14,7 +14,7 @@
<span class="material-icons text-7xl">show_chart</span>
<div class="px-1">
<p class="text-4xl md:text-5xl font-bold">{{ totalTime }}</p>
<p class="font-book text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelStatsOverall }} {{ useOverallHours ? $strings.LabelStatsHours : $strings.LabelStatsDays }}</p>
<p class="text-xs md:text-sm text-white text-opacity-80">{{ useOverallHours ? $strings.LabelStatsOverallHours : $strings.LabelStatsOverallDays }}</p>
</div>
</div>
@@ -24,7 +24,7 @@
</svg>
<div class="px-1">
<p class="text-4xl md:text-5xl font-bold">{{ totalAuthors }}</p>
<p class="font-book text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelStatsAuthors }}</p>
<p class="text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelStatsAuthors }}</p>
</div>
</div>
@@ -32,7 +32,7 @@
<span class="material-icons-outlined text-6xl pt-1">insert_drive_file</span>
<div class="px-1">
<p class="text-4xl md:text-5xl font-bold">{{ totalSizeNum }}</p>
<p class="font-book text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelSize }} ({{ totalSizeMod }})</p>
<p class="text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelSize }} ({{ totalSizeMod }})</p>
</div>
</div>
@@ -40,7 +40,7 @@
<span class="material-icons-outlined text-6xl pt-1">audio_file</span>
<div class="px-1">
<p class="text-4xl md:text-5xl font-bold">{{ numAudioTracks }}</p>
<p class="font-book text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelStatsAudioTracks }}</p>
<p class="text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelStatsAudioTracks }}</p>
</div>
</div>
</div>

View File

@@ -25,7 +25,7 @@
<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-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-error">error_outline</span>
<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>
@@ -64,13 +64,11 @@ export default {
showConfirmApply: false,
selectedBackup: null,
isBackingUp: false,
processing: false
processing: false,
backups: []
}
},
computed: {
backups() {
return this.$store.state.backups || []
},
userToken() {
return this.$store.getters['user/getToken']
}
@@ -96,9 +94,8 @@ export default {
this.processing = true
this.$axios
.$delete(`/api/backups/${backup.id}`)
.then((backups) => {
console.log('Backup deleted', backups)
this.$store.commit('setBackups', backups)
.then((data) => {
this.setBackups(data.backups || [])
this.$toast.success(this.$strings.ToastBackupDeleteSuccess)
this.processing = false
})
@@ -117,10 +114,10 @@ export default {
this.isBackingUp = true
this.$axios
.$post('/api/backups')
.then((backups) => {
.then((data) => {
this.isBackingUp = false
this.$toast.success(this.$strings.ToastBackupCreateSuccess)
this.$store.commit('setBackups', backups)
this.setBackups(data.backups || [])
})
.catch((error) => {
this.isBackingUp = false
@@ -136,9 +133,8 @@ export default {
this.$axios
.$post('/api/backups/upload', form)
.then((result) => {
console.log('Upload backup result', result)
this.$store.commit('setBackups', result)
.then((data) => {
this.setBackups(data.backups || [])
this.$toast.success(this.$strings.ToastBackupUploadSuccess)
this.processing = false
})
@@ -148,9 +144,29 @@ export default {
this.$toast.error(errorMessage)
this.processing = false
})
},
setBackups(backups) {
backups.sort((a, b) => b.createdAt - a.createdAt)
this.backups = backups
},
loadBackups() {
this.processing = true
this.$axios
.$get('/api/backups')
.then((data) => {
this.setBackups(data.backups || [])
})
.catch((error) => {
console.error('Failed to load backups', error)
this.$toast.error('Failed to load backups')
})
.finally(() => {
this.processing = false
})
}
},
mounted() {
this.loadBackups()
if (this.$route.query.backup) {
this.$toast.success('Backup applied successfully')
this.$router.replace('/config')

View File

@@ -11,7 +11,7 @@
</div>
<transition name="slide">
<table class="text-sm tracksTable" v-show="expanded || keepOpen">
<tr class="font-book">
<tr>
<th class="text-left w-16"><span class="px-4">Id</span></th>
<th class="text-left">{{ $strings.LabelTitle }}</th>
<th class="text-center">{{ $strings.LabelStart }}</th>
@@ -21,7 +21,7 @@
<td class="text-left">
<p class="px-4">{{ chapter.id }}</p>
</td>
<td class="font-book">
<td>
{{ chapter.title }}
</td>
<td class="font-mono text-center hover:underline cursor-pointer" @click.stop="goToTimestamp(chapter.start)">

View File

@@ -14,7 +14,7 @@
<transition name="slide">
<div class="w-full" v-show="showFiles">
<table class="text-sm tracksTable">
<tr class="font-book">
<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.LabelType }}</th>
@@ -22,7 +22,7 @@
</tr>
<template v-for="file in files">
<tr :key="file.path">
<td class="font-book px-4">
<td class="px-4">
{{ showFullPath ? file.metadata.path : file.metadata.relPath }}
</td>
<td class="font-mono">

View File

@@ -0,0 +1,119 @@
<template>
<div class="w-full bg-primary bg-opacity-40">
<div class="w-full h-14 flex items-center px-4 md:px-6 py-2 bg-primary">
<p class="pr-4">{{ $strings.HeaderPlaylistItems }}</p>
<div class="w-6 h-6 md:w-7 md:h-7 bg-white bg-opacity-10 rounded-full flex items-center justify-center">
<span class="text-xs md:text-sm font-mono leading-none">{{ items.length }}</span>
</div>
<div class="flex-grow" />
<p v-if="totalDuration" class="text-sm text-gray-200">{{ totalDurationPretty }}</p>
</div>
<draggable v-model="itemsCopy" v-bind="dragOptions" class="list-group" handle=".drag-handle" draggable=".item" tag="div" @start="drag = true" @end="drag = false" @update="draggableUpdate">
<transition-group type="transition" :name="!drag ? 'playlist-item' : null">
<template v-for="(item, index) in itemsCopy">
<tables-playlist-item-table-row :key="index" :is-dragging="drag" :item="item" :playlist-id="playlistId" :book-cover-aspect-ratio="bookCoverAspectRatio" class="item" :class="drag ? '' : 'playlist-item-item'" @edit="editItem" />
</template>
</transition-group>
</draggable>
</div>
</template>
<script>
import draggable from 'vuedraggable'
export default {
components: {
draggable
},
props: {
playlistId: String,
items: {
type: Array,
default: () => []
}
},
data() {
return {
drag: false,
dragOptions: {
animation: 200,
group: 'description',
ghostClass: 'ghost'
},
itemsCopy: []
}
},
watch: {
items: {
handler(newVal) {
this.init()
}
}
},
computed: {
bookCoverAspectRatio() {
return this.$store.getters['libraries/getBookCoverAspectRatio']
},
totalDuration() {
var _total = 0
this.items.forEach((item) => {
if (item.episode) _total += item.episode.duration
else _total += item.libraryItem.media.duration
})
return _total
},
totalDurationPretty() {
return this.$elapsedPrettyExtended(this.totalDuration)
}
},
methods: {
editItem(playlistItem) {
if (playlistItem.episode) {
this.$store.commit('globals/setSelectedEpisode', playlist.episode)
this.$store.commit('globals/setShowEditPodcastEpisodeModal', true)
} else {
const itemIds = this.items.map((i) => i.libraryItemId)
this.$store.commit('setBookshelfBookIds', itemIds)
this.$store.commit('showEditModal', playlistItem.libraryItem)
}
},
draggableUpdate() {
var playlistUpdate = {
items: this.itemsCopy.map((i) => ({ libraryItemId: i.libraryItemId, episodeId: i.episodeId }))
}
this.$axios
.$patch(`/api/playlists/${this.playlistId}`, playlistUpdate)
.then((playlist) => {
console.log('Playlist updated', playlist)
})
.catch((error) => {
console.error('Failed to update playlist', error)
this.$toast.error('Failed to save playlist items order')
})
},
init() {
this.itemsCopy = this.items.map((i) => ({ ...i }))
}
},
mounted() {
this.init()
}
}
</script>
<style>
.playlist-item-item {
transition: all 0.4s ease;
}
.playlist-item-enter-from,
.playlist-item-leave-to {
opacity: 0;
transform: translateX(30px);
}
.playlist-item-leave-active {
position: absolute;
}
</style>

View File

@@ -18,7 +18,7 @@
<transition name="slide">
<div class="w-full" v-show="showTracks">
<table class="text-sm tracksTable">
<tr class="font-book">
<tr>
<th class="w-10">#</th>
<th class="text-left">{{ $strings.LabelFilename }}</th>
<th class="text-left w-20">{{ $strings.LabelSize }}</th>

View File

@@ -11,20 +11,20 @@
<transition name="slide">
<div class="w-full" v-show="expand">
<table class="text-sm tracksTable">
<tr class="font-book">
<tr>
<th class="text-left">{{ $strings.LabelFilename }}</th>
<th class="text-left">{{ $strings.LabelSize }}</th>
<th class="text-left">{{ $strings.LabelType }}</th>
</tr>
<template v-for="file in files">
<tr :key="file.path">
<td class="font-book pl-2">
<td class="pl-2">
{{ file.name }}
</td>
<td class="font-mono">
{{ $bytesPretty(file.size) }}
</td>
<td class="font-book">
<td>
{{ file.filetype }}
</td>
</tr>

View File

@@ -1,12 +1,5 @@
<template>
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8">
<div class="flex items-center mb-2">
<h1 class="text-xl">{{ $strings.HeaderUsers }}</h1>
<div class="mx-2 w-7 h-7 flex items-center justify-center rounded-full cursor-pointer hover:bg-white hover:bg-opacity-10 text-center" @click="clickAddUser">
<span class="material-icons" style="font-size: 1.4rem">add</span>
</div>
</div>
<div>
<div class="text-center">
<table id="accounts">
<tr>
@@ -26,11 +19,9 @@
</td>
<td class="text-sm">{{ user.type }}</td>
<td class="hidden lg:table-cell">
<div v-if="usersOnline[user.id] && usersOnline[user.id].session && usersOnline[user.id].session.libraryItem && usersOnline[user.id].session.libraryItem.media">
<p class="truncate text-xs">Listening: {{ usersOnline[user.id].session.libraryItem.media.metadata.title || '' }}</p>
</div>
<div v-else-if="user.mostRecent">
<p class="truncate text-xs">Last: {{ user.mostRecent.metadata.title }}</p>
<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>
</td>
<td class="text-xs font-mono hidden sm:table-cell">
@@ -44,13 +35,13 @@
</ui-tooltip>
</td>
<td class="py-0">
<div class="w-full flex justify-center">
<div class="w-full flex justify-left">
<!-- Dont show edit for non-root users -->
<div v-if="user.type !== 'root' || userIsRoot" class="h-8 w-8 flex items-center justify-center text-white text-opacity-50 hover:text-opacity-100 cursor-pointer" @click.stop="editUser(user)">
<span class="material-icons text-base">edit</span>
<button type="button" :aria-label="$getString('ButtonUserEdit', [user.username])" class="material-icons text-base">edit</button>
</div>
<div v-show="user.type !== 'root'" class="h-8 w-8 flex items-center justify-center text-white text-opacity-50 hover:text-error cursor-pointer" @click.stop="deleteUserClick(user)">
<span class="material-icons text-base">delete</span>
<button type="button" :aria-label="$getString('ButtonUserDelete', [user.username])" class="material-icons text-base">delete</button>
</div>
</div>
</td>
@@ -81,7 +72,7 @@ export default {
},
usersOnline() {
var usermap = {}
this.$store.state.users.users.forEach((u) => (usermap[u.id] = { online: true, session: u.session }))
this.$store.state.users.usersOnline.forEach((u) => (usermap[u.id] = u))
return usermap
}
},
@@ -118,8 +109,8 @@ export default {
loadUsers() {
this.$axios
.$get('/api/users')
.then((users) => {
this.users = users.sort((a, b) => {
.then((res) => {
this.users = res.users.sort((a, b) => {
return a.createdAt - b.createdAt
})
})

View File

@@ -10,7 +10,7 @@
<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">play_arrow</span>
<span class="material-icons text-2xl">play_arrow</span>
</div>
</div>
</div>

View File

@@ -1,11 +1,5 @@
<template>
<div id="librariesTable" class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8">
<div class="flex items-center mb-2">
<h1 class="text-xl">{{ $strings.HeaderLibraries }}</h1>
<div class="mx-2 w-7 h-7 flex items-center justify-center rounded-full cursor-pointer hover:bg-white hover:bg-opacity-10 text-center" @click="clickAddLibrary">
<span class="material-icons" style="font-size: 1.4rem">add</span>
</div>
</div>
<div>
<draggable v-if="libraryCopies.length" :list="libraryCopies" v-bind="dragOptions" class="list-group" handle=".drag-handle" draggable=".item" tag="div" @start="startDrag" @end="endDrag">
<template v-for="library in libraryCopies">
<div :key="library.id" class="item">
@@ -88,10 +82,10 @@ export default {
})
var newOrder = libraryOrderData.map((lib) => lib.id).join(',')
if (currOrder !== newOrder) {
this.$axios.$post('/api/libraries/order', libraryOrderData).then((libraries) => {
if (libraries && libraries.length) {
this.$axios.$post('/api/libraries/order', libraryOrderData).then((response) => {
if (response.libraries && response.libraries.length) {
this.$toast.success('Library order saved', { timeout: 1500 })
this.$store.commit('libraries/set', libraries)
this.$store.commit('libraries/set', response.libraries)
}
})
}

View File

@@ -1,32 +1,29 @@
<template>
<div class="w-full px-4 h-12 border border-white border-opacity-10 flex items-center relative -mt-px" :class="selected ? 'bg-primary bg-opacity-50' : 'hover:bg-primary hover:bg-opacity-25'" @mouseover="mouseover = true" @mouseleave="mouseover = false">
<div class="w-full pl-2 pr-4 md:px-4 h-12 border border-white border-opacity-10 flex items-center relative -mt-px" :class="selected ? 'bg-primary bg-opacity-50' : 'hover:bg-primary hover:bg-opacity-25'" @mouseover="mouseover = true" @mouseleave="mouseover = false">
<div v-show="selected" class="absolute top-0 left-0 h-full w-0.5 bg-warning z-10" />
<ui-library-icon v-if="!libraryScan" :icon="library.icon" :size="6" font-size="xl" class="text-white" :class="isHovering ? 'text-opacity-90' : 'text-opacity-50'" />
<ui-library-icon v-if="!libraryScan" :icon="library.icon" :size="6" font-size="lg md:text-xl" class="text-white" :class="isHovering ? 'text-opacity-90' : 'text-opacity-50'" />
<svg v-else viewBox="0 0 24 24" class="h-6 w-6 text-white text-opacity-50 animate-spin">
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
</svg>
<p class="text-xl font-book pl-4 hover:underline cursor-pointer" @click.stop="$emit('click', library)">{{ library.name }}</p>
<p class="text-base md:text-xl pl-2 md:pl-4 hover:underline cursor-pointer" @click.stop="$emit('click', library)">{{ library.name }}</p>
<div class="flex-grow" />
<ui-btn v-show="isHovering && !libraryScan" class="hidden md:block" small color="success" @click.stop="scan">{{ $strings.ButtonScan }}</ui-btn>
<ui-btn v-show="isHovering && !libraryScan" small color="bg" class="ml-2 hidden md:block" @click.stop="forceScan">{{ $strings.ButtonForceReScan }}</ui-btn>
<ui-btn v-show="isHovering && !libraryScan && isBookLibrary" small color="bg" class="ml-2 hidden md:block" @click.stop="matchAll">{{ $strings.ButtonMatchBooks }}</ui-btn>
<!-- Desktop context menu icon -->
<ui-context-menu-dropdown v-if="!libraryScan && !isDeleting" :items="contextMenuItems" :icon-class="`text-1.5xl text-gray-${isHovering ? 50 : 400}`" class="!hidden md:!block" @action="contextMenuAction" />
<span v-if="isHovering && !libraryScan" class="!hidden md:!block material-icons text-xl text-gray-300 hover:text-gray-50 ml-4 cursor-pointer" @click.stop="editClick">edit</span>
<span v-if="!libraryScan && isHovering && !isDeleting" class="!hidden md:!block material-icons text-xl text-gray-300 ml-3 hover:text-gray-50 cursor-pointer" @click.stop="deleteClick">delete</span>
<!-- Mobile context menu icon -->
<span v-if="!libraryScan && !isDeleting" class="!block md:!hidden material-icons text-xl text-gray-300 ml-3 cursor-pointer" @click.stop="showMenu">more_vert</span>
<!-- For mobile -->
<span v-if="!libraryScan" class="!block md:!hidden material-icons text-xl text-gray-300 ml-4 cursor-pointer" @click.stop="editClick">edit</span>
<span v-if="!libraryScan && !isDeleting" class="!block md:!hidden material-icons text-2xl text-gray-300 ml-3 cursor-pointer" @click.stop="showMenu">more_vert</span>
<div v-show="isDeleting" class="text-xl text-gray-300 ml-3 animate-spin">
<svg viewBox="0 0 24 24" class="w-6 h-6">
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
</svg>
</div>
<span class="material-icons drag-handle text-xl text-gray-400 hover:text-gray-50 ml-4">reorder</span>
<span class="material-icons drag-handle text-xl text-gray-400 hover:text-gray-50 ml-2 md:ml-4">reorder</span>
<!-- For mobile -->
<modals-dialog v-model="showMobileMenu" :title="menuTitle" :items="mobileMenuItems" @action="mobileMenuAction" />
<modals-dialog v-model="showMobileMenu" :title="menuTitle" :items="contextMenuItems" @action="contextMenuAction" />
</div>
</template>
@@ -63,34 +60,45 @@ export default {
menuTitle() {
return this.library.name
},
mobileMenuItems() {
contextMenuItems() {
const items = [
{
text: this.$strings.ButtonEdit,
action: 'edit',
value: 'edit'
},
{
text: this.$strings.ButtonScan,
action: 'scan',
value: 'scan'
},
{
text: this.$strings.ButtonForceReScan,
action: 'force-scan',
value: 'force-scan'
}
]
if (this.isBookLibrary) {
items.push({
text: this.$strings.ButtonMatchBooks,
action: 'match-books',
value: 'match-books'
})
}
items.push({
text: this.$strings.ButtonDelete,
action: 'delete',
value: 'delete'
})
return items
}
},
methods: {
mobileMenuAction(action) {
contextMenuAction(action) {
this.showMobileMenu = false
if (action === 'scan') {
if (action === 'edit') {
this.editClick()
} else if (action === 'scan') {
this.scan()
} else if (action === 'force-scan') {
this.forceScan()
@@ -130,37 +138,52 @@ export default {
})
},
forceScan() {
if (confirm(this.$strings.MessageConfirmForceReScan)) {
this.$store
.dispatch('libraries/requestLibraryScan', { libraryId: this.library.id, force: 1 })
.then(() => {
this.$toast.success(this.$strings.ToastLibraryScanStarted)
})
.catch((error) => {
console.error('Failed to start scan', error)
this.$toast.error(this.$strings.ToastLibraryScanFailedToStart)
})
const payload = {
message: this.$strings.MessageConfirmForceReScan,
callback: (confirmed) => {
if (confirmed) {
this.$store
.dispatch('libraries/requestLibraryScan', { libraryId: this.library.id, force: 1 })
.then(() => {
this.$toast.success(this.$strings.ToastLibraryScanStarted)
})
.catch((error) => {
console.error('Failed to start scan', error)
this.$toast.error(this.$strings.ToastLibraryScanFailedToStart)
})
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
deleteClick() {
if (confirm(this.$getString('MessageConfirmDeleteLibrary', [this.library.name]))) {
this.isDeleting = true
this.$axios
.$delete(`/api/libraries/${this.library.id}`)
.then((data) => {
this.isDeleting = false
if (data.error) {
this.$toast.error(data.error)
} else {
this.$toast.success(this.$strings.ToastLibraryDeleteSuccess)
}
})
.catch((error) => {
console.error('Failed to delete library', error)
this.$toast.error(this.$strings.ToastLibraryDeleteFailed)
this.isDeleting = false
})
const payload = {
message: this.$getString('MessageConfirmDeleteLibrary', [this.library.name]),
callback: (confirmed) => {
if (confirmed) {
this.isDeleting = true
this.$axios
.$delete(`/api/libraries/${this.library.id}`)
.then((data) => {
if (data.error) {
this.$toast.error(data.error)
} else {
this.$toast.success(this.$strings.ToastLibraryDeleteSuccess)
}
})
.catch((error) => {
console.error('Failed to delete library', error)
this.$toast.error(this.$strings.ToastLibraryDeleteFailed)
})
.finally(() => {
this.isDeleting = false
})
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
}
},
mounted() {}

View File

@@ -0,0 +1,237 @@
<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="item" class="flex h-16 md:h-20">
<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="libraryItem" :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>
</div>
</div>
<div class="flex-grow overflow-hidden max-w-48 md:max-w-md h-full flex items-center px-2 md:px-3">
<div>
<div class="truncate max-w-48 md:max-w-md">
<nuxt-link :to="`/item/${libraryItem.id}`" class="truncate hover:underline text-sm md:text-base">{{ itemTitle }}</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
><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>
</div>
<p class="text-xs md:text-sm text-gray-400">{{ itemDuration }}</p>
</div>
</div>
</div>
<div class="w-40 absolute top-0 -right-24 h-full transform transition-transform" :class="!isHovering ? 'translate-x-0' : translateDistance">
<div class="flex h-full items-center">
<ui-tooltip :text="userIsFinished ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" direction="top">
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" borderless class="mx-1 mt-0.5" @click="toggleFinished" />
</ui-tooltip>
<div v-if="userCanUpdate" class="mx-1" :class="isHovering ? '' : 'ml-6'">
<ui-icon-btn icon="edit" borderless @click="clickEdit" />
</div>
<div v-if="userCanDelete" class="mx-1">
<ui-icon-btn icon="close" borderless @click="removeClick" />
</div>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
playlistId: String,
item: {
type: Object,
default: () => {}
},
isDragging: Boolean,
bookCoverAspectRatio: Number
},
data() {
return {
isProcessingReadUpdate: false,
processingRemove: false,
isHovering: false
}
},
watch: {
isDragging: {
handler(newVal) {
if (newVal) {
this.isHovering = false
}
}
}
},
computed: {
translateDistance() {
if (!this.userCanUpdate && !this.userCanDelete) return 'translate-x-0'
else if (!this.userCanUpdate || !this.userCanDelete) return '-translate-x-12'
return '-translate-x-24'
},
libraryItem() {
return this.item.libraryItem || {}
},
episode() {
return this.item.episode
},
episodeId() {
return this.episode ? this.episode.id : null
},
media() {
return this.libraryItem.media || {}
},
mediaMetadata() {
return this.media.metadata || {}
},
tracks() {
if (this.episode) return []
return this.media.tracks || []
},
itemTitle() {
if (this.episode) return this.episode.title
return this.mediaMetadata.title || ''
},
bookAuthors() {
if (this.episode) return []
return this.mediaMetadata.authors || []
},
itemDuration() {
if (this.episode) return this.$elapsedPretty(this.episode.duration)
return this.$elapsedPretty(this.media.duration)
},
isMissing() {
return this.libraryItem.isMissing
},
isInvalid() {
return this.libraryItem.isInvalid
},
isStreaming() {
return this.$store.getters['getIsMediaStreaming'](this.libraryItem.id, this.episodeId)
},
showPlayBtn() {
return !this.isMissing && !this.isInvalid && !this.isStreaming && (this.tracks.length || this.episode)
},
itemProgress() {
return this.$store.getters['user/getUserMediaProgress'](this.libraryItem.id, this.episodeId)
},
userIsFinished() {
return this.itemProgress ? !!this.itemProgress.isFinished : false
},
coverSize() {
return this.$store.state.globals.isMobile ? 30 : 50
},
coverWidth() {
if (this.bookCoverAspectRatio === 1) return this.coverSize * 1.6
return this.coverSize
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
}
},
methods: {
mouseover() {
if (this.isDragging) return
this.isHovering = true
},
mouseleave() {
this.isHovering = false
},
playClick() {
let queueItem = null
if (this.episode) {
queueItem = {
libraryItemId: this.libraryItem.id,
libraryId: this.libraryItem.libraryId,
episodeId: this.episodeId,
title: this.itemTitle,
subtitle: this.mediaMetadata.title,
caption: '',
duration: this.media.duration || null,
coverPath: this.media.coverPath || null
}
} else {
queueItem = {
libraryItemId: this.libraryItem.id,
libraryId: this.libraryItem.libraryId,
episodeId: null,
title: this.itemTitle,
subtitle: this.bookAuthors.map((au) => au.name).join(', '),
caption: '',
duration: this.media.duration || null,
coverPath: this.media.coverPath || null
}
}
this.$eventBus.$emit('play-item', {
libraryItemId: this.libraryItem.id,
episodeId: this.episodeId,
queueItems: [queueItem]
})
},
clickEdit() {
this.$emit('edit', this.item)
},
toggleFinished() {
var updatePayload = {
isFinished: !this.userIsFinished
}
this.isProcessingReadUpdate = true
let routepath = `/api/me/progress/${this.libraryItem.id}`
if (this.episodeId) routepath += `/${this.episodeId}`
this.$axios
.$patch(routepath, updatePayload)
.then(() => {
this.isProcessingReadUpdate = false
this.$toast.success(updatePayload.isFinished ? this.$strings.ToastItemMarkedAsFinishedSuccess : this.$strings.ToastItemMarkedAsNotFinishedSuccess)
})
.catch((error) => {
console.error('Failed', error)
this.isProcessingReadUpdate = false
this.$toast.error(updatePayload.isFinished ? this.$strings.ToastItemMarkedAsFinishedFailed : this.$strings.ToastItemMarkedAsNotFinishedFailed)
})
},
removeClick() {
this.processingRemove = true
let routepath = `/api/playlists/${this.playlistId}/item/${this.libraryItem.id}`
if (this.episodeId) routepath += `/${this.episodeId}`
this.$axios
.$delete(routepath)
.then((updatedPlaylist) => {
if (!updatedPlaylist.items.length) {
console.log(`All items removed so playlist was removed`, updatedPlaylist)
this.$toast.success(this.$strings.ToastPlaylistRemoveSuccess)
} else {
console.log(`Item removed from playlist`, updatedPlaylist)
this.$toast.success('Item removed from playlist')
}
})
.catch((error) => {
console.error('Failed to remove item from playlist', error)
this.$toast.error('Failed to remove item from playlist')
})
.finally(() => {
this.processingRemove = false
})
}
},
mounted() {}
}
</script>

View File

@@ -16,18 +16,26 @@
<div class="flex items-center pt-2">
<button class="h-8 px-4 border border-white border-opacity-20 hover:bg-white hover:bg-opacity-10 rounded-full flex items-center justify-center cursor-pointer focus:outline-none" :class="userIsFinished ? 'text-white text-opacity-40' : ''" @click.stop="playClick">
<span class="material-icons" :class="streamIsPlaying ? '' : 'text-success'">{{ streamIsPlaying ? 'pause' : 'play_arrow' }}</span>
<span class="material-icons text-2xl" :class="streamIsPlaying ? '' : 'text-success'">{{ streamIsPlaying ? 'pause' : 'play_arrow' }}</span>
<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' : 'playlist_add' }}</span>
</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>
<ui-tooltip :text="userIsFinished ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" direction="top">
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" borderless class="mx-1 mt-0.5" @click="toggleFinished" />
</ui-tooltip>
<ui-tooltip :text="$strings.LabelYourPlaylists" direction="top">
<ui-icon-btn icon="playlist_add" borderless @click="clickAddToPlaylist" />
</ui-tooltip>
<ui-icon-btn v-if="userCanUpdate" icon="edit" borderless @click="clickEdit" />
<ui-icon-btn v-if="userCanDelete" icon="close" borderless @click="removeClick" />
</div>
@@ -123,6 +131,9 @@ export default {
}
},
methods: {
clickAddToPlaylist() {
this.$emit('addToPlaylist', this.episode)
},
clickedEpisode() {
this.$emit('view', this.episode)
},

View File

@@ -1,8 +1,9 @@
<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">{{ $strings.HeaderEpisodes }}</p>
<div class="flex-grow" />
<p class="text-lg mb-0 font-semibold hidden md:block">{{ $strings.HeaderEpisodes }}</p>
<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" />
@@ -11,13 +12,15 @@
<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-32 md:w-36 h-9 ml-1 sm:ml-4" />
<controls-sort-select v-model="sortKey" :descending.sync="sortDesc" :items="sortItems" class="w-32 sm:w-44 md:w-48 h-9 ml-1 sm:ml-4" />
<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>
<p v-if="!episodes.length" class="py-4 text-center text-lg">{{ $strings.MessageNoEpisodes }}</p>
<template v-for="episode in episodesSorted">
<tables-podcast-episode-table-row ref="episodeRow" :key="episode.id" :episode="episode" :library-item-id="libraryItem.id" :selection-mode="isSelectionMode" class="item" @play="playEpisode" @remove="removeEpisode" @edit="editEpisode" @view="viewEpisode" @selected="episodeSelected" @addToQueue="addEpisodeToQueue" />
<tables-podcast-episode-table-row ref="episodeRow" :key="episode.id" :episode="episode" :library-item-id="libraryItem.id" :selection-mode="isSelectionMode" class="item" @play="playEpisode" @remove="removeEpisode" @edit="editEpisode" @view="viewEpisode" @selected="episodeSelected" @addToQueue="addEpisodeToQueue" @addToPlaylist="addToPlaylist" />
</template>
<modals-podcast-remove-episode v-model="showPodcastRemoveModal" @input="removeEpisodeModalToggled" :library-item="libraryItem" :episodes="episodesToRemove" @clearSelected="clearSelected" />
@@ -42,15 +45,27 @@ export default {
showPodcastRemoveModal: false,
selectedEpisodes: [],
episodesToRemove: [],
processing: false
processing: false,
quickMatchingEpisodes: false
}
},
watch: {
libraryItem() {
this.init()
libraryItem: {
handler() {
this.init()
}
}
},
computed: {
contextMenuItems() {
if (!this.userIsAdminOrUp) return []
return [
{
text: 'Quick match all episodes',
action: 'quick-match-episodes'
}
]
},
sortItems() {
return [
{
@@ -94,8 +109,8 @@ export default {
isSelectionMode() {
return this.selectedEpisodes.length > 0
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
},
media() {
return this.libraryItem.media || {}
@@ -131,6 +146,48 @@ export default {
}
},
methods: {
contextMenuAction(action) {
if (action === 'quick-match-episodes') {
if (this.quickMatchingEpisodes) return
this.quickMatchAllEpisodes()
}
},
quickMatchAllEpisodes() {
if (!this.mediaMetadata.feedUrl) {
this.$toast.error(this.$strings.MessagePodcastHasNoRSSFeedForMatching)
return
}
this.quickMatchingEpisodes = true
const payload = {
message: 'Quick matching episodes will overwrite details if a match is found. Only unmatched episodes will be updated. Are you sure?',
callback: (confirmed) => {
if (confirmed) {
this.$axios
.$post(`/api/podcasts/${this.libraryItem.id}/match-episodes?override=1`)
.then((data) => {
if (data.numEpisodesUpdated) {
this.$toast.success(`${data.numEpisodesUpdated} episodes updated`)
} else {
this.$toast.info('No changes were made')
}
})
.catch((error) => {
console.error('Failed to request match episodes', error)
this.$toast.error('Failed to match episodes')
})
}
this.quickMatchingEpisodes = false
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
addToPlaylist(episode) {
this.$store.commit('globals/setSelectedPlaylistItems', [{ libraryItem: this.libraryItem, episode }])
this.$store.commit('globals/setShowPlaylistsModal', true)
},
addEpisodeToQueue(episode) {
const queueItem = {
libraryItemId: this.libraryItem.id,

View File

@@ -2,7 +2,6 @@
<nuxt-link v-if="to" :to="to" class="btn outline-none rounded-md shadow-md relative border border-gray-600 text-center" :disabled="disabled || loading" :class="classList">
<slot />
<div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100">
<!-- <span class="material-icons animate-spin">refresh</span> -->
<svg class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24">
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
</svg>
@@ -11,7 +10,6 @@
<button v-else class="btn outline-none rounded-md shadow-md relative border border-gray-600" :disabled="disabled || loading" :type="type" :class="classList" @mousedown.prevent @click="click">
<slot />
<div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100">
<!-- <span class="material-icons animate-spin">refresh</span> -->
<svg class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24">
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
</svg>

View File

@@ -0,0 +1,59 @@
<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>
<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">
<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>
</div>
</template>
</div>
</transition>
</div>
</template>
<script>
export default {
props: {
disabled: Boolean,
items: {
type: Array,
default: () => []
},
iconClass: {
type: String,
default: ''
}
},
data() {
return {
clickOutsideObj: {
handler: this.clickedOutside,
events: ['mousedown'],
isActive: true
},
showMenu: false
}
},
computed: {},
methods: {
clickShowMenu() {
if (this.disabled) return
this.showMenu = !this.showMenu
},
clickedOutside() {
this.showMenu = false
},
clickAction(action) {
if (this.disabled) return
this.showMenu = false
this.$emit('action', action)
}
},
mounted() {}
}
</script>

View File

@@ -1,21 +1,21 @@
<template>
<div class="relative w-full" v-click-outside="clickOutsideObj">
<p class="text-sm font-semibold px-1" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p>
<button type="button" :disabled="disabled" class="relative w-full border rounded shadow-sm pl-3 pr-8 py-2 text-left focus:outline-none sm:text-sm" :class="buttonClass" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
<button type="button" :aria-label="longLabel" :disabled="disabled" class="relative w-full border rounded shadow-sm pl-3 pr-8 py-2 text-left sm:text-sm" :class="buttonClass" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
<span class="flex items-center">
<span class="block truncate font-sans" :class="{ 'font-semibold': selectedSubtext, 'text-sm': small }">{{ selectedText }}</span>
<span v-if="selectedSubtext">:&nbsp;</span>
<span v-if="selectedSubtext" class="font-normal block truncate font-sans text-sm text-gray-400">{{ selectedSubtext }}</span>
</span>
<span class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<span class="material-icons">expand_more</span>
<span class="material-icons text-2xl">expand_more</span>
</span>
</button>
<transition name="menu">
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full bg-primary border border-black-200 shadow-lg max-h-56 rounded-b-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox">
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full bg-primary border border-black-200 shadow-lg max-h-56 rounded-b-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto sm:text-sm" tabindex="-1" role="listbox">
<template v-for="item in itemsToShow">
<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)">
<li :key="item.value" class="text-gray-100 relative py-2 cursor-pointer hover:bg-black-400" :id="'listbox-option-' + item.value" role="option" tabindex="0" @keyup.enter="clickedOption(item.value)" @click="clickedOption(item.value)">
<div class="flex items-center">
<span class="ml-3 block truncate font-sans text-sm" :class="{ 'font-semibold': item.subtext }">{{ item.text }}</span>
<span v-if="item.subtext">:&nbsp;</span>
@@ -91,6 +91,13 @@ export default {
else classes.push('cursor-pointer border-gray-600 bg-primary text-gray-100')
return classes.join(' ')
},
longLabel() {
let result = ''
if (this.label) result += this.label + ': '
if (this.selectedText) result += this.selectedText
if (this.selectedSubtext) result += ' ' + this.selectedSubtext
return result
}
},
methods: {

View File

@@ -1,6 +1,6 @@
<template>
<div class="w-full">
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</p>
<label class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</label>
<div ref="wrapper" class="relative">
<form @submit.prevent="submitForm">
<div ref="inputWrapper" class="input-wrapper flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-2" :class="disabled ? 'pointer-events-none bg-black-300 text-gray-400' : 'bg-primary'">

View File

@@ -1,6 +1,6 @@
<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 focus:outline-none cursor-pointer bg-black bg-opacity-20 text-gray-400 hover:text-gray-200" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
<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>
@@ -10,7 +10,7 @@
<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">
<template v-for="library in librariesFiltered">
<li :key="library.id" class="text-gray-400 hover:text-white select-none relative py-2 cursor-pointer hover:bg-black-400" role="option" @click="selectLibrary(library)">
<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">
<ui-library-icon :icon="library.icon" class="mr-1.5" />
<span class="font-normal block truncate font-sans text-sm">{{ library.name }}</span>

View File

@@ -5,7 +5,7 @@
<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-gray-100" aria-label="User Account" role="button">person</span>
<span class="material-icons text-2xl text-gray-100" aria-label="User Account" role="button">person</span>
</span>
</button>

View File

@@ -15,7 +15,7 @@
</div>
</form>
<ul ref="menu" v-show="showMenu" class="absolute z-50 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-56 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 ref="menu" v-show="showMenu" class="absolute z-60 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-56 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">
<template v-for="item in itemsToShow">
<li :key="item" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent>
<div class="flex items-center">
@@ -117,7 +117,7 @@ export default {
}, 50)
},
recalcMenuPos() {
if (!this.menu) return
if (!this.menu || !this.$refs.inputWrapper) return
var boundingBox = this.$refs.inputWrapper.getBoundingClientRect()
if (boundingBox.y > window.innerHeight - 8) {
// Input is off the page
@@ -135,7 +135,7 @@ export default {
this.menu.style.width = boundingBox.width + 'px'
},
unmountMountMenu() {
if (!this.$refs.menu) return
if (!this.$refs.menu || !this.$refs.inputWrapper) return
this.menu = this.$refs.menu
var boundingBox = this.$refs.inputWrapper.getBoundingClientRect()

View File

@@ -11,7 +11,7 @@
</div>
</div>
<ul ref="menu" v-show="showMenu" class="absolute z-50 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-56 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 ref="menu" v-show="showMenu" class="absolute z-60 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-56 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">
<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" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent>
<div class="flex items-center">
@@ -68,14 +68,14 @@ export default {
},
methods: {
recalcMenuPos() {
if (!this.menu) return
if (!this.menu || !this.$refs.inputWrapper) return
var boundingBox = this.$refs.inputWrapper.getBoundingClientRect()
this.menu.style.top = boundingBox.y + boundingBox.height - 4 + 'px'
this.menu.style.left = boundingBox.x + 'px'
this.menu.style.width = boundingBox.width + 'px'
},
unmountMountMenu() {
if (!this.$refs.menu) return
if (!this.$refs.menu || !this.$refs.inputWrapper) return
this.menu = this.$refs.menu
var boundingBox = this.$refs.inputWrapper.getBoundingClientRect()

View File

@@ -6,7 +6,7 @@
<div ref="inputWrapper" style="min-height: 36px" class="flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-0.5" :class="wrapperClass" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent>
<div v-for="item in selected" :key="item.id" class="rounded-full px-2 py-0.5 m-0.5 text-xs bg-bg flex flex-nowrap whitespace-nowrap items-center justify-center relative min-w-12">
<div v-if="!disabled" class="w-full h-full rounded-full absolute top-0 left-0 opacity-0 hover:opacity-100 px-1 bg-bg bg-opacity-75 flex items-center justify-end cursor-pointer">
<span v-if="showEdit" class="material-icons text-white hover:text-warning mr-1" style="font-size: 1rem" @click.stop="editItem(item)">edit</span>
<span v-if="showEdit" class="material-icons text-base text-white hover:text-warning mr-1" @click.stop="editItem(item)">edit</span>
<span class="material-icons text-white hover:text-error" style="font-size: 1.1rem" @click.stop="removeItem(item.id)">close</span>
</div>
{{ item[textKey] }}
@@ -18,7 +18,7 @@
</div>
</form>
<ul ref="menu" v-show="showMenu" class="absolute z-50 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-56 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 ref="menu" v-show="showMenu" class="absolute z-60 w-full bg-bg border border-black-200 shadow-lg max-h-56 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">
<template v-for="item in itemsToShow">
<li :key="item.id" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent>
<div class="flex items-center">
@@ -113,10 +113,14 @@ export default {
if (this.searching) return
this.currentSearch = this.textInput
this.searching = true
var results = await this.$axios.$get(`/api/${this.endpoint}?q=${this.currentSearch}&limit=15&token=${this.userToken}`).catch((error) => {
console.error('Failed to get search results', error)
return []
})
const results = await this.$axios
.$get(`/api/${this.endpoint}?q=${this.currentSearch}&limit=15&token=${this.userToken}`)
.then((res) => res.results || res)
.catch((error) => {
console.error('Failed to get search results', error)
return []
})
this.items = results || []
this.searching = false
},
@@ -136,7 +140,7 @@ export default {
}, 50)
},
recalcMenuPos() {
if (!this.menu) return
if (!this.menu || !this.$refs.inputWrapper) return
var boundingBox = this.$refs.inputWrapper.getBoundingClientRect()
if (boundingBox.y > window.innerHeight - 8) {
// Input is off the page
@@ -154,7 +158,7 @@ export default {
this.menu.style.width = boundingBox.width + 'px'
},
unmountMountMenu() {
if (!this.$refs.menu) return
if (!this.$refs.menu || !this.$refs.inputWrapper) return
this.menu = this.$refs.menu
var boundingBox = this.$refs.inputWrapper.getBoundingClientRect()
@@ -200,15 +204,21 @@ export default {
}
if (this.$refs.input) this.$refs.input.focus()
var newSelected = null
let newSelected = null
if (this.getIsSelected(item.id)) {
newSelected = this.selected.filter((s) => s.id !== item.id)
this.$emit('removedItem', item.id)
} else {
newSelected = this.selected.concat([item])
newSelected = this.selected.concat([
{
id: item.id,
name: item.name
}
])
}
this.textInput = null
this.currentSearch = null
this.$emit('input', newSelected)
this.$nextTick(() => {
this.recalcMenuPos()
@@ -242,10 +252,11 @@ export default {
submitForm() {
if (!this.textInput) return
var cleaned = this.textInput.trim()
var matchesItem = this.items.find((i) => {
return i === cleaned
const cleaned = this.textInput.trim()
const matchesItem = this.items.find((i) => {
return i.name === cleaned
})
if (matchesItem) {
this.clickedOption(null, matchesItem)
} else {

View File

@@ -8,7 +8,7 @@
</div>
</form>
<ul ref="menu" v-show="isFocused && currentSearch" class="absolute z-50 mt-0 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded 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 ref="menu" v-show="isFocused && currentSearch" class="absolute z-60 mt-0 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded 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">
<template v-for="item in items">
<li :key="item.id" class="text-gray-50 select-none relative py-2 pr-3 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent>
<div class="flex items-center">

View File

@@ -1,6 +1,6 @@
<template>
<div ref="wrapper" class="relative">
<input ref="input" v-model="inputValue" :type="actualType" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" class="rounded bg-primary text-gray-200 focus:border-gray-300 focus:bg-bg focus:outline-none border border-gray-600 h-full w-full" :class="classList" @keyup="keyup" @change="change" @focus="focused" @blur="blurred" />
<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" />
<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>
@@ -31,7 +31,8 @@ export default {
},
noSpinner: Boolean,
textCenter: Boolean,
clearable: Boolean
clearable: Boolean,
inputId: String
},
data() {
return {

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