Compare commits

...
Author SHA1 Message Date
advplyr 15b7fbce47 iOS version bump 0.9.43 2022-04-28 18:06:13 -05:00
advplyr e03f878865 iOS fix: Logging out when player is open crashing because server config is nil, added nil check in Api requests 2022-04-28 18:05:33 -05:00
advplyr cffa7f5344 Web and android version bump 0.9.43-beta 2022-04-28 17:44:44 -05:00
advplyr 8411428241 Fix:Auto rewind and jump forward/jump backward on multi-tracks #143 2022-04-28 17:19:02 -05:00
advplyr be885009ad Change:persist last search term #142 2022-04-26 17:05:49 -05:00
advplyr d64dd63ea4 Fix:Close playback when logging out 2022-04-26 16:46:29 -05:00
advplyrandGitHub 72f10e5f42 Merge pull request #148 from selfhost-alt/fix-local-scanning-android
Handle audio probes that don't return any tags
2022-04-26 16:13:04 -05:00
Selfhost Alt 511386e80a Handle audio probes that don't return any tags 2022-04-26 10:32:14 -07:00
advplyr b219756cb7 Fix:Save local media progress when downloading library item by requesting user media progress with item from server (will require server update) #145 2022-04-25 19:01:20 -05:00
advplyr 31c753ffcc Fix:Downloading cover art query strings to set token and jpeg format #144 2022-04-25 17:30:59 -05:00
advplyr 2dd822e04d Fix: Sleep timer for multi-track playback sessions - use duration of all tracks stored in playback session instead of duration from exoplayer #140 2022-04-25 17:22:12 -05:00
advplyrandGitHub d18972e2f3 Merge pull request #132 from benonymity/DataClasses
New Data Classes
2022-04-25 07:27:22 -05:00
benonymity 81ca757c77 Book-Podcast combination, optional fixes 2022-04-25 00:15:44 -04:00
BenandGitHub 7189588c2b Merge branch 'advplyr:master' into DataClasses 2022-04-24 22:47:48 -04:00
advplyr 843f6ca606 Update home page for displaying podcast episode shelves 2022-04-24 14:15:37 -05:00
advplyr 52fd8ac5e8 Fix crash when probing audio files by updating start/end chapter data type to Long #128, remove unused files and generic database save/load methods 2022-04-24 13:28:14 -05:00
advplyrandGitHub d5b69be7c1 Merge pull request #126 from benonymity/SwipeBack
Enable swipe gesture to go back
2022-04-23 15:57:28 -05:00
advplyr 729e959506 iOS fix crash when playing item if player is already open, persist playback rate between playbacks, version bump 0.9.42 2022-04-23 15:40:18 -05:00
advplyr a0a7fd1e14 Andriod version bump 0.9.42-beta 2022-04-23 15:36:21 -05:00
advplyr 0b4c999018 Android persist playback rate #138 2022-04-23 15:06:51 -05:00
advplyr c5a9677ac6 Fix cover aspect ratio #136 2022-04-23 14:38:29 -05:00
advplyr dbe0b7ea69 Merge branch 'master' of https://github.com/advplyr/audiobookshelf-app 2022-04-23 14:20:31 -05:00
advplyr 32df099e8a Fix bookmarks and hide for local items #137 2022-04-23 14:19:56 -05:00
advplyrandGitHub 612c48174c Merge pull request #135 from benonymity/LoadingLibrary
Loading indicator while awaiting library
2022-04-23 13:51:18 -05:00
advplyr e8e7a241c6 Android fix downloader to first download to temp directory then move files to final destination folder 2022-04-23 13:40:55 -05:00
benonymity 9263028c56 Loading indicator while awaiting library 2022-04-23 00:56:46 -04:00
benonymity 4d0d1eb88f New Data Classes 2022-04-22 20:31:45 -04:00
advplyr b72aba36b8 Fix android download complete crash removing unnecessary intent #128, fix author image path #131 2022-04-22 18:21:27 -05:00
advplyr 9d6a467517 Fix local audio playback issue, update ffmpeg-kit to min version 2022-04-21 18:50:01 -05:00
advplyrandGitHub 441bc24e2d Merge pull request #127 from benonymity/PingTimeout
Increase server ping timeout
2022-04-20 17:17:14 -05:00
benonymity 033f066568 Increase server ping timeout 2022-04-20 17:52:16 -04:00
BenandGitHub 49ad1ef601 Merge branch 'advplyr:master' into SwipeBack 2022-04-20 17:25:46 -04:00
benonymity a17b588e29 Hacky SwipeBack 2022-04-20 17:25:12 -04:00
advplyr f65f7b01c3 Fixing gradle dependency issues on android, minor warning fixes 2022-04-19 19:25:16 -05:00
advplyrandGitHub 379612d874 Merge pull request #124 from benonymity/master
Fix Book/Podcast Search
2022-04-19 15:44:41 -05:00
benonymity 25bccdfce6 Fix Book/Podcast Search 2022-04-19 11:02:22 -04:00
advplyr f974fc6e54 Version bump iOS 0.9.41 2022-04-18 20:45:55 -05:00
advplyr 4d71bc48aa Version bump android 0.9.41-beta 2022-04-18 20:37:40 -05:00
advplyr df1ce6d91f Android auto update for downloaded media category, currently listening, and initialize when MainActivity wasnt started 2022-04-18 20:23:26 -05:00
advplyrandGitHub ae65bed352 Merge pull request #123 from rasmuslos/master
Properly close playback
2022-04-18 07:04:54 -05:00
Rasmus Krämer 2f8e30accb Fixed closePlayback 2022-04-18 12:11:29 +02:00
advplyr 13665af0ed Update cast button on fullscreen player 2022-04-17 18:14:45 -05:00
advplyr c4c6377925 Minor UI fixes, hide cast button when not connected to server, update sort options 2022-04-17 17:54:13 -05:00
advplyr 493d7aecc9 Add chromecast support for android, update package versions 2022-04-17 16:59:49 -05:00
advplyr f7516889e4 Merge branch 'master' of https://github.com/advplyr/audiobookshelf-app 2022-04-16 13:36:36 -05:00
advplyr 760d05cf1b Add android graceful fallback to transcode & onPlaybackFailed event 2022-04-16 13:36:30 -05:00
advplyr 3f2b3f2e9c iOS fix infinite loop in AVPlayer observer 2022-04-16 10:35:48 -05:00
advplyr 6cea0ba03d Fix mobile ereader for new data model 2022-04-16 06:10:10 -05:00
advplyrandGitHub 066050700c Merge pull request #118 from rasmuslos/master
Player rework
2022-04-15 21:05:57 -05:00
advplyrandGitHub 448f7855af Merge branch 'master' into master 2022-04-15 21:05:40 -05:00
advplyr ae195e7b58 Merge 2022-04-15 20:48:39 -05:00
Rasmus Krämer 03c3f37e1e added removeServerConnectionConfig method 2022-04-15 13:01:58 +02:00
Rasmus Krämer 363bfd206c Sync playback position (online only) 2022-04-15 12:21:46 +02:00
Rasmus Krämer 29fd29728a support multiple servers 2022-04-15 10:16:11 +02:00
Rasmus Krämer 9a7b2e8172 Removed nil from dict 2022-04-14 15:13:22 +02:00
Rasmus Krämer 0339f731ea Fixed audiobook playback issue 2022-04-14 15:03:14 +02:00
Rasmus Krämer 7fbc2dddbe Send metadata when app did become active 2022-04-14 14:45:34 +02:00
Rasmus Krämer 7a9f6ff2ca Added basic player methods 2022-04-14 14:39:09 +02:00
Rasmus Krämer 6c65c8a33e Fixed metatdata 2022-04-14 12:24:27 +02:00
Rasmus Krämer 736af85855 Improved realm thread and added http client 2022-04-14 11:54:53 +02:00
Rasmus KrämerandGitHub 4abd1c8b49 Merge branch 'advplyr:master' into master 2022-04-14 09:25:53 +02:00
advplyr ccba8dc3c7 Fix episodeId download issue, add draggable for local tracks 2022-04-13 20:24:54 -05:00
advplyr 331a3b4a1a Add new library icons 2022-04-12 19:19:46 -05:00
advplyr 24d124e2e8 Update search page for new data model and adding podcasts 2022-04-12 19:10:11 -05:00
advplyr 021538820a Update sorting/filtering for podcasts, show sort line on bookshelf list view, update podcast episode UI 2022-04-12 18:40:35 -05:00
Rasmus Krämer 0bc8b32ef5 Merge branch 'master' of https://github.com/rasmuslos/audiobookshelf-app 2022-04-12 14:28:52 +02:00
Rasmus Krämer 239938e069 Added PlaybackSession 2022-04-12 14:28:47 +02:00
Rasmus KrämerandGitHub 6db2c7805d Merge branch 'advplyr:master' into master 2022-04-12 13:08:33 +02:00
advplyr eed8a29c9b Fix web player plugin random characters 2022-04-11 18:39:46 -05:00
advplyr 6e8d84496b Updates to audio player plugin to method naming and using seconds for time values. Adding web audio player to run in browser. 2022-04-11 18:38:01 -05:00
Rasmus Krämer 782f11ff28 Implemented missing methods 2022-04-11 18:50:25 +02:00
Rasmus Krämer 6de4626239 Login is now working 2022-04-11 18:31:14 +02:00
Rasmus Krämer a0ee82bb31 added thread warning 2022-04-11 16:30:13 +02:00
Rasmus Krämer c0ac3b7bb5 Added realm 2022-04-11 16:29:19 +02:00
Rasmus KrämerandGitHub 2f4b3050fd Merge branch 'advplyr:master' into master 2022-04-11 11:29:46 +02:00
Rasmus Krämer bf3a97523d Added scheme 2022-04-11 11:27:31 +02:00
advplyr c94e57f55e Add podcast pages, android download podcast, scan podcast folders 2022-04-10 20:31:47 -05:00
advplyr ef65b4c278 Android cleaning up chromecast player and more failed attempts 2022-04-10 16:46:09 -05:00
advplyr e5c8d5d4d4 Android clean up audio player, update android auto for new data model 2022-04-10 14:22:57 -05:00
advplyr abf140bd21 UI updates 2022-04-09 20:29:59 -05:00
advplyr 12a153d423 Add sync local media progress 2022-04-09 18:36:32 -05:00
advplyr d9e4469089 Add keep local media progress and playback sessions, update data models to support syncing local progress and support for podcast episodes 2022-04-09 12:03:37 -05:00
advplyr 526fca98b9 Android add media progress sync for server sessions 2022-04-08 20:27:54 -05:00
advplyr 9ad351f0d7 Persist bookshelf list view setting, fix collections for new data model, add authors cards, update filters 2022-04-08 19:05:32 -05:00
advplyr f309e1fcf2 Updates to downloader, audio track ordering, hard deleting from file system, UI updates and fixes 2022-04-08 18:07:31 -05:00
advplyr 105451ebf1 Add bookshelf list view 2022-04-07 19:59:23 -05:00
advplyr 119bfd6c98 Add download UI indicator, download progress, update bookshelf item to show local items and items matches with local item, remove item before downloading if already exists in file system 2022-04-07 18:46:58 -05:00
advplyr ee942c6704 Update android scanner and add local media file page, scan library items 2022-04-06 20:36:17 -05:00
advplyr 12de187b7a Update folder scanner and db to store LocalLibraryItem objects instead of LocalMediaItem objects, some ui fixes and audio player service binding fix 2022-04-05 19:44:14 -05:00
advplyr 77ef0c119b Refactor capacitor plugins, clean up and organize android plugin classes 2022-04-04 19:08:27 -05:00
advplyr cc744bb975 Android cleaning up and refactoring 2022-04-03 20:45:19 -05:00
advplyr 4b834cb5c1 Remove old code for downloads, user progress, sql, server config. Add web plugin for DbManager 2022-04-03 19:16:17 -05:00
advplyr 9fd3dc6978 Add local library items to bookshelf and landing page 2022-04-03 17:07:26 -05:00
advplyr 4f8b13b23d Uncomment server configs from testing 2022-04-03 14:52:30 -05:00
advplyr 0b9b0341b5 Cleanup old unused and missing vars 2022-04-03 14:37:44 -05:00
advplyr f57f0e4e0d Add new connection page to support multiple server connection configs 2022-04-03 14:24:17 -05:00
advplyr 7a091dd428 New downloader for downloading multiple tracks, android media player support for using multiple tracks 2022-04-02 19:43:43 -05:00
advplyr f70f707100 Rebuilding audio player and handling playback sessions in android. Moving all logic natively 2022-04-02 12:12:00 -05:00
advplyr 314dc960f2 Android playback session for local media items 2022-04-01 19:38:23 -05:00
advplyr 94b9dbb8b3 Media folder management page, android media folder scanner 2022-04-01 18:33:40 -05:00
advplyr a259883979 Add start of new local media page for adding folders and scanning local media items 2022-03-31 20:23:38 -05:00
advplyr a8de03b82d Add new folder scanner for media items with ffprober 2022-03-30 19:41:04 -05:00
advplyr 4fc70cd3dd New data model classes, ffmpeg-kit, jackson json deserializer, add permission 2022-03-28 19:53:53 -05:00
Rasmus Krämer f98aea71cb - Changes icon
- Improved state sync
- Moved player to shared
2022-03-28 22:09:43 +02:00
advplyr 461733854a New data model in android: Adding jackson for JSON deserialization, adding data classes, setting up API caller 2022-03-24 19:28:26 -05:00
advplyrandGitHub b849882664 Merge pull request #117 from rasmuslos/master
Small improvements
2022-03-23 18:01:24 -05:00
advplyr 03312390cb New data model updates for bookshelf, covers, cards 2022-03-23 17:59:14 -05:00
Rasmus Krämer 4f1f0e4db7 Merge branch 'master' of https://github.com/rasmuslos/audiobookshelf-app 2022-03-23 16:13:31 +01:00
Rasmus Krämer 73602a51ce match android app 2022-03-23 16:13:28 +01:00
Rasmus KrämerandGitHub 40b9d4a012 Merge branch 'advplyr:master' into master 2022-03-22 17:31:11 +01:00
Rasmus Krämer 2d1e4b9836 updated proj 2022-03-22 16:59:03 +01:00
Rasmus Krämer e998a03571 Moved NowPlaingInfo to its own class 2022-03-22 16:56:59 +01:00
Rasmus Krämer d0b7a958aa Moved to beginReceiving to player 2022-03-22 15:38:54 +01:00
Rasmus Krämer d8d02984fd Go pack a few seconds when resuming after a long time 2022-03-22 15:38:31 +01:00
advplyrandGitHub 84bab5de1b add License #114 2022-03-20 05:54:56 -05:00
Rasmus Krämer 37b5554b39 bump 2022-03-15 22:51:36 +01:00
advplyrandGitHub 704318071e Merge pull request #109 from rasmuslos/master
Added sleep timer
2022-03-10 19:24:42 -06:00
Rasmus Krämer 4d4d3730bd Merge branch 'master' of https://github.com/rasmuslos/audiobookshelf-app 2022-03-09 22:14:09 +01:00
Rasmus Krämer 61433ef678 Added sleep timer 2022-03-09 22:13:07 +01:00
Rasmus Krämer 926c17543f Memorize playback rate 2022-03-09 22:10:15 +01:00
Rasmus Krämer f76128c2b3 Small fixes 2022-03-09 20:20:47 +01:00
advplyrandGitHub a42f94e367 Merge pull request #108 from rasmuslos/master
Move the audio-player to its own file
2022-03-07 15:24:35 -06:00
Rasmus KrämerandGitHub f9be51184d Merge branch 'advplyr:master' into master 2022-03-07 20:47:32 +01:00
Rasmus Krämer 52ec8b4234 Seperate MyNativeAudio & Player 2022-03-07 20:46:59 +01:00
advplyrandGitHub 2b692309fa Merge pull request #107 from rasmuslos/master
Massively improved the MPNowPlayingInfoCenter
2022-03-06 12:55:07 -06:00
advplyr 6d8479ac7d Add:Start adding new nosql database plugin for android 2022-03-06 12:53:18 -06:00
Rasmus Krämer 999315ca91 Massively improved the MPNowPlayingInfoCenter 2022-03-06 19:24:07 +01:00
advplyrandGitHub f5088d0384 Merge pull request #106 from rasmuslos/master
Show "now playing" on lock screen & in control center
2022-03-06 09:25:09 -06:00
Rasmus Krämer 979b74f516 Show "now playing" notification 2022-03-06 15:56:25 +01:00
Rasmus Krämer 9253a9bf6e Seek audio player to last known position 2022-03-06 12:31:19 +01:00
advplyr 69127944a3 Merge branch 'master' of https://github.com/advplyr/audiobookshelf-app 2022-02-16 16:49:25 -06:00
advplyr fd4974dfce Change:Media background notification set importance_low to stop noise/vibration 2022-02-16 16:49:20 -06:00
advplyrandGitHub 06f4c933ca Update readme add discord invite link 2022-02-14 17:52:51 -06:00
advplyr a4ce1dbe1d iOS version bump 0.9.40 2022-02-06 18:06:29 -06:00
advplyr abb41979f4 Version bump 0.9.40-beta 2022-02-03 09:22:43 -06:00
advplyr 6b59ad5bd3 Fix:Sleep timer independent from audio player time to be uneffected by playback speed changes #86 2022-02-03 09:19:27 -06:00
advplyr a30fe74da2 Change:No seek back if pause less than 1 minute #96 2022-02-03 08:35:48 -06:00
advplyr 6157b5923a Fix:Sony xperia cover art in notification crash temp workaround #94 2022-02-03 08:26:57 -06:00
advplyr cdcf152049 Version bump 0.9.39-beta 2022-01-27 17:12:26 -06:00
advplyr 16da0c909f Add:Sync local progress to server natively #74 2022-01-26 18:03:12 -06:00
advplyr 1a555eab63 Version bump 0.9.38-beta 2022-01-20 14:02:09 -06:00
advplyr 4cdcbf79d7 Add:Check for OnePlus on playing ab with local cover #35 2022-01-20 13:57:55 -06:00
advplyr c07b527a1d Fix:User accessible libraries #84 2022-01-16 14:14:02 -06:00
advplyr a2031f1d88 Version bump 0.9.37-beta 2022-01-13 16:55:19 -06:00
advplyr 42fec4831e Fix:Native progress sync sending time in ms #78, Fix:Chapter start/end times are strings #83 2022-01-13 15:44:16 -06:00
advplyr 9da21b57c7 Merge branch 'master' of https://github.com/advplyr/audiobookshelf-app 2022-01-08 14:33:11 -06:00
advplyr 2d7b539e90 Version bump v0.9.36-beta 2022-01-08 14:33:03 -06:00
advplyr 4dbaf3c864 Add:Android auto to use saved playback rate #66 2022-01-08 14:25:28 -06:00
advplyr e6e7ad8266 Change:Use new server cover url for cached and compressed covers 2022-01-08 13:56:17 -06:00
advplyr 080a08aebc Fix:Check local cover image read permission and remove cover #35 2022-01-08 12:57:30 -06:00
advplyr 8d4831f912 Change:ios version string 2022-01-06 18:35:12 -06:00
advplyr 72732025ae Change:Remove android auto reference for apple app store approval 2022-01-06 17:11:09 -06:00
advplyr b0c316d2f0 Fix:Audio player dropdown menu 2022-01-05 19:19:59 -06:00
advplyr f7a2393d99 Fix:Downloaded audiobook progress sync #77, Fix:Downloads page not showing, Fix: Audio player dropdown menu staying open 2022-01-05 19:09:18 -06:00
advplyr 6229a7f0a8 Merge branch 'master' of https://github.com/advplyr/audiobookshelf-app 2022-01-04 18:40:57 -06:00
advplyr a0768da143 Fix:Audiobook page progress time remaining for 100% #73 2022-01-04 18:40:49 -06:00
advplyr 04b453060d Add:iOS icons 2022-01-01 12:10:45 -06:00
advplyr 4bf75c606a Add:iOS audio player 2021-12-31 16:57:53 -06:00
248 changed files with 18811 additions and 12318 deletions
+674
View File
@@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
Audiobookshelf Self-hosted audiobook server
Copyright (C) 2021 advplyr
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
Audiobookshelf Copyright (C) 2021 advplyr
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
-304
View File
@@ -1,304 +0,0 @@
import { io } from 'socket.io-client'
import { Storage } from '@capacitor/storage'
import axios from 'axios'
import EventEmitter from 'events'
class Server extends EventEmitter {
constructor(store) {
super()
this.store = store
this.url = null
this.socket = null
this.user = null
this.connected = false
this.initialized = false
this.stream = null
this.isConnectingSocket = false
}
get token() {
return this.user ? this.user.token : null
}
getAxiosConfig() {
return { headers: { Authorization: `Bearer ${this.token}` } }
}
getServerUrl(url) {
if (!url) return null
try {
var urlObject = new URL(url)
return `${urlObject.protocol}//${urlObject.hostname}:${urlObject.port}`
} catch (error) {
console.error('Invalid URL', error)
return null
}
}
setUser(user) {
this.user = user
this.store.commit('user/setUser', user)
if (user) {
// this.store.commit('user/setSettings', user.settings)
Storage.set({ key: 'token', value: user.token })
} else {
Storage.remove({ key: 'token' })
}
}
setServerUrl(url) {
this.url = url
this.store.commit('setServerUrl', url)
if (url) {
Storage.set({ key: 'serverUrl', value: url })
} else {
Storage.remove({ key: 'serverUrl' })
}
}
async connect(url, token) {
if (this.connected) {
console.warn('[SOCKET] Connection already established for ' + this.url)
return { success: true }
}
if (!url) {
console.error('Invalid url to connect')
return {
error: 'Invalid URL'
}
}
var serverUrl = this.getServerUrl(url)
var res = await this.ping(serverUrl)
if (!res || !res.success) {
return {
error: res ? res.error : 'Unknown Error'
}
}
var authRes = await this.authorize(serverUrl, token)
if (!authRes || authRes.error) {
return {
error: authRes ? authRes.error : 'Authorization Error'
}
}
this.setServerUrl(serverUrl)
this.setUser(authRes.user)
this.connectSocket()
return { success: true }
}
async check(url) {
var serverUrl = this.getServerUrl(url)
if (!serverUrl) {
return {
error: 'Invalid server url'
}
}
var res = await this.ping(serverUrl)
if (!res || res.error) {
return {
error: res ? res.error : 'Ping Failed'
}
}
return {
success: true,
serverUrl
}
}
async login(url, username, password) {
var serverUrl = this.getServerUrl(url)
var authUrl = serverUrl + '/login'
return axios.post(authUrl, { username, password }).then((res) => {
if (!res.data || !res.data.user) {
console.error(res.data.error)
return {
error: res.data.error || 'Unknown Error'
}
}
this.setServerUrl(serverUrl)
this.setUser(res.data.user)
this.connectSocket()
return {
user: res.data.user
}
}).catch(error => {
console.error('[Server] Server auth failed', error)
var errorMsg = null
if (error.response) {
errorMsg = error.response.data || 'Unknown Error'
} else if (error.request) {
errorMsg = 'Server did not respond'
} else {
errorMsg = 'Failed to send request'
}
return {
error: errorMsg
}
})
}
logout() {
this.setUser(null)
this.stream = null
if (this.socket) {
this.socket.disconnect()
}
this.emit('logout')
}
authorize(serverUrl, token) {
var authUrl = serverUrl + '/api/authorize'
return axios.post(authUrl, null, { headers: { Authorization: `Bearer ${token}` } }).then((res) => {
return res.data
}).catch(error => {
console.error('[Server] Server auth failed', error)
var errorMsg = null
if (error.response) {
errorMsg = error.response.data || 'Unknown Error'
} else if (error.request) {
errorMsg = 'Server did not respond'
} else {
errorMsg = 'Failed to send request'
}
return {
error: errorMsg
}
})
}
ping(url) {
var pingUrl = url + '/ping'
console.log('[Server] Check server', pingUrl)
return axios.get(pingUrl, { timeout: 1000 }).then((res) => {
return res.data
}).catch(error => {
console.error('Server check failed', error)
var errorMsg = null
if (error.response) {
errorMsg = error.response.data || 'Unknown Error'
} else if (error.request) {
errorMsg = 'Server did not respond'
} else {
errorMsg = 'Failed to send request'
}
return {
success: false,
error: errorMsg
}
})
}
connectSocket() {
if (this.socket && !this.connected) {
this.socket.connect()
console.log('[SOCKET] Submitting connect')
return
}
if (this.connected || this.socket) {
if (this.socket) console.error('[SOCKET] Socket already established', this.url)
else console.error('[SOCKET] Already connected to socket', this.url)
return
}
console.log('[SOCKET] Connect Socket', this.url)
const socketOptions = {
transports: ['websocket'],
upgrade: false,
// reconnectionAttempts: 3
}
this.socket = io(this.url, socketOptions)
this.socket.on('connect', () => {
console.log('[SOCKET] Socket Connected ' + this.socket.id)
// Authenticate socket with token
this.socket.emit('auth', this.token)
this.connected = true
this.emit('connected', true)
this.store.commit('setSocketConnected', true)
})
this.socket.on('disconnect', (reason) => {
console.log('[SOCKET] Socket Disconnected: ' + reason)
this.connected = false
this.emit('connected', false)
this.emit('initialized', false)
this.initialized = false
this.store.commit('setSocketConnected', false)
// this.socket.removeAllListeners()
// if (this.socket.io && this.socket.io.removeAllListeners) {
// console.log(`[SOCKET] Removing ALL IO listeners`)
// this.socket.io.removeAllListeners()
// }
})
this.socket.on('init', (data) => {
console.log('[SOCKET] Initial socket data received', data)
if (data.stream) {
this.stream = data.stream
this.store.commit('setStreamAudiobook', data.stream.audiobook)
this.emit('initialStream', data.stream)
}
if (data.serverSettings) {
this.store.commit('setServerSettings', data.serverSettings)
}
this.initialized = true
this.emit('initialized', true)
})
this.socket.on('user_updated', (user) => {
if (this.user && user.id === this.user.id) {
this.setUser(user)
}
})
this.socket.on('current_user_audiobook_update', (payload) => {
this.emit('currentUserAudiobookUpdate', payload)
})
this.socket.on('show_error_toast', (payload) => {
this.emit('show_error_toast', payload)
})
this.socket.on('show_success_toast', (payload) => {
this.emit('show_success_toast', payload)
})
this.socket.onAny((evt, args) => {
console.log(`[SOCKET] ${this.socket.id}: ${evt} ${JSON.stringify(args)}`)
})
this.socket.on('connect_error', (err) => {
console.error('[SOCKET] connection failed', err)
this.emit('socketConnectionFailed', err)
})
this.socket.io.on("reconnect_attempt", (attempt) => {
console.log(`[SOCKET] Reconnect Attempt ${this.socket.id}: ${attempt}`)
})
this.socket.io.on("reconnect_error", (err) => {
console.log(`[SOCKET] Reconnect Error ${this.socket.id}: ${err}`)
})
this.socket.io.on("reconnect_failed", () => {
console.log(`[SOCKET] Reconnect Failed ${this.socket.id}`)
})
this.socket.io.on("reconnect", () => {
console.log(`[SOCKET] Reconnect Success ${this.socket.id}`)
})
}
}
export default Server
+55 -12
View File
@@ -4,6 +4,22 @@ plugins {
id 'kotlin-kapt'
}
kotlin {
kotlinDaemonJvmArgs = [
"-Dfile.encoding=UTF-8",
"--add-opens=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED",
"--add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED",
"--add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED",
"--add-opens=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED",
"--add-opens=jdk.compiler/com.sun.tools.javac.jvm=ALL-UNNAMED",
"--add-opens=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED",
"--add-opens=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED",
"--add-opens=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED",
"--add-opens=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED",
"--add-opens=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED"
]
}
android {
kotlinOptions {
freeCompilerArgs = ['-Xjvm-default=all']
@@ -13,8 +29,8 @@ android {
applicationId "com.audiobookshelf.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 55
versionName "0.9.35-beta"
versionCode 72
versionName "0.9.43-beta"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
@@ -37,23 +53,34 @@ repositories {
mavenCentral()
}
configurations.all {
resolutionStrategy {
force("com.google.android.gms:play-services-base:18.0.1")
force("androidx.appcompat:appcompat:$androidxAppCompatVersion")
force("androidx.core:core-ktx:$androidx_core_ktx_version")
force("org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version")
force("androidx.media:media:$androidx_media_version")
force("org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlin_coroutines_version")
force("org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version")
// failOnVersionConflict()
}
}
dependencies {
implementation "com.anggrayudi:storage:0.13.0"
implementation fileTree(include: ['*.jar'], dir: 'libs')
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
implementation project(':capacitor-android')
implementation 'androidx.coordinatorlayout:coordinatorlayout:1.1.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.3'
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
implementation 'com.squareup.okhttp3:okhttp:4.9.2'
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
// testImplementation "junit:junit:$junitVersion"
// androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
// androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
implementation project(':capacitor-cordova-android-plugins')
implementation "androidx.core:core-ktx:1.6.0"
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation "androidx.core:core-ktx:$androidx_core_ktx_version"
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlin_coroutines_version"
@@ -61,6 +88,7 @@ dependencies {
implementation "androidx.media:media:$androidx_media_version"
// Exo Player
if (findProject(':exoplayer-library-core') != null) {
implementation project(':exoplayer-library-core')
implementation project(':exoplayer-library-ui')
@@ -75,9 +103,24 @@ dependencies {
implementation "com.google.android.exoplayer:exoplayer-hls:$exoplayer_version"
}
// Glide for images
implementation "com.github.bumptech.glide:glide:$glide_version"
kapt "com.github.bumptech.glide:compiler:$glide_version"
// Paper NoSQL Db
implementation 'io.github.pilgr:paperdb:2.7.2'
// Simple Storage
implementation "com.anggrayudi:storage:0.13.0"
// OK HTTP
implementation 'com.squareup.okhttp3:okhttp:4.9.2'
// Jackson for JSON
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.12.1'
// FFMPEG-Kit
implementation 'com.arthenica:ffmpeg-kit-min:4.5.1'
}
apply from: 'capacitor.build.gradle'
+2 -2
View File
@@ -9,13 +9,13 @@ android {
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-community-sqlite')
implementation project(':capacitor-app')
implementation project(':capacitor-dialog')
implementation project(':capacitor-haptics')
implementation project(':capacitor-network')
implementation project(':capacitor-status-bar')
implementation project(':capacitor-storage')
implementation project(':robingenz-capacitor-app-update')
implementation project(':capacitor-data-storage-sqlite')
}
+3 -2
View File
@@ -7,7 +7,8 @@
<!-- Permissions -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<application
@@ -70,7 +71,7 @@
<service
android:exported="true"
android:enabled="true"
android:name=".PlayerNotificationService">
android:name=".player.PlayerNotificationService">
<intent-filter>
<action android:name="android.media.browse.MediaBrowserService"/>
</intent-filter>
@@ -1,8 +1,4 @@
[
{
"pkg": "@capacitor-community/sqlite",
"classpath": "com.getcapacitor.community.database.sqlite.CapacitorSQLitePlugin"
},
{
"pkg": "@capacitor/app",
"classpath": "com.capacitorjs.plugins.app.AppPlugin"
@@ -11,10 +7,18 @@
"pkg": "@capacitor/dialog",
"classpath": "com.capacitorjs.plugins.dialog.DialogPlugin"
},
{
"pkg": "@capacitor/haptics",
"classpath": "com.capacitorjs.plugins.haptics.HapticsPlugin"
},
{
"pkg": "@capacitor/network",
"classpath": "com.capacitorjs.plugins.network.NetworkPlugin"
},
{
"pkg": "@capacitor/status-bar",
"classpath": "com.capacitorjs.plugins.statusbar.StatusBarPlugin"
},
{
"pkg": "@capacitor/storage",
"classpath": "com.capacitorjs.plugins.storage.StoragePlugin"
@@ -22,9 +26,5 @@
{
"pkg": "@robingenz/capacitor-app-update",
"classpath": "dev.robingenz.capacitor.appupdate.AppUpdatePlugin"
},
{
"pkg": "capacitor-data-storage-sqlite",
"classpath": "com.jeep.plugin.capacitor.capacitordatastoragesqlite.CapacitorDataStorageSqlitePlugin"
}
]
@@ -1,354 +0,0 @@
package com.audiobookshelf.app
import android.app.DownloadManager
import android.content.Context
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.util.Log
import androidx.documentfile.provider.DocumentFile
import com.anggrayudi.storage.callback.FileCallback
import com.anggrayudi.storage.file.*
import com.anggrayudi.storage.media.FileDescription
import com.getcapacitor.JSObject
import com.getcapacitor.Plugin
import com.getcapacitor.PluginCall
import com.getcapacitor.PluginMethod
import com.getcapacitor.annotation.CapacitorPlugin
import java.io.File
@CapacitorPlugin(name = "AudioDownloader")
class AudioDownloader : Plugin() {
private val tag = "AudioDownloader"
lateinit var mainActivity:MainActivity
lateinit var downloadManager:DownloadManager
// data class AudiobookItem(val uri: Uri, val name: String, val size: Long, val coverUrl: String) {
// fun toJSObject() : JSObject {
// var obj = JSObject()
// obj.put("uri", this.uri)
// obj.put("name", this.name)
// obj.put("size", this.size)
// obj.put("coverUrl", this.coverUrl)
// return obj
// }
// }
override fun load() {
mainActivity = (activity as MainActivity)
downloadManager = activity.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
var recieverEvent: (evt: String, id: Long) -> Unit = { evt: String, id: Long ->
if (evt == "complete") {
}
if (evt == "clicked") {
Log.d(tag, "Clicked $id back in the audiodownloader")
}
}
mainActivity.registerBroadcastReceiver(recieverEvent)
Log.d(tag, "Build SDK ${Build.VERSION.SDK_INT}")
}
// @PluginMethod
// fun load(call: PluginCall) {
// var audiobookUrls = call.data.getJSONArray("audiobookUrls")
// var len = audiobookUrls?.length()
// if (len == null) {
// len = 0
// }
// Log.d(tag, "CALLED LOAD $len")
// var audiobookItems:MutableList<AudiobookItem> = mutableListOf()
//
// (0 until len).forEach {
// var jsobj = audiobookUrls.get(it) as JSONObject
// var audiobookUrl = jsobj.get("contentUrl").toString()
// var coverUrl = jsobj.get("coverUrl").toString()
// var storageId = ""
// if(jsobj.has("storageId")) jsobj.get("storageId").toString()
//
// var basePath = ""
// if(jsobj.has("basePath")) jsobj.get("basePath").toString()
//
// var coverBasePath = ""
// if(jsobj.has("coverBasePath")) jsobj.get("coverBasePath").toString()
//
// Log.d(tag, "LOOKUP $storageId $basePath $audiobookUrl")
//
// var audiobookFile: DocumentFile? = null
// var coverFile: DocumentFile? = null
//
// // Android 9 OR Below use storage id and base path
// if (Build.VERSION.SDK_INT <= android.os.Build.VERSION_CODES.P) {
// audiobookFile = DocumentFileCompat.fromSimplePath(context, storageId, basePath)
// if (coverUrl != null && coverUrl != "") {
// coverFile = DocumentFileCompat.fromSimplePath(context, storageId, coverBasePath)
// }
// } else {
// // Android 10 and up manually deleting will still load the file causing crash
// var exists = checkUriExists(Uri.parse(audiobookUrl))
// if (exists) {
// Log.d(tag, "Audiobook exists")
// audiobookFile = DocumentFileCompat.fromUri(context, Uri.parse(audiobookUrl))
// } else {
// Log.e(tag, "Audiobook does not exist")
// }
//
// var coverExists = checkUriExists(Uri.parse(coverUrl))
// if (coverExists) {
// Log.d(tag, "Cover Exists")
// coverFile = DocumentFileCompat.fromUri(context, Uri.parse(coverUrl))
// } else if (coverUrl != null && coverUrl != "") {
// Log.e(tag, "Cover does not exist")
// }
// }
//
// if (audiobookFile == null) {
// Log.e(tag, "Audiobook was not found $audiobookUrl")
// } else {
// Log.d(tag, "Audiobook File Found StorageId:${audiobookFile.getStorageId(context)} | AbsolutePath:${audiobookFile.getAbsolutePath(context)} | BasePath:${audiobookFile.getBasePath(context)}")
//
// var _name = audiobookFile.name
// if (_name == null) _name = ""
//
// var size = audiobookFile.length()
//
// if (audiobookFile.uri.toString() !== audiobookUrl) {
// Log.d(tag, "Audiobook URI ${audiobookFile.uri} is different from $audiobookUrl => using the latter")
// }
//
// // Use existing URI's - bug happening where new uri is different from initial
// var abItem = AudiobookItem(Uri.parse(audiobookUrl), _name, size, coverUrl)
//
// Log.d(tag, "Setting AB ITEM ${abItem.name} | ${abItem.size} | ${abItem.uri} | ${abItem.coverUrl}")
//
// audiobookItems.add(abItem)
// }
// }
//
// Log.d(tag, "Load Finished ${audiobookItems.size} found")
//
// var audiobookObjs:List<JSObject> = audiobookItems.map{ it.toJSObject() }
// var mediaItemNoticePayload = JSObject()
// mediaItemNoticePayload.put("items", audiobookObjs)
// notifyListeners("onMediaLoaded", mediaItemNoticePayload)
// }
@PluginMethod
fun download(call: PluginCall) {
var audiobookId = call.data.getString("audiobookId", "audiobook").toString()
var url = call.data.getString("downloadUrl", "unknown").toString()
var coverDownloadUrl = call.data.getString("coverDownloadUrl", "").toString()
var title = call.data.getString("title", "Audiobook").toString()
var filename = call.data.getString("filename", "audiobook.mp3").toString()
var coverFilename = call.data.getString("coverFilename", "cover.png").toString()
var downloadFolderUrl = call.data.getString("downloadFolderUrl", "").toString()
var folder = DocumentFileCompat.fromUri(context, Uri.parse(downloadFolderUrl))!!
Log.d(tag, "Called download: $url | Folder: ${folder.name} | $downloadFolderUrl")
var dlfilename = audiobookId + "." + File(filename).extension
var coverdlfilename = audiobookId + "." + File(coverFilename).extension
Log.d(tag, "DL Filename $dlfilename | Cover DL Filename $coverdlfilename")
var canWriteToFolder = folder.canWrite()
if (!canWriteToFolder) {
Log.e(tag, "Error Cannot Write to Folder ${folder.baseName}")
val ret = JSObject()
ret.put("error", "Cannot write to ${folder.baseName}")
call.resolve(ret)
return
}
var dlRequest = DownloadManager.Request(Uri.parse(url))
dlRequest.setTitle("Ab: $title")
dlRequest.setDescription("Downloading to ${folder.name}")
dlRequest.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
dlRequest.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, dlfilename)
var audiobookDownloadId = downloadManager.enqueue(dlRequest)
var coverDownloadId:Long? = null
if (coverDownloadUrl != "") {
var coverDlRequest = DownloadManager.Request(Uri.parse(coverDownloadUrl))
coverDlRequest.setTitle("Cover: $title")
coverDlRequest.setDescription("Downloading to ${folder.name}")
coverDlRequest.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_ONLY_COMPLETION)
coverDlRequest.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, coverdlfilename)
coverDownloadId = downloadManager.enqueue(coverDlRequest)
}
var progressReceiver : (id:Long, prog: Long) -> Unit = { id:Long, prog: Long ->
if (id == audiobookDownloadId) {
var jsobj = JSObject()
jsobj.put("audiobookId", audiobookId)
jsobj.put("progress", prog)
notifyListeners("onDownloadProgress", jsobj)
}
}
var coverDocFile:DocumentFile? = null
var doneReceiver : (id:Long, success: Boolean) -> Unit = { id:Long, success: Boolean ->
Log.d(tag, "RECEIVER DONE $id, SUCCES? $success")
var docfile:DocumentFile? = null
// Download was complete, now find downloaded file
if (id == coverDownloadId) {
docfile = DocumentFileCompat.fromPublicFolder(context, PublicDirectory.DOWNLOADS, coverdlfilename)
Log.d(tag, "Move Cover File ${docfile?.name}")
// For unknown reason, Android 10 test was using the title set in "setTitle" for the dl manager as the filename
// check if this was the case
if (docfile?.name == null) {
docfile = DocumentFileCompat.fromPublicFolder(context, PublicDirectory.DOWNLOADS, "Cover: $title")
Log.d(tag, "Cover File name attempt 2 ${docfile?.name}")
}
} else if (id == audiobookDownloadId) {
docfile = DocumentFileCompat.fromPublicFolder(context, PublicDirectory.DOWNLOADS, dlfilename)
Log.d(tag, "Move Audiobook File ${docfile?.name}")
if (docfile?.name == null) {
docfile = DocumentFileCompat.fromPublicFolder(context, PublicDirectory.DOWNLOADS, "Ab: $title")
Log.d(tag, "File name attempt 2 ${docfile?.name}")
}
}
// Callback for moving the downloaded file
var callback = object : FileCallback() {
override fun onPrepare() {
Log.d(tag, "PREPARING MOVE FILE")
}
override fun onFailed(errorCode:ErrorCode) {
Log.e(tag, "FAILED MOVE FILE $errorCode")
docfile?.delete()
coverDocFile?.delete()
if (id == audiobookDownloadId) {
var jsobj = JSObject()
jsobj.put("audiobookId", audiobookId)
jsobj.put("error", "Move failed")
notifyListeners("onDownloadFailed", jsobj)
}
}
override fun onCompleted(result:Any) {
var resultDocFile = result as DocumentFile
var simplePath = resultDocFile.getSimplePath(context)
var storageId = resultDocFile.getStorageId(context)
var size = resultDocFile.length()
Log.d(tag, "Finished Moving File, NAME: ${resultDocFile.name} | URI:${resultDocFile.uri} | AbsolutePath:${resultDocFile.getAbsolutePath(context)} | $storageId | SimplePath: $simplePath")
var abFolder = folder.findFolder(title)
var jsobj = JSObject()
jsobj.put("audiobookId", audiobookId)
jsobj.put("downloadId", id)
jsobj.put("storageId", storageId)
jsobj.put("storageType", resultDocFile.getStorageType(context))
jsobj.put("folderUrl", abFolder?.uri)
jsobj.put("folderName", abFolder?.name)
jsobj.put("downloadFolderUrl", downloadFolderUrl)
jsobj.put("contentUrl", resultDocFile.uri)
jsobj.put("basePath", resultDocFile.getBasePath(context))
jsobj.put("filename", filename)
jsobj.put("simplePath", simplePath)
jsobj.put("size", size)
if (resultDocFile.name == filename) {
Log.d(tag, "Audiobook Finishing Moving")
} else if (resultDocFile.name == coverFilename) {
coverDocFile = docfile
Log.d(tag, "Audiobook Cover Finished Moving")
jsobj.put("isCover", true)
}
notifyListeners("onDownloadComplete", jsobj)
}
}
// After file is downloaded, move the files into an audiobook directory inside the user selected folder
if (id == coverDownloadId) {
docfile?.moveFileTo(context, folder, FileDescription(coverFilename, title, MimeType.IMAGE), callback)
} else if (id == audiobookDownloadId) {
docfile?.moveFileTo(context, folder, FileDescription(filename, title, MimeType.AUDIO), callback)
}
}
var progressUpdater = DownloadProgressUpdater(downloadManager, audiobookDownloadId, progressReceiver, doneReceiver)
progressUpdater.run()
if (coverDownloadId != null) {
var coverProgressUpdater = DownloadProgressUpdater(downloadManager, coverDownloadId, progressReceiver, doneReceiver)
coverProgressUpdater.run()
}
val ret = JSObject()
ret.put("audiobookDownloadId", audiobookDownloadId)
ret.put("coverDownloadId", coverDownloadId)
call.resolve(ret)
}
internal class DownloadProgressUpdater(private val manager: DownloadManager, private val downloadId: Long, private var receiver: (Long, Long) -> Unit, private var doneReceiver: (Long, Boolean) -> Unit) : Thread() {
private val query: DownloadManager.Query = DownloadManager.Query()
private var totalBytes: Int = 0
private var TAG = "DownloadProgressUpdater"
init {
query.setFilterById(this.downloadId)
}
override fun run() {
Log.d(TAG, "RUN FOR ID $downloadId")
var keepRunning = true
var increment = 0
while (keepRunning) {
Thread.sleep(500)
increment++
if (increment % 4 == 0) {
Log.d(TAG, "Loop $increment : $downloadId")
}
manager.query(query).use {
if (it.moveToFirst()) {
//get total bytes of the file
if (totalBytes <= 0) {
totalBytes = it.getInt(it.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES))
if (totalBytes <= 0) {
Log.e(TAG, "Download Is 0 Bytes $downloadId")
doneReceiver(downloadId, false)
keepRunning = false
this.interrupt()
return
}
}
val downloadStatus = it.getInt(it.getColumnIndex(DownloadManager.COLUMN_STATUS))
val bytesDownloadedSoFar = it.getInt(it.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR))
if (increment % 4 == 0) {
Log.d(TAG, "BYTES $increment : $downloadId : $bytesDownloadedSoFar : TOTAL: $totalBytes")
}
if (downloadStatus == DownloadManager.STATUS_SUCCESSFUL || downloadStatus == DownloadManager.STATUS_FAILED) {
if (downloadStatus == DownloadManager.STATUS_SUCCESSFUL) {
doneReceiver(downloadId, true)
} else {
doneReceiver(downloadId, false)
}
keepRunning = false
this.interrupt()
} else {
//update progress
val percentProgress = ((bytesDownloadedSoFar * 100L) / totalBytes)
receiver(downloadId, percentProgress)
}
} else {
Log.e(TAG, "NOT FOUND IN QUERY")
keepRunning = false
}
}
}
}
}
}
@@ -1,102 +0,0 @@
package com.audiobookshelf.app
import android.net.Uri
import android.os.Bundle
import android.support.v4.media.MediaBrowserCompat
import android.support.v4.media.MediaDescriptionCompat
import android.support.v4.media.MediaMetadataCompat
import com.getcapacitor.JSObject
class Audiobook {
var id:String
var ino:String
var libraryId:String
var folderId:String
var book:Book
var duration:Float
var size:Long
var numTracks:Int
var isMissing:Boolean
var isInvalid:Boolean
var path:String
var isDownloaded:Boolean = false
var downloadFolderUrl:String = ""
var folderUrl:String = ""
var contentUrl:String = ""
var filename:String = ""
var localCoverUrl:String = ""
var localCover:String = ""
var serverUrl:String = ""
var token:String = ""
constructor(jsobj: JSObject, serverUrl:String, token:String) {
this.serverUrl = serverUrl
this.token = token
id = jsobj.getString("id", "").toString()
ino = jsobj.getString("ino", "").toString()
libraryId = jsobj.getString("libraryId", "").toString()
folderId = jsobj.getString("folderId", "").toString()
var bookJsObj = jsobj.getJSObject("book")
book = bookJsObj?.let { Book(it) }!!
duration = jsobj.getDouble("duration").toFloat()
size = jsobj.getLong("size")
numTracks = jsobj.getInteger("numTracks")!!
isMissing = jsobj.getBoolean("isMissing")
isInvalid = jsobj.getBoolean("isInvalid")
path = jsobj.getString("path", "").toString()
isDownloaded = jsobj.getBoolean("isDownloaded")
if (isDownloaded) {
downloadFolderUrl = jsobj.getString("downloadFolderUrl", "").toString()
folderUrl = jsobj.getString("folderUrl", "").toString()
contentUrl = jsobj.getString("contentUrl", "").toString()
filename = jsobj.getString("filename", "").toString()
localCover = jsobj.getString("localCover", "").toString()
localCoverUrl = jsobj.getString("localCoverUrl", "").toString()
}
}
fun getCover():Uri {
if (isDownloaded) {
// return Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon)
return Uri.parse(localCoverUrl)
}
if (book.cover == "" || serverUrl == "" || token == "") return Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon)
return Uri.parse("$serverUrl${book.cover}?token=$token&ts=${book.lastUpdate}")
}
fun getDurationLong():Long {
return duration.toLong() * 1000L
}
fun toMediaMetadata():MediaMetadataCompat {
return MediaMetadataCompat.Builder().apply {
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, book.title)
putString(MediaMetadataCompat.METADATA_KEY_TITLE, book.title)
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, book.authorFL)
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, getCover().toString())
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getCover().toString())
putString(MediaMetadataCompat.METADATA_KEY_ART_URI, getCover().toString())
putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, book.authorFL)
// val extras = Bundle()
// if (isDownloaded) {
// extras.putLong(
// MediaDescriptionCompat.EXTRA_DOWNLOAD_STATUS,
// MediaDescriptionCompat.STATUS_DOWNLOADED)
// }
// extras.putInt(
// MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
// MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_PARTIALLY_PLAYED)
// putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, RESOURCE_ROOT_URI +
// context.resources.getResourceEntryName(R.drawable.notification_bg_low_normal))
}.build()
}
}
@@ -1,369 +0,0 @@
package com.audiobookshelf.app
import android.app.Activity
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.support.v4.media.MediaMetadataCompat
import android.util.Log
import com.getcapacitor.JSArray
import com.getcapacitor.JSObject
import com.jeep.plugin.capacitor.capacitordatastoragesqlite.CapacitorDataStorageSqlite
import okhttp3.*
import org.json.JSONArray
import java.io.IOException
class AudiobookManager {
var tag = "AudiobookManager"
interface OnStreamData {
fun onStreamReady(asd:AudiobookStreamData)
}
var hasLoaded = false
var isLoading = false
var ctx: Context
var serverUrl = ""
var token = ""
private var client:OkHttpClient
var localMediaManager:LocalMediaManager
var audiobooks:MutableList<Audiobook> = mutableListOf()
var audiobooksInProgress:MutableList<Audiobook> = mutableListOf()
constructor(_ctx:Context, _client:OkHttpClient) {
ctx = _ctx
client = _client
localMediaManager = LocalMediaManager(ctx)
}
fun init() {
var sharedPreferences = ctx.getSharedPreferences("CapacitorStorage", Activity.MODE_PRIVATE)
serverUrl = sharedPreferences.getString("serverUrl", "").toString()
Log.d(tag, "SHARED PREF SERVERURL $serverUrl")
token = sharedPreferences.getString("token", "").toString()
Log.d(tag, "SHARED PREF TOKEN $token")
}
fun loadCategories(cb: (() -> Unit)) {
Log.d(tag, "LOAD Categories $serverUrl | $token")
var url = "$serverUrl/api/libraries/main/categories"
val request = Request.Builder()
.url(url).addHeader("Authorization", "Bearer $token")
.build()
client.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
Log.d(tag, "FAILURE TO CONNECT")
e.printStackTrace()
cb()
}
override fun onResponse(call: Call, response: Response) {
response.use {
if (!response.isSuccessful) throw IOException("Unexpected code $response")
var bodyString = response.body!!.string()
var results = JSONArray(bodyString)
// var results = resJson.getJSONArray("results")
var totalShelves = results.length() - 1
Log.d(tag, "Got categories $totalShelves")
for (i in 0..totalShelves) {
var shelfobj = results.get(i)
var jsobj = JSObject(shelfobj.toString())
var shelfId = jsobj.getString("id", "")
Log.d(tag, "Category shelf id $shelfId")
if (shelfId == "continue-reading") {
var entities = jsobj.getJSONArray("entities")
var totalEntities = entities.length() - 1
Log.d(tag, "Shelf total entities $totalEntities")
for (y in 0..totalEntities) {
var abobj = entities.get(y)
Log.d(tag, "Shelf category ab id $y = ${abobj.toString()}")
var abjsobj = JSObject(abobj.toString())
abjsobj.put("isDownloaded", false)
var audiobook = Audiobook(abjsobj, serverUrl, token)
if (audiobook.isMissing || audiobook.isInvalid || audiobook.numTracks <= 0) {
Log.d(tag, "Not an audiobook or invalid/missing")
} else {
var audiobookExists = audiobooksInProgress.find { it.id == audiobook.id }
if (audiobookExists == null) {
audiobooksInProgress.add(audiobook)
}
}
}
}
}
Log.d(tag, "${audiobooksInProgress.size} Audiobooks In Progress Loaded")
cb()
}
}
})
}
fun loadAudiobooks(cb: (() -> Unit)) {
Log.d(tag, "Load Audiobooks: $serverUrl | $token")
if (serverUrl == "" || token == "") {
Log.d(tag, "Load Audiobooks: No Server or Token set")
cb()
return
} else if (!serverUrl.startsWith("http")) {
Log.e(tag, "Load Audiobooks: Invalid server url $serverUrl")
cb()
return
}
// First load currently reading
loadCategories() {
// Then load all
var url = "$serverUrl/api/libraries/main/books/all?sort=book.title"
val request = Request.Builder()
.url(url).addHeader("Authorization", "Bearer $token")
.build()
client.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
Log.d(tag, "Load Audiobooks: FAILURE TO CONNECT")
e.printStackTrace()
cb()
}
override fun onResponse(call: Call, response: Response) {
response.use {
if (!response.isSuccessful) throw IOException("Unexpected code $response")
var bodyString = response.body!!.string()
var resJson = JSObject(bodyString)
var results = resJson.getJSONArray("results")
var totalBooks = results.length() - 1
for (i in 0..totalBooks) {
var abobj = results.get(i)
var jsobj = JSObject(abobj.toString())
jsobj.put("isDownloaded", false)
var audiobook = Audiobook(jsobj, serverUrl, token)
if (audiobook.isMissing || audiobook.isInvalid) {
Log.d(tag, "Audiobook ${audiobook.book.title} is missing or invalid")
} else if (audiobook.numTracks <= 0) {
Log.d(tag, "Audiobook ${audiobook.book.title} has audio tracks")
} else {
var audiobookExists = audiobooks.find { it.id == audiobook.id }
if (audiobookExists == null) {
audiobooks.add(audiobook)
} else {
Log.d(tag, "Audiobook already there from downloaded")
}
}
}
Log.d(tag, "${audiobooks.size} Audiobooks Loaded")
cb()
}
}
})
}
}
fun load() {
isLoading = true
hasLoaded = true
localMediaManager.loadLocalAudio()
// Load downloads from sql db
var db = CapacitorDataStorageSqlite(ctx)
db.openStore("storage", "downloads", false, "no-encryption", 1)
var keyvalues = db.keysvalues()
keyvalues.forEach {
Log.d(tag, "keyvalue ${it.getString("key")} | ${it.getString("value")}")
var dlobj = JSObject(it.getString("value"))
if (dlobj.has("audiobook")) {
var abobj = dlobj.getJSObject("audiobook")!!
abobj.put("isDownloaded", true)
abobj.put("contentUrl", dlobj.getString("contentUrl", "").toString())
abobj.put("filename", dlobj.getString("filename", "").toString())
abobj.put("folderUrl", dlobj.getString("folderUrl", "").toString())
abobj.put("downloadFolderUrl", dlobj.getString("downloadFolderUrl", "").toString())
abobj.put("localCoverUrl", dlobj.getString("coverUrl", "").toString())
abobj.put("localCover", dlobj.getString("cover", "").toString())
var audiobook = Audiobook(abobj, serverUrl, token)
audiobooks.add(audiobook)
}
}
}
fun openStream(audiobook:Audiobook, streamListener:OnStreamData) {
var url = "$serverUrl/api/books/${audiobook.id}/stream"
val request = Request.Builder()
.url(url).addHeader("Authorization", "Bearer $token")
.build()
client.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
e.printStackTrace()
}
override fun onResponse(call: Call, response: Response) {
response.use {
if (!response.isSuccessful) throw IOException("Unexpected code $response")
var bodyString = response.body!!.string()
var stream = JSObject(bodyString)
var streamId = stream.getString("streamId", "").toString()
var startTime = stream.getDouble("startTime")
var streamUrl = stream.getString("streamUrl", "").toString()
var startTimeLong = (startTime * 1000).toLong()
var abStreamDataObj = JSObject()
abStreamDataObj.put("id", streamId)
abStreamDataObj.put("audiobookId", audiobook.id)
abStreamDataObj.put("playlistUrl", "$serverUrl$streamUrl")
abStreamDataObj.put("title", audiobook.book.title)
abStreamDataObj.put("author", audiobook.book.authorFL)
abStreamDataObj.put("token", token)
abStreamDataObj.put("cover", audiobook.getCover())
abStreamDataObj.put("duration", audiobook.getDurationLong())
abStreamDataObj.put("startTime", startTimeLong)
abStreamDataObj.put("playbackSpeed", 1)
abStreamDataObj.put("playWhenReady", true)
abStreamDataObj.put("isLocal", false)
var audiobookStreamData = AudiobookStreamData(abStreamDataObj)
Handler(Looper.getMainLooper()).post() {
Log.d(tag, "Stream Ready on Main Looper")
streamListener.onStreamReady(audiobookStreamData)
}
Log.d(tag, "Init Player Stream")
}
}
})
}
fun initDownloadPlay(audiobook:Audiobook):AudiobookStreamData {
var abStreamDataObj = JSObject()
abStreamDataObj.put("id", "download")
abStreamDataObj.put("audiobookId", audiobook.id)
abStreamDataObj.put("contentUrl", audiobook.contentUrl)
abStreamDataObj.put("title", audiobook.book.title)
abStreamDataObj.put("author", audiobook.book.authorFL)
abStreamDataObj.put("token", null)
abStreamDataObj.put("cover", audiobook.getCover())
abStreamDataObj.put("duration", audiobook.getDurationLong())
abStreamDataObj.put("startTime", 0)
abStreamDataObj.put("playbackSpeed", 1)
abStreamDataObj.put("playWhenReady", true)
abStreamDataObj.put("isLocal", true)
var audiobookStreamData = AudiobookStreamData(abStreamDataObj)
return audiobookStreamData
}
fun initLocalPlay(local: LocalMediaManager.LocalAudio):AudiobookStreamData {
var abStreamDataObj = JSObject()
abStreamDataObj.put("id", "local")
abStreamDataObj.put("audiobookId", local.id)
abStreamDataObj.put("contentUrl", local.uri.toString())
abStreamDataObj.put("title", local.name)
abStreamDataObj.put("author", "")
abStreamDataObj.put("token", null)
abStreamDataObj.put("cover", local.coverUri)
abStreamDataObj.put("duration", local.duration)
abStreamDataObj.put("startTime", 0)
abStreamDataObj.put("playbackSpeed", 1)
abStreamDataObj.put("playWhenReady", true)
abStreamDataObj.put("isLocal", true)
var audiobookStreamData = AudiobookStreamData(abStreamDataObj)
return audiobookStreamData
}
private fun levenshtein(lhs : CharSequence, rhs : CharSequence) : Int {
val lhsLength = lhs.length + 1
val rhsLength = rhs.length + 1
var cost = Array(lhsLength) { it }
var newCost = Array(lhsLength) { 0 }
for (i in 1..rhsLength-1) {
newCost[0] = i
for (j in 1..lhsLength-1) {
val match = if(lhs[j - 1] == rhs[i - 1]) 0 else 1
val costReplace = cost[j - 1] + match
val costInsert = cost[j] + 1
val costDelete = newCost[j - 1] + 1
newCost[j] = Math.min(Math.min(costInsert, costDelete), costReplace)
}
val swap = cost
cost = newCost
newCost = swap
}
return cost[lhsLength - 1]
}
fun searchForAudiobook(query:String):Audiobook? {
var closestDistance = 99
var closestMatch:Audiobook? = null
audiobooks.forEach {
var dist = levenshtein(it.book.title, query)
Log.d(tag, "LEVENSHTEIN $dist")
if (dist < closestDistance) {
closestDistance = dist
closestMatch = it
}
}
if (closestMatch != null) {
Log.d(tag, "Closest Search is ${closestMatch?.book?.title} with distance $closestDistance")
if (closestDistance < 2) {
return closestMatch
}
return null
}
return null
}
fun getFirstAudiobook():Audiobook? {
if (audiobooks.isEmpty()) return null
return audiobooks[0]
}
fun getFirstLocal(): LocalMediaManager.LocalAudio? {
if (localMediaManager.localAudioFiles.isEmpty()) return null
return localMediaManager.localAudioFiles[0]
}
// Used for media browser loadChildren, fallback to using the samples if no audiobooks are there
fun getAudiobooksMediaMetadata() : List<MediaMetadataCompat> {
var mediaMetadata:MutableList<MediaMetadataCompat> = mutableListOf()
if (audiobooks.isEmpty()) {
localMediaManager.localAudioFiles.forEach { mediaMetadata.add(it.toMediaMetadata()) }
} else {
audiobooks.forEach { mediaMetadata.add(it.toMediaMetadata()) }
}
return mediaMetadata
}
// Used for media browser loadChildren, fallback to using the samples if no audiobooks are there
fun getDownloadedAudiobooksMediaMetadata() : List<MediaMetadataCompat> {
var mediaMetadata:MutableList<MediaMetadataCompat> = mutableListOf()
if (audiobooks.isEmpty()) {
localMediaManager.localAudioFiles.forEach { mediaMetadata.add(it.toMediaMetadata()) }
} else {
audiobooks.forEach { if (it.isDownloaded) { mediaMetadata.add(it.toMediaMetadata()) } }
}
return mediaMetadata
}
}
@@ -1,142 +0,0 @@
package com.audiobookshelf.app
import android.os.Handler
import android.os.Looper
import android.util.Log
import com.getcapacitor.JSObject
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.IOException
import java.util.*
import kotlin.concurrent.schedule
/*
* Normal progress sync is handled in webview, but when using android auto webview may not be open.
* If webview is not open sync progress every 5s. Webview can be closed at any time so interval is always set.
*/
class AudiobookProgressSyncer constructor(playerNotificationService:PlayerNotificationService, client: OkHttpClient) {
private val tag = "AudiobookProgressSync"
private val playerNotificationService:PlayerNotificationService = playerNotificationService
private val client:OkHttpClient = client
private var listeningTimerTask: TimerTask? = null
var listeningTimerRunning:Boolean = false
private var webviewOpenOnStart:Boolean = false
private var listeningBookTitle:String? = ""
private var listeningBookIsLocal:Boolean = false
private var listeningBookId:String? = ""
private var listeningStreamId:String? = ""
private var lastPlaybackTime:Long = 0
private var lastUpdateTime:Long = 0
fun start() {
if (listeningTimerRunning) {
Log.d(tag, "start: Timer already running for $listeningBookTitle")
if (playerNotificationService.getCurrentBookTitle() != listeningBookTitle) {
Log.d(tag, "start: Changed audiobook stream - resetting timer")
listeningTimerTask?.cancel()
}
}
listeningTimerRunning = true
webviewOpenOnStart = playerNotificationService.getIsWebviewOpen()
listeningBookTitle = playerNotificationService.getCurrentBookTitle()
listeningBookIsLocal = playerNotificationService.getCurrentBookIsLocal()
listeningBookId = playerNotificationService.getCurrentBookId()
listeningStreamId = playerNotificationService.getCurrentStreamId()
lastPlaybackTime = playerNotificationService.getCurrentTime()
lastUpdateTime = System.currentTimeMillis() / 1000L
listeningTimerTask = Timer("ListeningTimer", false).schedule(0L, 5000L) {
Handler(Looper.getMainLooper()).post() {
// Webview was closed while android auto is open - switch to native sync
if (!playerNotificationService.getIsWebviewOpen() && webviewOpenOnStart) {
Log.d(tag, "Listening Timer: webview closed Switching to native sync tracking")
webviewOpenOnStart = false
lastUpdateTime = System.currentTimeMillis() / 1000L
}
if (!webviewOpenOnStart && playerNotificationService.currentPlayer.isPlaying) {
sync()
}
}
}
}
fun stop() {
if (!listeningTimerRunning) return
Log.d(tag, "stop: Stopping listening for $listeningBookTitle")
reset()
sync()
}
fun reset() {
listeningTimerTask?.cancel()
listeningTimerTask = null
listeningTimerRunning = false
listeningBookTitle = ""
listeningBookId = ""
listeningBookIsLocal = false
listeningStreamId = ""
}
fun sync() {
var currTime = System.currentTimeMillis() / 1000L
var elapsed = currTime - lastUpdateTime
lastUpdateTime = currTime
if (!listeningBookIsLocal) {
Log.d(tag, "ListeningTimer: Sending sync data to server: elapsed $elapsed | $listeningStreamId | $listeningBookId")
// Send sync data only for streaming books
var syncData: JSObject = JSObject()
syncData.put("timeListened", elapsed)
syncData.put("currentTime", playerNotificationService.getCurrentTime())
syncData.put("streamId", listeningStreamId)
syncData.put("audiobookId", listeningBookId)
sendStreamSyncData(syncData) {
Log.d(tag, "Stream sync done")
}
} else if (listeningStreamId == "download") {
// TODO: Save downloaded audiobook progress & send to server if connected
Log.d(tag, "ListeningTimer: Is listening download")
}
}
fun sendStreamSyncData(payload:JSObject, cb: (() -> Unit)) {
var serverUrl = playerNotificationService.getServerUrl()
var token = playerNotificationService.getUserToken()
if (serverUrl == "" || token == "") {
return
}
Log.d(tag, "Sync Stream $serverUrl | $token")
var url = "$serverUrl/api/syncStream"
val mediaType = "application/json; charset=utf-8".toMediaType()
val requestBody = payload.toString().toRequestBody(mediaType)
val request = Request.Builder().post(requestBody)
.url(url).addHeader("Authorization", "Bearer $token")
.build()
client.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
Log.d(tag, "FAILURE TO CONNECT")
e.printStackTrace()
cb()
}
override fun onResponse(call: Call, response: Response) {
response.use {
if (!response.isSuccessful) throw IOException("Unexpected code $response")
cb()
}
}
})
}
}
@@ -1,171 +0,0 @@
package com.audiobookshelf.app
import android.net.Uri
import android.support.v4.media.MediaMetadataCompat
import android.util.Log
import com.getcapacitor.JSObject
import com.google.android.exoplayer2.MediaItem
import com.google.android.exoplayer2.MediaMetadata
import com.google.android.exoplayer2.util.MimeTypes
import java.lang.Exception
class AudiobookStreamData {
var id:String = "unset"
var audiobookId:String = ""
var token:String = ""
var playlistUrl:String = ""
var title:String = "No Title"
var author:String = "Unknown"
var series:String = ""
var cover:String = ""
var playWhenReady:Boolean = false
var startTime:Long = 0
var playbackSpeed:Float = 1f
var duration:Long = 0
var tracks:MutableList<String> = mutableListOf()
var isLocal:Boolean = false
var contentUrl:String = ""
var hasPlayerLoaded:Boolean = false
var playlistUri:Uri = Uri.EMPTY
var coverUri:Uri = Uri.EMPTY
var contentUri:Uri = Uri.EMPTY // For Local only
constructor(jsondata:JSObject) {
id = jsondata.getString("id", "unset").toString()
audiobookId = jsondata.getString("audiobookId", "").toString()
title = jsondata.getString("title", "No Title").toString()
token = jsondata.getString("token", "").toString()
author = jsondata.getString("author", "Unknown").toString()
series = jsondata.getString("series", "").toString()
cover = jsondata.getString("cover", "").toString()
playlistUrl = jsondata.getString("playlistUrl", "").toString()
playWhenReady = jsondata.getBoolean("playWhenReady", false) == true
if (jsondata.has("startTime")) {
startTime = jsondata.getString("startTime", "0")!!.toLong()
}
if (jsondata.has("duration")) {
duration = jsondata.getString("duration", "0")!!.toLong()
}
if (jsondata.has("playbackSpeed")) {
playbackSpeed = jsondata.getDouble("playbackSpeed")!!.toFloat()
}
// Local data
isLocal = jsondata.getBoolean("isLocal", false) == true
contentUrl = jsondata.getString("contentUrl", "").toString()
if (playlistUrl != "") {
playlistUri = Uri.parse(playlistUrl)
}
if (cover != "" && cover != null) {
coverUri = Uri.parse(cover)
} else {
coverUri = Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon)
cover = coverUri.toString()
}
if (contentUrl != "") {
contentUri = Uri.parse(contentUrl)
}
// Tracks for cast
try {
var tracksTest = jsondata.getJSONArray("tracks")
Log.d("AudiobookStreamData", "Load tracks from json array ${tracksTest.length()}")
for (i in 0 until tracksTest.length()) {
var track = tracksTest.get(i)
Log.d("AudiobookStreamData", "Extracting track $track")
tracks.add(track as String)
}
} catch(e:Exception) {
Log.d("AudiobookStreamData", "No tracks found $e")
}
}
fun getMediaMetadataCompat():MediaMetadataCompat {
var metadataBuilder = MediaMetadataCompat.Builder()
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, title)
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title)
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, author)
.putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, author)
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, author)
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, series)
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
if (cover != "") {
metadataBuilder.putString(MediaMetadataCompat.METADATA_KEY_ART_URI, cover)
metadataBuilder.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, cover)
}
return metadataBuilder.build()
}
fun getMediaMetadata():MediaMetadata {
var metadataBuilder = MediaMetadata.Builder()
.setTitle(title)
.setDisplayTitle(title)
.setArtist(author)
.setAlbumArtist(author)
.setSubtitle(author)
if (coverUri != Uri.EMPTY) {
metadataBuilder.setArtworkUri(coverUri)
}
if (playlistUri != Uri.EMPTY) {
metadataBuilder.setMediaUri(playlistUri)
}
if (contentUri != Uri.EMPTY) {
metadataBuilder.setMediaUri(contentUri)
}
return metadataBuilder.build()
}
fun getMimeType():String {
return if (isLocal) {
MimeTypes.BASE_TYPE_AUDIO
} else {
MimeTypes.APPLICATION_M3U8
}
}
fun getMediaUri():Uri {
return if (isLocal) {
contentUri
} else {
Uri.parse("$playlistUrl?token=$token")
}
}
fun getCastQueue():ArrayList<MediaItem> {
var mediaQueue: java.util.ArrayList<MediaItem> = java.util.ArrayList<MediaItem>()
for (i in 0 until tracks.size) {
var track = tracks[i]
var metadataBuilder = MediaMetadata.Builder()
.setTitle(title)
.setDisplayTitle(title)
.setArtist(author)
.setAlbumArtist(author)
.setSubtitle(author)
.setTrackNumber(i + 1)
if (coverUri != Uri.EMPTY) {
metadataBuilder.setArtworkUri(coverUri)
}
var mimeType = MimeTypes.BASE_TYPE_AUDIO
var mediaMetadata = metadataBuilder.build()
var mediaItem = MediaItem.Builder().setUri(Uri.parse(track)).setMediaMetadata(mediaMetadata).setMimeType(mimeType).build()
mediaQueue.add(mediaItem)
}
return mediaQueue
}
}
@@ -1,39 +0,0 @@
package com.audiobookshelf.app
import com.getcapacitor.JSObject
class Book {
var title:String
var subtitle:String
var author:String
var authorFL:String
var narrator:String
var series:String
var volumeNumber:String
var publisher:String
var description:String
var publishYear:String
var language:String
var cover:String
var coverFullPath:String
var genres:String
var lastUpdate:Long
constructor(jsobj: JSObject) {
title = jsobj.getString("title", "").toString()
subtitle = jsobj.getString("subtitle", "").toString()
author = jsobj.getString("author", "").toString()
authorFL = jsobj.getString("authorFL", "").toString()
narrator = jsobj.getString("narrator", "").toString()
series = jsobj.getString("series", "").toString()
volumeNumber = jsobj.getString("volumeNumber", "").toString()
publisher = jsobj.getString("publisher", "").toString()
description = jsobj.getString("description", "").toString()
publishYear = jsobj.getString("publishYear", "").toString()
language = jsobj.getString("language", "").toString()
cover = jsobj.getString("cover", "").toString()
coverFullPath = jsobj.getString("coverFullPath", "").toString()
genres = jsobj.getString("genres", "").toString()
lastUpdate = jsobj.getLong("lastUpdate")
}
}
@@ -11,8 +11,10 @@ import com.google.android.gms.cast.framework.media.CastMediaOptions
class CastOptionsProvider : OptionsProvider {
override fun getCastOptions(context: Context): CastOptions {
Log.d("CastOptionsProvider", "getCastOptions")
var appId = "FD1F76C5"
// var defaultId =CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID
return CastOptions.Builder()
.setReceiverApplicationId(CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID).setCastMediaOptions(
.setReceiverApplicationId(appId).setCastMediaOptions(
CastMediaOptions.Builder()
// We manage the media session and the notifications ourselves.
.setMediaSessionEnabled(false)
@@ -1,120 +0,0 @@
package com.audiobookshelf.app
import android.Manifest
import android.content.ContentResolver
import android.content.ContentUris
import android.content.Context
import android.content.pm.PackageManager
import android.content.res.AssetFileDescriptor
import android.database.Cursor
import android.media.MediaPlayer
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import android.support.v4.media.MediaMetadataCompat
import android.util.Log
import androidx.annotation.AnyRes
import androidx.core.content.ContextCompat
import androidx.core.net.toFile
import androidx.core.net.toUri
import com.bumptech.glide.Glide
import java.io.File
import java.io.IOException
class LocalMediaManager {
private var ctx: Context
val tag = "LocalAudioManager"
constructor(ctx: Context) {
this.ctx = ctx
}
data class LocalAudio(val uri: Uri,
val id: String,
val name: String,
val duration: Int,
val size: Int,
val coverUri: Uri?
) {
fun toMediaMetadata(): MediaMetadataCompat {
return MediaMetadataCompat.Builder().apply {
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, name)
putString(MediaMetadataCompat.METADATA_KEY_TITLE, name)
if (coverUri != null) {
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, coverUri.toString())
}
}.build()
}
}
val localAudioFiles = mutableListOf<LocalAudio>()
/**
* get uri to drawable or any other resource type if u wish
* @param context - context
* @param drawableId - drawable res id
* @return - uri
*/
fun getUriToDrawable(context: Context,
@AnyRes drawableId: Int): Uri {
return Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE
+ "://" + context.resources.getResourcePackageName(drawableId)
+ '/' + context.resources.getResourceTypeName(drawableId)
+ '/' + context.resources.getResourceEntryName(drawableId))
}
fun loadLocalAudio() {
localAudioFiles.clear()
localAudioFiles += LocalAudio(Uri.parse("asset:///public/samples/Anthem/AnthemSample.m4b"), "anthem_sample", "Anthem", 60000, 10000, getUriToDrawable(ctx, R.drawable.exo_icon_localaudio))
localAudioFiles += LocalAudio(Uri.parse("asset:///public/samples/Legend of Sleepy Hollow/LegendOfSleepyHollowSample.m4b"), "sleepy_hollow", "Legend of Sleepy Hollow", 60000, 10000, getUriToDrawable(ctx, R.drawable.exo_icon_localaudio))
// TODO: No longer reading in local audio files - just use samples
// if (ContextCompat.checkSelfPermission(ctx, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
// Log.e(tag, "Permission not granted to read from external storage")
// return
// }
//
// val collection =
// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// MediaStore.Audio.Media.getContentUri(
// MediaStore.VOLUME_EXTERNAL
// )
// } else {
// MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
// }
//
// val proj = arrayOf(MediaStore.Audio.Media._ID, MediaStore.Audio.Media.DISPLAY_NAME, MediaStore.Audio.Media.DURATION, MediaStore.Audio.Media.SIZE)
// val audioCursor: Cursor? = ctx.contentResolver.query(collection, proj, null, null, null)
//
// audioCursor?.use { cursor ->
// // Cache column indices.
// val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media._ID)
// val nameColumn =
// cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DISPLAY_NAME)
// val durationColumn =
// cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DURATION)
// val sizeColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.SIZE)
//
// while (cursor.moveToNext()) {
// // Get values of columns for a given video.
// val id = cursor.getLong(idColumn)
// val name = cursor.getString(nameColumn)
// val duration = cursor.getInt(durationColumn)
// val size = cursor.getInt(sizeColumn)
//
// val contentUri: Uri = ContentUris.withAppendedId(
// MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
// id
// )
// Log.d(tag, "Found local audio file $name")
// localAudioFiles += LocalAudio(contentUri, id.toString(), name, duration, size, null)
// }
// }
//
// Log.d(tag, "${localAudioFiles.size} Local Audio Files found")
}
}
@@ -1,12 +1,21 @@
package com.audiobookshelf.app
import android.Manifest
import android.app.DownloadManager
import android.content.*
import android.content.pm.PackageManager
import android.os.*
import android.util.Log
import androidx.core.app.ActivityCompat
import com.anggrayudi.storage.SimpleStorage
import com.anggrayudi.storage.SimpleStorageHelper
import com.audiobookshelf.app.data.AbsDatabase
import com.audiobookshelf.app.player.PlayerNotificationService
import com.audiobookshelf.app.plugins.AbsAudioPlayer
import com.audiobookshelf.app.plugins.AbsDownloader
import com.audiobookshelf.app.plugins.AbsFileSystem
import com.getcapacitor.BridgeActivity
import io.paperdb.Paper
class MainActivity : BridgeActivity() {
@@ -17,47 +26,59 @@ class MainActivity : BridgeActivity() {
private lateinit var mConnection : ServiceConnection
lateinit var pluginCallback : () -> Unit
lateinit var downloaderCallback : (String, Long) -> Unit
val storageHelper = SimpleStorageHelper(this)
val storage = SimpleStorage(this)
val broadcastReceiver = object: BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
when (intent?.action) {
DownloadManager.ACTION_DOWNLOAD_COMPLETE -> {
var thisdlid = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, 0L)
downloaderCallback("complete", thisdlid)
}
DownloadManager.ACTION_NOTIFICATION_CLICKED -> {
var thisdlid = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, 0L)
downloaderCallback("clicked", thisdlid)
}
}
}
}
val REQUEST_PERMISSIONS = 1
var PERMISSIONS_ALL = arrayOf(
Manifest.permission.READ_EXTERNAL_STORAGE
)
public override fun onCreate(savedInstanceState: Bundle?) {
// TODO: Optimize using strict mode logs
// StrictMode.setThreadPolicy(StrictMode.ThreadPolicy.Builder()
// .detectDiskReads()
// .detectDiskWrites().detectAll()
// .detectNetwork() // or .detectAll() for all detectable problems
// .penaltyLog()
// .build())
// StrictMode.setVmPolicy(VmPolicy.Builder()
// .detectLeakedSqlLiteObjects()
// .detectLeakedClosableObjects()
// .penaltyLog()
// .build())
super.onCreate(savedInstanceState)
Log.d(tag, "onCreate")
registerPlugin(MyNativeAudio::class.java)
registerPlugin(AudioDownloader::class.java)
registerPlugin(StorageManager::class.java)
var filter = IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE).apply {
addAction(DownloadManager.ACTION_NOTIFICATION_CLICKED)
// Grant full storage access for testing
// var ss = SimpleStorage(this)
// ss.requestFullStorageAccess()
var permission = ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE)
if (permission != PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(this,
PERMISSIONS_ALL,
REQUEST_PERMISSIONS)
}
registerReceiver(broadcastReceiver, filter)
registerPlugin(AbsAudioPlayer::class.java)
registerPlugin(AbsDownloader::class.java)
registerPlugin(AbsFileSystem::class.java)
registerPlugin(AbsDatabase::class.java)
Paper.init(applicationContext)
}
override fun onDestroy() {
super.onDestroy()
unregisterReceiver(broadcastReceiver)
}
override fun onPostCreate(savedInstanceState: Bundle?) {
super.onPostCreate(savedInstanceState)
Log.d(tag, "onPostCreate MainActivity")
mConnection = object : ServiceConnection {
override fun onServiceDisconnected(name: ComponentName) {
@@ -68,36 +89,33 @@ class MainActivity : BridgeActivity() {
override fun onServiceConnected(name: ComponentName, service: IBinder) {
Log.d(tag, "Service Connected $name")
mBounded = true
val mLocalBinder = service as PlayerNotificationService.LocalBinder
foregroundService = mLocalBinder.getService()
// Let MyNativeAudio know foreground service is ready and setup event listener
// Let NativeAudio know foreground service is ready and setup event listener
if (pluginCallback != null) {
pluginCallback()
}
}
}
val startIntent = Intent(this, PlayerNotificationService::class.java)
bindService(startIntent, mConnection as ServiceConnection, Context.BIND_AUTO_CREATE);
Intent(this, PlayerNotificationService::class.java).also { intent ->
Log.d(tag, "Binding PlayerNotificationService")
bindService(intent, mConnection, Context.BIND_AUTO_CREATE)
}
}
fun stopMyService() {
if (mBounded) {
mConnection?.let { unbindService(it) };
mConnection.let { unbindService(it) };
mBounded = false;
}
val stopIntent = Intent(this, PlayerNotificationService::class.java)
stopService(stopIntent)
}
fun registerBroadcastReceiver(cb: (String, Long) -> Unit) {
downloaderCallback = cb
}
override fun onSaveInstanceState(outState: Bundle) {
storageHelper.onSaveInstanceState(outState)
super.onSaveInstanceState(outState)
@@ -1,251 +0,0 @@
package com.audiobookshelf.app
import android.content.Intent
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.core.content.ContextCompat
import com.capacitorjs.plugins.app.AppPlugin
import com.getcapacitor.*
import com.getcapacitor.annotation.CapacitorPlugin
import org.json.JSONObject
@CapacitorPlugin(name = "MyNativeAudio")
class MyNativeAudio : Plugin() {
private val tag = "MyNativeAudio"
lateinit var mainActivity:MainActivity
lateinit var playerNotificationService: PlayerNotificationService
override fun load() {
mainActivity = (activity as MainActivity)
var foregroundServiceReady : () -> Unit = {
playerNotificationService = mainActivity.foregroundService
playerNotificationService.setBridge(bridge)
playerNotificationService.setCustomObjectListener(object : PlayerNotificationService.MyCustomObjectListener {
override fun onPlayingUpdate(isPlaying: Boolean) {
emit("onPlayingUpdate", isPlaying)
}
override fun onMetadata(metadata: JSObject) {
notifyListeners("onMetadata", metadata)
}
override fun onPrepare(audiobookId: String, playWhenReady: Boolean) {
var jsobj = JSObject()
jsobj.put("audiobookId", audiobookId)
jsobj.put("playWhenReady", playWhenReady)
notifyListeners("onPrepareMedia", jsobj)
}
override fun onSleepTimerEnded(currentPosition: Long) {
emit("onSleepTimerEnded", currentPosition)
}
override fun onSleepTimerSet(sleepTimerEndTime: Long) {
emit("onSleepTimerSet", sleepTimerEndTime)
}
})
}
mainActivity.pluginCallback = foregroundServiceReady
}
fun emit(evtName: String, value: Any) {
var ret:JSObject = JSObject()
ret.put("value", value)
notifyListeners(evtName, ret)
}
@PluginMethod
fun initPlayer(call: PluginCall) {
if (!PlayerNotificationService.isStarted) {
Log.w(tag, "Starting foreground service --")
Intent(mainActivity, PlayerNotificationService::class.java).also { intent ->
ContextCompat.startForegroundService(mainActivity, intent)
}
}
var jsobj = JSObject()
var audiobookStreamData:AudiobookStreamData = AudiobookStreamData(call.data)
if (audiobookStreamData.playlistUrl == "" && audiobookStreamData.contentUrl == "") {
Log.e(tag, "Invalid URL for init audio player")
jsobj.put("success", false)
return call.resolve(jsobj)
}
Handler(Looper.getMainLooper()).post() {
playerNotificationService.initPlayer(audiobookStreamData)
jsobj.put("success", true)
call.resolve(jsobj)
}
}
@PluginMethod
fun getCurrentTime(call: PluginCall) {
Handler(Looper.getMainLooper()).post() {
var currentTime = playerNotificationService.getCurrentTime()
var bufferedTime = playerNotificationService.getBufferedTime()
val ret = JSObject()
ret.put("value", currentTime)
ret.put("bufferedTime", bufferedTime)
call.resolve(ret)
}
}
@PluginMethod
fun getStreamSyncData(call: PluginCall) {
Handler(Looper.getMainLooper()).post() {
var isPlaying = playerNotificationService.getPlayStatus()
var lastPauseTime = playerNotificationService.getTheLastPauseTime()
Log.d(tag, "Get Last Pause Time $lastPauseTime")
var currentTime = playerNotificationService.getCurrentTime()
//if (!isPlaying) currentTime -= playerNotificationService.calcPauseSeekBackTime()
var id = playerNotificationService.getCurrentAudiobookId()
Log.d(tag, "Get Current id $id")
var duration = playerNotificationService.getDuration()
Log.d(tag, "Get duration $duration")
val ret = JSObject()
ret.put("lastPauseTime", lastPauseTime)
ret.put("currentTime", currentTime)
ret.put("isPlaying", isPlaying)
ret.put("id", id)
ret.put("duration", duration)
call.resolve(ret)
}
}
@PluginMethod
fun pausePlayer(call: PluginCall) {
Handler(Looper.getMainLooper()).post() {
playerNotificationService.pause()
call.resolve()
}
}
@PluginMethod
fun playPlayer(call: PluginCall) {
Handler(Looper.getMainLooper()).post() {
playerNotificationService.play()
call.resolve()
}
}
@PluginMethod
fun seekPlayer(call: PluginCall) {
var time:Long = call.getString("timeMs", "0")!!.toLong()
Handler(Looper.getMainLooper()).post() {
playerNotificationService.seekPlayer(time)
call.resolve()
}
}
@PluginMethod
fun seekForward(call: PluginCall) {
var amount:Long = call.getString("amount", "0")!!.toLong()
Handler(Looper.getMainLooper()).post() {
playerNotificationService.seekForward(amount)
call.resolve()
}
}
@PluginMethod
fun seekBackward(call: PluginCall) {
var amount:Long = call.getString("amount", "0")!!.toLong()
Handler(Looper.getMainLooper()).post() {
playerNotificationService.seekBackward(amount)
call.resolve()
}
}
@PluginMethod
fun setPlaybackSpeed(call: PluginCall) {
var playbackSpeed:Float = call.getFloat("speed", 1.0f)!!
Handler(Looper.getMainLooper()).post() {
playerNotificationService.setPlaybackSpeed(playbackSpeed)
call.resolve()
}
}
@PluginMethod
fun terminateStream(call: PluginCall) {
Handler(Looper.getMainLooper()).post() {
playerNotificationService.terminateStream()
call.resolve()
}
}
@PluginMethod
fun setSleepTimer(call: PluginCall) {
var time:Long = call.getString("time", "360000")!!.toLong()
var isChapterTime:Boolean = call.getBoolean("isChapterTime", false) == true
Handler(Looper.getMainLooper()).post() {
var success:Boolean = playerNotificationService.sleepTimerManager.setSleepTimer(time, isChapterTime)
val ret = JSObject()
ret.put("success", success)
call.resolve(ret)
}
}
@PluginMethod
fun getSleepTimerTime(call: PluginCall) {
var time = playerNotificationService.sleepTimerManager.getSleepTimerTime()
val ret = JSObject()
ret.put("value", time)
call.resolve(ret)
}
@PluginMethod
fun increaseSleepTime(call: PluginCall) {
var time:Long = call.getString("time", "300000")!!.toLong()
Handler(Looper.getMainLooper()).post() {
playerNotificationService.sleepTimerManager.increaseSleepTime(time)
val ret = JSObject()
ret.put("success", true)
call.resolve()
}
}
@PluginMethod
fun decreaseSleepTime(call: PluginCall) {
var time:Long = call.getString("time", "300000")!!.toLong()
Handler(Looper.getMainLooper()).post() {
playerNotificationService.sleepTimerManager.decreaseSleepTime(time)
val ret = JSObject()
ret.put("success", true)
call.resolve()
}
}
@PluginMethod
fun cancelSleepTimer(call: PluginCall) {
playerNotificationService.sleepTimerManager.cancelSleepTimer()
call.resolve()
}
@PluginMethod
fun requestSession(call: PluginCall) {
Log.d(tag, "CAST REQUEST SESSION PLUGIN")
playerNotificationService.castManager.requestSession(mainActivity, object : CastManager.RequestSessionCallback() {
override fun onError(errorCode: Int) {
Log.e(tag, "CAST REQUEST SESSION CALLBACK ERROR $errorCode")
}
override fun onCancel() {
Log.d(tag, "CAST REQUEST SESSION ON CANCEL")
}
override fun onJoin(jsonSession: JSONObject?) {
Log.d(tag, "CAST REQUEST SESSION ON JOIN")
}
})
}
}
File diff suppressed because it is too large Load Diff
@@ -1,264 +0,0 @@
package com.audiobookshelf.app
import android.database.Cursor
import android.net.Uri
import android.os.Build
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.documentfile.provider.DocumentFile
import com.anggrayudi.storage.SimpleStorage
import com.anggrayudi.storage.callback.FolderPickerCallback
import com.anggrayudi.storage.callback.StorageAccessCallback
import com.anggrayudi.storage.file.*
import com.getcapacitor.*
import com.getcapacitor.annotation.CapacitorPlugin
@CapacitorPlugin(name = "StorageManager")
class StorageManager : Plugin() {
private val TAG = "StorageManager"
lateinit var mainActivity:MainActivity
data class MediaFile(val uri: Uri, val name: String, val simplePath: String, val size: Long, val type: String, val isAudio: Boolean) {
fun toJSObject() : JSObject {
var obj = JSObject()
obj.put("uri", this.uri)
obj.put("name", this.name)
obj.put("simplePath", this.simplePath)
obj.put("size", this.size)
obj.put("type", this.type)
obj.put("isAudio", this.isAudio)
return obj
}
}
data class MediaFolder(val uri: Uri, val name: String, val simplePath: String, val mediaFiles:List<MediaFile>) {
fun toJSObject() : JSObject {
var obj = JSObject()
obj.put("uri", this.uri)
obj.put("name", this.name)
obj.put("simplePath", this.simplePath)
obj.put("files", this.mediaFiles.map { it.toJSObject() })
return obj
}
}
override fun load() {
mainActivity = (activity as MainActivity)
mainActivity.storage.storageAccessCallback = object : StorageAccessCallback {
override fun onRootPathNotSelected(
requestCode: Int,
rootPath: String,
uri: Uri,
selectedStorageType: StorageType,
expectedStorageType: StorageType
) {
Log.d(TAG, "STORAGE ACCESS CALLBACK")
}
override fun onCanceledByUser(requestCode: Int) {
Log.d(TAG, "STORAGE ACCESS CALLBACK")
}
override fun onExpectedStorageNotSelected(requestCode: Int, selectedFolder: DocumentFile, selectedStorageType: StorageType, expectedBasePath: String, expectedStorageType: StorageType) {
Log.d(TAG, "STORAGE ACCESS CALLBACK")
}
override fun onStoragePermissionDenied(requestCode: Int) {
Log.d(TAG, "STORAGE ACCESS CALLBACK")
}
override fun onRootPathPermissionGranted(requestCode: Int, root: DocumentFile) {
Log.d(TAG, "STORAGE ACCESS CALLBACK")
}
}
}
@PluginMethod
fun selectFolder(call: PluginCall) {
mainActivity.storage.folderPickerCallback = object : FolderPickerCallback {
override fun onFolderSelected(requestCode: Int, folder: DocumentFile) {
Log.d(TAG, "ON FOLDER SELECTED ${folder.uri} ${folder.name}")
var absolutePath = folder.getAbsolutePath(activity)
var storageId = folder.getStorageId(activity)
var storageType = folder.getStorageType(activity)
var simplePath = folder.getSimplePath(activity)
var basePath = folder.getBasePath(activity)
var jsobj = JSObject()
jsobj.put("uri", folder.uri)
jsobj.put("absolutePath", absolutePath)
jsobj.put("storageId", storageId)
jsobj.put("storageType", storageType)
jsobj.put("simplePath", simplePath)
jsobj.put("basePath", basePath)
call.resolve(jsobj)
}
override fun onStorageAccessDenied(requestCode: Int, folder: DocumentFile?, storageType: StorageType) {
Log.e(TAG, "STORAGE ACCESS DENIED")
var jsobj = JSObject()
jsobj.put("error", "Access Denied")
call.resolve(jsobj)
}
override fun onStoragePermissionDenied(requestCode: Int) {
Log.d(TAG, "STORAGE PERMISSION DENIED $requestCode")
var jsobj = JSObject()
jsobj.put("error", "Permission Denied")
call.resolve(jsobj)
}
}
mainActivity.storage.openFolderPicker(6)
}
@RequiresApi(Build.VERSION_CODES.R)
@PluginMethod
fun requestStoragePermission(call: PluginCall) {
Log.d(TAG, "Request Storage Permissions")
mainActivity.storageHelper.requestStorageAccess()
call.resolve()
}
@PluginMethod
fun checkStoragePermission(call: PluginCall) {
var res = false
if (Build.VERSION.SDK_INT <= android.os.Build.VERSION_CODES.P) {
res = SimpleStorage.hasStoragePermission(context)
Log.d(TAG, "Check Storage Access $res")
} else {
Log.d(TAG, "Has permission on Android 10 or up")
res = true
}
var jsobj = JSObject()
jsobj.put("value", res)
call.resolve(jsobj)
}
@PluginMethod
fun checkFolderPermissions(call: PluginCall) {
var folderUrl = call.data.getString("folderUrl", "").toString()
Log.d(TAG, "Check Folder Permissions for $folderUrl")
var hasAccess = SimpleStorage.hasStorageAccess(context,folderUrl,true)
var jsobj = JSObject()
jsobj.put("value", hasAccess)
call.resolve(jsobj)
}
@PluginMethod
fun searchFolder(call: PluginCall) {
var folderUrl = call.data.getString("folderUrl", "").toString()
Log.d(TAG, "Searching folder $folderUrl")
var df: DocumentFile? = DocumentFileCompat.fromUri(context, Uri.parse(folderUrl))
if (df == null) {
Log.e(TAG, "Folder Doc File Invalid $folderUrl")
var jsobj = JSObject()
jsobj.put("folders", JSArray())
jsobj.put("files", JSArray())
call.resolve(jsobj)
return
}
Log.d(TAG, "Folder as DF ${df.isDirectory} | ${df.getSimplePath(context)} | ${df.getBasePath(context)} | ${df.name}")
var mediaFolders = mutableListOf<MediaFolder>()
var foldersFound = df.search(false, DocumentFileType.FOLDER)
foldersFound.forEach {
Log.d(TAG, "Iterating over Folder Found ${it.name} | ${it.getSimplePath(context)} | URI: ${it.uri}")
var folderName = it.name ?: ""
var mediaFiles = mutableListOf<MediaFile>()
var filesInFolder = it.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*"))
filesInFolder.forEach { it2 ->
var mimeType = it2?.mimeType ?: ""
var filename = it2?.name ?: ""
var isAudio = mimeType.startsWith("audio")
Log.d(TAG, "Found $mimeType file $filename in folder $folderName")
var imageFile = MediaFile(it2.uri, filename, it2.getSimplePath(context), it2.length(), mimeType, isAudio)
mediaFiles.add(imageFile)
}
if (mediaFiles.size > 0) {
mediaFolders.add(MediaFolder(it.uri, folderName, it.getSimplePath(context), mediaFiles))
}
}
// Files in root dir
var rootMediaFiles = mutableListOf<MediaFile>()
var mediaFilesFound:List<DocumentFile> = df.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*"))
mediaFilesFound.forEach {
Log.d(TAG, "Folder Root File Found ${it.name} | ${it.getSimplePath(context)} | URI: ${it.uri} | ${it.mimeType}")
var mimeType = it?.mimeType ?: ""
var filename = it?.name ?: ""
var isAudio = mimeType.startsWith("audio")
Log.d(TAG, "Found $mimeType file $filename in root folder")
var imageFile = MediaFile(it.uri, filename, it.getSimplePath(context), it.length(), mimeType, isAudio)
rootMediaFiles.add(imageFile)
}
var jsobj = JSObject()
jsobj.put("folders", mediaFolders.map{ it.toJSObject() })
jsobj.put("files", rootMediaFiles.map{ it.toJSObject() })
call.resolve(jsobj)
}
@PluginMethod
fun delete(call: PluginCall) {
var url = call.data.getString("url", "").toString()
var coverUrl = call.data.getString("coverUrl", "").toString()
var folderUrl = call.data.getString("folderUrl", "").toString()
if (folderUrl != "") {
Log.d(TAG, "CALLED DELETE FOLDER: $folderUrl")
var folder = DocumentFileCompat.fromUri(context, Uri.parse(folderUrl))
var success = folder?.deleteRecursively(context)
var jsobj = JSObject()
jsobj.put("success", success)
call.resolve()
} else {
// Older audiobooks did not store a folder url, use cover and audiobook url
var abExists = checkUriExists(Uri.parse(url))
if (abExists) {
var abfile = DocumentFileCompat.fromUri(context, Uri.parse(url))
abfile?.delete()
}
var coverExists = checkUriExists(Uri.parse(coverUrl))
if (coverExists) {
var coverfile = DocumentFileCompat.fromUri(context, Uri.parse(coverUrl))
coverfile?.delete()
}
}
}
fun checkUriExists(uri: Uri?): Boolean {
if (uri == null) return false
val resolver = context.contentResolver
var cursor: Cursor? = null
return try {
cursor = resolver.query(uri, null, null, null, null)
//cursor null: content Uri was invalid or some other error occurred
//cursor.moveToFirst() false: Uri was ok but no entry found.
(cursor != null && cursor.moveToFirst())
} catch (t: Throwable) {
false
} finally {
try {
cursor?.close()
} catch (t: Throwable) {
}
false
}
}
}
@@ -0,0 +1,74 @@
package com.audiobookshelf.app.data
import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
@JsonIgnoreProperties(ignoreUnknown = true)
data class AudioProbeStream(
val index:Int,
val codec_name:String,
val codec_long_name:String,
val channels:Int,
val channel_layout:String,
val duration:Double,
val bit_rate:Double
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class AudioProbeChapterTags(
val title:String
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class AudioProbeChapter(
val id:Int,
val start:Long,
val end:Long,
val tags:AudioProbeChapterTags?
) {
@JsonIgnore
fun getBookChapter():BookChapter {
val startS = start / 1000.0
val endS = end / 1000.0
val title = tags?.title ?: "Chapter $id"
return BookChapter(id, startS, endS, title)
}
}
@JsonIgnoreProperties(ignoreUnknown = true)
data class AudioProbeFormatTags(
val artist:String?,
val album:String?,
val comment:String?,
val date:String?,
val genre:String?,
val title:String?
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class AudioProbeFormat(
val filename:String,
val format_name:String,
val duration:Double,
val size:Long,
val bit_rate:Double,
val tags:AudioProbeFormatTags?
)
@JsonIgnoreProperties(ignoreUnknown = true)
class AudioProbeResult (
val streams:MutableList<AudioProbeStream>,
val chapters:MutableList<AudioProbeChapter>,
val format:AudioProbeFormat) {
val duration get() = format.duration
val size get() = format.size
val title get() = format.tags?.title ?: format.filename.split("/").last()
val artist get() = format.tags?.artist ?: ""
@JsonIgnore
fun getBookChapters(): List<BookChapter> {
if (chapters.isEmpty()) return mutableListOf()
return chapters.map { it.getBookChapter() }
}
}
@@ -0,0 +1,373 @@
package com.audiobookshelf.app.data
import android.net.Uri
import android.support.v4.media.MediaMetadataCompat
import com.audiobookshelf.app.R
import com.audiobookshelf.app.device.DeviceManager
import com.fasterxml.jackson.annotation.*
@JsonIgnoreProperties(ignoreUnknown = true)
data class LibraryItem(
var id:String,
var ino:String,
var libraryId:String,
var folderId:String,
var path:String,
var relPath:String,
var mtimeMs:Long,
var ctimeMs:Long,
var birthtimeMs:Long,
var addedAt:Long,
var updatedAt:Long,
var lastScan:Long?,
var scanVersion:String?,
var isMissing:Boolean,
var isInvalid:Boolean,
var mediaType:String,
var media:MediaType,
var libraryFiles:MutableList<LibraryFile>?,
var userMediaProgress:MediaProgress? // Only included when requesting library item with progress (for downloads)
) : LibraryItemWrapper() {
@get:JsonIgnore
val title get() = media.metadata.title
@get:JsonIgnore
val authorName get() = media.metadata.getAuthorDisplayName()
@JsonIgnore
fun getCoverUri():Uri {
if (media.coverPath == null) {
return Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon)
}
return Uri.parse("${DeviceManager.serverAddress}/api/items/$id/cover?token=${DeviceManager.token}")
}
@JsonIgnore
fun getMediaMetadata(): MediaMetadataCompat {
return MediaMetadataCompat.Builder().apply {
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title)
putString(MediaMetadataCompat.METADATA_KEY_TITLE, title)
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, authorName)
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, getCoverUri().toString())
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getCoverUri().toString())
putString(MediaMetadataCompat.METADATA_KEY_ART_URI, getCoverUri().toString())
putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, authorName)
}.build()
}
}
// This auto-detects whether it is a Book or Podcast
@JsonTypeInfo(use=JsonTypeInfo.Id.DEDUCTION)
@JsonSubTypes(
JsonSubTypes.Type(Book::class),
JsonSubTypes.Type(Podcast::class)
)
open class MediaType(var metadata:MediaTypeMetadata, var coverPath:String?) {
@JsonIgnore
open fun getAudioTracks():List<AudioTrack> { return mutableListOf() }
@JsonIgnore
open fun setAudioTracks(audioTracks:MutableList<AudioTrack>) { }
@JsonIgnore
open fun addAudioTrack(audioTrack:AudioTrack) { }
@JsonIgnore
open fun removeAudioTrack(localFileId:String) { }
@JsonIgnore
open fun getLocalCopy():MediaType { return MediaType(MediaTypeMetadata(""),null) }
}
@JsonIgnoreProperties(ignoreUnknown = true)
class Podcast(
metadata:PodcastMetadata,
coverPath:String?,
var tags:MutableList<String>,
var episodes:MutableList<PodcastEpisode>?,
var autoDownloadEpisodes:Boolean
) : MediaType(metadata, coverPath) {
@JsonIgnore
override fun getAudioTracks():List<AudioTrack> {
val tracks = episodes?.map { it.audioTrack }
return tracks?.filterNotNull() ?: mutableListOf()
}
@JsonIgnore
override fun setAudioTracks(audioTracks:MutableList<AudioTrack>) {
// Remove episodes no longer there in tracks
episodes = episodes?.filter { ep ->
audioTracks.find { it.localFileId == ep.audioTrack?.localFileId } != null
} as MutableList<PodcastEpisode>
// Add new episodes
audioTracks.forEach { at ->
if (episodes?.find{ it.audioTrack?.localFileId == at.localFileId } == null) {
val newEpisode = PodcastEpisode("local_" + at.localFileId,episodes?.size ?: 0 + 1,null,null,at.title,null,null,null,at,at.duration,0, null)
episodes?.add(newEpisode)
}
}
var index = 1
episodes?.forEach {
it.index = index
index++
}
}
@JsonIgnore
override fun addAudioTrack(audioTrack:AudioTrack) {
val newEpisode = PodcastEpisode("local_" + audioTrack.localFileId,episodes?.size ?: 0 + 1,null,null,audioTrack.title,null,null,null,audioTrack,audioTrack.duration,0, null)
episodes?.add(newEpisode)
var index = 1
episodes?.forEach {
it.index = index
index++
}
}
@JsonIgnore
override fun removeAudioTrack(localFileId:String) {
episodes?.removeIf { it.audioTrack?.localFileId == localFileId }
var index = 1
episodes?.forEach {
it.index = index
index++
}
}
@JsonIgnore
fun addEpisode(audioTrack:AudioTrack, episode:PodcastEpisode):PodcastEpisode {
val newEpisode = PodcastEpisode("local_" + episode.id,episodes?.size ?: 0 + 1,episode.episode,episode.episodeType,episode.title,episode.subtitle,episode.description,null,audioTrack,audioTrack.duration,0, episode.id)
episodes?.add(newEpisode)
var index = 1
episodes?.forEach {
it.index = index
index++
}
return newEpisode
}
// Used for FolderScanner local podcast item to get copy of Podcast excluding episodes
@JsonIgnore
override fun getLocalCopy(): Podcast {
return Podcast(metadata as PodcastMetadata,coverPath,tags, mutableListOf(),autoDownloadEpisodes)
}
}
@JsonIgnoreProperties(ignoreUnknown = true)
class Book(
metadata:BookMetadata,
coverPath:String?,
var tags:List<String>,
var audioFiles:List<AudioFile>?,
var chapters:List<BookChapter>?,
var tracks:MutableList<AudioTrack>?,
var size:Long?,
var duration:Double?
) : MediaType(metadata, coverPath) {
@JsonIgnore
override fun getAudioTracks():List<AudioTrack> {
return tracks ?: mutableListOf()
}
@JsonIgnore
override fun setAudioTracks(audioTracks:MutableList<AudioTrack>) {
tracks = audioTracks
tracks?.sortBy { it.index }
// TODO: Is it necessary to calculate this each time? check if can remove safely
var totalDuration = 0.0
tracks?.forEach {
totalDuration += it.duration
}
duration = totalDuration
}
@JsonIgnore
override fun addAudioTrack(audioTrack:AudioTrack) {
tracks?.add(audioTrack)
var totalDuration = 0.0
tracks?.forEach {
totalDuration += it.duration
}
duration = totalDuration
}
@JsonIgnore
override fun removeAudioTrack(localFileId:String) {
tracks?.removeIf { it.localFileId == localFileId }
tracks?.sortBy { it.index }
var index = 1
var startOffset = 0.0
var totalDuration = 0.0
tracks?.forEach {
it.index = index
it.startOffset = startOffset
totalDuration += it.duration
index++
startOffset += it.duration
}
duration = totalDuration
}
@JsonIgnore
override fun getLocalCopy(): Book {
return Book(metadata as BookMetadata,coverPath,tags, mutableListOf(),chapters,mutableListOf(),null,null)
}
}
// This auto-detects whether it is a BookMetadata or PodcastMetadata
@JsonTypeInfo(use=JsonTypeInfo.Id.DEDUCTION)
@JsonSubTypes(
JsonSubTypes.Type(BookMetadata::class),
JsonSubTypes.Type(PodcastMetadata::class)
)
open class MediaTypeMetadata(var title:String) {
@JsonIgnore
open fun getAuthorDisplayName():String { return "Unknown" }
}
@JsonIgnoreProperties(ignoreUnknown = true)
class BookMetadata(
title:String,
var subtitle:String?,
var authors:MutableList<Author>?,
var narrators:MutableList<String>?,
var genres:MutableList<String>,
var publishedYear:String?,
var publishedDate:String?,
var publisher:String?,
var description:String?,
var isbn:String?,
var asin:String?,
var language:String?,
var explicit:Boolean,
// In toJSONExpanded
var authorName:String?,
var authorNameLF:String?,
var narratorName:String?,
var seriesName:String?
) : MediaTypeMetadata(title) {
@JsonIgnore
override fun getAuthorDisplayName():String { return authorName ?: "Unknown" }
}
@JsonIgnoreProperties(ignoreUnknown = true)
class PodcastMetadata(
title:String,
var author:String?,
var feedUrl:String?,
var genres:MutableList<String>
) : MediaTypeMetadata(title) {
@JsonIgnore
override fun getAuthorDisplayName():String { return author ?: "Unknown" }
}
@JsonIgnoreProperties(ignoreUnknown = true)
data class Author(
var id:String,
var name:String,
var coverPath:String?
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class PodcastEpisode(
var id:String,
var index:Int,
var episode:String?,
var episodeType:String?,
var title:String?,
var subtitle:String?,
var description:String?,
var audioFile:AudioFile?,
var audioTrack:AudioTrack?,
var duration:Double?,
var size:Long?,
var serverEpisodeId:String? // For local podcasts to match with server podcasts
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class LibraryFile(
var ino:String,
var metadata:FileMetadata
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class FileMetadata(
var filename:String,
var ext:String,
var path:String,
var relPath:String,
var size:Long?
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class AudioFile(
var index:Int,
var ino:String,
var metadata:FileMetadata
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class Library(
var id:String,
var name:String,
var folders:MutableList<Folder>,
var icon:String,
var mediaType:String
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class Folder(
var id:String,
var fullPath:String
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class AudioTrack(
var index:Int,
var startOffset:Double,
var duration:Double,
var title:String,
var contentUrl:String,
var mimeType:String,
var metadata:FileMetadata?,
var isLocal:Boolean,
var localFileId:String?,
var audioProbeResult:AudioProbeResult?,
var serverIndex:Int? // Need to know if server track index is different
) {
@get:JsonIgnore
val startOffsetMs get() = (startOffset * 1000L).toLong()
@get:JsonIgnore
val durationMs get() = (duration * 1000L).toLong()
@get:JsonIgnore
val endOffsetMs get() = startOffsetMs + durationMs
@get:JsonIgnore
val relPath get() = metadata?.relPath ?: ""
@JsonIgnore
fun getBookChapter():BookChapter {
return BookChapter(index + 1,startOffset, startOffset + duration, title)
}
}
@JsonIgnoreProperties(ignoreUnknown = true)
data class BookChapter(
var id:Int,
var start:Double,
var end:Double,
var title:String?
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class MediaProgress(
var id:String,
var libraryItemId:String,
var episodeId:String?,
var duration:Double, // seconds
var progress:Double, // 0 to 1
var currentTime:Double,
var isFinished:Boolean,
var lastUpdate:Long,
var startedAt:Long,
var finishedAt:Long?
)
@@ -0,0 +1,215 @@
package com.audiobookshelf.app.data
import android.util.Log
import com.audiobookshelf.app.plugins.AbsDownloader
import io.paperdb.Paper
import org.json.JSONObject
import java.io.File
class DbManager {
val tag = "DbManager"
fun getDeviceData(): DeviceData {
return Paper.book("device").read("data") ?: DeviceData(mutableListOf(), null, null)
}
fun saveDeviceData(deviceData:DeviceData) {
Paper.book("device").write("data", deviceData)
}
fun getLocalLibraryItems(mediaType:String? = null):MutableList<LocalLibraryItem> {
val localLibraryItems:MutableList<LocalLibraryItem> = mutableListOf()
Paper.book("localLibraryItems").allKeys.forEach {
val localLibraryItem:LocalLibraryItem? = Paper.book("localLibraryItems").read(it)
if (localLibraryItem != null && (mediaType.isNullOrEmpty() || mediaType == localLibraryItem.mediaType)) {
localLibraryItems.add(localLibraryItem)
}
}
return localLibraryItems
}
fun getLocalLibraryItemsInFolder(folderId:String):List<LocalLibraryItem> {
val localLibraryItems = getLocalLibraryItems()
return localLibraryItems.filter {
it.folderId == folderId
}
}
fun getLocalLibraryItemByLLId(libraryItemId:String):LocalLibraryItem? {
return getLocalLibraryItems().find { it.libraryItemId == libraryItemId }
}
fun getLocalLibraryItem(localLibraryItemId:String):LocalLibraryItem? {
return Paper.book("localLibraryItems").read(localLibraryItemId)
}
fun removeLocalLibraryItem(localLibraryItemId:String) {
Paper.book("localLibraryItems").delete(localLibraryItemId)
}
fun saveLocalLibraryItems(localLibraryItems:List<LocalLibraryItem>) {
localLibraryItems.map {
Paper.book("localLibraryItems").write(it.id, it)
}
}
fun saveLocalLibraryItem(localLibraryItem:LocalLibraryItem) {
Paper.book("localLibraryItems").write(localLibraryItem.id, localLibraryItem)
}
fun saveLocalFolder(localFolder:LocalFolder) {
Paper.book("localFolders").write(localFolder.id,localFolder)
}
fun getLocalFolder(folderId:String):LocalFolder? {
return Paper.book("localFolders").read(folderId)
}
fun getAllLocalFolders():List<LocalFolder> {
val localFolders:MutableList<LocalFolder> = mutableListOf()
Paper.book("localFolders").allKeys.forEach { localFolderId ->
Paper.book("localFolders").read<LocalFolder>(localFolderId)?.let {
localFolders.add(it)
}
}
return localFolders
}
fun removeLocalFolder(folderId:String) {
val localLibraryItems = getLocalLibraryItemsInFolder(folderId)
localLibraryItems.forEach {
Paper.book("localLibraryItems").delete(it.id)
}
Paper.book("localFolders").delete(folderId)
}
fun saveDownloadItem(downloadItem: AbsDownloader.DownloadItem) {
Paper.book("downloadItems").write(downloadItem.id, downloadItem)
}
fun removeDownloadItem(downloadItemId:String) {
Paper.book("downloadItems").delete(downloadItemId)
}
fun getDownloadItems():List<AbsDownloader.DownloadItem> {
val downloadItems:MutableList<AbsDownloader.DownloadItem> = mutableListOf()
Paper.book("downloadItems").allKeys.forEach { downloadItemId ->
Paper.book("downloadItems").read<AbsDownloader.DownloadItem>(downloadItemId)?.let {
downloadItems.add(it)
}
}
return downloadItems
}
fun saveLocalMediaProgress(mediaProgress:LocalMediaProgress) {
Paper.book("localMediaProgress").write(mediaProgress.id,mediaProgress)
}
// For books this will just be the localLibraryItemId for podcast episodes this will be "{localLibraryItemId}-{episodeId}"
fun getLocalMediaProgress(localMediaProgressId:String):LocalMediaProgress? {
return Paper.book("localMediaProgress").read(localMediaProgressId)
}
fun getAllLocalMediaProgress():List<LocalMediaProgress> {
val mediaProgress:MutableList<LocalMediaProgress> = mutableListOf()
Paper.book("localMediaProgress").allKeys.forEach { localMediaProgressId ->
Paper.book("localMediaProgress").read<LocalMediaProgress>(localMediaProgressId)?.let {
mediaProgress.add(it)
}
}
return mediaProgress
}
fun removeLocalMediaProgress(localMediaProgressId:String) {
Paper.book("localMediaProgress").delete(localMediaProgressId)
}
fun removeAllLocalMediaProgress() {
Paper.book("localMediaProgress").destroy()
}
// Make sure all local file ids still exist
fun cleanLocalLibraryItems() {
val localLibraryItems = getLocalLibraryItems()
localLibraryItems.forEach { lli ->
var hasUpates = false
// Check local files
lli.localFiles = lli.localFiles.filter { localFile ->
val file = File(localFile.absolutePath)
if (!file.exists()) {
Log.d(tag, "cleanLocalLibraryItems: Local file ${localFile.absolutePath} was removed from library item ${lli.media.metadata.title}")
hasUpates = true
}
file.exists()
} as MutableList<LocalFile>
// Check audio tracks and episodes
if (lli.isPodcast) {
val podcast = lli.media as Podcast
podcast.episodes = podcast.episodes?.filter { ep ->
if (lli.localFiles.find { lf -> lf.id == ep.audioTrack?.localFileId } == null) {
Log.d(tag, "cleanLocalLibraryItems: Podcast episode ${ep.title} was removed from library item ${lli.media.metadata.title}")
hasUpates = true
}
ep.audioTrack != null && lli.localFiles.find { lf -> lf.id == ep.audioTrack?.localFileId } != null
} as MutableList<PodcastEpisode>
} else {
val book = lli.media as Book
book.tracks = book.tracks?.filter { track ->
if (lli.localFiles.find { lf -> lf.id == track.localFileId } == null) {
Log.d(tag, "cleanLocalLibraryItems: Audio track ${track.title} was removed from library item ${lli.media.metadata.title}")
hasUpates = true
}
lli.localFiles.find { lf -> lf.id == track.localFileId } != null
} as MutableList<AudioTrack>
}
// Check cover still there
lli.coverAbsolutePath?.let {
val coverFile = File(it)
if (!coverFile.exists()) {
Log.d(tag, "cleanLocalLibraryItems: Cover $it was removed from library item ${lli.media.metadata.title}")
lli.coverAbsolutePath = null
lli.coverContentUrl = null
hasUpates = true
}
}
if (hasUpates) {
Log.d(tag, "cleanLocalLibraryItems: Saving local library item ${lli.id}")
Paper.book("localLibraryItems").write(lli.id, lli)
}
}
}
// Remove any local media progress where the local media item is not found
fun cleanLocalMediaProgress() {
val localMediaProgress = getAllLocalMediaProgress()
val localLibraryItems = getLocalLibraryItems()
localMediaProgress.forEach {
val matchingLLI = localLibraryItems.find { lli -> lli.id == it.localLibraryItemId }
if (matchingLLI == null) {
Log.d(tag, "cleanLocalMediaProgress: No matching local library item for local media progress ${it.id} - removing")
Paper.book("localMediaProgress").delete(it.id)
} else if (matchingLLI.isPodcast) {
if (it.localEpisodeId.isNullOrEmpty()) {
Log.d(tag, "cleanLocalMediaProgress: Podcast media progress has no episode id - removing")
Paper.book("localMediaProgress").delete(it.id)
} else {
val podcast = matchingLLI.media as Podcast
val matchingLEp = podcast.episodes?.find { ep -> ep.id == it.localEpisodeId }
if (matchingLEp == null) {
Log.d(tag, "cleanLocalMediaProgress: Podcast media progress for episode ${it.localEpisodeId} not found - removing")
Paper.book("localMediaProgress").delete(it.id)
}
}
}
}
}
fun saveLocalPlaybackSession(playbackSession:PlaybackSession) {
Paper.book("localPlaybackSession").write(playbackSession.id,playbackSession)
}
fun getLocalPlaybackSession(playbackSessionId:String):PlaybackSession? {
return Paper.book("localPlaybackSession").read(playbackSessionId)
}
}
@@ -0,0 +1,66 @@
package com.audiobookshelf.app.data
import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonSubTypes
import com.fasterxml.jackson.annotation.JsonTypeInfo
import java.util.*
data class ServerConnectionConfig(
var id:String,
var index:Int,
var name:String,
var address:String,
var userId:String,
var username:String,
var token:String
)
data class DeviceData(
var serverConnectionConfigs:MutableList<ServerConnectionConfig>,
var lastServerConnectionConfigId:String?,
var currentLocalPlaybackSession:PlaybackSession? // Stored to open up where left off for local media
) {
@JsonIgnore
fun getLastServerConnectionConfig():ServerConnectionConfig? {
return lastServerConnectionConfigId?.let { lsccid ->
return serverConnectionConfigs.find { it.id == lsccid }
}
}
}
@JsonIgnoreProperties(ignoreUnknown = true)
data class LocalFile(
var id:String,
var filename:String?,
var contentUrl:String,
var basePath:String,
var absolutePath:String,
var simplePath:String,
var mimeType:String?,
var size:Long
) {
@JsonIgnore
fun isAudioFile():Boolean {
return mimeType?.startsWith("audio") == true
}
}
@JsonIgnoreProperties(ignoreUnknown = true)
data class LocalFolder(
var id:String,
var name:String,
var contentUrl:String,
var basePath:String,
var absolutePath:String,
var simplePath:String,
var storageType:String,
var mediaType:String
)
@JsonTypeInfo(use= JsonTypeInfo.Id.DEDUCTION)
@JsonSubTypes(
JsonSubTypes.Type(LibraryItem::class),
JsonSubTypes.Type(LocalLibraryItem::class)
)
open class LibraryItemWrapper()
@@ -0,0 +1,15 @@
package com.audiobookshelf.app.data
data class FolderScanResult(
var itemsAdded:Int,
var itemsUpdated:Int,
var itemsRemoved:Int,
var itemsUpToDate:Int,
val localFolder:LocalFolder,
val localLibraryItems:List<LocalLibraryItem>,
)
data class LocalLibraryItemScanResult(
val updated:Boolean,
val localLibraryItem:LocalLibraryItem,
)
@@ -0,0 +1,12 @@
package com.audiobookshelf.app.data
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
@JsonIgnoreProperties(ignoreUnknown = true)
data class LibraryCategory(
var id:String,
var label:String,
var type:String,
var entities:List<LibraryItemWrapper>,
var isLocal:Boolean
)
@@ -0,0 +1,109 @@
package com.audiobookshelf.app.data
import android.net.Uri
import android.support.v4.media.MediaMetadataCompat
import android.util.Log
import com.audiobookshelf.app.R
import com.audiobookshelf.app.device.DeviceManager
import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import java.util.*
@JsonIgnoreProperties(ignoreUnknown = true)
data class LocalLibraryItem(
var id:String,
var folderId:String,
var basePath:String,
var absolutePath:String,
var contentUrl:String,
var isInvalid:Boolean,
var mediaType:String,
var media:MediaType,
var localFiles:MutableList<LocalFile>,
var coverContentUrl:String?,
var coverAbsolutePath:String?,
var isLocal:Boolean,
// If local library item is linked to a server item
var serverConnectionConfigId:String?,
var serverAddress:String?,
var serverUserId:String?,
var libraryItemId:String?
) : LibraryItemWrapper() {
@get:JsonIgnore
val title get() = media.metadata.title
@get:JsonIgnore
val authorName get() = media.metadata.getAuthorDisplayName()
@get:JsonIgnore
val isPodcast get() = mediaType == "podcast"
@JsonIgnore
fun getCoverUri(): Uri {
return if (coverContentUrl != null) Uri.parse(coverContentUrl) else Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon)
}
@JsonIgnore
fun getDuration():Double {
var total = 0.0
var audioTracks = media.getAudioTracks()
audioTracks.forEach{ total += it.duration }
return total
}
@JsonIgnore
fun updateFromScan(audioTracks:MutableList<AudioTrack>, _localFiles:MutableList<LocalFile>) {
localFiles = _localFiles
media.setAudioTracks(audioTracks)
if (coverContentUrl != null) {
if (localFiles.find { it.contentUrl == coverContentUrl } == null) {
// Cover was removed
coverContentUrl = null
coverAbsolutePath = null
media.coverPath = null
}
}
}
@JsonIgnore
fun getPlaybackSession(episode:PodcastEpisode?):PlaybackSession {
val localEpisodeId = episode?.id
val sessionId = "play_local_${UUID.randomUUID()}"
// Get current progress for local media
val mediaProgressId = if (localEpisodeId.isNullOrEmpty()) id else "$id-$localEpisodeId"
val mediaProgress = DeviceManager.dbManager.getLocalMediaProgress(mediaProgressId)
val currentTime = mediaProgress?.currentTime ?: 0.0
// TODO: Clean up add mediaType methods for displayTitle and displayAuthor
val mediaMetadata = media.metadata
val chapters = if (mediaType == "book") (media as Book).chapters else mutableListOf()
var audioTracks = media.getAudioTracks() as MutableList<AudioTrack>
val authorName = mediaMetadata.getAuthorDisplayName()
if (episode != null) { // Get podcast episode audio track
episode.audioTrack?.let { at -> mutableListOf(at) }?.let { tracks -> audioTracks = tracks }
Log.d("LocalLibraryItem", "getPlaybackSession: Got podcast episode audio track ${audioTracks.size}")
}
val dateNow = System.currentTimeMillis()
return PlaybackSession(sessionId,serverUserId,libraryItemId,episode?.serverEpisodeId, mediaType, mediaMetadata, chapters ?: mutableListOf(), mediaMetadata.title, authorName,null,getDuration(),PLAYMETHOD_LOCAL,dateNow,0L,0L, audioTracks,currentTime,null,this,localEpisodeId,serverConnectionConfigId, serverAddress, "exo-player")
}
@JsonIgnore
fun removeLocalFile(localFileId:String) {
localFiles.removeIf { it.id == localFileId }
}
@JsonIgnore
fun getMediaMetadata(): MediaMetadataCompat {
return MediaMetadataCompat.Builder().apply {
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title)
putString(MediaMetadataCompat.METADATA_KEY_TITLE, title)
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, authorName)
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, getCoverUri().toString())
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getCoverUri().toString())
putString(MediaMetadataCompat.METADATA_KEY_ART_URI, getCoverUri().toString())
putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, authorName)
}.build()
}
}
@@ -0,0 +1,72 @@
package com.audiobookshelf.app.data
import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
/*
Used as a helper class to generate LocalLibraryItem from scan results
*/
@JsonIgnoreProperties(ignoreUnknown = true)
data class LocalMediaItem(
var id:String,
var name: String,
var mediaType:String,
var folderId:String,
var contentUrl:String,
var simplePath: String,
var basePath:String,
var absolutePath:String,
var audioTracks:MutableList<AudioTrack>,
var localFiles:MutableList<LocalFile>,
var coverContentUrl:String?,
var coverAbsolutePath:String?
) {
@JsonIgnore
fun getDuration():Double {
var total = 0.0
audioTracks.forEach{ total += it.duration }
return total
}
@JsonIgnore
fun getTotalSize():Long {
var total = 0L
localFiles.forEach { total += it.size }
return total
}
@JsonIgnore
fun getMediaMetadata():MediaTypeMetadata {
return if (mediaType == "book") {
BookMetadata(name,null, mutableListOf(), mutableListOf(), mutableListOf(),null,null,null,null,null,null,null,false,null,null,null,null)
} else {
PodcastMetadata(name,null,null, mutableListOf())
}
}
@JsonIgnore
fun getAudiobookChapters():List<BookChapter> {
if (mediaType != "book" || audioTracks.isEmpty()) return mutableListOf()
if (audioTracks.size == 1) { // Single track audiobook look for chapters from ffprobe
return audioTracks[0].audioProbeResult?.getBookChapters() ?: mutableListOf()
}
// Multi-track make chapters from tracks
return audioTracks.map { it.getBookChapter() }
}
@JsonIgnore
fun getLocalLibraryItem():LocalLibraryItem {
var mediaMetadata = getMediaMetadata()
if (mediaType == "book") {
var chapters = getAudiobookChapters()
var book = Book(mediaMetadata as BookMetadata, coverAbsolutePath, mutableListOf(), mutableListOf(), chapters,audioTracks,getTotalSize(),getDuration())
return LocalLibraryItem(id, folderId, basePath,absolutePath, contentUrl, false,mediaType, book, localFiles, coverContentUrl, coverAbsolutePath,true,null,null,null,null)
} else {
var podcast = Podcast(mediaMetadata as PodcastMetadata, coverAbsolutePath, mutableListOf(), mutableListOf(), false)
podcast.setAudioTracks(audioTracks) // Builds episodes from audio tracks
return LocalLibraryItem(id, folderId, basePath,absolutePath, contentUrl, false, mediaType, podcast,localFiles,coverContentUrl, coverAbsolutePath, true, null,null,null,null)
}
}
}
@@ -0,0 +1,45 @@
package com.audiobookshelf.app.data
import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
@JsonIgnoreProperties(ignoreUnknown = true)
data class LocalMediaProgress(
var id:String,
var localLibraryItemId:String,
var localEpisodeId:String?,
var duration:Double,
var progress:Double, // 0 to 1
var currentTime:Double,
var isFinished:Boolean,
var lastUpdate:Long,
var startedAt:Long,
var finishedAt:Long?,
// For local lib items from server to support server sync
var serverConnectionConfigId:String?,
var serverAddress:String?,
var serverUserId:String?,
var libraryItemId:String?,
var episodeId:String?
) {
@JsonIgnore
fun updateIsFinished(finished:Boolean) {
if (isFinished != finished) { // If finished changed then set progress
progress = if (finished) 1.0 else 0.0
}
isFinished = finished
lastUpdate = System.currentTimeMillis()
finishedAt = if (isFinished) lastUpdate else null
}
@JsonIgnore
fun updateFromPlaybackSession(playbackSession:PlaybackSession) {
currentTime = playbackSession.currentTime
progress = playbackSession.progress
lastUpdate = System.currentTimeMillis()
isFinished = playbackSession.progress >= 0.99
finishedAt = if (isFinished) lastUpdate else null
}
}
@@ -0,0 +1,11 @@
package com.audiobookshelf.app.data
enum class PlayerState {
IDLE, BUFFERING, READY, ENDED
}
data class PlaybackMetadata(
val duration:Double,
val currentTime:Double,
val playerState:PlayerState
)
@@ -0,0 +1,206 @@
package com.audiobookshelf.app.data
import android.net.Uri
import android.support.v4.media.MediaMetadataCompat
import androidx.core.app.NotificationCompat
import com.audiobookshelf.app.R
import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.player.MediaProgressSyncData
import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.google.android.exoplayer2.C
import com.google.android.exoplayer2.MediaItem
import com.google.android.exoplayer2.MediaMetadata
import com.google.android.gms.cast.MediaInfo
import com.google.android.gms.cast.MediaQueueItem
import com.google.android.gms.common.images.WebImage
// TODO: enum or something in kotlin?
val PLAYMETHOD_DIRECTPLAY = 0
val PLAYMETHOD_DIRECTSTREAM = 1
val PLAYMETHOD_TRANSCODE = 2
val PLAYMETHOD_LOCAL = 3
@JsonIgnoreProperties(ignoreUnknown = true)
class PlaybackSession(
var id:String,
var userId:String?,
var libraryItemId:String?,
var episodeId:String?,
var mediaType:String,
var mediaMetadata:MediaTypeMetadata,
var chapters:List<BookChapter>,
var displayTitle: String?,
var displayAuthor: String?,
var coverPath:String?,
var duration:Double,
var playMethod:Int,
var startedAt:Long,
var updatedAt:Long,
var timeListening:Long,
var audioTracks:MutableList<AudioTrack>,
var currentTime:Double,
var libraryItem:LibraryItem?,
var localLibraryItem:LocalLibraryItem?,
var localEpisodeId:String?,
var serverConnectionConfigId:String?,
var serverAddress:String?,
var mediaPlayer:String?
) {
@get:JsonIgnore
val isHLS get() = playMethod == PLAYMETHOD_TRANSCODE
@get:JsonIgnore
val isDirectPlay get() = playMethod == PLAYMETHOD_DIRECTPLAY
@get:JsonIgnore
val isLocal get() = playMethod == PLAYMETHOD_LOCAL
@get:JsonIgnore
val currentTimeMs get() = (currentTime * 1000L).toLong()
@get:JsonIgnore
val totalDurationMs get() = (getTotalDuration() * 1000L).toLong()
@get:JsonIgnore
val localLibraryItemId get() = localLibraryItem?.id ?: ""
@get:JsonIgnore
val localMediaProgressId get() = if (episodeId.isNullOrEmpty()) localLibraryItemId else "$localLibraryItemId-$localEpisodeId"
@get:JsonIgnore
val progress get() = currentTime / getTotalDuration()
@JsonIgnore
fun getCurrentTrackIndex():Int {
for (i in 0..(audioTracks.size - 1)) {
var track = audioTracks[i]
if (currentTimeMs >= track.startOffsetMs && (track.endOffsetMs) > currentTimeMs) {
return i
}
}
return audioTracks.size - 1
}
@JsonIgnore
fun getCurrentTrackTimeMs():Long {
var currentTrack = audioTracks[this.getCurrentTrackIndex()]
var time = currentTime - currentTrack.startOffset
return (time * 1000L).toLong()
}
@JsonIgnore
fun getTrackStartOffsetMs(index:Int):Long {
var currentTrack = audioTracks[index]
return (currentTrack.startOffset * 1000L).toLong()
}
@JsonIgnore
fun getTotalDuration():Double {
var total = 0.0
audioTracks.forEach { total += it.duration }
return total
}
@JsonIgnore
fun getCoverUri(): Uri {
if (localLibraryItem?.coverContentUrl != null) return Uri.parse(localLibraryItem?.coverContentUrl) ?: Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon)
if (coverPath == null) return Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon)
return Uri.parse("$serverAddress/api/items/$libraryItemId/cover?token=${DeviceManager.token}")
}
@JsonIgnore
fun getContentUri(audioTrack:AudioTrack): Uri {
if (isLocal) return Uri.parse(audioTrack.contentUrl) // Local content url
return Uri.parse("$serverAddress${audioTrack.contentUrl}?token=${DeviceManager.token}")
}
@JsonIgnore
fun getMediaMetadataCompat(): MediaMetadataCompat {
var metadataBuilder = MediaMetadataCompat.Builder()
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, displayTitle)
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, displayTitle)
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, displayAuthor)
.putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, displayAuthor)
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, displayAuthor)
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, "series")
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
return metadataBuilder.build()
}
@JsonIgnore
fun getExoMediaMetadata(audioTrack:AudioTrack): MediaMetadata {
var metadataBuilder = MediaMetadata.Builder()
.setTitle(displayTitle)
.setDisplayTitle(displayTitle)
.setArtist(displayAuthor)
.setAlbumArtist(displayAuthor)
.setSubtitle(displayAuthor)
var contentUri = this.getContentUri(audioTrack)
metadataBuilder.setMediaUri(contentUri)
return metadataBuilder.build()
}
@JsonIgnore
fun getMediaItems():List<MediaItem> {
var mediaItems:MutableList<MediaItem> = mutableListOf()
for (audioTrack in audioTracks) {
var mediaMetadata = this.getExoMediaMetadata(audioTrack)
var mediaUri = this.getContentUri(audioTrack)
var mimeType = audioTrack.mimeType
var queueItem = getQueueItem(audioTrack) // Queue item used in exo player CastManager
var mediaItem = MediaItem.Builder().setUri(mediaUri).setTag(queueItem).setMediaMetadata(mediaMetadata).setMimeType(mimeType).build()
mediaItems.add(mediaItem)
}
return mediaItems
}
@JsonIgnore
fun getCastMediaMetadata(audioTrack:AudioTrack):com.google.android.gms.cast.MediaMetadata {
var castMetadata = com.google.android.gms.cast.MediaMetadata(com.google.android.gms.cast.MediaMetadata.MEDIA_TYPE_AUDIOBOOK_CHAPTER)
coverPath?.let {
castMetadata.addImage(WebImage(Uri.parse("$serverAddress/api/items/$libraryItemId/cover?token=${DeviceManager.token}")))
}
castMetadata.putString(com.google.android.gms.cast.MediaMetadata.KEY_TITLE, displayTitle ?: "")
castMetadata.putString(com.google.android.gms.cast.MediaMetadata.KEY_ARTIST, displayAuthor ?: "")
castMetadata.putString(com.google.android.gms.cast.MediaMetadata.KEY_CHAPTER_TITLE, audioTrack.title)
castMetadata.putInt(com.google.android.gms.cast.MediaMetadata.KEY_TRACK_NUMBER, audioTrack.index)
return castMetadata
}
@JsonIgnore
fun getQueueItem(audioTrack:AudioTrack):MediaQueueItem {
var castMetadata = getCastMediaMetadata(audioTrack)
var mediaUri = getContentUri(audioTrack)
var mediaInfo = MediaInfo.Builder(mediaUri.toString()).apply {
setContentUrl(mediaUri.toString())
setContentType(audioTrack.mimeType)
setMetadata(castMetadata)
setStreamType(MediaInfo.STREAM_TYPE_BUFFERED)
}.build()
return MediaQueueItem.Builder(mediaInfo).apply {
setPlaybackDuration(audioTrack.duration)
}.build()
}
@JsonIgnore
fun clone():PlaybackSession {
return PlaybackSession(id,userId,libraryItemId,episodeId,mediaType,mediaMetadata,chapters,displayTitle,displayAuthor,coverPath,duration,playMethod,startedAt,updatedAt,timeListening,audioTracks,currentTime,libraryItem,localLibraryItem,localEpisodeId,serverConnectionConfigId,serverAddress, mediaPlayer)
}
@JsonIgnore
fun syncData(syncData:MediaProgressSyncData) {
timeListening += syncData.timeListened
updatedAt = System.currentTimeMillis()
currentTime = syncData.currentTime
}
@JsonIgnore
fun getNewLocalMediaProgress():LocalMediaProgress {
return LocalMediaProgress(localMediaProgressId,localLibraryItemId,localEpisodeId,getTotalDuration(),progress,currentTime,false,updatedAt,startedAt,null,serverConnectionConfigId,serverAddress,userId,libraryItemId,episodeId)
}
}
@@ -0,0 +1,29 @@
package com.audiobookshelf.app.device
import android.util.Log
import com.audiobookshelf.app.data.DbManager
import com.audiobookshelf.app.data.DeviceData
import com.audiobookshelf.app.data.ServerConnectionConfig
object DeviceManager {
const val tag = "DeviceManager"
val dbManager:DbManager = DbManager()
var deviceData:DeviceData = dbManager.getDeviceData()
var serverConnectionConfig: ServerConnectionConfig? = null
val serverConnectionConfigId get() = serverConnectionConfig?.id ?: ""
val serverAddress get() = serverConnectionConfig?.address ?: ""
val serverUserId get() = serverConnectionConfig?.userId ?: ""
val token get() = serverConnectionConfig?.token ?: ""
val isConnectedToServer get() = serverConnectionConfig != null
val hasLastServerConnectionConfig get() = deviceData.getLastServerConnectionConfig() != null
init {
Log.d(tag, "Device Manager Singleton invoked")
}
fun getBase64Id(id:String):String {
return android.util.Base64.encodeToString(id.toByteArray(), android.util.Base64.NO_WRAP)
}
}
@@ -0,0 +1,469 @@
package com.audiobookshelf.app.device
import android.content.Context
import android.net.Uri
import android.util.Log
import androidx.documentfile.provider.DocumentFile
import com.anggrayudi.storage.file.*
import com.arthenica.ffmpegkit.FFmpegKitConfig
import com.arthenica.ffmpegkit.FFprobeKit
import com.arthenica.ffmpegkit.Level
import com.audiobookshelf.app.data.*
import com.audiobookshelf.app.plugins.AbsDownloader
import com.fasterxml.jackson.core.json.JsonReadFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import com.getcapacitor.JSObject
class FolderScanner(var ctx: Context) {
private val tag = "FolderScanner"
var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
data class DownloadItemScanResult(val localLibraryItem:LocalLibraryItem, var localMediaProgress:LocalMediaProgress?)
private fun getLocalLibraryItemId(mediaItemId:String):String {
return "local_" + DeviceManager.getBase64Id(mediaItemId)
}
enum class ItemScanResult {
ADDED, REMOVED, UPDATED, UPTODATE
}
// TODO: CLEAN this monster! Divide into bite-size methods
fun scanForMediaItems(localFolder:LocalFolder, forceAudioProbe:Boolean):FolderScanResult? {
FFmpegKitConfig.enableLogCallback { log ->
if (log.level != Level.AV_LOG_STDERR) { // STDERR is filled with junk
Log.d(tag, "FFmpeg-Kit Log: (${log.level}) ${log.message}")
}
}
val df: DocumentFile? = DocumentFileCompat.fromUri(ctx, Uri.parse(localFolder.contentUrl))
if (df == null) {
Log.e(tag, "Folder Doc File Invalid $localFolder.contentUrl")
return null
}
var mediaItemsUpdated = 0
var mediaItemsAdded = 0
var mediaItemsRemoved = 0
var mediaItemsUpToDate = 0
// Search for files in media item folder
val foldersFound = df.search(false, DocumentFileType.FOLDER)
// Match folders found with local library items already saved in db
var existingLocalLibraryItems = DeviceManager.dbManager.getLocalLibraryItemsInFolder(localFolder.id)
// Remove existing items no longer there
existingLocalLibraryItems = existingLocalLibraryItems.filter { lli ->
Log.d(tag, "scanForMediaItems Checking Existing LLI ${lli.id}")
val fileFound = foldersFound.find { f -> lli.id == getLocalLibraryItemId(f.id) }
if (fileFound == null) {
Log.d(tag, "Existing local library item is no longer in file system ${lli.media.metadata.title}")
DeviceManager.dbManager.removeLocalLibraryItem(lli.id)
mediaItemsRemoved++
}
fileFound != null
}
foldersFound.forEach { itemFolder ->
Log.d(tag, "Iterating over Folder Found ${itemFolder.name} | ${itemFolder.getSimplePath(ctx)} | URI: ${itemFolder.uri}")
val existingItem = existingLocalLibraryItems.find { emi -> emi.id == getLocalLibraryItemId(itemFolder.id) }
val result = scanLibraryItemFolder(itemFolder, localFolder, existingItem, forceAudioProbe)
if (result == ItemScanResult.REMOVED) mediaItemsRemoved++
else if (result == ItemScanResult.UPDATED) mediaItemsUpdated++
else if (result == ItemScanResult.ADDED) mediaItemsAdded++
else mediaItemsUpToDate++
}
Log.d(tag, "Folder $${localFolder.name} scan Results: $mediaItemsAdded Added | $mediaItemsUpdated Updated | $mediaItemsRemoved Removed | $mediaItemsUpToDate Up-to-date")
return if (mediaItemsAdded > 0 || mediaItemsUpdated > 0 || mediaItemsRemoved > 0) {
var folderLibraryItems = DeviceManager.dbManager.getLocalLibraryItemsInFolder(localFolder.id) // Get all local media items
FolderScanResult(mediaItemsAdded, mediaItemsUpdated, mediaItemsRemoved, mediaItemsUpToDate, localFolder, folderLibraryItems)
} else {
Log.d(tag, "No Media Items to save")
FolderScanResult(mediaItemsAdded, mediaItemsUpdated, mediaItemsRemoved, mediaItemsUpToDate, localFolder, mutableListOf())
}
}
fun scanLibraryItemFolder(itemFolder:DocumentFile, localFolder:LocalFolder, existingItem:LocalLibraryItem?, forceAudioProbe:Boolean):ItemScanResult {
val itemFolderName = itemFolder.name ?: ""
val itemId = getLocalLibraryItemId(itemFolder.id)
val existingLocalFiles = existingItem?.localFiles ?: mutableListOf()
val existingAudioTracks = existingItem?.media?.getAudioTracks() ?: mutableListOf()
var isNewOrUpdated = existingItem == null
val audioTracks = mutableListOf<AudioTrack>()
val localFiles = mutableListOf<LocalFile>()
var index = 1
var startOffset = 0.0
var coverContentUrl:String? = null
var coverAbsolutePath:String? = null
val filesInFolder = itemFolder.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4"))
val existingLocalFilesRemoved = existingLocalFiles.filter { elf ->
filesInFolder.find { fif -> DeviceManager.getBase64Id(fif.id) == elf.id } == null // File was not found in media item folder
}
if (existingLocalFilesRemoved.isNotEmpty()) {
Log.d(tag, "${existingLocalFilesRemoved.size} Local files were removed from local media item ${existingItem?.media?.metadata?.title}")
isNewOrUpdated = true
}
filesInFolder.forEach { file ->
val mimeType = file.mimeType ?: ""
val filename = file.name ?: ""
val isAudio = mimeType.startsWith("audio") || mimeType == "video/mp4"
Log.d(tag, "Found $mimeType file $filename in folder $itemFolderName")
val localFileId = DeviceManager.getBase64Id(file.id)
val localFile = LocalFile(localFileId,filename,file.uri.toString(),file.getBasePath(ctx), file.getAbsolutePath(ctx),file.getSimplePath(ctx),mimeType,file.length())
localFiles.add(localFile)
Log.d(tag, "File attributes Id:${localFileId}|ContentUrl:${localFile.contentUrl}|isDownloadsDocument:${file.isDownloadsDocument}")
if (isAudio) {
var audioTrackToAdd:AudioTrack? = null
val existingAudioTrack = existingAudioTracks.find { eat -> eat.localFileId == localFileId }
if (existingAudioTrack != null) { // Update existing audio track
if (existingAudioTrack.index != index) {
Log.d(tag, "scanLibraryItemFolder Updating Audio track index from ${existingAudioTrack.index} to $index")
existingAudioTrack.index = index
isNewOrUpdated = true
}
if (existingAudioTrack.startOffset != startOffset) {
Log.d(tag, "scanLibraryItemFolder Updating Audio track startOffset ${existingAudioTrack.startOffset} to $startOffset")
existingAudioTrack.startOffset = startOffset
isNewOrUpdated = true
}
}
if (existingAudioTrack == null || forceAudioProbe) {
Log.d(tag, "scanLibraryItemFolder Scanning Audio File Path ${localFile.absolutePath} | ForceAudioProbe=${forceAudioProbe}")
// TODO: Make asynchronous
val audioProbeResult = probeAudioFile(localFile.absolutePath)
if (existingAudioTrack != null) {
// Update audio probe data on existing audio track
existingAudioTrack.audioProbeResult = audioProbeResult
audioTrackToAdd = existingAudioTrack
} else {
// Create new audio track
var track = AudioTrack(index, startOffset, audioProbeResult.duration, filename, localFile.contentUrl, mimeType, null, true, localFileId, audioProbeResult, null)
audioTrackToAdd = track
}
startOffset += audioProbeResult.duration
isNewOrUpdated = true
} else {
audioTrackToAdd = existingAudioTrack
}
startOffset += audioTrackToAdd.duration
index++
audioTracks.add(audioTrackToAdd)
} else {
val existingLocalFile = existingLocalFiles.find { elf -> elf.id == localFileId }
if (existingLocalFile == null) {
Log.d(tag, "scanLibraryItemFolder new local file found ${localFile.absolutePath}")
isNewOrUpdated = true
}
if (existingItem != null && existingItem.coverContentUrl == null) {
// Existing media item did not have a cover - cover found on scan
Log.d(tag, "scanLibraryItemFolder setting cover ${localFile.absolutePath}")
isNewOrUpdated = true
existingItem.coverAbsolutePath = localFile.absolutePath
existingItem.coverContentUrl = localFile.contentUrl
existingItem.media.coverPath = localFile.absolutePath
}
// First image file use as cover path
if (coverContentUrl == null) {
coverContentUrl = localFile.contentUrl
coverAbsolutePath = localFile.absolutePath
}
}
}
if (existingItem != null && audioTracks.isEmpty()) {
Log.d(tag, "Local library item ${existingItem.media.metadata.title} no longer has audio tracks - removing item")
DeviceManager.dbManager.removeLocalLibraryItem(existingItem.id)
return ItemScanResult.REMOVED
} else if (existingItem != null && !isNewOrUpdated) {
Log.d(tag, "Local library item ${existingItem.media.metadata.title} has no updates")
return ItemScanResult.UPTODATE
} else if (existingItem != null) {
Log.d(tag, "Updating local library item ${existingItem.media.metadata.title}")
existingItem.updateFromScan(audioTracks,localFiles)
DeviceManager.dbManager.saveLocalLibraryItem(existingItem)
return ItemScanResult.UPDATED
} else if (audioTracks.isNotEmpty()) {
Log.d(tag, "Found local media item named $itemFolderName with ${audioTracks.size} tracks and ${localFiles.size} local files")
val localMediaItem = LocalMediaItem(itemId, itemFolderName, localFolder.mediaType, localFolder.id, itemFolder.uri.toString(), itemFolder.getSimplePath(ctx), itemFolder.getBasePath(ctx), itemFolder.getAbsolutePath(ctx),audioTracks,localFiles,coverContentUrl,coverAbsolutePath)
val localLibraryItem = localMediaItem.getLocalLibraryItem()
DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem)
return ItemScanResult.ADDED
} else {
return ItemScanResult.UPTODATE
}
}
// Scan item after download and create local library item
fun scanDownloadItem(downloadItem: AbsDownloader.DownloadItem):DownloadItemScanResult? {
val folderDf = DocumentFileCompat.fromUri(ctx, Uri.parse(downloadItem.localFolder.contentUrl))
val foldersFound = folderDf?.search(false, DocumentFileType.FOLDER) ?: mutableListOf()
var itemFolderId = ""
var itemFolderUrl = ""
var itemFolderBasePath = ""
var itemFolderAbsolutePath = ""
foldersFound.forEach {
if (it.name == downloadItem.itemTitle) {
itemFolderId = it.id
itemFolderUrl = it.uri.toString()
itemFolderBasePath = it.getBasePath(ctx)
itemFolderAbsolutePath = it.getAbsolutePath(ctx)
}
}
if (itemFolderUrl == "") {
Log.d(tag, "scanDownloadItem failed to find media folder")
return null
}
val df: DocumentFile? = DocumentFileCompat.fromUri(ctx, Uri.parse(itemFolderUrl))
if (df == null) {
Log.e(tag, "Folder Doc File Invalid ${downloadItem.itemFolderPath}")
return null
}
val localLibraryItemId = getLocalLibraryItemId(itemFolderId)
Log.d(tag, "scanDownloadItem starting for ${downloadItem.itemFolderPath} | ${df.uri} | Item Folder Id:$itemFolderId | LLI Id:$localLibraryItemId")
// Search for files in media item folder
val filesFound = df.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4"))
Log.d(tag, "scanDownloadItem ${filesFound.size} files found in ${downloadItem.itemFolderPath}")
var localEpisodeId:String? = null
var localLibraryItem:LocalLibraryItem? = null
if (downloadItem.mediaType == "book") {
localLibraryItem = LocalLibraryItem(localLibraryItemId, downloadItem.localFolder.id, itemFolderBasePath, itemFolderAbsolutePath, itemFolderUrl, false, downloadItem.mediaType, downloadItem.media.getLocalCopy(), mutableListOf(), null, null, true, downloadItem.serverConnectionConfigId, downloadItem.serverAddress, downloadItem.serverUserId, downloadItem.libraryItemId)
} else {
// Lookup or create podcast local library item
localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
if (localLibraryItem == null) {
Log.d(tag, "[FolderScanner] Podcast local library item not created yet for ${downloadItem.media.metadata.title}")
localLibraryItem = LocalLibraryItem(localLibraryItemId, downloadItem.localFolder.id, itemFolderBasePath, itemFolderAbsolutePath, itemFolderUrl, false, downloadItem.mediaType, downloadItem.media.getLocalCopy(), mutableListOf(), null, null, true,downloadItem.serverConnectionConfigId,downloadItem.serverAddress,downloadItem.serverUserId,downloadItem.libraryItemId)
}
}
val audioTracks:MutableList<AudioTrack> = mutableListOf()
filesFound.forEach { docFile ->
val itemPart = downloadItem.downloadItemParts.find { itemPart ->
itemPart.filename == docFile.name
}
if (itemPart == null) {
if (downloadItem.mediaType == "book") { // for books every download item should be a file found
Log.e(tag, "scanDownloadItem: Item part not found for doc file ${docFile.name} | ${docFile.getAbsolutePath(ctx)} | ${docFile.uri}")
}
} else if (itemPart.audioTrack != null) { // Is audio track
val audioTrackFromServer = itemPart.audioTrack
Log.d(tag, "scanDownloadItem: Audio Track from Server index = ${audioTrackFromServer?.index}")
val localFileId = DeviceManager.getBase64Id(docFile.id)
val localFile = LocalFile(localFileId,docFile.name,docFile.uri.toString(),docFile.getBasePath(ctx),docFile.getAbsolutePath(ctx),docFile.getSimplePath(ctx),docFile.mimeType,docFile.length())
localLibraryItem.localFiles.add(localFile)
// TODO: Make asynchronous
val audioProbeResult = probeAudioFile(localFile.absolutePath)
// Create new audio track
val track = AudioTrack(audioTrackFromServer?.index ?: -1, audioTrackFromServer?.startOffset ?: 0.0, audioProbeResult.duration, localFile.filename ?: "", localFile.contentUrl, localFile.mimeType ?: "", null, true, localFileId, audioProbeResult, audioTrackFromServer?.index ?: -1)
audioTracks.add(track)
Log.d(tag, "scanDownloadItem: Created Audio Track with index ${track.index} from local file ${localFile.absolutePath}")
// Add podcast episodes to library
itemPart.episode?.let { podcastEpisode ->
val podcast = localLibraryItem.media as Podcast
var newEpisode = podcast.addEpisode(track, podcastEpisode)
localEpisodeId = newEpisode.id
Log.d(tag, "scanDownloadItem: Added episode to podcast ${podcastEpisode.title} ${track.title} | Track index: ${podcastEpisode.audioTrack?.index}")
}
} else { // Cover image
val localFileId = DeviceManager.getBase64Id(docFile.id)
val localFile = LocalFile(localFileId,docFile.name,docFile.uri.toString(),docFile.getBasePath(ctx),docFile.getAbsolutePath(ctx),docFile.getSimplePath(ctx),docFile.mimeType,docFile.length())
localLibraryItem.coverAbsolutePath = localFile.absolutePath
localLibraryItem.coverContentUrl = localFile.contentUrl
localLibraryItem.localFiles.add(localFile)
}
}
if (audioTracks.isEmpty()) {
Log.d(tag, "scanDownloadItem did not find any audio tracks in folder for ${downloadItem.itemFolderPath}")
return null
}
// For books sort audio tracks then set
if (downloadItem.mediaType == "book") {
audioTracks.sortBy { it.index }
var indexCheck = 1
var startOffset = 0.0
audioTracks.forEach { audioTrack ->
if (audioTrack.index != indexCheck || audioTrack.startOffset != startOffset) {
audioTrack.index = indexCheck
audioTrack.startOffset = startOffset
}
indexCheck++
startOffset += audioTrack.duration
}
localLibraryItem.media.setAudioTracks(audioTracks)
}
val downloadItemScanResult = DownloadItemScanResult(localLibraryItem,null)
// If library item had media progress then make local media progress and save
downloadItem.userMediaProgress?.let { mediaProgress ->
val localMediaProgressId = if (downloadItem.episodeId.isNullOrEmpty()) localLibraryItemId else "$localLibraryItemId-$localEpisodeId"
val newLocalMediaProgress = LocalMediaProgress(
id = localMediaProgressId,
localLibraryItemId = localLibraryItemId,
localEpisodeId = localEpisodeId,
duration = mediaProgress.duration,
progress = mediaProgress.progress,
currentTime = mediaProgress.currentTime,
isFinished = false,
lastUpdate = mediaProgress.lastUpdate,
startedAt = mediaProgress.startedAt,
finishedAt = mediaProgress.finishedAt,
serverConnectionConfigId = downloadItem.serverConnectionConfigId,
serverAddress = downloadItem.serverAddress,
serverUserId = downloadItem.serverUserId,
libraryItemId = downloadItem.libraryItemId,
episodeId = downloadItem.episodeId)
Log.d(tag, "scanLibraryItemFolder: Saving local media progress ${newLocalMediaProgress.id} at progress ${newLocalMediaProgress.progress}")
DeviceManager.dbManager.saveLocalMediaProgress(newLocalMediaProgress)
downloadItemScanResult.localMediaProgress = newLocalMediaProgress
}
DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem)
return downloadItemScanResult
}
fun scanLocalLibraryItem(localLibraryItem:LocalLibraryItem, forceAudioProbe:Boolean):LocalLibraryItemScanResult? {
var df: DocumentFile? = DocumentFileCompat.fromUri(ctx, Uri.parse(localLibraryItem.contentUrl))
if (df == null) {
Log.e(tag, "Item Folder Doc File Invalid ${localLibraryItem.absolutePath}")
return null
}
Log.d(tag, "scanLocalLibraryItem starting for ${localLibraryItem.absolutePath} | ${df.uri}")
var wasUpdated = false
// Search for files in media item folder
var filesFound = df.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4"))
Log.d(tag, "scanLocalLibraryItem ${filesFound.size} files found in ${localLibraryItem.absolutePath}")
filesFound.forEach {
try {
Log.d(tag, "Checking file found ${it.name} | ${it.id}")
}catch(e:Exception) {
Log.d(tag, "Check file found exception", e)
}
}
var existingAudioTracks = localLibraryItem.media.getAudioTracks()
// Remove any files no longer found in library item folder
var existingLocalFileIds = localLibraryItem.localFiles.map { it.id }
existingLocalFileIds.forEach { localFileId ->
Log.d(tag, "Checking local file id is there $localFileId")
if (filesFound.find { DeviceManager.getBase64Id(it.id) == localFileId } == null) {
Log.d(tag, "scanLocalLibraryItem file $localFileId was removed from ${localLibraryItem.absolutePath}")
localLibraryItem.localFiles.removeIf { it.id == localFileId }
if (existingAudioTracks.find { it.localFileId == localFileId } != null) {
Log.d(tag, "scanLocalLibraryItem audio track file ${localFileId} was removed from ${localLibraryItem.absolutePath}")
localLibraryItem.media.removeAudioTrack(localFileId)
}
wasUpdated = true
}
}
filesFound.forEach { docFile ->
var localFileId = DeviceManager.getBase64Id(docFile.id)
var existingLocalFile = localLibraryItem.localFiles.find { it.id == localFileId }
if (existingLocalFile == null || (existingLocalFile.isAudioFile() && forceAudioProbe)) {
var localFile = existingLocalFile ?: LocalFile(localFileId,docFile.name,docFile.uri.toString(),docFile.getBasePath(ctx), docFile.getAbsolutePath(ctx),docFile.getSimplePath(ctx),docFile.mimeType,docFile.length())
if (existingLocalFile == null) {
localLibraryItem.localFiles.add(localFile)
Log.d(tag, "scanLocalLibraryItem new file found ${localFile.filename}")
}
if (localFile.isAudioFile()) {
// TODO: Make asynchronous
var audioProbeResult = probeAudioFile(localFile.absolutePath)
var existingTrack = existingAudioTracks.find { audioTrack ->
audioTrack.localFileId == localFile.id
}
if (existingTrack == null) {
// Create new audio track
var lastTrack = existingAudioTracks.lastOrNull()
var startOffset = (lastTrack?.startOffset ?: 0.0) + (lastTrack?.duration ?: 0.0)
var track = AudioTrack(existingAudioTracks.size, startOffset, audioProbeResult.duration, localFile.filename ?: "", localFile.contentUrl, localFile.mimeType ?: "", null, true, localFileId, audioProbeResult, null)
localLibraryItem.media.addAudioTrack(track)
wasUpdated = true
} else {
existingTrack.audioProbeResult = audioProbeResult
// TODO: Update data found from probe
wasUpdated = true
}
} else { // Check if cover is empty
if (localLibraryItem.coverContentUrl == null) {
Log.d(tag, "scanLocalLibraryItem setting cover for ${localLibraryItem.media.metadata.title}")
localLibraryItem.coverContentUrl = localFile.contentUrl
localLibraryItem.coverAbsolutePath = localFile.absolutePath
wasUpdated = true
}
}
}
}
if (wasUpdated) {
Log.d(tag, "Local library item was updated - saving it")
DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem)
} else {
Log.d(tag, "Local library item was up-to-date")
}
return LocalLibraryItemScanResult(wasUpdated, localLibraryItem)
}
fun probeAudioFile(absolutePath:String):AudioProbeResult {
var session = FFprobeKit.execute("-i \"${absolutePath}\" -print_format json -show_format -show_streams -select_streams a -show_chapters -loglevel quiet")
Log.d(tag, "FFprobe output ${JSObject(session.output)}")
val audioProbeResult = jacksonMapper.readValue<AudioProbeResult>(session.output)
Log.d(tag, "Probe Result DATA ${audioProbeResult.duration} | ${audioProbeResult.size} | ${audioProbeResult.title} | ${audioProbeResult.artist}")
return audioProbeResult
}
}
@@ -0,0 +1,177 @@
package com.audiobookshelf.app.media
import android.bluetooth.BluetoothClass
import android.content.Context
import android.util.Log
import com.audiobookshelf.app.data.*
import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.player.PlayerNotificationService
import com.audiobookshelf.app.server.ApiHandler
import java.util.*
import io.paperdb.Paper
class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
val tag = "MediaManager"
var serverLibraryItems = listOf<LibraryItem>()
var serverLibraryCategories = listOf<LibraryCategory>()
var serverLibraries = listOf<Library>()
fun initializeAndroidAuto() {
Log.d(tag, "Android Auto started when MainActivity was never started - initializing Paper")
Paper.init(ctx)
}
fun loadLibraryCategories(libraryId:String, cb: (List<LibraryCategory>) -> Unit) {
if (serverLibraryCategories.isNotEmpty()) {
cb(serverLibraryCategories)
} else {
apiHandler.getLibraryCategories(libraryId) {
serverLibraryCategories = it
cb(it)
}
}
}
fun loadLibraryItems(libraryId:String, cb: (List<LibraryItem>) -> Unit) {
if (serverLibraryItems.isNotEmpty()) {
cb(serverLibraryItems)
} else {
apiHandler.getLibraryItems(libraryId) { libraryItems ->
serverLibraryItems = libraryItems
cb(libraryItems)
}
}
}
fun loadLibraries(cb: (List<Library>) -> Unit) {
if (serverLibraries.isNotEmpty()) {
cb(serverLibraries)
} else {
apiHandler.getLibraries {
serverLibraries = it
cb(it)
}
}
}
// TODO: Load currently listening category for local items
fun loadLocalCategory():List<LibraryCategory> {
var localBooks = DeviceManager.dbManager.getLocalLibraryItems("book")
var localPodcasts = DeviceManager.dbManager.getLocalLibraryItems("podcast")
var cats = mutableListOf<LibraryCategory>()
if (localBooks.isNotEmpty()) {
cats.add(LibraryCategory("local-books", "Local Books", "book", localBooks, true))
}
if (localPodcasts.isNotEmpty()) {
cats.add(LibraryCategory("local-podcasts", "Local Podcasts", "podcast", localPodcasts, true))
}
return cats
}
fun loadAndroidAutoItems(libraryId:String, cb: (List<LibraryCategory>) -> Unit) {
Log.d(tag, "Load android auto items for library id $libraryId")
var cats = mutableListOf<LibraryCategory>()
var localCategories = loadLocalCategory()
cats.addAll(localCategories)
// Connected to server and has internet - load other cats
if (apiHandler.isOnline() && (DeviceManager.isConnectedToServer || DeviceManager.hasLastServerConnectionConfig)) {
if (!DeviceManager.isConnectedToServer) {
DeviceManager.serverConnectionConfig = DeviceManager.deviceData.getLastServerConnectionConfig()
Log.d(tag, "Not connected to server, set last server \"${DeviceManager.serverAddress}\"")
}
loadLibraries { libraries ->
var library = libraries.find { it.id == libraryId } ?: libraries[0]
Log.d(tag, "Loading categories for library ${library.name} - ${library.id} - ${library.mediaType}")
loadLibraryCategories(libraryId) { libraryCategories ->
// Only using book or podcast library categories for now
libraryCategories.forEach {
Log.d(tag, "Found library category ${it.label} with type ${it.type}")
if (it.type == library.mediaType) {
Log.d(tag, "Using library category ${it.id}")
cats.add(it)
}
}
loadLibraryItems(libraryId) { libraryItems ->
var mainCat = LibraryCategory("library", "Library", library.mediaType, libraryItems, false)
cats.add(mainCat)
cb(cats)
}
}
}
} else { // Not connected/no internet sent downloaded cats only
cb(cats)
}
}
fun getFirstItem() : LibraryItemWrapper? {
if (serverLibraryItems.isNotEmpty()) {
return serverLibraryItems[0]
} else {
var localBooks = DeviceManager.dbManager.getLocalLibraryItems("book")
return if (localBooks.isNotEmpty()) return localBooks[0] else null
}
}
fun getById(id:String) : LibraryItemWrapper? {
if (id.startsWith("local")) {
return DeviceManager.dbManager.getLocalLibraryItem(id)
} else {
return serverLibraryItems.find { it.id == id }
}
}
fun getFromSearch(query:String?) : LibraryItemWrapper? {
if (query.isNullOrEmpty()) return getFirstItem()
return serverLibraryItems.find {
it.title.lowercase(Locale.getDefault()).contains(query.lowercase(Locale.getDefault()))
}
}
fun play(libraryItemWrapper:LibraryItemWrapper, mediaPlayer:String, cb: (PlaybackSession) -> Unit) {
if (libraryItemWrapper is LocalLibraryItem) {
var localLibraryItem = libraryItemWrapper as LocalLibraryItem
cb(localLibraryItem.getPlaybackSession(null))
} else {
var libraryItem = libraryItemWrapper as LibraryItem
apiHandler.playLibraryItem(libraryItem.id,"",false, mediaPlayer) {
cb(it)
}
}
}
private fun levenshtein(lhs : CharSequence, rhs : CharSequence) : Int {
val lhsLength = lhs.length + 1
val rhsLength = rhs.length + 1
var cost = Array(lhsLength) { it }
var newCost = Array(lhsLength) { 0 }
for (i in 1..rhsLength-1) {
newCost[0] = i
for (j in 1..lhsLength-1) {
val match = if(lhs[j - 1] == rhs[i - 1]) 0 else 1
val costReplace = cost[j - 1] + match
val costInsert = cost[j] + 1
val costDelete = newCost[j - 1] + 1
newCost[j] = Math.min(Math.min(costInsert, costDelete), costReplace)
}
val swap = cost
cost = newCost
newCost = swap
}
return cost[lhsLength - 1]
}
}
@@ -1,10 +1,11 @@
package com.audiobookshelf.app
package com.audiobookshelf.app.player
import android.app.PendingIntent
import android.graphics.Bitmap
import android.net.Uri
import android.support.v4.media.session.MediaControllerCompat
import android.util.Log
import com.audiobookshelf.app.R
import com.bumptech.glide.Glide
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.request.RequestOptions
@@ -14,9 +15,8 @@ import kotlinx.coroutines.*
const val NOTIFICATION_LARGE_ICON_SIZE = 144 // px
class AbMediaDescriptionAdapter constructor(private val controller: MediaControllerCompat, playerNotificationService: PlayerNotificationService) : PlayerNotificationManager.MediaDescriptionAdapter {
class AbMediaDescriptionAdapter constructor(private val controller: MediaControllerCompat, val playerNotificationService: PlayerNotificationService) : PlayerNotificationManager.MediaDescriptionAdapter {
private val tag = "MediaDescriptionAdapter"
private val playerNotificationService:PlayerNotificationService = playerNotificationService
var currentIconUri: Uri? = null
var currentBitmap: Bitmap? = null
@@ -1,4 +1,4 @@
package com.audiobookshelf.app
package com.audiobookshelf.app.player
import android.content.ContentResolver
import android.content.Context
@@ -6,13 +6,15 @@ import android.net.Uri
import android.support.v4.media.MediaMetadataCompat
import android.util.Log
import androidx.annotation.AnyRes
import com.audiobookshelf.app.R
import com.audiobookshelf.app.data.LibraryCategory
import com.audiobookshelf.app.data.LibraryItem
import com.audiobookshelf.app.data.LocalLibraryItem
class BrowseTree(
val context: Context,
audiobooksInProgress: List<Audiobook>,
audiobookMetadata: List<MediaMetadataCompat>,
downloadedMetadata: List<MediaMetadataCompat>
libraryCategories: List<LibraryCategory>
) {
private val mediaIdToChildren = mutableMapOf<String, MutableList<MediaMetadataCompat>>()
@@ -33,67 +35,69 @@ class BrowseTree(
init {
val rootList = mediaIdToChildren[AUTO_BROWSE_ROOT] ?: mutableListOf()
val continueReadingMetadata = MediaMetadataCompat.Builder().apply {
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, CONTINUE_ROOT)
putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Reading")
putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Listening")
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.exo_icon_localaudio).toString())
}.build()
val allMetadata = MediaMetadataCompat.Builder().apply {
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, ALL_ROOT)
putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Audiobooks")
var resource = getUriToDrawable(context, R.drawable.exo_icon_books).toString()
Log.d("BrowseTree", "RESOURCE $resource")
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, resource)
putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Library Items")
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.exo_icon_books).toString())
}.build()
val downloadsMetadata = MediaMetadataCompat.Builder().apply {
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, DOWNLOADS_ROOT)
putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Downloads")
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.exo_icon_downloaddone).toString())
}.build()
// val localsMetadata = MediaMetadataCompat.Builder().apply {
// putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, LOCAL_ROOT)
// putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Samples")
// putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.exo_icon_localaudio).toString())
// }.build()
if (audiobooksInProgress.isNotEmpty()) {
rootList += continueReadingMetadata
// Server continue Listening cat
libraryCategories.find { it.id == "continue-listening" }?.let { continueListeningCategory ->
var continueListeningMediaMetadata = continueListeningCategory.entities.map { liw ->
var libraryItem = liw as LibraryItem
libraryItem.getMediaMetadata()
}
if (continueListeningMediaMetadata.isNotEmpty()) {
rootList += continueReadingMetadata
}
continueListeningMediaMetadata.forEach {
val children = mediaIdToChildren[CONTINUE_ROOT] ?: mutableListOf()
children += it
mediaIdToChildren[CONTINUE_ROOT] = children
}
}
rootList += allMetadata
rootList += downloadsMetadata
// rootList += localsMetadata
// Server library cat
libraryCategories.find { it.id == "library" }?.let { libraryCategory ->
var libraryMediaMetadata = libraryCategory.entities.map { libc ->
var libraryItem = libc as LibraryItem
libraryItem.getMediaMetadata()
}
libraryMediaMetadata.forEach {
val children = mediaIdToChildren[ALL_ROOT] ?: mutableListOf()
children += it
mediaIdToChildren[ALL_ROOT] = children
}
}
libraryCategories.find { it.id == "local-books" }?.let { localBooksCat ->
var localMediaMetadata = localBooksCat.entities.map { libc ->
var libraryItem = libc as LocalLibraryItem
libraryItem.getMediaMetadata()
}
localMediaMetadata.forEach {
val children = mediaIdToChildren[DOWNLOADS_ROOT] ?: mutableListOf()
children += it
mediaIdToChildren[DOWNLOADS_ROOT] = children
}
}
mediaIdToChildren[AUTO_BROWSE_ROOT] = rootList
audiobooksInProgress.forEach { audiobook ->
val children = mediaIdToChildren[CONTINUE_ROOT] ?: mutableListOf()
children += audiobook.toMediaMetadata()
mediaIdToChildren[CONTINUE_ROOT] = children
}
audiobookMetadata.forEach {
val allChildren = mediaIdToChildren[ALL_ROOT] ?: mutableListOf()
allChildren += it
mediaIdToChildren[ALL_ROOT] = allChildren
}
downloadedMetadata.forEach {
val allChildren = mediaIdToChildren[DOWNLOADS_ROOT] ?: mutableListOf()
allChildren += it
mediaIdToChildren[DOWNLOADS_ROOT] = allChildren
}
// localAudio.forEach { local ->
// val localChildren = mediaIdToChildren[LOCAL_ROOT] ?: mutableListOf()
// localChildren += local.toMediaMetadata()
// mediaIdToChildren[LOCAL_ROOT] = localChildren
// }
// Log.d("BrowseTree", "Set LOCAL AUDIO ${localAudio.size}")
}
operator fun get(mediaId: String) = mediaIdToChildren[mediaId]
@@ -103,4 +107,3 @@ const val AUTO_BROWSE_ROOT = "/"
const val ALL_ROOT = "__ALL__"
const val CONTINUE_ROOT = "__CONTINUE__"
const val DOWNLOADS_ROOT = "__DOWNLOADS__"
//const val LOCAL_ROOT = "__LOCAL__"
@@ -1,32 +1,31 @@
package com.audiobookshelf.app
package com.audiobookshelf.app.player
import android.app.Activity
import android.app.AlertDialog
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.appcompat.R
import androidx.mediarouter.app.MediaRouteChooserDialog
import androidx.mediarouter.media.MediaRouteSelector
import androidx.mediarouter.media.MediaRouter
import com.getcapacitor.PluginCall
import com.google.android.exoplayer2.ext.cast.CastPlayer
import com.google.android.exoplayer2.ext.cast.SessionAvailabilityListener
import com.google.android.gms.cast.Cast
import com.google.android.gms.cast.CastDevice
import com.google.android.gms.cast.CastMediaControlIntent
import com.google.android.gms.cast.*
import com.google.android.gms.cast.framework.*
import org.json.JSONObject
import java.util.ArrayList
class CastManager constructor(playerNotificationService:PlayerNotificationService) {
private val tag = "SleepTimerManager"
private val playerNotificationService:PlayerNotificationService = playerNotificationService
class CastManager constructor(val mainActivity:Activity) {
private val tag = "CastManager"
private var newConnectionListener:SessionListener? = null
private var mainActivity:Activity? = null
private var playerNotificationService:PlayerNotificationService? = null
private var newConnectionListener: SessionListener? = null
private fun switchToPlayer(useCastPlayer:Boolean) {
playerNotificationService.switchToPlayer(useCastPlayer)
Handler(Looper.getMainLooper()).post() {
playerNotificationService?.switchToPlayer(useCastPlayer)
}
}
private inner class CastSessionAvailabilityListener : SessionAvailabilityListener {
@@ -36,6 +35,7 @@ class CastManager constructor(playerNotificationService:PlayerNotificationServic
* remote Cast receiver rather than play audio locally.
*/
override fun onCastSessionAvailable() {
Log.d(tag, "SessionAvailabilityListener: onCastSessionAvailable")
switchToPlayer(true)
}
@@ -48,42 +48,39 @@ class CastManager constructor(playerNotificationService:PlayerNotificationServic
}
}
fun requestSession(mainActivity: Activity, callback: RequestSessionCallback) {
this.mainActivity = mainActivity
fun requestSession(playerNotificationService: PlayerNotificationService, callback: RequestSessionCallback) {
this.playerNotificationService = playerNotificationService
mainActivity.runOnUiThread(object : Runnable {
override fun run() {
Log.d(tag, "CAST RUNNING ON MAIN THREAD")
mainActivity.runOnUiThread {
val session: CastSession? = getSession()
if (session == null) {
// show the "choose a connection" dialog
// Add the connection listener callback
listenForConnection(callback)
val session: CastSession? = getSession()
if (session == null) {
// show the "choose a connection" dialog
// Add the connection listener callback
listenForConnection(callback)
val builder = MediaRouteChooserDialog(mainActivity, R.style.Theme_AppCompat_NoActionBar)
builder.routeSelector = MediaRouteSelector.Builder()
.addControlCategory(CastMediaControlIntent.categoryForCast(CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID))
.build()
builder.setCanceledOnTouchOutside(true)
builder.setOnCancelListener {
getSessionManager()!!.removeSessionManagerListener(newConnectionListener, CastSession::class.java)
callback.onCancel()
val builder = MediaRouteChooserDialog(mainActivity, R.style.Theme_AppCompat_NoActionBar)
builder.routeSelector = MediaRouteSelector.Builder()
.addControlCategory(CastMediaControlIntent.categoryForCast(CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID))
.build()
builder.setCanceledOnTouchOutside(true)
builder.setOnCancelListener {
newConnectionListener?.let { ncl ->
getSessionManager()?.removeSessionManagerListener(ncl, CastSession::class.java)
}
builder.show()
} else {
// We are are already connected, so show the "connection options" Dialog
val builder: AlertDialog.Builder = AlertDialog.Builder(mainActivity)
if (session.castDevice != null) {
builder.setTitle(session.castDevice.friendlyName)
}
builder.setOnDismissListener { callback.onCancel() }
builder.setPositiveButton("Stop Casting") { dialog, which -> endSession(true, null) }
builder.show()
callback.onCancel()
}
builder.show()
} else {
// We are are already connected, so show the "connection options" Dialog
val builder: AlertDialog.Builder = AlertDialog.Builder(mainActivity)
session.castDevice?.let {
builder.setTitle(it.friendlyName)
}
builder.setOnDismissListener { callback.onCancel() }
builder.setPositiveButton("Stop Casting") { _, _ -> endSession(true, null) }
builder.show()
}
})
}
}
abstract class RequestSessionCallback : ConnectionCallback {
@@ -103,12 +100,10 @@ class CastManager constructor(playerNotificationService:PlayerNotificationServic
fun endSession(stopCasting: Boolean, pluginCall: PluginCall?) {
getSessionManager()!!.addSessionManagerListener(object : SessionListener() {
override fun onSessionEnded(castSession: CastSession?, error: Int) {
override fun onSessionEnded(castSession: CastSession, error: Int) {
getSessionManager()!!.removeSessionManagerListener(this, CastSession::class.java)
Log.d(tag, "CAST END SESSION")
// media.setSession(null)
pluginCall?.resolve()
// listener.onSessionEnd(ChromecastUtilities.createSessionObject(castSession, if (stopCasting) "stopped" else "disconnected"))
}
}, CastSession::class.java)
getSessionManager()!!.endCurrentSession(stopCasting)
@@ -116,78 +111,47 @@ class CastManager constructor(playerNotificationService:PlayerNotificationServic
}
open class SessionListener : SessionManagerListener<CastSession> {
override fun onSessionStarting(castSession: CastSession?) {}
override fun onSessionStarted(castSession: CastSession?, sessionId: String) {}
override fun onSessionStartFailed(castSession: CastSession?, error: Int) {}
override fun onSessionEnding(castSession: CastSession?) {}
override fun onSessionEnded(castSession: CastSession?, error: Int) {}
override fun onSessionResuming(castSession: CastSession?, sessionId: String) {}
override fun onSessionResumed(castSession: CastSession?, wasSuspended: Boolean) {}
override fun onSessionResumeFailed(castSession: CastSession?, error: Int) {}
override fun onSessionSuspended(castSession: CastSession?, reason: Int) {}
override fun onSessionStarting(castSession: CastSession) {}
override fun onSessionStarted(castSession: CastSession, sessionId: String) {}
override fun onSessionStartFailed(castSession: CastSession, error: Int) {}
override fun onSessionEnding(castSession: CastSession) {}
override fun onSessionEnded(castSession: CastSession, error: Int) {}
override fun onSessionResuming(castSession: CastSession, sessionId: String) {}
override fun onSessionResumed(castSession: CastSession, wasSuspended: Boolean) {}
override fun onSessionResumeFailed(castSession: CastSession, error: Int) {}
override fun onSessionSuspended(castSession: CastSession, reason: Int) {}
}
private fun startRouteScan() {
var connListener = object: ChromecastListener() {
override fun onReceiverAvailableUpdate(available: Boolean) {
Log.d(tag, "CAST RECEIVER UPDATE AVAILABLE $available")
}
override fun onSessionRejoin(jsonSession: JSONObject?) {
Log.d(tag, "CAST onSessionRejoin")
}
override fun onMediaLoaded(jsonMedia: JSONObject?) {
Log.d(tag, "CAST onMediaLoaded")
}
override fun onMediaUpdate(jsonMedia: JSONObject?) {
Log.d(tag, "CAST onMediaUpdate")
}
override fun onSessionUpdate(jsonSession: JSONObject?) {
Log.d(tag, "CAST onSessionUpdate")
}
override fun onSessionEnd(jsonSession: JSONObject?) {
Log.d(tag, "CAST onSessionEnd")
}
override fun onMessageReceived(p0: CastDevice, p1: String, p2: String) {
Log.d(tag, "CAST onMessageReceived")
}
}
fun startRouteScan(connListener:ChromecastListener) {
var callback = object : ScanCallback() {
override fun onRouteUpdate(routes: List<MediaRouter.RouteInfo>?) {
Log.d(tag, "CAST On ROUTE UPDATED ${routes?.size} | ${getContext().castState}")
// if the routes have changed, we may have an available device
// If there is at least one device available
if (getContext().castState != CastState.NO_DEVICES_AVAILABLE) {
routes?.forEach { Log.d(tag, "CAST ROUTE ${it.description} | ${it.deviceType} | ${it.isBluetooth} | ${it.name}") }
// Stop the scan
stopRouteScan(this, null);
stopRouteScan(this, null)
// Let the client know a receiver is available
connListener.onReceiverAvailableUpdate(true);
connListener.onReceiverAvailableUpdate(true)
// Since we have a receiver we may also have an active session
var session = getSessionManager()?.currentCastSession;
var session = getSessionManager()?.currentCastSession
// If we do have a session
if (session != null) {
// Let the client know
Log.d(tag, "LET SESSION KNOW ABOUT")
// media.setSession(session);
// connListener.onSessionRejoin(ChromecastUtilities.createSessionObject(session));
}
} else {
Log.d(tag, "No cast devices available")
}
}
}
callback.setMediaRouter(getMediaRouter())
callback.onFilteredRouteUpdate();
callback.onFilteredRouteUpdate()
getMediaRouter()!!.addCallback(MediaRouteSelector.Builder()
getMediaRouter()?.addCallback(MediaRouteSelector.Builder()
.addControlCategory(CastMediaControlIntent.categoryForCast(CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID))
.build(),
callback,
@@ -201,7 +165,7 @@ class CastManager constructor(playerNotificationService:PlayerNotificationServic
fun onSessionEnd(jsonSession: JSONObject?)
}
internal abstract class ChromecastListener : CastStateListener, CastListener {
abstract class ChromecastListener : CastStateListener, CastListener {
abstract fun onReceiverAvailableUpdate(available: Boolean)
abstract fun onSessionRejoin(jsonSession: JSONObject?)
@@ -212,15 +176,19 @@ class CastManager constructor(playerNotificationService:PlayerNotificationServic
}
fun stopRouteScan(callback: ScanCallback?, completionCallback: Runnable?) {
Log.d(tag, "stopRouteScan")
if (callback == null) {
completionCallback!!.run()
completionCallback?.run()
return
}
// ctx.runOnUiThread(Runnable {
callback.stop()
getMediaRouter()!!.removeCallback(callback)
completionCallback?.run()
// })
// mainActivity.runOnUiThread {
Log.d(tag, "Removing callback on media router")
callback.stop()
getMediaRouter()?.removeCallback(callback)
completionCallback?.run()
// }
}
abstract class ScanCallback : MediaRouter.Callback() {
@@ -271,7 +239,7 @@ class CastManager constructor(playerNotificationService:PlayerNotificationServic
}
if (!route.isDefault
&& !route.description.equals("Google Cast Multizone Member")
&& route.playbackType === MediaRouter.RouteInfo.PLAYBACK_TYPE_REMOTE) {
&& route.playbackType == MediaRouter.RouteInfo.PLAYBACK_TYPE_REMOTE) {
outRoutes.add(route)
}
}
@@ -293,46 +261,48 @@ class CastManager constructor(playerNotificationService:PlayerNotificationServic
private fun listenForConnection(callback: ConnectionCallback) {
// We should only ever have one of these listeners active at a time, so remove previous
getSessionManager()?.removeSessionManagerListener(newConnectionListener, CastSession::class.java)
newConnectionListener?.let { ncl ->
getSessionManager()?.removeSessionManagerListener(ncl, CastSession::class.java)
}
newConnectionListener = object : SessionListener() {
override fun onSessionStarted(castSession: CastSession?, sessionId: String) {
Log.d(tag, "CAST SESSION STARTED ${castSession?.castDevice?.friendlyName}")
override fun onSessionStarted(castSession: CastSession, sessionId: String) {
Log.d(tag, "CAST SESSION STARTED ${castSession.castDevice?.friendlyName}")
getSessionManager()?.removeSessionManagerListener(this, CastSession::class.java)
try {
val castContext = CastContext.getSharedInstance(mainActivity)
playerNotificationService.castPlayer = CastPlayer(castContext).apply {
setSessionAvailabilityListener(CastSessionAvailabilityListener())
addListener(playerNotificationService.getPlayerListener())
val castContext = CastContext.getSharedInstance(mainActivity)
playerNotificationService?.let {
if (it.castPlayer == null) {
Log.d(tag, "Initializing castPlayer on session started - switch to cast player")
it.castPlayer = CastPlayer(castContext).apply {
addListener(PlayerListener(it))
setSessionAvailabilityListener(CastSessionAvailabilityListener())
}
switchToPlayer(true)
} else {
Log.d(tag, "castPlayer is already initialized on session started")
}
Log.d(tag, "CAST Cast Player Applied")
switchToPlayer(true)
} catch (e: Exception) {
Log.i(tag, "Cast is not available on this device. " +
"Exception thrown when attempting to obtain CastContext. " + e.message)
return
}
// media.setSession(castSession)
// callback.onJoin(ChromecastUtilities.createSessionObject(castSession))
}
override fun onSessionStartFailed(castSession: CastSession?, errCode: Int) {
if (callback.onSessionStartFailed(errCode)) {
override fun onSessionStartFailed(castSession: CastSession, error: Int) {
if (callback.onSessionStartFailed(error)) {
getSessionManager()?.removeSessionManagerListener(this, CastSession::class.java)
}
}
override fun onSessionEnded(castSession: CastSession?, errCode: Int) {
if (callback.onSessionEndedBeforeStart(errCode)) {
override fun onSessionEnded(castSession: CastSession, error: Int) {
if (callback.onSessionEndedBeforeStart(error)) {
getSessionManager()?.removeSessionManagerListener(this, CastSession::class.java)
}
}
}
getSessionManager()?.addSessionManagerListener(newConnectionListener, CastSession::class.java)
newConnectionListener?.let {
Log.d(tag, "Add session manager listener")
getSessionManager()?.addSessionManagerListener(it, CastSession::class.java)
}
}
private fun getContext(): CastContext {
@@ -344,7 +314,7 @@ class CastManager constructor(playerNotificationService:PlayerNotificationServic
}
private fun getMediaRouter(): MediaRouter? {
return mainActivity?.let { MediaRouter.getInstance(it) }
return MediaRouter.getInstance(mainActivity)
}
private fun getSession(): CastSession? {
@@ -0,0 +1,978 @@
package com.audiobookshelf.app.player
import android.annotation.SuppressLint
import android.os.Looper
import android.util.Log
import android.view.Surface
import android.view.SurfaceHolder
import android.view.SurfaceView
import android.view.TextureView
import com.google.android.exoplayer2.*
import com.google.android.exoplayer2.C.TrackType
import com.google.android.exoplayer2.MediaMetadata
import com.google.android.exoplayer2.Player.*
import com.google.android.exoplayer2.TracksInfo.TrackGroupInfo
import com.google.android.exoplayer2.audio.AudioAttributes
import com.google.android.exoplayer2.ext.cast.SessionAvailabilityListener
import com.google.android.exoplayer2.source.TrackGroup
import com.google.android.exoplayer2.source.TrackGroupArray
import com.google.android.exoplayer2.text.Cue
import com.google.android.exoplayer2.trackselection.TrackSelection
import com.google.android.exoplayer2.trackselection.TrackSelectionArray
import com.google.android.exoplayer2.trackselection.TrackSelectionParameters
import com.google.android.exoplayer2.util.*
import com.google.android.exoplayer2.util.Util.castNonNull
import com.google.android.exoplayer2.video.VideoSize
import com.google.android.gms.cast.*
import com.google.android.gms.cast.framework.CastContext
import com.google.android.gms.cast.framework.CastSession
import com.google.android.gms.cast.framework.SessionManagerListener
import com.google.android.gms.cast.framework.media.RemoteMediaClient
import com.google.android.gms.common.api.PendingResult
import com.google.android.gms.common.api.ResultCallback
import org.json.JSONObject
class CastPlayer(var castContext: CastContext) : BasePlayer() {
val tag = "CastPlayer"
private val RENDERER_COUNT = 3
private val RENDERER_INDEX_VIDEO = 0
private val RENDERER_INDEX_AUDIO = 1
private val RENDERER_INDEX_TEXT = 2
private val PROGRESS_REPORT_PERIOD_MS: Long = 1000
private val EMPTY_TRACK_SELECTION_ARRAY = TrackSelectionArray()
private val EMPTY_TRACK_ID_ARRAY = LongArray(0)
private val seekBackIncrementMs: Long = 5000L
private val seekForwardIncrementMs: Long = 5000L
private var myPlayWhenReady:Boolean = false
private var playbackParameters:PlaybackParameters = PlaybackParameters.DEFAULT
private var myAvailableCommands: Commands
private var myCurrentTracksInfo: TracksInfo
private var myCurrentTrackGroups: TrackGroupArray
private var myCurrentTrackSelections: TrackSelectionArray
var currentMediaItems:List<MediaItem> = mutableListOf()
var remoteMediaClient:RemoteMediaClient? = null
var sessionAvailabilityListener:SessionAvailabilityListener? = null
var myCurrentTimeline: CastTimeline
val timelineTracker: CastTimelineTracker
val period: Timeline.Period
var listeners: ListenerSet<Player.Listener>
/* package */
val PERMANENT_AVAILABLE_COMMANDS = Commands.Builder()
.addAll(
COMMAND_PLAY_PAUSE,
COMMAND_PREPARE,
COMMAND_STOP,
COMMAND_SEEK_TO_DEFAULT_POSITION,
COMMAND_SEEK_TO_MEDIA_ITEM,
COMMAND_SET_REPEAT_MODE,
COMMAND_SET_SPEED_AND_PITCH,
COMMAND_GET_CURRENT_MEDIA_ITEM,
COMMAND_GET_TIMELINE,
COMMAND_GET_MEDIA_ITEMS_METADATA,
COMMAND_SET_MEDIA_ITEMS_METADATA,
COMMAND_CHANGE_MEDIA_ITEMS,
COMMAND_GET_TRACK_INFOS)
.build()
var currentPlaybackState = Player.STATE_IDLE
var statusListener:StatusListener
val deviceInfo get() = castContext.sessionManager.currentCastSession?.castDevice.toString()
var lastReportedPositionMs = 0L
private var currentMediaItemIndex = 0
private var pendingMediaItemRemovalPosition:PositionInfo? = null
private var pendingSeekCount = 0
private var pendingSeekWindowIndex = 0
private var pendingSeekPositionMs = 0L
init {
Log.d(tag, "Init CastPlayer")
myCurrentTrackGroups = TrackGroupArray.EMPTY
myCurrentTrackSelections = EMPTY_TRACK_SELECTION_ARRAY
myCurrentTracksInfo = TracksInfo.EMPTY
statusListener = StatusListener()
timelineTracker = CastTimelineTracker()
myCurrentTimeline = CastTimeline.EMPTY_CAST_TIMELINE
period = Timeline.Period()
myAvailableCommands = Commands.Builder().addAll(PERMANENT_AVAILABLE_COMMANDS).build()
listeners = ListenerSet(
Looper.getMainLooper(),
Clock.DEFAULT
) { listener: Player.Listener, flags: FlagSet? -> listener.onEvents(this, Player.Events(flags ?: FlagSet.Builder().build())) }
val sessionManager = castContext.sessionManager
sessionManager.addSessionManagerListener(statusListener, CastSession::class.java)
val session = sessionManager.currentCastSession
setRemoteMediaClient(session?.remoteMediaClient)
}
fun load(mediaItems:List<MediaItem>, startIndex:Int, startTime:Long, playWhenReady:Boolean, playbackRate:Float, mediaType:String) {
Log.d(tag, "Load called")
if (remoteMediaClient == null) {
Log.d(tag, "Remote Media Client not set")
return
}
currentMediaItems = mediaItems
var mediaQueueItems = mediaItems.map { toMediaQueueItem(it) }
var queueData = MediaQueueData.Builder().apply {
setItems(mediaQueueItems)
setQueueType(if (mediaType == "book") MediaQueueData.MEDIA_QUEUE_TYPE_AUDIO_BOOK else MediaQueueData.MEDIA_QUEUE_TYPE_PODCAST_SERIES)
setRepeatMode(MediaStatus.REPEAT_MODE_REPEAT_OFF)
setStartIndex(startIndex)
setStartTime(startTime)
}.build()
var loadRequestData = MediaLoadRequestData.Builder().apply {
setPlaybackRate(playbackRate.toDouble())
setQueueData(queueData)
setAutoplay(playWhenReady)
setCurrentTime(startTime)
}.build()
remoteMediaClient?.load(loadRequestData)?.setResultCallback {
Log.d(tag, "Loaded cast player result ${it.status} | ${it.mediaError} | ${it.customData}")
}
Log.d(tag, "Loaded cast player request data $loadRequestData")
}
private fun toMediaQueueItem(mediaItem: MediaItem): MediaQueueItem {
// The MediaQueueItem you build is expected to be in the tag.
return (mediaItem.localConfiguration!!.tag as MediaQueueItem?)!!
}
@JvmName("setRemoteMediaClient1")
private fun setRemoteMediaClient(remoteMediaClient: RemoteMediaClient?) {
if (this.remoteMediaClient === remoteMediaClient) {
// Do nothing.
return
}
if (this.remoteMediaClient != null) {
this.remoteMediaClient?.unregisterCallback(statusListener)
this.remoteMediaClient?.removeProgressListener(statusListener)
}
this.remoteMediaClient = remoteMediaClient
if (remoteMediaClient != null) {
sessionAvailabilityListener?.let {
it.onCastSessionAvailable()
}
remoteMediaClient.registerCallback(statusListener)
remoteMediaClient.addProgressListener(statusListener, PROGRESS_REPORT_PERIOD_MS)
updateInternalStateAndNotifyIfChanged()
} else {
updateTimelineAndNotifyIfChanged()
sessionAvailabilityListener?.let {
it.onCastSessionUnavailable()
}
}
}
private fun setPlayerStateAndNotifyIfChanged(
playWhenReady: Boolean,
@PlayWhenReadyChangeReason playWhenReadyChangeReason: Int,
@Player.State playbackState: Int) {
val wasPlaying = this.currentPlaybackState == STATE_READY && this.playWhenReady
val playWhenReadyChanged = this.playWhenReady != playWhenReady
val playbackStateChanged = this.currentPlaybackState != playbackState
Log.d(tag, "setPlayerStateAndNotifyIfChanged newPlayWhenReady:$playWhenReady | playbackStateChanged:$playbackStateChanged | playWhenReadyChanged:$playWhenReadyChanged")
if (playWhenReadyChanged || playbackStateChanged) {
this.currentPlaybackState = playbackState
this.myPlayWhenReady = playWhenReady
if (playbackStateChanged) {
Log.d(tag, "CastPlayer About to emit onPlaybackStateChanged")
listeners.queueEvent(EVENT_PLAYBACK_STATE_CHANGED) {
it.onPlaybackStateChanged(playbackState)
}
}
if (playWhenReadyChanged) {
listeners.queueEvent(EVENT_PLAY_WHEN_READY_CHANGED) {
it.onPlayWhenReadyChanged(playWhenReady, playWhenReadyChangeReason)
}
}
val isPlaying = playbackState == STATE_READY && playWhenReady
if (wasPlaying != isPlaying) {
Log.d(tag, "CastPlayer About to emit onIsPlayingChanged $isPlaying")
listeners.queueEvent(EVENT_IS_PLAYING_CHANGED) {
it.onIsPlayingChanged(isPlaying)
}
} else {
Log.d(tag, "Is playing and was playing are equal $wasPlaying")
}
} else {
Log.d(tag, "setPlayerStateAndNotifyIfChanged No changes")
}
listeners.flushEvents()
}
private fun updatePlayerStateAndNotifyIfChanged() {
var newPlayWhenReadyValue = remoteMediaClient?.isPaused != true
@PlayWhenReadyChangeReason val playWhenReadyChangeReason = if (newPlayWhenReadyValue != playWhenReady) PLAY_WHEN_READY_CHANGE_REASON_REMOTE else PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST
// We do not mask the playback state, so try setting it regardless of the playWhenReady masking.
setPlayerStateAndNotifyIfChanged(
newPlayWhenReadyValue, playWhenReadyChangeReason, fetchPlaybackState(remoteMediaClient))
}
private fun fetchPlaybackState(remoteMediaClient: RemoteMediaClient?): Int {
return when (remoteMediaClient?.playerState) {
MediaStatus.PLAYER_STATE_BUFFERING -> STATE_BUFFERING
MediaStatus.PLAYER_STATE_PLAYING, MediaStatus.PLAYER_STATE_PAUSED -> STATE_READY
MediaStatus.PLAYER_STATE_IDLE, MediaStatus.PLAYER_STATE_UNKNOWN -> STATE_IDLE
else -> STATE_IDLE
}
}
private fun updatePlaybackRateAndNotifyIfChanged() {
val mediaStatus = remoteMediaClient!!.mediaStatus
val speed = mediaStatus?.playbackRate?.toFloat() ?: PlaybackParameters.DEFAULT.speed
if (speed > 0.0f) {
// Set the speed if not paused.
setPlaybackParametersAndNotifyIfChanged(PlaybackParameters(speed))
}
}
private fun setPlaybackParametersAndNotifyIfChanged(playbackParameters: PlaybackParameters) {
if (this.playbackParameters == playbackParameters) {
return
}
this.playbackParameters = playbackParameters
listeners.queueEvent(
EVENT_PLAYBACK_PARAMETERS_CHANGED
) { listener -> listener.onPlaybackParametersChanged(playbackParameters) }
listeners.flushEvents()
updateAvailableCommandsAndNotifyIfChanged()
}
private fun updateTimelineAndNotifyIfChanged(): Boolean {
val oldTimeline: Timeline = this.myCurrentTimeline
val oldWindowIndex = currentMediaItemIndex
var playingPeriodChanged = false
if (updateTimeline()) {
val timeline: Timeline = this.myCurrentTimeline
// Call onTimelineChanged.
listeners.queueEvent(
EVENT_TIMELINE_CHANGED
) { listener: Player.Listener -> listener.onTimelineChanged(timeline, TIMELINE_CHANGE_REASON_SOURCE_UPDATE) }
// Call onPositionDiscontinuity if required.
val currentTimeline = currentTimeline
var playingPeriodRemoved = false
if (!oldTimeline.isEmpty) {
val oldPeriodUid = castNonNull(oldTimeline.getPeriod(oldWindowIndex, period, /* setIds= */true).uid)
playingPeriodRemoved = currentTimeline.getIndexOfPeriod(oldPeriodUid) == C.INDEX_UNSET
}
if (playingPeriodRemoved) {
var oldPosition: PositionInfo = getCurrentPositionInfo()
if (pendingMediaItemRemovalPosition != null) {
pendingMediaItemRemovalPosition?.let {
oldPosition = it
}
pendingMediaItemRemovalPosition = null
} else {
// If the media item has been removed by another client, we don't know the removal
// position. We use the current position as a fallback.
oldTimeline.getPeriod(oldWindowIndex, period, /* setIds= */true)
oldTimeline.getWindow(period.windowIndex, window)
oldPosition = PositionInfo(
window.uid,
period.windowIndex,
window.mediaItem,
period.uid,
period.windowIndex,
currentPosition,
contentPosition,
C.INDEX_UNSET,/* adGroupIndex= */
C.INDEX_UNSET) /* adIndexInAdGroup= */
}
val newPosition: PositionInfo = getCurrentPositionInfo()
listeners.queueEvent(
EVENT_POSITION_DISCONTINUITY
) { listener: Player.Listener ->
listener.onPositionDiscontinuity(
oldPosition, newPosition, DISCONTINUITY_REASON_REMOVE)
}
}
// Call onMediaItemTransition if required.
playingPeriodChanged = currentTimeline.isEmpty != oldTimeline.isEmpty || playingPeriodRemoved
if (playingPeriodChanged) {
listeners.queueEvent(
EVENT_MEDIA_ITEM_TRANSITION
) { listener: Player.Listener ->
listener.onMediaItemTransition(
currentMediaItem, MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED)
}
}
updateAvailableCommandsAndNotifyIfChanged()
}
return playingPeriodChanged
}
private fun updateInternalStateAndNotifyIfChanged() {
if (remoteMediaClient == null) {
// There is no session. We leave the state of the player as it is now.
return
}
val oldWindowIndex = this.currentMediaItemIndex
val oldPeriodUid = if (!currentTimeline.isEmpty) currentTimeline.getPeriod(oldWindowIndex, period, /* setIds= */true).uid else null
updatePlayerStateAndNotifyIfChanged()
// updateRepeatModeAndNotifyIfChanged( /* resultCallback= */null)
updatePlaybackRateAndNotifyIfChanged()
val playingPeriodChangedByTimelineChange = updateTimelineAndNotifyIfChanged()
val currentTimeline = currentTimeline
currentMediaItemIndex = fetchCurrentWindowIndex(remoteMediaClient, currentTimeline)
val currentPeriodUid = if (!currentTimeline.isEmpty) currentTimeline.getPeriod(currentMediaItemIndex, period, /* setIds= */true).uid else null
if (!playingPeriodChangedByTimelineChange
&& !Util.areEqual(oldPeriodUid, currentPeriodUid)
&& pendingSeekCount == 0) {
// Report discontinuity and media item auto transition.
currentTimeline.getPeriod(oldWindowIndex, period, /* setIds= */true)
currentTimeline.getWindow(oldWindowIndex, window)
val windowDurationMs = window.durationMs
val oldPosition = PositionInfo(
window.uid,
period.windowIndex,
window.mediaItem,
period.uid,
period.windowIndex, /* positionMs= */
windowDurationMs, /* contentPositionMs= */
windowDurationMs, /* adGroupIndex= */
C.INDEX_UNSET, /* adIndexInAdGroup= */
C.INDEX_UNSET)
currentTimeline.getPeriod(currentMediaItemIndex, period, /* setIds= */true)
currentTimeline.getWindow(currentMediaItemIndex, window)
val newPosition = PositionInfo(
window.uid,
period.windowIndex,
window.mediaItem,
period.uid,
period.windowIndex, /* positionMs= */
window.defaultPositionMs, /* contentPositionMs= */
window.defaultPositionMs, /* adGroupIndex= */
C.INDEX_UNSET, /* adIndexInAdGroup= */
C.INDEX_UNSET)
listeners.queueEvent(
EVENT_POSITION_DISCONTINUITY
) { listener: Listener ->
listener.onPositionDiscontinuity(
oldPosition, newPosition, DISCONTINUITY_REASON_AUTO_TRANSITION)
}
listeners.queueEvent(
EVENT_MEDIA_ITEM_TRANSITION
) { listener: Listener ->
listener.onMediaItemTransition(
currentMediaItem, MEDIA_ITEM_TRANSITION_REASON_AUTO)
}
}
if (updateTracksAndSelectionsAndNotifyIfChanged()) {
listeners.queueEvent(
EVENT_TRACKS_CHANGED) { listener: Listener -> listener.onTracksInfoChanged(currentTracksInfo) }
}
updateAvailableCommandsAndNotifyIfChanged()
listeners.flushEvents()
}
/** Updates the internal tracks and selection and returns whether they have changed. */
@SuppressLint("WrongConstant")
private fun updateTracksAndSelectionsAndNotifyIfChanged(): Boolean {
if (remoteMediaClient == null) {
// There is no session. We leave the state of the player as it is now.
return false
}
val mediaStatus = getMediaStatus()
val mediaInfo = mediaStatus?.mediaInfo
val castMediaTracks = mediaInfo?.mediaTracks
if (castMediaTracks == null || castMediaTracks.isEmpty()) {
val hasChanged = !myCurrentTrackGroups.isEmpty
myCurrentTrackGroups = TrackGroupArray.EMPTY
myCurrentTrackSelections = EMPTY_TRACK_SELECTION_ARRAY
myCurrentTracksInfo = TracksInfo.EMPTY
return hasChanged
}
var activeTrackIds = mediaStatus.activeTrackIds
if (activeTrackIds == null) {
activeTrackIds = EMPTY_TRACK_ID_ARRAY
}
val trackGroups = arrayOfNulls<TrackGroup>(castMediaTracks.size)
val trackSelections = arrayOfNulls<TrackSelection>(RENDERER_COUNT)
val trackGroupInfos = arrayOfNulls<TrackGroupInfo>(castMediaTracks.size)
for (i in castMediaTracks.indices) {
val mediaTrack = castMediaTracks[i]
trackGroups[i] = TrackGroup( /* id= */i.toString(), mediaTrackToFormat(mediaTrack))
val id = mediaTrack.id
val trackType = MimeTypes.getTrackType(mediaTrack.contentType)
val rendererIndex: Int = getRendererIndexForTrackType(trackType)
val supported = rendererIndex != C.INDEX_UNSET
val selected = isTrackActive(id, activeTrackIds) && supported && trackSelections[rendererIndex] == null
if (selected) {
trackSelections[rendererIndex] = trackGroups[i]?.let { CastTrackSelection(it) }
}
val trackSupport = intArrayOf(if (supported) C.FORMAT_HANDLED else C.FORMAT_UNSUPPORTED_TYPE)
val trackSelected = booleanArrayOf(selected)
trackGroupInfos[i] = TrackGroupInfo(trackGroups[i]!!, trackSupport, trackType, trackSelected)
}
val tg = trackGroups.filterNotNull().toTypedArray()
var ts = trackSelections.filterNotNull().toTypedArray()
var tgi = trackGroupInfos.filterNotNull().toMutableList()
val newTrackGroups = TrackGroupArray(*tg)
val newTrackSelections = TrackSelectionArray(*ts)
val newTracksInfo = TracksInfo(tgi)
if (newTrackGroups != currentTrackGroups
|| newTrackSelections != currentTrackSelections
|| newTracksInfo != currentTracksInfo) {
myCurrentTrackSelections = newTrackSelections
myCurrentTrackGroups = newTrackGroups
myCurrentTracksInfo = newTracksInfo
return true
}
return false
}
/**
* Creates a [Format] instance containing all information contained in the given [ ] object.
*
* @param mediaTrack The [MediaTrack].
* @return The equivalent [Format].
*/
fun mediaTrackToFormat(mediaTrack: MediaTrack): Format {
return Format.Builder()
.setId(mediaTrack.contentId)
.setContainerMimeType(mediaTrack.contentType)
.setLanguage(mediaTrack.language)
.build()
}
private fun getRendererIndexForTrackType(trackType: @TrackType Int): Int {
return if (trackType == C.TRACK_TYPE_VIDEO) RENDERER_INDEX_VIDEO else if (trackType == C.TRACK_TYPE_AUDIO) RENDERER_INDEX_AUDIO else if (trackType == C.TRACK_TYPE_TEXT) RENDERER_INDEX_TEXT else C.INDEX_UNSET
}
private fun isTrackActive(id: Long, activeTrackIds: LongArray): Boolean {
for (activeTrackId in activeTrackIds) {
if (activeTrackId == id) {
return true
}
}
return false
}
private fun getCurrentPositionInfo(): PositionInfo {
val currentTimeline = currentTimeline
var newPeriodUid: Any? = null
var newWindowUid: Any? = null
var newMediaItem: MediaItem? = null
if (!currentTimeline.isEmpty) {
newPeriodUid = currentTimeline.getPeriod(currentPeriodIndex, period, /* setIds= */true).uid
newWindowUid = currentTimeline.getWindow(period.windowIndex, window).uid
newMediaItem = window.mediaItem
}
return PositionInfo(
newWindowUid,
currentMediaItemIndex,
newMediaItem,
newPeriodUid,
currentPeriodIndex,
currentPosition,
contentPosition,
C.INDEX_UNSET,/* adGroupIndex= */
C.INDEX_UNSET) /* adIndexInAdGroup= */
}
private fun updateTimeline(): Boolean {
val oldTimeline = this.myCurrentTimeline
val status: MediaStatus? = getMediaStatus()
remoteMediaClient?.let {
this.myCurrentTimeline = if (status != null) timelineTracker.getCastTimeline(it) else CastTimeline.EMPTY_CAST_TIMELINE
}
val timelineChanged = oldTimeline != this.myCurrentTimeline
if (timelineChanged) {
currentMediaItemIndex = fetchCurrentWindowIndex(remoteMediaClient, this.myCurrentTimeline)
Log.d(tag, "timelineChanged $currentMediaItemIndex")
}
return timelineChanged
}
private fun fetchCurrentWindowIndex(remoteMediaClient: RemoteMediaClient?, timeline: Timeline): Int {
if (remoteMediaClient == null) {
return 0
}
var currentWindowIndex = C.INDEX_UNSET
val currentItem = remoteMediaClient.currentItem
if (currentItem != null) {
currentWindowIndex = timeline.getIndexOfPeriod(currentItem.itemId)
}
if (currentWindowIndex == C.INDEX_UNSET) {
// The timeline is empty. Fall back to index 0.
currentWindowIndex = 0
}
return currentWindowIndex
}
private fun getMediaStatus():MediaStatus? {
return remoteMediaClient?.mediaStatus
}
@JvmName("setSessionAvailabilityListener1")
fun setSessionAvailabilityListener(listener: SessionAvailabilityListener) {
sessionAvailabilityListener = listener
}
override fun getApplicationLooper(): Looper {
return Looper.getMainLooper()
}
override fun addListener(listener: Player.Listener) {
listeners.add(listener)
Log.d(tag, "addListener player listener $listener")
}
override fun removeListener(listener: Player.Listener) {
listeners.remove(listener)
}
override fun setMediaItems(mediaItems: MutableList<MediaItem>, resetPosition: Boolean) {
}
override fun setMediaItems(mediaItems: MutableList<MediaItem>, startWindowIndex: Int, startPositionMs: Long) {
}
override fun addMediaItems(index: Int, mediaItems: MutableList<MediaItem>) {
}
override fun moveMediaItems(fromIndex: Int, toIndex: Int, newIndex: Int) {
}
override fun removeMediaItems(fromIndex: Int, toIndex: Int) {
Log.d(tag, "removeMediaItems called not configured yet $fromIndex to $toIndex")
}
override fun getAvailableCommands(): Player.Commands {
return myAvailableCommands
}
override fun prepare() {
}
override fun getPlaybackState(): Int {
return currentPlaybackState
}
override fun getPlaybackSuppressionReason(): Int {
return Player.PLAYBACK_SUPPRESSION_REASON_NONE
}
override fun getPlayerError(): PlaybackException? {
return null
}
override fun setPlayWhenReady(playWhenReady: Boolean) {
setPlayerStateAndNotifyIfChanged(
playWhenReady, PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST, playbackState)
listeners.flushEvents()
val pendingResult: PendingResult<RemoteMediaClient.MediaChannelResult> = if (playWhenReady) remoteMediaClient!!.play() else remoteMediaClient!!.pause()
var resultCb = ResultCallback<RemoteMediaClient.MediaChannelResult?> {
updatePlayerStateAndNotifyIfChanged()
}
pendingResult.setResultCallback(resultCb)
}
override fun getPlayWhenReady(): Boolean {
return myPlayWhenReady
}
override fun setRepeatMode(repeatMode: Int) {
}
override fun getRepeatMode(): Int {
return Player.REPEAT_MODE_OFF
}
override fun setShuffleModeEnabled(shuffleModeEnabled: Boolean) {
}
override fun getShuffleModeEnabled(): Boolean {
return false
}
override fun isLoading(): Boolean {
return false
}
override fun seekTo(mediaItemIndex: Int, positionMs: Long) {
Log.d(tag, "seekTo $mediaItemIndex position $positionMs")
var resultCb = ResultCallback<RemoteMediaClient.MediaChannelResult?> {
val statusCode: Int = it.status.statusCode
if (statusCode != CastStatusCodes.SUCCESS && statusCode != CastStatusCodes.REPLACED) {
Log.e(tag, "Seek failed. Error code $statusCode")
}
if (--pendingSeekCount == 0) {
currentMediaItemIndex = pendingSeekWindowIndex
Log.d(tag, "seekTo CB $currentMediaItemIndex")
pendingSeekWindowIndex = C.INDEX_UNSET
pendingSeekPositionMs = C.TIME_UNSET
// Playback state change will send metadata to client and stop seek loading
listeners.sendEvent(EVENT_PLAYBACK_STATE_CHANGED) { obj: Player.Listener -> obj.onPlaybackStateChanged(currentPlaybackState) }
}
}
val mediaStatus = getMediaStatus()
// We assume the default position is 0. There is no support for seeking to the default position
// in RemoteMediaClient.
var positionMsFinal = if (positionMs != C.TIME_UNSET) positionMs else 0
if (mediaStatus != null) {
if (currentMediaItemIndex != mediaItemIndex) {
Log.d(tag, "seekTo: Changing media item index from $currentMediaItemIndex to $mediaItemIndex")
remoteMediaClient?.queueJumpToItem(myCurrentTimeline.getPeriod(mediaItemIndex, period).uid as Int, positionMsFinal, JSONObject())?.setResultCallback(resultCb)
} else {
Log.d(tag, "seekTo: Same media index seek to position $positionMsFinal")
var mediaSeekOptions = MediaSeekOptions.Builder().setPosition(positionMsFinal).build()
remoteMediaClient?.seek(mediaSeekOptions)?.setResultCallback(resultCb)
}
val oldPosition = getCurrentPositionInfo()
pendingSeekCount++
pendingSeekWindowIndex = mediaItemIndex
pendingSeekPositionMs = positionMsFinal
val newPosition = getCurrentPositionInfo()
listeners.queueEvent(
EVENT_POSITION_DISCONTINUITY
) { listener: Player.Listener ->
listener.onPositionDiscontinuity(oldPosition, newPosition, DISCONTINUITY_REASON_SEEK)
}
if (oldPosition.mediaItemIndex != newPosition.mediaItemIndex) {
val mediaItem = currentTimeline.getWindow(mediaItemIndex, window).mediaItem
listeners.queueEvent(
EVENT_MEDIA_ITEM_TRANSITION
) { listener: Player.Listener -> listener.onMediaItemTransition(mediaItem, MEDIA_ITEM_TRANSITION_REASON_SEEK) }
}
updateAvailableCommandsAndNotifyIfChanged()
} else if (pendingSeekCount == 0) {
Log.w(tag, "seekTo Media Status is null")
// Playback state change will send metadata to client and stop seek loading
listeners.sendEvent(EVENT_PLAYBACK_STATE_CHANGED) { obj: Player.Listener -> obj.onPlaybackStateChanged(currentPlaybackState) }
}
listeners.flushEvents()
}
private fun updateAvailableCommandsAndNotifyIfChanged() {
val previousAvailableCommands = availableCommands
myAvailableCommands = Util.getAvailableCommands(this,PERMANENT_AVAILABLE_COMMANDS)
if (availableCommands != previousAvailableCommands) {
listeners.queueEvent(
EVENT_AVAILABLE_COMMANDS_CHANGED
) { listener: Listener -> listener.onAvailableCommandsChanged(availableCommands) }
}
}
override fun getSeekBackIncrement(): Long {
return seekBackIncrementMs
}
override fun getSeekForwardIncrement(): Long {
return seekForwardIncrementMs
}
override fun getMaxSeekToPreviousPosition(): Long {
return C.DEFAULT_MAX_SEEK_TO_PREVIOUS_POSITION_MS
}
override fun setPlaybackParameters(playbackParameters: PlaybackParameters) {
val actualPlaybackParameters = PlaybackParameters(playbackParameters.speed)
setPlaybackParametersAndNotifyIfChanged(actualPlaybackParameters)
listeners.flushEvents()
val pendingResult: PendingResult<RemoteMediaClient.MediaChannelResult>? = remoteMediaClient?.setPlaybackRate(actualPlaybackParameters.speed.toDouble(), /* customData= */null)
var resultCb = ResultCallback<RemoteMediaClient.MediaChannelResult?> {
updatePlaybackRateAndNotifyIfChanged()
listeners.flushEvents()
}
pendingResult?.setResultCallback(resultCb)
}
override fun getPlaybackParameters(): PlaybackParameters {
return playbackParameters
}
override fun stop() {
Log.d(tag, "stop CastPlayer")
currentPlaybackState = Player.STATE_IDLE
remoteMediaClient?.stop()
}
override fun stop(reset: Boolean) {
stop()
}
override fun release() {
val sessionManager = castContext.sessionManager
sessionManager.removeSessionManagerListener(statusListener, CastSession::class.java)
sessionManager.endCurrentSession(false)
}
override fun getCurrentTrackGroups(): TrackGroupArray {
return this.myCurrentTrackGroups
}
override fun getCurrentTrackSelections(): TrackSelectionArray {
return this.myCurrentTrackSelections
}
override fun getCurrentTracksInfo(): TracksInfo {
return this.myCurrentTracksInfo
}
override fun getTrackSelectionParameters(): TrackSelectionParameters {
return TrackSelectionParameters.DEFAULT_WITHOUT_CONTEXT
}
override fun setTrackSelectionParameters(parameters: TrackSelectionParameters) {}
override fun getMediaMetadata(): MediaMetadata {
return MediaMetadata.EMPTY
}
override fun getPlaylistMetadata(): MediaMetadata {
return MediaMetadata.EMPTY
}
override fun setPlaylistMetadata(mediaMetadata: MediaMetadata) {
}
override fun getCurrentTimeline(): Timeline {
return this.myCurrentTimeline
}
override fun getCurrentPeriodIndex(): Int {
return getCurrentMediaItemIndex()
}
override fun getCurrentMediaItemIndex(): Int {
return if (pendingSeekWindowIndex != C.INDEX_UNSET) pendingSeekWindowIndex else currentMediaItemIndex
}
override fun getDuration(): Long {
return contentDuration
}
override fun getCurrentPosition(): Long {
return remoteMediaClient?.approximateStreamPosition ?: 0L
}
override fun getBufferedPosition(): Long {
return currentPosition
}
override fun getTotalBufferedDuration(): Long {
return 0L
}
override fun isPlayingAd(): Boolean {
return false
}
override fun getCurrentAdGroupIndex(): Int {
return 0
}
override fun getCurrentAdIndexInAdGroup(): Int {
return 0
}
override fun getContentPosition(): Long {
return currentPosition
}
override fun getContentBufferedPosition(): Long {
return currentPosition
}
override fun getAudioAttributes(): AudioAttributes {
return AudioAttributes.DEFAULT
}
override fun setVolume(audioVolume: Float) {
}
override fun getVolume(): Float {
return 0F
}
override fun clearVideoSurface() {
}
override fun clearVideoSurface(surface: Surface?) {
}
override fun setVideoSurface(surface: Surface?) {
}
override fun setVideoSurfaceHolder(surfaceHolder: SurfaceHolder?) {
}
override fun clearVideoSurfaceHolder(surfaceHolder: SurfaceHolder?) {
}
override fun setVideoSurfaceView(surfaceView: SurfaceView?) {
}
override fun clearVideoSurfaceView(surfaceView: SurfaceView?) {
}
override fun setVideoTextureView(textureView: TextureView?) {
}
override fun clearVideoTextureView(textureView: TextureView?) {
}
override fun getVideoSize(): VideoSize {
return VideoSize.UNKNOWN
}
override fun getCurrentCues(): MutableList<Cue> {
return mutableListOf()
}
override fun getDeviceInfo(): DeviceInfo {
return DeviceInfo.UNKNOWN
}
override fun getDeviceVolume(): Int {
return 0
}
override fun isDeviceMuted(): Boolean {
return false
}
override fun setDeviceVolume(volume: Int) {
}
override fun increaseDeviceVolume() {
}
override fun decreaseDeviceVolume() {
}
override fun setDeviceMuted(muted: Boolean) {
}
inner class StatusListener() : RemoteMediaClient.Callback(), SessionManagerListener<CastSession>, RemoteMediaClient.ProgressListener {
val TAG = "StatusListener"
// RemoteMediaClient.ProgressListener implementation.
override fun onProgressUpdated(progressMs: Long, unusedDurationMs: Long) {
lastReportedPositionMs = progressMs
}
// RemoteMediaClient.Callback implementation.
override fun onStatusUpdated() {
Log.d(TAG, "StatusListener status queue items " + remoteMediaClient?.mediaStatus?.queueItemCount)
if (remoteMediaClient?.playerState == MediaStatus.PLAYER_STATE_IDLE) {
Log.d(TAG, "StatusListener CastPlayer STATE_IDLE")
} else if (remoteMediaClient?.playerState == MediaStatus.PLAYER_STATE_BUFFERING) {
Log.d(TAG, "StatusListener CastPlayer BUFFERING")
} else if (remoteMediaClient?.playerState == MediaStatus.PLAYER_STATE_LOADING) {
Log.d(TAG, "StatusListener CastPlayer PLAYER_STATE_LOADING")
} else if (remoteMediaClient?.playerState == MediaStatus.PLAYER_STATE_PAUSED) {
Log.d(TAG, "StatusListener CastPlayer PLAYER_STATE_PAUSED")
} else if (remoteMediaClient?.playerState == MediaStatus.PLAYER_STATE_PLAYING) {
Log.d(TAG, "StatusListener CastPlayer PLAYER_STATE_PLAYING")
} else if (remoteMediaClient?.playerState == MediaStatus.PLAYER_STATE_UNKNOWN) {
Log.d(TAG, "StatusListener CastPlayer PLAYER_STATE_UNKNOWN")
}
updateInternalStateAndNotifyIfChanged()
}
override fun onMetadataUpdated() {}
override fun onQueueStatusUpdated() {
Log.d(TAG, "onQueueStatusUpdated")
updateTimelineAndNotifyIfChanged()
listeners.flushEvents()
}
override fun onPreloadStatusUpdated() {}
override fun onSendingRemoteMediaRequest() {}
override fun onAdBreakStatusUpdated() {}
// SessionManagerListener implementation.
override fun onSessionStarted(castSession: CastSession, s: String) {
Log.d(TAG, "StatusListener onSessionStarted")
setRemoteMediaClient(castSession.remoteMediaClient)
}
override fun onSessionResumed(castSession: CastSession, b: Boolean) {
setRemoteMediaClient(castSession.remoteMediaClient)
}
override fun onSessionEnded(castSession: CastSession, i: Int) {
Log.d(TAG, "StatusListener onSessionEnded")
setRemoteMediaClient(null)
}
override fun onSessionSuspended(castSession: CastSession, i: Int) {
setRemoteMediaClient(null)
}
override fun onSessionResumeFailed(castSession: CastSession, statusCode: Int) {
Log.e(
TAG,
"Session resume failed. Error code "
+ statusCode)
}
override fun onSessionStarting(castSession: CastSession) {
Log.d(TAG, "StatusListener onSessionStarting")
// Do nothing.
}
override fun onSessionStartFailed(castSession: CastSession, statusCode: Int) {
Log.e(
TAG,
("Session start failed. Error code "
+ statusCode))
}
override fun onSessionEnding(castSession: CastSession) {
// Do nothing.
}
override fun onSessionResuming(castSession: CastSession, s: String) {
// Do nothing.
}
}
}
@@ -0,0 +1,162 @@
/*
* Copyright (C) 2017 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.audiobookshelf.app.player
import android.net.Uri
import android.util.Log
import android.util.SparseArray
import android.util.SparseIntArray
import com.google.android.exoplayer2.C
import com.google.android.exoplayer2.MediaItem
import com.google.android.exoplayer2.Timeline
import java.util.*
/** A [Timeline] for Cast media queues. */ /* package */
class CastTimeline(itemIds: IntArray, itemIdToData: SparseArray<ItemData>) : Timeline() {
/** Holds [Timeline] related data for a Cast media item. */
class ItemData(
/** The duration of the item in microseconds, or [C.TIME_UNSET] if unknown. */
val durationUs: Long,
/**
* The default start position of the item in microseconds, or [C.TIME_UNSET] if unknown.
*/
var defaultPositionUs: Long, isLive: Boolean) {
/** Whether the item is live content, or `false` if unknown. */
val isLive: Boolean = isLive
/**
* Returns a copy of this instance with the given values.
*
* @param durationUs The duration in microseconds, or [C.TIME_UNSET] if unknown.
* @param defaultPositionUs The default start position in microseconds, or [C.TIME_UNSET]
* if unknown.
* @param isLive Whether the item is live, or `false` if unknown.
*/
fun copyWithNewValues(durationUs: Long, defaultPositionUs: Long, isLive: Boolean): ItemData {
return if (durationUs == this.durationUs && defaultPositionUs == this.defaultPositionUs && isLive == this.isLive) {
this
} else ItemData(durationUs, defaultPositionUs, isLive)
}
companion object {
/** Holds no media information. */
val EMPTY = ItemData( /* durationUs= */
C.TIME_UNSET, /* defaultPositionUs= */
C.TIME_UNSET, /* isLive= */
false)
}
}
private val idsToIndex: SparseIntArray
private val ids: IntArray
private val durationsUs: LongArray
private val defaultPositionsUs: LongArray
private val isLive: BooleanArray
// Timeline implementation.
override fun getWindowCount(): Int {
return ids.size
}
override fun getWindow(windowIndex: Int, window: Window, defaultPositionProjectionUs: Long): Window {
val durationUs = durationsUs[windowIndex]
val isDynamic = durationUs == C.TIME_UNSET
val mediaItem = MediaItem.Builder().setUri(Uri.EMPTY).setTag(ids[windowIndex]).build()
return window.set( /* uid= */
ids[windowIndex], /* mediaItem= */
mediaItem, /* manifest= */
null, /* presentationStartTimeMs= */
C.TIME_UNSET, /* windowStartTimeMs= */
C.TIME_UNSET, /* elapsedRealtimeEpochOffsetMs= */
C.TIME_UNSET, /* isSeekable= */
!isDynamic,
isDynamic,
if (isLive[windowIndex]) mediaItem.liveConfiguration else null,
defaultPositionsUs[windowIndex],
durationUs, /* firstPeriodIndex= */
windowIndex, /* lastPeriodIndex= */
windowIndex, /* positionInFirstPeriodUs= */
0)
}
override fun getPeriodCount(): Int {
return ids.size
}
override fun getPeriod(periodIndex: Int, period: Period, setIds: Boolean): Period {
val id = ids[periodIndex]
return period.set(id, id, periodIndex, durationsUs[periodIndex], 0)
}
override fun getIndexOfPeriod(uid: Any): Int {
return if (uid is Int) idsToIndex[uid, C.INDEX_UNSET] else C.INDEX_UNSET
}
override fun getUidOfPeriod(periodIndex: Int): Int {
return ids[periodIndex]
}
// equals and hashCode implementations.
override fun equals(other: Any?): Boolean {
if (this === other) {
return true
} else if (other !is CastTimeline) {
return false
}
val that = other
return (Arrays.equals(ids, that.ids)
&& Arrays.equals(durationsUs, that.durationsUs)
&& Arrays.equals(defaultPositionsUs, that.defaultPositionsUs)
&& Arrays.equals(isLive, that.isLive))
}
override fun hashCode(): Int {
var result = Arrays.hashCode(ids)
result = 31 * result + Arrays.hashCode(durationsUs)
result = 31 * result + Arrays.hashCode(defaultPositionsUs)
result = 31 * result + Arrays.hashCode(isLive)
return result
}
companion object {
/** [Timeline] for a cast queue that has no items. */
val EMPTY_CAST_TIMELINE = CastTimeline(IntArray(0), SparseArray())
}
/**
* Creates a Cast timeline from the given data.
*
* @param itemIds The ids of the items in the timeline.
* @param itemIdToData Maps item ids to [ItemData].
*/
init {
val itemCount = itemIds.size
idsToIndex = SparseIntArray(itemCount)
ids = itemIds.copyOf(itemCount)
durationsUs = LongArray(itemCount)
defaultPositionsUs = LongArray(itemCount)
isLive = BooleanArray(itemCount)
for (i in ids.indices) {
val id = ids[i]
idsToIndex.put(id, i)
val data = itemIdToData[id, ItemData.EMPTY]
durationsUs[i] = data.durationUs
defaultPositionsUs[i] = if (data.defaultPositionUs == C.TIME_UNSET) 0 else data.defaultPositionUs
isLive[i] = data.isLive
}
}
}
@@ -0,0 +1,109 @@
/*
* Copyright (C) 2018 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.audiobookshelf.app.player
import android.util.SparseArray
import com.google.android.exoplayer2.C
import com.google.android.gms.cast.MediaInfo
import com.google.android.gms.cast.framework.media.RemoteMediaClient
/**
* Creates [CastTimelines][CastTimeline] from cast receiver app status updates.
*
*
* This class keeps track of the duration reported by the current item to fill any missing
* durations in the media queue items [See internal: b/65152553].
*/
/* package */
class CastTimelineTracker {
private val itemIdToData: SparseArray<CastTimeline.ItemData>
/**
* Returns a [CastTimeline] that represents the state of the given `remoteMediaClient`.
*
*
* Returned timelines may contain values obtained from `remoteMediaClient` in previous
* invocations of this method.
*
* @param remoteMediaClient The Cast media client.
* @return A [CastTimeline] that represents the given `remoteMediaClient` status.
*/
fun getCastTimeline(remoteMediaClient: RemoteMediaClient): CastTimeline {
val itemIds = remoteMediaClient.mediaQueue.itemIds
if (itemIds.size > 0) {
// Only remove unused items when there is something in the queue to avoid removing all entries
// if the remote media client clears the queue temporarily. See [Internal ref: b/128825216].
removeUnusedItemDataEntries(itemIds)
}
// TODO: Reset state when the app instance changes [Internal ref: b/129672468].
val mediaStatus = remoteMediaClient.mediaStatus
?: return CastTimeline.EMPTY_CAST_TIMELINE
val currentItemId = mediaStatus.currentItemId
updateItemData(
currentItemId, mediaStatus.mediaInfo, /* defaultPositionUs= */C.TIME_UNSET)
for (item in mediaStatus.queueItems) {
val defaultPositionUs = (item.startTime * C.MICROS_PER_SECOND).toLong()
updateItemData(item.itemId, item.media, defaultPositionUs)
}
return CastTimeline(itemIds, itemIdToData)
}
private fun updateItemData(itemId: Int, mediaInfo: MediaInfo?, defaultPositionUs: Long) {
var defaultPositionUs = defaultPositionUs
val previousData = itemIdToData[itemId, CastTimeline.ItemData.EMPTY]
var durationUs = getStreamDurationUs(mediaInfo)
if (durationUs == C.TIME_UNSET) {
durationUs = previousData.durationUs
}
val isLive = if (mediaInfo == null) previousData.isLive else mediaInfo.streamType == MediaInfo.STREAM_TYPE_LIVE
if (defaultPositionUs == C.TIME_UNSET) {
defaultPositionUs = previousData.defaultPositionUs
}
itemIdToData.put(itemId, previousData.copyWithNewValues(durationUs, defaultPositionUs, isLive))
}
private fun getStreamDurationUs(mediaInfo: MediaInfo?): Long {
if (mediaInfo == null) {
return C.TIME_UNSET
}
val durationMs = mediaInfo.streamDuration
return if (durationMs != MediaInfo.UNKNOWN_DURATION) msToUs(durationMs) else C.TIME_UNSET
}
fun msToUs(timeMs: Long): Long {
return if (timeMs == C.TIME_UNSET || timeMs == C.TIME_END_OF_SOURCE) timeMs else timeMs * 1000
}
private fun removeUnusedItemDataEntries(itemIds: IntArray) {
val scratchItemIds = HashSet<Int>( /* initialCapacity= */itemIds.size * 2)
for (id in itemIds) {
scratchItemIds.add(id)
}
var index = 0
while (index < itemIdToData.size()) {
if (!scratchItemIds.contains(itemIdToData.keyAt(index))) {
itemIdToData.removeAt(index)
} else {
index++
}
}
}
init {
itemIdToData = SparseArray()
}
}
@@ -0,0 +1,79 @@
/*
* Copyright (C) 2021 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.audiobookshelf.app.player
import com.google.android.exoplayer2.C
import com.google.android.exoplayer2.Format
import com.google.android.exoplayer2.source.TrackGroup
import com.google.android.exoplayer2.trackselection.TrackSelection
import com.google.android.exoplayer2.util.Assertions
/**
* [TrackSelection] that only selects the first track of the provided [TrackGroup].
*
*
* This relies on [CastPlayer] track groups only having one track.
*/
/* package */
internal class CastTrackSelection
/** @param trackGroup The [TrackGroup] from which the first track will only be selected.
*/(private val trackGroup: TrackGroup) : TrackSelection {
override fun getType(): Int {
return TrackSelection.TYPE_UNSET
}
override fun getTrackGroup(): TrackGroup {
return trackGroup
}
override fun length(): Int {
return 1
}
override fun getFormat(index: Int): Format {
Assertions.checkArgument(index == 0)
return trackGroup.getFormat(0)
}
override fun getIndexInTrackGroup(index: Int): Int {
return if (index == 0) 0 else C.INDEX_UNSET
}
override fun indexOf(format: Format): Int {
return if (format === trackGroup.getFormat(0)) 0 else C.INDEX_UNSET
}
override fun indexOf(indexInTrackGroup: Int): Int {
return if (indexInTrackGroup == 0) 0 else C.INDEX_UNSET
}
// Object overrides.
override fun hashCode(): Int {
return System.identityHashCode(trackGroup)
}
// Track groups are compared by identity not value, as distinct groups may have the same value.
override fun equals(obj: Any?): Boolean {
if (this === obj) {
return true
}
if (obj == null || javaClass != obj.javaClass) {
return false
}
val other = obj as CastTrackSelection
return trackGroup === other.trackGroup
}
}
@@ -0,0 +1,130 @@
package com.audiobookshelf.app.player
import android.os.Handler
import android.os.Looper
import android.util.Log
import com.audiobookshelf.app.data.LocalMediaProgress
import com.audiobookshelf.app.data.PlaybackSession
import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.server.ApiHandler
import java.util.*
import kotlin.concurrent.schedule
import kotlin.math.roundToInt
data class MediaProgressSyncData(
var timeListened:Long, // seconds
var duration:Double, // seconds
var currentTime:Double // seconds
)
class MediaProgressSyncer(playerNotificationService:PlayerNotificationService, apiHandler: ApiHandler) {
private val tag = "MediaProgressSync"
private val playerNotificationService:PlayerNotificationService = playerNotificationService
private val apiHandler = apiHandler
private var listeningTimerTask: TimerTask? = null
var listeningTimerRunning:Boolean = false
private var lastSyncTime:Long = 0
var currentPlaybackSession: PlaybackSession? = null // copy of pb session currently syncing
var currentLocalMediaProgress: LocalMediaProgress? = null
val currentDisplayTitle get() = currentPlaybackSession?.displayTitle ?: "Unset"
val currentIsLocal get() = currentPlaybackSession?.isLocal == true
val currentSessionId get() = currentPlaybackSession?.id ?: ""
val currentPlaybackDuration get() = currentPlaybackSession?.duration ?: 0.0
fun start() {
if (listeningTimerRunning) {
Log.d(tag, "start: Timer already running for $currentDisplayTitle")
if (playerNotificationService.getCurrentPlaybackSessionId() != currentSessionId) {
Log.d(tag, "Playback session changed, reset timer")
currentLocalMediaProgress = null
listeningTimerTask?.cancel()
lastSyncTime = 0L
} else {
return
}
}
listeningTimerRunning = true
lastSyncTime = System.currentTimeMillis()
currentPlaybackSession = playerNotificationService.getCurrentPlaybackSessionCopy()
listeningTimerTask = Timer("ListeningTimer", false).schedule(0L, 5000L) {
Handler(Looper.getMainLooper()).post() {
if (playerNotificationService.currentPlayer.isPlaying) {
var currentTime = playerNotificationService.getCurrentTimeSeconds()
sync(currentTime)
}
}
}
}
fun stop() {
if (!listeningTimerRunning) return
Log.d(tag, "stop: Stopping listening for $currentDisplayTitle")
var currentTime = playerNotificationService.getCurrentTimeSeconds()
sync(currentTime)
reset()
}
fun sync(currentTime:Double) {
var diffSinceLastSync = System.currentTimeMillis() - lastSyncTime
if (diffSinceLastSync < 1000L) {
return
}
var listeningTimeToAdd = diffSinceLastSync / 1000L
lastSyncTime = System.currentTimeMillis()
var syncData = MediaProgressSyncData(listeningTimeToAdd,currentPlaybackDuration,currentTime)
currentPlaybackSession?.syncData(syncData)
if (currentIsLocal) {
// Save local progress sync
currentPlaybackSession?.let {
DeviceManager.dbManager.saveLocalPlaybackSession(it)
saveLocalProgress(it)
// Send sync to server also if connected to this server and local item belongs to this server
if (it.serverConnectionConfigId != null && DeviceManager.serverConnectionConfig?.id == it.serverConnectionConfigId) {
apiHandler.sendLocalProgressSync(it) {
Log.d(tag, "Local progress sync data sent to server $currentDisplayTitle for time $currentTime")
}
}
}
} else {
apiHandler.sendProgressSync(currentSessionId, syncData) {
Log.d(tag, "Progress sync data sent to server $currentDisplayTitle for time $currentTime")
}
}
}
private fun saveLocalProgress(playbackSession:PlaybackSession) {
if (currentLocalMediaProgress == null) {
var mediaProgress = DeviceManager.dbManager.getLocalMediaProgress(playbackSession.localMediaProgressId)
if (mediaProgress == null) {
currentLocalMediaProgress = playbackSession.getNewLocalMediaProgress()
} else {
currentLocalMediaProgress = mediaProgress
}
} else {
currentLocalMediaProgress?.updateFromPlaybackSession(playbackSession)
}
currentLocalMediaProgress?.let {
DeviceManager.dbManager.saveLocalMediaProgress(it)
playerNotificationService.clientEventEmitter?.onLocalMediaProgressUpdate(it)
Log.d(tag, "Saved Local Progress Current Time: ID ${it.id} | ${it.currentTime} | Duration ${it.duration} | Progress ${(it.progress * 100).roundToInt()}%")
}
}
fun reset() {
listeningTimerTask?.cancel()
listeningTimerTask = null
listeningTimerRunning = false
currentPlaybackSession = null
currentLocalMediaProgress = null
lastSyncTime = 0L
}
}
@@ -0,0 +1,195 @@
package com.audiobookshelf.app.player
import android.annotation.SuppressLint
import android.content.Intent
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.Message
import android.support.v4.media.session.MediaSessionCompat
import android.util.Log
import android.view.KeyEvent
import com.audiobookshelf.app.data.LibraryItem
import com.audiobookshelf.app.data.LibraryItemWrapper
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import java.util.*
import kotlin.concurrent.schedule
class MediaSessionCallback(var playerNotificationService:PlayerNotificationService) : MediaSessionCompat.Callback() {
var tag = "MediaSessionCallback"
private var mediaButtonClickCount: Int = 0
var mediaButtonClickTimeout: Long = 1000 //ms
var seekAmount: Long = 20000 //ms
override fun onPrepare() {
Log.d(tag, "ON PREPARE MEDIA SESSION COMPAT")
playerNotificationService.mediaManager.getFirstItem()?.let { li ->
playerNotificationService.mediaManager.play(li, playerNotificationService.getMediaPlayer()) {
Log.d(tag, "About to prepare player with ${it.displayTitle}")
Handler(Looper.getMainLooper()).post() {
playerNotificationService.preparePlayer(it,true,null)
}
}
}
}
override fun onPlay() {
Log.d(tag, "ON PLAY MEDIA SESSION COMPAT")
playerNotificationService.play()
}
override fun onPrepareFromSearch(query: String?, extras: Bundle?) {
Log.d(tag, "ON PREPARE FROM SEARCH $query")
super.onPrepareFromSearch(query, extras)
}
override fun onPlayFromSearch(query: String?, extras: Bundle?) {
Log.d(tag, "ON PLAY FROM SEARCH $query")
playerNotificationService.mediaManager.getFromSearch(query)?.let { li ->
playerNotificationService.mediaManager.play(li, playerNotificationService.getMediaPlayer()) {
Log.d(tag, "About to prepare player with ${it.displayTitle}")
Handler(Looper.getMainLooper()).post() {
playerNotificationService.preparePlayer(it,true,null)
}
}
}
}
override fun onPause() {
Log.d(tag, "ON PAUSE MEDIA SESSION COMPAT")
playerNotificationService.pause()
}
override fun onStop() {
playerNotificationService.pause()
}
override fun onSkipToPrevious() {
playerNotificationService.seekBackward(seekAmount)
}
override fun onSkipToNext() {
playerNotificationService.seekForward(seekAmount)
}
override fun onFastForward() {
playerNotificationService.seekForward(seekAmount)
}
override fun onRewind() {
playerNotificationService.seekForward(seekAmount)
}
override fun onSeekTo(pos: Long) {
playerNotificationService.seekPlayer(pos)
}
override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) {
Log.d(tag, "ON PLAY FROM MEDIA ID $mediaId")
var libraryItemWrapper: LibraryItemWrapper? = null
if (mediaId.isNullOrEmpty()) {
libraryItemWrapper = playerNotificationService.mediaManager.getFirstItem()
} else {
libraryItemWrapper = playerNotificationService.mediaManager.getById(mediaId)
}
libraryItemWrapper?.let { li ->
playerNotificationService.mediaManager.play(li, playerNotificationService.getMediaPlayer()) {
Log.d(tag, "About to prepare player with ${it.displayTitle}")
Handler(Looper.getMainLooper()).post() {
playerNotificationService.preparePlayer(it,true,null)
}
}
}
}
override fun onMediaButtonEvent(mediaButtonEvent: Intent): Boolean {
return handleCallMediaButton(mediaButtonEvent)
}
fun handleCallMediaButton(intent: Intent): Boolean {
if(Intent.ACTION_MEDIA_BUTTON == intent.action) {
var keyEvent = intent.getParcelableExtra<KeyEvent>(Intent.EXTRA_KEY_EVENT)
if (keyEvent?.getAction() == KeyEvent.ACTION_UP) {
when (keyEvent?.getKeyCode()) {
KeyEvent.KEYCODE_HEADSETHOOK -> {
if (0 == mediaButtonClickCount) {
if (playerNotificationService.mPlayer.isPlaying)
playerNotificationService.pause()
else
playerNotificationService.play()
}
handleMediaButtonClickCount()
}
KeyEvent.KEYCODE_MEDIA_PLAY -> {
if (0 == mediaButtonClickCount) {
playerNotificationService.play()
playerNotificationService.sleepTimerManager.checkShouldExtendSleepTimer()
}
handleMediaButtonClickCount()
}
KeyEvent.KEYCODE_MEDIA_PAUSE -> {
if (0 == mediaButtonClickCount) playerNotificationService.pause()
handleMediaButtonClickCount()
}
KeyEvent.KEYCODE_MEDIA_NEXT -> {
playerNotificationService.seekForward(seekAmount)
}
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> {
playerNotificationService.seekBackward(seekAmount)
}
KeyEvent.KEYCODE_MEDIA_STOP -> {
playerNotificationService.closePlayback()
}
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> {
if (playerNotificationService.mPlayer.isPlaying) {
if (0 == mediaButtonClickCount) playerNotificationService.pause()
handleMediaButtonClickCount()
} else {
if (0 == mediaButtonClickCount) {
playerNotificationService.play()
playerNotificationService.sleepTimerManager.checkShouldExtendSleepTimer()
}
handleMediaButtonClickCount()
}
}
else -> {
Log.d(tag, "KeyCode:${keyEvent.getKeyCode()}")
return false
}
}
}
}
return true
}
fun handleMediaButtonClickCount() {
mediaButtonClickCount++
if (1 == mediaButtonClickCount) {
Timer().schedule(mediaButtonClickTimeout) {
mediaBtnHandler.sendEmptyMessage(mediaButtonClickCount)
mediaButtonClickCount = 0
}
}
}
private val mediaBtnHandler : Handler = @SuppressLint("HandlerLeak")
object : Handler(){
override fun handleMessage(msg: Message) {
super.handleMessage(msg)
if (2 == msg.what) {
playerNotificationService.seekBackward(seekAmount)
playerNotificationService.play()
}
else if (msg.what >= 3) {
playerNotificationService.seekForward(seekAmount)
playerNotificationService.play()
}
}
}
}
@@ -0,0 +1,70 @@
package com.audiobookshelf.app.player
import android.net.Uri
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.ResultReceiver
import android.support.v4.media.session.PlaybackStateCompat
import android.util.Log
import com.audiobookshelf.app.data.LibraryItem
import com.audiobookshelf.app.data.LibraryItemWrapper
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
class MediaSessionPlaybackPreparer(var playerNotificationService:PlayerNotificationService) : MediaSessionConnector.PlaybackPreparer {
var tag = "MediaSessionPlaybackPreparer"
override fun onCommand(player: Player, command: String, extras: Bundle?, cb: ResultReceiver?): Boolean {
Log.d(tag, "ON COMMAND $command")
return false
}
override fun getSupportedPrepareActions(): Long {
return PlaybackStateCompat.ACTION_PREPARE_FROM_MEDIA_ID or
PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID or
PlaybackStateCompat.ACTION_PREPARE_FROM_SEARCH or
PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH
}
override fun onPrepare(playWhenReady: Boolean) {
Log.d(tag, "ON PREPARE $playWhenReady")
playerNotificationService.mediaManager.getFirstItem()?.let { li ->
playerNotificationService.mediaManager.play(li, playerNotificationService.getMediaPlayer()) {
Handler(Looper.getMainLooper()).post() {
playerNotificationService.preparePlayer(it,playWhenReady,null)
}
}
}
}
override fun onPrepareFromMediaId(mediaId: String, playWhenReady: Boolean, extras: Bundle?) {
Log.d(tag, "ON PREPARE FROM MEDIA ID $mediaId $playWhenReady")
var libraryItemWrapper: LibraryItemWrapper? = playerNotificationService.mediaManager.getById(mediaId)
libraryItemWrapper?.let { li ->
playerNotificationService.mediaManager.play(li, playerNotificationService.getMediaPlayer()) {
Log.d(tag, "About to prepare player with ${it.displayTitle}")
Handler(Looper.getMainLooper()).post() {
playerNotificationService.preparePlayer(it,playWhenReady,null)
}
}
}
}
override fun onPrepareFromSearch(query: String, playWhenReady: Boolean, extras: Bundle?) {
Log.d(tag, "ON PREPARE FROM SEARCH $query")
playerNotificationService.mediaManager.getFromSearch(query)?.let { li ->
playerNotificationService.mediaManager.play(li, playerNotificationService.getMediaPlayer()) {
Log.d(tag, "About to prepare player with ${it.displayTitle}")
Handler(Looper.getMainLooper()).post() {
playerNotificationService.preparePlayer(it,playWhenReady,null)
}
}
}
}
override fun onPrepareFromUri(uri: Uri, playWhenReady: Boolean, extras: Bundle?) {
Log.d(tag, "ON PREPARE FROM URI $uri")
}
}
@@ -0,0 +1,112 @@
package com.audiobookshelf.app.player
import android.util.Log
import com.audiobookshelf.app.data.PlayerState
import com.google.android.exoplayer2.PlaybackException
import com.google.android.exoplayer2.Player
class PlayerListener(var playerNotificationService:PlayerNotificationService) : Player.Listener {
var tag = "PlayerListener"
companion object {
var lastPauseTime: Long = 0 //ms
}
private var onSeekBack: Boolean = false
override fun onPlayerError(error: PlaybackException) {
var errorMessage = error.message ?: "Unknown Error"
Log.e(tag, "onPlayerError $errorMessage")
playerNotificationService.handlePlayerPlaybackError(errorMessage) // If was direct playing session, fallback to transcode
}
override fun onEvents(player: Player, events: Player.Events) {
Log.d(tag, "onEvents ${player.deviceInfo} | ${playerNotificationService.getMediaPlayer()} | ${events.size()}")
if (events.contains(Player.EVENT_POSITION_DISCONTINUITY)) {
Log.d(tag, "EVENT_POSITION_DISCONTINUITY")
}
if (events.contains(Player.EVENT_IS_LOADING_CHANGED)) {
Log.d(tag, "EVENT_IS_LOADING_CHANGED : " + playerNotificationService.currentPlayer.isLoading)
}
if (events.contains(Player.EVENT_PLAYBACK_STATE_CHANGED)) {
Log.d(tag, "EVENT_PLAYBACK_STATE_CHANGED MediaPlayer = ${playerNotificationService.getMediaPlayer()}")
if (playerNotificationService.currentPlayer.playbackState == Player.STATE_READY) {
Log.d(tag, "STATE_READY : " + playerNotificationService.currentPlayer.duration)
if (lastPauseTime == 0L) {
lastPauseTime = -1;
}
playerNotificationService.sendClientMetadata(PlayerState.READY)
}
if (playerNotificationService.currentPlayer.playbackState == Player.STATE_BUFFERING) {
Log.d(tag, "STATE_BUFFERING : " + playerNotificationService.currentPlayer.currentPosition)
playerNotificationService.sendClientMetadata(PlayerState.BUFFERING)
}
if (playerNotificationService.currentPlayer.playbackState == Player.STATE_ENDED) {
Log.d(tag, "STATE_ENDED")
playerNotificationService.sendClientMetadata(PlayerState.ENDED)
}
if (playerNotificationService.currentPlayer.playbackState == Player.STATE_IDLE) {
Log.d(tag, "STATE_IDLE")
playerNotificationService.sendClientMetadata(PlayerState.IDLE)
}
}
if (events.contains(Player.EVENT_MEDIA_METADATA_CHANGED)) {
Log.d(tag, "EVENT_MEDIA_METADATA_CHANGED ${playerNotificationService.getMediaPlayer()}")
}
if (events.contains(Player.EVENT_PLAYLIST_METADATA_CHANGED)) {
Log.d(tag, "EVENT_PLAYLIST_METADATA_CHANGED ${playerNotificationService.getMediaPlayer()}")
}
if (events.contains(Player.EVENT_IS_PLAYING_CHANGED)) {
Log.d(tag, "EVENT IS PLAYING CHANGED ${playerNotificationService.getMediaPlayer()}")
if (player.isPlaying) {
Log.d(tag, "SeekBackTime: Player is playing")
if (lastPauseTime > 0) {
if (onSeekBack) onSeekBack = false
else {
Log.d(tag, "SeekBackTime: playing started now set seek back time $lastPauseTime")
var backTime = calcPauseSeekBackTime()
if (backTime > 0) {
if (backTime >= playerNotificationService.getCurrentTime()) backTime = playerNotificationService.getCurrentTime() - 500
Log.d(tag, "SeekBackTime $backTime")
onSeekBack = true
playerNotificationService.seekBackward(backTime)
} else {
Log.d(tag, "SeekBackTime: back time is 0")
}
}
}
} else {
Log.d(tag, "SeekBackTime: Player not playing set last pause time")
lastPauseTime = System.currentTimeMillis()
}
// Start/stop progress sync interval
Log.d(tag, "Playing ${playerNotificationService.getCurrentBookTitle()}")
if (player.isPlaying) {
playerNotificationService.mediaProgressSyncer.start()
} else {
playerNotificationService.mediaProgressSyncer.stop()
}
playerNotificationService.clientEventEmitter?.onPlayingUpdate(player.isPlaying)
}
}
private fun calcPauseSeekBackTime() : Long {
if (lastPauseTime <= 0) return 0
var time: Long = System.currentTimeMillis() - lastPauseTime
var seekback: Long
if (time < 3000) seekback = 0
else if (time < 300000) seekback = 10000 // 3s to 5m = jump back 10s
else if (time < 1800000) seekback = 20000 // 5m to 30m = jump back 20s
else seekback = 29500 // 30m and up = jump back 30s
return seekback
}
}
@@ -0,0 +1,31 @@
package com.audiobookshelf.app.player
import android.app.Notification
import android.util.Log
import com.google.android.exoplayer2.ui.PlayerNotificationManager
class PlayerNotificationListener(var playerNotificationService:PlayerNotificationService) : PlayerNotificationManager.NotificationListener {
var tag = "PlayerNotificationListener"
override fun onNotificationPosted(
notificationId: Int,
notification: Notification,
onGoing: Boolean) {
// Start foreground service
Log.d(tag, "Notification Posted $notificationId - Start Foreground | $notification")
playerNotificationService.startForeground(notificationId, notification)
}
override fun onNotificationCancelled(
notificationId: Int,
dismissedByUser: Boolean
) {
if (dismissedByUser) {
Log.d(tag, "onNotificationCancelled dismissed by user")
playerNotificationService.stopSelf()
} else {
Log.d(tag, "onNotificationCancelled not dismissed by user")
}
}
}
@@ -0,0 +1,680 @@
package com.audiobookshelf.app.player
import android.app.*
import android.content.Context
import android.content.Intent
import android.graphics.Color
import android.hardware.Sensor
import android.hardware.SensorManager
import android.os.*
import android.support.v4.media.MediaBrowserCompat
import android.support.v4.media.MediaDescriptionCompat
import android.support.v4.media.session.MediaControllerCompat
import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.session.PlaybackStateCompat
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat
import androidx.media.MediaBrowserServiceCompat
import androidx.media.utils.MediaConstants
import com.audiobookshelf.app.data.*
import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.media.MediaManager
import com.audiobookshelf.app.server.ApiHandler
import com.google.android.exoplayer2.*
import com.google.android.exoplayer2.audio.AudioAttributes
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
import com.google.android.exoplayer2.ext.mediasession.TimelineQueueNavigator
import com.google.android.exoplayer2.source.MediaSource
import com.google.android.exoplayer2.source.ProgressiveMediaSource
import com.google.android.exoplayer2.source.hls.HlsMediaSource
import com.google.android.exoplayer2.ui.PlayerNotificationManager
import com.google.android.exoplayer2.upstream.*
import java.util.*
import kotlin.concurrent.schedule
const val SLEEP_TIMER_WAKE_UP_EXPIRATION = 120000L // 2m
class PlayerNotificationService : MediaBrowserServiceCompat() {
companion object {
var isStarted = false
}
interface ClientEventEmitter {
fun onPlaybackSession(playbackSession:PlaybackSession)
fun onPlaybackClosed()
fun onPlayingUpdate(isPlaying: Boolean)
fun onMetadata(metadata: PlaybackMetadata)
fun onPrepare(audiobookId: String, playWhenReady: Boolean)
fun onSleepTimerEnded(currentPosition: Long)
fun onSleepTimerSet(sleepTimeRemaining: Int)
fun onLocalMediaProgressUpdate(localMediaProgress: LocalMediaProgress)
fun onPlaybackFailed(errorMessage:String)
fun onMediaPlayerChanged(mediaPlayer:String)
}
private val tag = "PlayerService"
private val binder = LocalBinder()
var clientEventEmitter:ClientEventEmitter? = null
private lateinit var ctx:Context
private lateinit var mediaSessionConnector: MediaSessionConnector
private lateinit var playerNotificationManager: PlayerNotificationManager
private lateinit var mediaSession: MediaSessionCompat
private lateinit var transportControls:MediaControllerCompat.TransportControls
lateinit var mediaManager: MediaManager
lateinit var apiHandler: ApiHandler
lateinit var mPlayer: ExoPlayer
lateinit var currentPlayer:Player
var castPlayer:CastPlayer? = null
lateinit var sleepTimerManager:SleepTimerManager
lateinit var mediaProgressSyncer:MediaProgressSyncer
private var notificationId = 10;
private var channelId = "audiobookshelf_channel"
private var channelName = "Audiobookshelf Channel"
private var currentPlaybackSession:PlaybackSession? = null
private var initialPlaybackRate:Float? = null
private var isAndroidAuto = false
// The following are used for the shake detection
private var isShakeSensorRegistered:Boolean = false
private var mSensorManager: SensorManager? = null
private var mAccelerometer: Sensor? = null
private var mShakeDetector: ShakeDetector? = null
private var shakeSensorUnregisterTask:TimerTask? = null
/*
Service related stuff
*/
override fun onBind(intent: Intent): IBinder? {
Log.d(tag, "onBind")
// Android Auto Media Browser Service
if (SERVICE_INTERFACE == intent.action) {
Log.d(tag, "Is Media Browser Service")
return super.onBind(intent);
}
return binder
}
inner class LocalBinder : Binder() {
// Return this instance of LocalService so clients can call public methods
fun getService(): PlayerNotificationService = this@PlayerNotificationService
}
fun stopService(context: Context) {
val stopIntent = Intent(context, PlayerNotificationService::class.java)
context.stopService(stopIntent)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
isStarted = true
Log.d(tag, "onStartCommand $startId")
return START_STICKY
}
override fun onStart(intent: Intent?, startId: Int) {
Log.d(tag, "onStart $startId")
}
@RequiresApi(Build.VERSION_CODES.O)
private fun createNotificationChannel(channelId: String, channelName: String): String {
val chan = NotificationChannel(channelId,
channelName, NotificationManager.IMPORTANCE_LOW)
chan.lightColor = Color.DKGRAY
chan.lockscreenVisibility = Notification.VISIBILITY_PUBLIC
val service = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
service.createNotificationChannel(chan)
return channelId
}
// detach player
override fun onDestroy() {
playerNotificationManager.setPlayer(null)
mPlayer.release()
castPlayer?.release()
mediaSession.release()
mediaProgressSyncer.reset()
Log.d(tag, "onDestroy")
isStarted = false
super.onDestroy()
}
//removing service when user swipe out our app
override fun onTaskRemoved(rootIntent: Intent?) {
super.onTaskRemoved(rootIntent)
Log.d(tag, "onTaskRemoved")
stopSelf()
}
override fun onCreate() {
super.onCreate()
ctx = this
// Initialize player
val customLoadControl:LoadControl = DefaultLoadControl.Builder().setBufferDurationsMs(
1000 * 20, // 20s min buffer
1000 * 45, // 45s max buffer
1000 * 5, // 5s playback start
1000 * 20 // 20s playback rebuffer
).build()
mPlayer = ExoPlayer.Builder(this)
.setLoadControl(customLoadControl)
.setSeekBackIncrementMs(10000)
.setSeekForwardIncrementMs(10000)
.build()
mPlayer.setHandleAudioBecomingNoisy(true)
mPlayer.addListener(PlayerListener(this))
val audioAttributes:AudioAttributes = AudioAttributes.Builder().setUsage(C.USAGE_MEDIA).setContentType(C.CONTENT_TYPE_SPEECH).build()
mPlayer.setAudioAttributes(audioAttributes, true)
currentPlayer = mPlayer
// Initialize API
apiHandler = ApiHandler(ctx)
// Initialize sleep timer
sleepTimerManager = SleepTimerManager(this)
// Initialize Media Progress Syncer
mediaProgressSyncer = MediaProgressSyncer(this, apiHandler)
// Initialize shake sensor
Log.d(tag, "onCreate Register sensor listener ${mAccelerometer?.isWakeUpSensor}")
initSensor()
// Initialize media manager
mediaManager = MediaManager(apiHandler, ctx)
channelId = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
createNotificationChannel(channelId, channelName)
} else ""
val sessionActivityPendingIntent =
packageManager?.getLaunchIntentForPackage(packageName)?.let { sessionIntent ->
PendingIntent.getActivity(this, 0, sessionIntent, PendingIntent.FLAG_IMMUTABLE)
}
mediaSession = MediaSessionCompat(this, tag)
.apply {
setSessionActivity(sessionActivityPendingIntent)
isActive = true
}
val mediaController = MediaControllerCompat(ctx, mediaSession.sessionToken)
// This is for Media Browser
sessionToken = mediaSession.sessionToken
val builder = PlayerNotificationManager.Builder(
ctx,
notificationId,
channelId)
builder.setMediaDescriptionAdapter(AbMediaDescriptionAdapter(mediaController, this))
builder.setNotificationListener(PlayerNotificationListener(this))
playerNotificationManager = builder.build()
playerNotificationManager.setMediaSessionToken(mediaSession.sessionToken)
playerNotificationManager.setUsePlayPauseActions(true)
playerNotificationManager.setUseNextAction(false)
playerNotificationManager.setUsePreviousAction(false)
playerNotificationManager.setUseChronometer(false)
playerNotificationManager.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
playerNotificationManager.setPriority(NotificationCompat.PRIORITY_MAX)
playerNotificationManager.setUseFastForwardActionInCompactView(true)
playerNotificationManager.setUseRewindActionInCompactView(true)
// Unknown action
playerNotificationManager.setBadgeIconType(NotificationCompat.BADGE_ICON_LARGE)
transportControls = mediaController.transportControls
mediaSessionConnector = MediaSessionConnector(mediaSession)
val queueNavigator: TimelineQueueNavigator = object : TimelineQueueNavigator(mediaSession) {
override fun getMediaDescription(player: Player, windowIndex: Int): MediaDescriptionCompat {
return MediaDescriptionCompat.Builder()
.setMediaId(currentPlaybackSession!!.id)
.setTitle(currentPlaybackSession!!.displayTitle)
.setSubtitle(currentPlaybackSession!!.displayAuthor)
.setIconUri(currentPlaybackSession!!.getCoverUri()).build()
}
}
mediaSessionConnector.setEnabledPlaybackActions(
PlaybackStateCompat.ACTION_PLAY_PAUSE
or PlaybackStateCompat.ACTION_PLAY
or PlaybackStateCompat.ACTION_PAUSE
or PlaybackStateCompat.ACTION_SEEK_TO
or PlaybackStateCompat.ACTION_FAST_FORWARD
or PlaybackStateCompat.ACTION_REWIND
or PlaybackStateCompat.ACTION_STOP
)
mediaSessionConnector.setQueueNavigator(queueNavigator)
mediaSessionConnector.setPlaybackPreparer(MediaSessionPlaybackPreparer(this))
mediaSessionConnector.setPlayer(mPlayer)
//attach player to playerNotificationManager
playerNotificationManager.setPlayer(mPlayer)
mediaSession.setCallback(MediaSessionCallback(this))
}
/*
User callable methods
*/
fun preparePlayer(playbackSession: PlaybackSession, playWhenReady:Boolean, playbackRate:Float?) {
val playbackRateToUse = playbackRate ?: initialPlaybackRate ?: 1f
initialPlaybackRate = playbackRate
playbackSession.mediaPlayer = getMediaPlayer()
if (playbackSession.mediaPlayer == "cast-player" && playbackSession.isLocal) {
Log.w(tag, "Cannot cast local media item - switching player")
currentPlaybackSession = null
switchToPlayer(false)
playbackSession.mediaPlayer = getMediaPlayer()
}
if (playbackSession.mediaPlayer == "cast-player") {
// If cast-player is the first player to be used
mediaSessionConnector.setPlayer(castPlayer)
playerNotificationManager.setPlayer(castPlayer)
}
currentPlaybackSession = playbackSession
Log.d(tag, "Set CurrentPlaybackSession MediaPlayer ${currentPlaybackSession?.mediaPlayer}")
clientEventEmitter?.onPlaybackSession(playbackSession)
val metadata = playbackSession.getMediaMetadataCompat()
mediaSession.setMetadata(metadata)
val mediaItems = playbackSession.getMediaItems()
if (mediaItems.isEmpty()) {
Log.e(tag, "Invalid playback session no media items to play")
currentPlaybackSession = null
return
}
if (mPlayer == currentPlayer) {
val mediaSource:MediaSource
if (playbackSession.isLocal) {
Log.d(tag, "Playing Local Item")
val dataSourceFactory = DefaultDataSource.Factory(ctx)
mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItems[0])
} else if (!playbackSession.isHLS) {
Log.d(tag, "Direct Playing Item")
val dataSourceFactory = DefaultHttpDataSource.Factory()
dataSourceFactory.setUserAgent(channelId)
mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItems[0])
} else {
Log.d(tag, "Playing HLS Item")
val dataSourceFactory = DefaultHttpDataSource.Factory()
dataSourceFactory.setUserAgent(channelId)
dataSourceFactory.setDefaultRequestProperties(hashMapOf("Authorization" to "Bearer ${DeviceManager.token}"))
mediaSource = HlsMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItems[0])
}
mPlayer.setMediaSource(mediaSource)
// Add remaining media items if multi-track
if (mediaItems.size > 1) {
currentPlayer.addMediaItems(mediaItems.subList(1, mediaItems.size))
Log.d(tag, "currentPlayer total media items ${currentPlayer.mediaItemCount}")
val currentTrackIndex = playbackSession.getCurrentTrackIndex()
val currentTrackTime = playbackSession.getCurrentTrackTimeMs()
Log.d(tag, "currentPlayer current track index $currentTrackIndex & current track time $currentTrackTime")
currentPlayer.seekTo(currentTrackIndex, currentTrackTime)
} else {
currentPlayer.seekTo(playbackSession.currentTimeMs)
}
Log.d(tag, "Prepare complete for session ${currentPlaybackSession?.displayTitle} | ${currentPlayer.mediaItemCount}")
currentPlayer.playWhenReady = playWhenReady
currentPlayer.setPlaybackSpeed(playbackRateToUse)
currentPlayer.prepare()
} else if (castPlayer != null) {
val currentTrackIndex = playbackSession.getCurrentTrackIndex()
val currentTrackTime = playbackSession.getCurrentTrackTimeMs()
val mediaType = playbackSession.mediaType
Log.d(tag, "Loading cast player $currentTrackIndex $currentTrackTime $mediaType")
castPlayer?.load(mediaItems, currentTrackIndex, currentTrackTime, playWhenReady, playbackRateToUse, mediaType)
}
}
fun handlePlayerPlaybackError(errorMessage:String) {
// On error and was attempting to direct play - fallback to transcode
currentPlaybackSession?.let { playbackSession ->
if (playbackSession.isDirectPlay) {
val mediaPlayer = getMediaPlayer()
Log.d(tag, "Fallback to transcode $mediaPlayer")
val libraryItemId = playbackSession.libraryItemId ?: "" // Must be true since direct play
val episodeId = playbackSession.episodeId
apiHandler.playLibraryItem(libraryItemId, episodeId, true, mediaPlayer) {
Handler(Looper.getMainLooper()).post() {
preparePlayer(it, true, null)
}
}
} else {
clientEventEmitter?.onPlaybackFailed(errorMessage)
closePlayback()
}
}
}
fun switchToPlayer(useCastPlayer: Boolean) {
val wasPlaying = currentPlayer.isPlaying
if (useCastPlayer) {
if (currentPlayer == castPlayer) {
Log.d(tag, "switchToPlayer: Already using Cast Player " + castPlayer?.deviceInfo)
return
} else {
Log.d(tag, "switchToPlayer: Switching to cast player from exo player stop exo player")
mPlayer.stop()
}
} else {
if (currentPlayer == mPlayer) {
Log.d(tag, "switchToPlayer: Already using Exo Player " + mPlayer.deviceInfo)
return
} else if (castPlayer != null) {
Log.d(tag, "switchToPlayer: Switching to exo player from cast player stop cast player")
castPlayer?.stop()
}
}
currentPlayer = if (useCastPlayer) {
Log.d(tag, "switchToPlayer: Using Cast Player " + castPlayer?.deviceInfo)
mediaSessionConnector.setPlayer(castPlayer)
playerNotificationManager.setPlayer(castPlayer)
castPlayer as CastPlayer
} else {
Log.d(tag, "switchToPlayer: Using ExoPlayer")
mediaSessionConnector.setPlayer(mPlayer)
playerNotificationManager.setPlayer(mPlayer)
mPlayer
}
clientEventEmitter?.onMediaPlayerChanged(getMediaPlayer())
if (currentPlaybackSession == null) {
Log.d(tag, "switchToPlayer: No Current playback session")
}
currentPlaybackSession?.let {
Log.d(tag, "switchToPlayer: Preparing current playback session ${it.displayTitle}")
if (wasPlaying) { // media is paused when switching players
clientEventEmitter?.onPlayingUpdate(false)
}
preparePlayer(it, false, null)
}
}
fun getCurrentTime() : Long {
return if (currentPlayer.mediaItemCount > 1) {
val windowIndex = currentPlayer.currentMediaItemIndex
val currentTrackStartOffset = currentPlaybackSession?.getTrackStartOffsetMs(windowIndex) ?: 0L
currentPlayer.currentPosition + currentTrackStartOffset
} else {
currentPlayer.currentPosition
}
}
fun getCurrentTimeSeconds() : Double {
return getCurrentTime() / 1000.0
}
private fun getBufferedTime() : Long {
return if (currentPlayer.mediaItemCount > 1) {
val windowIndex = currentPlayer.currentMediaItemIndex
val currentTrackStartOffset = currentPlaybackSession?.getTrackStartOffsetMs(windowIndex) ?: 0L
currentPlayer.bufferedPosition + currentTrackStartOffset
} else {
currentPlayer.bufferedPosition
}
}
fun getBufferedTimeSeconds() : Double {
return getBufferedTime() / 1000.0
}
fun getDuration() : Long {
return currentPlaybackSession?.totalDurationMs ?: 0L
}
fun getCurrentBookTitle() : String? {
return currentPlaybackSession?.displayTitle
}
fun getCurrentPlaybackSessionCopy() :PlaybackSession? {
return currentPlaybackSession?.clone()
}
fun getCurrentPlaybackSessionId() :String? {
return currentPlaybackSession?.id
}
fun play() {
if (currentPlayer.isPlaying) {
Log.d(tag, "Already playing")
return
}
currentPlayer.volume = 1F
if (currentPlayer == castPlayer) {
Log.d(tag, "CAST Player set on play ${currentPlayer.isLoading} || ${currentPlayer.duration} | ${currentPlayer.currentPosition}")
}
currentPlayer.play()
}
fun pause() {
currentPlayer.pause()
}
fun playPause():Boolean {
return if (currentPlayer.isPlaying) {
pause()
false
} else {
play()
true
}
}
fun seekPlayer(time: Long) {
Log.d(tag, "seekPlayer mediaCount = ${currentPlayer.mediaItemCount} | $time")
if (currentPlayer.mediaItemCount > 1) {
currentPlaybackSession?.currentTime = time / 1000.0
val newWindowIndex = currentPlaybackSession?.getCurrentTrackIndex() ?: 0
val newTimeOffset = currentPlaybackSession?.getCurrentTrackTimeMs() ?: 0
currentPlayer.seekTo(newWindowIndex, newTimeOffset)
} else {
currentPlayer.seekTo(time)
}
}
fun seekForward(amount: Long) {
seekPlayer(getCurrentTime() + amount)
// currentPlayer.seekTo(currentPlayer.currentPosition + amount)
}
fun seekBackward(amount: Long) {
seekPlayer(getCurrentTime() - amount)
// currentPlayer.seekTo(currentPlayer.currentPosition - amount)
}
fun setPlaybackSpeed(speed: Float) {
currentPlayer.setPlaybackSpeed(speed)
}
fun closePlayback() {
currentPlayer.clearMediaItems()
currentPlayer.stop()
currentPlaybackSession = null
clientEventEmitter?.onPlaybackClosed()
PlayerListener.lastPauseTime = 0
}
fun sendClientMetadata(playerState: PlayerState) {
val duration = currentPlaybackSession?.getTotalDuration() ?: 0.0
clientEventEmitter?.onMetadata(PlaybackMetadata(duration, getCurrentTimeSeconds(), playerState))
}
fun getMediaPlayer():String {
return if(currentPlayer == castPlayer) "cast-player" else "exo-player"
}
//
// MEDIA BROWSER STUFF (ANDROID AUTO)
//
private val ANDROID_AUTO_PKG_NAME = "com.google.android.projection.gearhead"
private val ANDROID_AUTO_SIMULATOR_PKG_NAME = "com.google.android.autosimulator"
private val ANDROID_WEARABLE_PKG_NAME = "com.google.android.wearable.app"
private val ANDROID_GSEARCH_PKG_NAME = "com.google.android.googlequicksearchbox"
private val ANDROID_AUTOMOTIVE_PKG_NAME = "com.google.android.carassistant"
private val VALID_MEDIA_BROWSERS = mutableListOf<String>(ANDROID_AUTO_PKG_NAME, ANDROID_AUTO_SIMULATOR_PKG_NAME, ANDROID_WEARABLE_PKG_NAME, ANDROID_GSEARCH_PKG_NAME, ANDROID_AUTOMOTIVE_PKG_NAME)
private val AUTO_MEDIA_ROOT = "/"
private val ALL_ROOT = "__ALL__"
private lateinit var browseTree:BrowseTree
// Only allowing android auto or similar to access media browser service
// normal loading of audiobooks is handled in webview (not natively)
private fun isValid(packageName: String, uid: Int) : Boolean {
Log.d(tag, "onGetRoot: Checking package $packageName with uid $uid")
if (!VALID_MEDIA_BROWSERS.contains(packageName)) {
Log.d(tag, "onGetRoot: package $packageName not valid for the media browser service")
return false
}
return true
}
override fun onGetRoot(clientPackageName: String, clientUid: Int, rootHints: Bundle?): BrowserRoot? {
// Verify that the specified package is allowed to access your content
return if (!isValid(clientPackageName, clientUid)) {
// No further calls will be made to other media browsing methods.
null
} else {
if (!isStarted) {
Log.d(tag, "AA Not yet started")
mediaManager.initializeAndroidAuto()
isStarted = true
}
isAndroidAuto = true
val extras = Bundle()
extras.putBoolean(
MediaConstants.BROWSER_SERVICE_EXTRAS_KEY_SEARCH_SUPPORTED, true)
extras.putInt(
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_BROWSABLE,
MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM)
extras.putInt(
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_PLAYABLE,
MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM)
BrowserRoot(AUTO_MEDIA_ROOT, extras)
}
}
override fun onLoadChildren(parentMediaId: String, result: Result<MutableList<MediaBrowserCompat.MediaItem>>) {
Log.d(tag, "ON LOAD CHILDREN $parentMediaId")
val flag = if (parentMediaId == AUTO_MEDIA_ROOT) MediaBrowserCompat.MediaItem.FLAG_BROWSABLE else MediaBrowserCompat.MediaItem.FLAG_PLAYABLE
result.detach()
mediaManager.loadAndroidAutoItems("main") { libraryCategories ->
browseTree = BrowseTree(this, libraryCategories)
val children = browseTree[parentMediaId]?.map { item ->
MediaBrowserCompat.MediaItem(item.description, flag)
}
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
}
// TODO: For using sub menus. Check if this is the root menu:
// if (AUTO_MEDIA_ROOT == parentMediaId) {
// build the MediaItem objects for the top level,
// and put them in the mediaItems list
// } else {
// examine the passed parentMediaId to see which submenu we're at,
// and put the children of that menu in the mediaItems list
// }
}
override fun onSearch(query: String, extras: Bundle?, result: Result<MutableList<MediaBrowserCompat.MediaItem>>) {
result.detach()
mediaManager.loadAndroidAutoItems("main") { libraryCategories ->
browseTree = BrowseTree(this, libraryCategories)
val children = browseTree[ALL_ROOT]?.map { item ->
MediaBrowserCompat.MediaItem(item.description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
}
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
}
}
//
// SHAKE SENSOR
//
private fun initSensor() {
// ShakeDetector initialization
mSensorManager = getSystemService(SENSOR_SERVICE) as SensorManager
mAccelerometer = mSensorManager!!.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
mShakeDetector = ShakeDetector()
mShakeDetector!!.setOnShakeListener(object : ShakeDetector.OnShakeListener {
override fun onShake(count: Int) {
Log.d(tag, "PHONE SHAKE! $count")
sleepTimerManager.handleShake()
}
})
}
// Shake sensor used for sleep timer
fun registerSensor() {
if (isShakeSensorRegistered) {
Log.w(tag, "Shake sensor already registered")
return
}
shakeSensorUnregisterTask?.cancel()
Log.d(tag, "Registering shake SENSOR ${mAccelerometer?.isWakeUpSensor}")
val success = mSensorManager!!.registerListener(
mShakeDetector,
mAccelerometer,
SensorManager.SENSOR_DELAY_UI
)
if (success) isShakeSensorRegistered = true
}
fun unregisterSensor() {
if (!isShakeSensorRegistered) return
// Unregister shake sensor after wake up expiration
shakeSensorUnregisterTask?.cancel()
shakeSensorUnregisterTask = Timer("ShakeUnregisterTimer", false).schedule(SLEEP_TIMER_WAKE_UP_EXPIRATION) {
Handler(Looper.getMainLooper()).post() {
Log.d(tag, "wake time expired: Unregistering shake sensor")
mSensorManager!!.unregisterListener(mShakeDetector)
isShakeSensorRegistered = false
}
}
}
}
@@ -1,10 +1,9 @@
package com.audiobookshelf.app
package com.audiobookshelf.app.player
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import java.lang.Math.sqrt
import kotlin.math.sqrt
class ShakeDetector : SensorEventListener {
@@ -1,6 +1,5 @@
package com.audiobookshelf.app
package com.audiobookshelf.app.player
import android.hardware.SensorManager
import android.os.Handler
import android.os.Looper
import android.util.Log
@@ -17,6 +16,8 @@ class SleepTimerManager constructor(playerNotificationService:PlayerNotification
private var sleepTimerTask:TimerTask? = null
private var sleepTimerRunning:Boolean = false
private var sleepTimerEndTime:Long = 0L
private var sleepTimerLength:Long = 0L
private var sleepTimerElapsed:Long = 0L
private var sleepTimerExtensionTime:Long = 0L
private var sleepTimerFinishedAt:Long = 0L
@@ -45,13 +46,12 @@ class SleepTimerManager constructor(playerNotificationService:PlayerNotification
}
private fun getSleepTimerTimeRemainingSeconds():Int {
if (sleepTimerEndTime == 0L && sleepTimerLength > 0) { // For regular timer
return ((sleepTimerLength - sleepTimerElapsed) / 1000).toDouble().roundToInt()
}
// For chapter end timer
if (sleepTimerEndTime <= 0) return 0
var sleepTimeRemaining = sleepTimerEndTime - getCurrentTime()
return ((sleepTimeRemaining / 1000).toDouble()).roundToInt()
}
fun getIsSleepTimerRunning():Boolean {
return sleepTimerRunning
return (((sleepTimerEndTime - getCurrentTime()) / 1000).toDouble()).roundToInt()
}
fun setSleepTimer(time: Long, isChapterTime: Boolean) : Boolean {
@@ -59,6 +59,7 @@ class SleepTimerManager constructor(playerNotificationService:PlayerNotification
sleepTimerTask?.cancel()
sleepTimerRunning = false
sleepTimerFinishedAt = 0L
sleepTimerElapsed = 0L
// Register shake sensor
playerNotificationService.registerSensor()
@@ -70,30 +71,42 @@ class SleepTimerManager constructor(playerNotificationService:PlayerNotification
return false
}
sleepTimerEndTime = time
sleepTimerLength = 0
sleepTimerExtensionTime = SLEEP_EXTENSION_TIME
if (sleepTimerEndTime > getDuration()) {
sleepTimerEndTime = getDuration()
}
} else {
sleepTimerEndTime = currentTime + time
sleepTimerLength = time
sleepTimerEndTime = 0L
sleepTimerExtensionTime = time
if (sleepTimerLength + getCurrentTime() > getDuration()) {
sleepTimerLength = getDuration() - getCurrentTime()
}
}
if (sleepTimerEndTime > getDuration()) {
sleepTimerEndTime = getDuration()
}
playerNotificationService.listener?.onSleepTimerSet(sleepTimerEndTime)
playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds())
sleepTimerRunning = true
sleepTimerTask = Timer("SleepTimer", false).schedule(0L, 1000L) {
Handler(Looper.getMainLooper()).post() {
if (getIsPlaying()) {
sleepTimerElapsed += 1000L
var sleepTimeSecondsRemaining = getSleepTimerTimeRemainingSeconds()
Log.d(tag, "Sleep TIMER time remaining $sleepTimeSecondsRemaining s")
Log.d(tag, "Timer Elapsed $sleepTimerElapsed | Sleep TIMER time remaining $sleepTimeSecondsRemaining s")
if (sleepTimeSecondsRemaining > 0) {
playerNotificationService.clientEventEmitter?.onSleepTimerSet(sleepTimeSecondsRemaining)
}
if (sleepTimeSecondsRemaining <= 0) {
Log.d(tag, "Sleep Timer Pausing Player on Chapter")
pause()
playerNotificationService.listener?.onSleepTimerEnded(getCurrentTime())
playerNotificationService.clientEventEmitter?.onSleepTimerEnded(getCurrentTime())
clearSleepTimer()
sleepTimerFinishedAt = System.currentTimeMillis()
} else if (sleepTimeSecondsRemaining <= 30) {
@@ -123,15 +136,21 @@ class SleepTimerManager constructor(playerNotificationService:PlayerNotification
fun cancelSleepTimer() {
Log.d(tag, "Canceling Sleep Timer")
clearSleepTimer()
playerNotificationService.listener?.onSleepTimerSet(0)
playerNotificationService.clientEventEmitter?.onSleepTimerSet(0)
}
private fun extendSleepTime() {
if (!sleepTimerRunning) return
setVolume(1F)
sleepTimerEndTime += sleepTimerExtensionTime
if (sleepTimerEndTime > getDuration()) sleepTimerEndTime = getDuration()
playerNotificationService.listener?.onSleepTimerSet(sleepTimerEndTime)
if (sleepTimerEndTime == 0L) {
sleepTimerLength += sleepTimerExtensionTime
if (sleepTimerLength + getCurrentTime() > getDuration()) sleepTimerLength = getDuration() - getCurrentTime()
} else {
sleepTimerEndTime += sleepTimerExtensionTime
if (sleepTimerEndTime > getDuration()) sleepTimerEndTime = getDuration()
}
playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds())
}
fun checkShouldExtendSleepTimer() {
@@ -164,27 +183,42 @@ class SleepTimerManager constructor(playerNotificationService:PlayerNotification
fun increaseSleepTime(time: Long) {
Log.d(tag, "Increase Sleep time $time")
if (!sleepTimerRunning) return
var newSleepEndTime = sleepTimerEndTime + time
sleepTimerEndTime = if (newSleepEndTime >= getDuration()) {
getDuration()
if (sleepTimerEndTime == 0L) {
sleepTimerLength += time
if (sleepTimerLength + getCurrentTime() > getDuration()) sleepTimerLength = getDuration() - getCurrentTime()
} else {
newSleepEndTime
var newSleepEndTime = sleepTimerEndTime + time
sleepTimerEndTime = if (newSleepEndTime >= getDuration()) {
getDuration()
} else {
newSleepEndTime
}
}
setVolume(1F)
playerNotificationService.listener?.onSleepTimerSet(sleepTimerEndTime)
playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds())
}
fun decreaseSleepTime(time: Long) {
Log.d(tag, "Decrease Sleep time $time")
if (!sleepTimerRunning) return
var newSleepEndTime = sleepTimerEndTime - time
sleepTimerEndTime = if (newSleepEndTime <= 1000) {
// End sleep timer in 1 second
getCurrentTime() + 1000
if (sleepTimerEndTime == 0L) {
sleepTimerLength -= time
if (sleepTimerLength <= 0) sleepTimerLength = 1000L
} else {
newSleepEndTime
var newSleepEndTime = sleepTimerEndTime - time
sleepTimerEndTime = if (newSleepEndTime <= 1000) {
// End sleep timer in 1 second
getCurrentTime() + 1000
} else {
newSleepEndTime
}
}
setVolume(1F)
playerNotificationService.listener?.onSleepTimerSet(sleepTimerEndTime)
playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds())
}
}
@@ -0,0 +1,346 @@
package com.audiobookshelf.app.plugins
import android.content.Intent
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.core.content.ContextCompat
import com.audiobookshelf.app.MainActivity
import com.audiobookshelf.app.data.*
import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.player.CastManager
import com.audiobookshelf.app.player.PlayerNotificationService
import com.audiobookshelf.app.server.ApiHandler
import com.fasterxml.jackson.core.json.JsonReadFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.getcapacitor.*
import com.getcapacitor.annotation.CapacitorPlugin
import com.google.android.gms.cast.CastDevice
import org.json.JSONObject
@CapacitorPlugin(name = "AbsAudioPlayer")
class AbsAudioPlayer : Plugin() {
private val tag = "AbsAudioPlayer"
var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
private lateinit var mainActivity: MainActivity
private lateinit var apiHandler:ApiHandler
lateinit var castManager:CastManager
lateinit var playerNotificationService: PlayerNotificationService
private var isCastAvailable:Boolean = false
override fun load() {
mainActivity = (activity as MainActivity)
apiHandler = ApiHandler(mainActivity)
initCastManager()
val foregroundServiceReady : () -> Unit = {
playerNotificationService = mainActivity.foregroundService
playerNotificationService.clientEventEmitter = (object : PlayerNotificationService.ClientEventEmitter {
override fun onPlaybackSession(playbackSession: PlaybackSession) {
notifyListeners("onPlaybackSession", JSObject(jacksonMapper.writeValueAsString(playbackSession)))
}
override fun onPlaybackClosed() {
emit("onPlaybackClosed", true)
}
override fun onPlayingUpdate(isPlaying: Boolean) {
emit("onPlayingUpdate", isPlaying)
}
override fun onMetadata(metadata: PlaybackMetadata) {
notifyListeners("onMetadata", JSObject(jacksonMapper.writeValueAsString(metadata)))
}
override fun onPrepare(audiobookId: String, playWhenReady: Boolean) {
val jsobj = JSObject()
jsobj.put("audiobookId", audiobookId)
jsobj.put("playWhenReady", playWhenReady)
notifyListeners("onPrepareMedia", jsobj)
}
override fun onSleepTimerEnded(currentPosition: Long) {
emit("onSleepTimerEnded", currentPosition)
}
override fun onSleepTimerSet(sleepTimeRemaining: Int) {
emit("onSleepTimerSet", sleepTimeRemaining)
}
override fun onLocalMediaProgressUpdate(localMediaProgress: LocalMediaProgress) {
notifyListeners("onLocalMediaProgressUpdate", JSObject(jacksonMapper.writeValueAsString(localMediaProgress)))
}
override fun onPlaybackFailed(errorMessage: String) {
emit("onPlaybackFailed", errorMessage)
}
override fun onMediaPlayerChanged(mediaPlayer:String) {
emit("onMediaPlayerChanged", mediaPlayer)
}
})
}
mainActivity.pluginCallback = foregroundServiceReady
}
fun emit(evtName: String, value: Any) {
val ret = JSObject()
ret.put("value", value)
notifyListeners(evtName, ret)
}
private fun initCastManager() {
val connListener = object: CastManager.ChromecastListener() {
override fun onReceiverAvailableUpdate(available: Boolean) {
Log.d(tag, "ChromecastListener: CAST Receiver Update Available $available")
isCastAvailable = available
emit("onCastAvailableUpdate", available)
}
override fun onSessionRejoin(jsonSession: JSONObject?) {
Log.d(tag, "ChromecastListener: CAST onSessionRejoin")
}
override fun onMediaLoaded(jsonMedia: JSONObject?) {
Log.d(tag, "ChromecastListener: CAST onMediaLoaded")
}
override fun onMediaUpdate(jsonMedia: JSONObject?) {
Log.d(tag, "ChromecastListener: CAST onMediaUpdate")
}
override fun onSessionUpdate(jsonSession: JSONObject?) {
Log.d(tag, "ChromecastListener: CAST onSessionUpdate")
}
override fun onSessionEnd(jsonSession: JSONObject?) {
Log.d(tag, "ChromecastListener: CAST onSessionEnd")
}
override fun onMessageReceived(p0: CastDevice, p1: String, p2: String) {
Log.d(tag, "ChromecastListener: CAST onMessageReceived")
}
}
castManager = CastManager(mainActivity)
castManager.startRouteScan(connListener)
}
@PluginMethod
fun prepareLibraryItem(call: PluginCall) {
// Need to make sure the player service has been started
if (!PlayerNotificationService.isStarted) {
Log.w(tag, "prepareLibraryItem: PlayerService not started - Starting foreground service --")
Intent(mainActivity, PlayerNotificationService::class.java).also { intent ->
ContextCompat.startForegroundService(mainActivity, intent)
}
}
val libraryItemId = call.getString("libraryItemId", "").toString()
val episodeId = call.getString("episodeId", "").toString()
val playWhenReady = call.getBoolean("playWhenReady") == true
var playbackRate = call.getFloat("playbackRate",1f) ?: 1f
if (libraryItemId.isEmpty()) {
Log.e(tag, "Invalid call to play library item no library item id")
return call.resolve()
}
if (libraryItemId.startsWith("local")) { // Play local media item
DeviceManager.dbManager.getLocalLibraryItem(libraryItemId)?.let {
var episode: PodcastEpisode? = null
if (episodeId.isNotEmpty()) {
val podcastMedia = it.media as Podcast
episode = podcastMedia.episodes?.find { ep -> ep.id == episodeId }
if (episode == null) {
Log.e(tag, "prepareLibraryItem: Podcast episode not found $episodeId")
return call.resolve(JSObject())
}
}
Handler(Looper.getMainLooper()).post {
Log.d(tag, "prepareLibraryItem: Preparing Local Media item ${jacksonMapper.writeValueAsString(it)}")
val playbackSession = it.getPlaybackSession(episode)
playerNotificationService.preparePlayer(playbackSession, playWhenReady, playbackRate)
}
return call.resolve(JSObject())
}
} else { // Play library item from server
val mediaPlayer = playerNotificationService.getMediaPlayer()
apiHandler.playLibraryItem(libraryItemId, episodeId, false, mediaPlayer) {
Handler(Looper.getMainLooper()).post {
Log.d(tag, "Preparing Player TEST ${jacksonMapper.writeValueAsString(it)}")
playerNotificationService.preparePlayer(it, playWhenReady, playbackRate)
}
call.resolve(JSObject(jacksonMapper.writeValueAsString(it)))
}
}
}
@PluginMethod
fun getCurrentTime(call: PluginCall) {
Handler(Looper.getMainLooper()).post {
val currentTime = playerNotificationService.getCurrentTimeSeconds()
val bufferedTime = playerNotificationService.getBufferedTimeSeconds()
val ret = JSObject()
ret.put("value", currentTime)
ret.put("bufferedTime", bufferedTime)
call.resolve(ret)
}
}
@PluginMethod
fun pausePlayer(call: PluginCall) {
Handler(Looper.getMainLooper()).post {
playerNotificationService.pause()
call.resolve()
}
}
@PluginMethod
fun playPlayer(call: PluginCall) {
Handler(Looper.getMainLooper()).post {
playerNotificationService.play()
call.resolve()
}
}
@PluginMethod
fun playPause(call: PluginCall) {
Handler(Looper.getMainLooper()).post {
val playing = playerNotificationService.playPause()
call.resolve(JSObject("{\"playing\":$playing}"))
}
}
@PluginMethod
fun seek(call: PluginCall) {
val time:Int = call.getInt("value", 0) ?: 0 // Value in seconds
Handler(Looper.getMainLooper()).post {
playerNotificationService.seekPlayer(time * 1000L) // convert to ms
call.resolve()
}
}
@PluginMethod
fun seekForward(call: PluginCall) {
val amount:Int = call.getInt("value", 0) ?: 0
Handler(Looper.getMainLooper()).post {
playerNotificationService.seekForward(amount * 1000L) // convert to ms
call.resolve()
}
}
@PluginMethod
fun seekBackward(call: PluginCall) {
val amount:Int = call.getInt("value", 0) ?: 0 // Value in seconds
Handler(Looper.getMainLooper()).post {
playerNotificationService.seekBackward(amount * 1000L) // convert to ms
call.resolve()
}
}
@PluginMethod
fun setPlaybackSpeed(call: PluginCall) {
val playbackSpeed:Float = call.getFloat("value", 1.0f) ?: 1.0f
Handler(Looper.getMainLooper()).post {
playerNotificationService.setPlaybackSpeed(playbackSpeed)
call.resolve()
}
}
@PluginMethod
fun closePlayback(call: PluginCall) {
Handler(Looper.getMainLooper()).post {
playerNotificationService.closePlayback()
call.resolve()
}
}
@PluginMethod
fun setSleepTimer(call: PluginCall) {
val time:Long = call.getString("time", "360000")!!.toLong()
val isChapterTime:Boolean = call.getBoolean("isChapterTime", false) == true
Handler(Looper.getMainLooper()).post {
val success:Boolean = playerNotificationService.sleepTimerManager.setSleepTimer(time, isChapterTime)
val ret = JSObject()
ret.put("success", success)
call.resolve(ret)
}
}
@PluginMethod
fun getSleepTimerTime(call: PluginCall) {
val time = playerNotificationService.sleepTimerManager.getSleepTimerTime()
val ret = JSObject()
ret.put("value", time)
call.resolve(ret)
}
@PluginMethod
fun increaseSleepTime(call: PluginCall) {
val time:Long = call.getString("time", "300000")!!.toLong()
Handler(Looper.getMainLooper()).post {
playerNotificationService.sleepTimerManager.increaseSleepTime(time)
val ret = JSObject()
ret.put("success", true)
call.resolve()
}
}
@PluginMethod
fun decreaseSleepTime(call: PluginCall) {
val time:Long = call.getString("time", "300000")!!.toLong()
Handler(Looper.getMainLooper()).post {
playerNotificationService.sleepTimerManager.decreaseSleepTime(time)
val ret = JSObject()
ret.put("success", true)
call.resolve()
}
}
@PluginMethod
fun cancelSleepTimer(call: PluginCall) {
playerNotificationService.sleepTimerManager.cancelSleepTimer()
call.resolve()
}
@PluginMethod
fun requestSession(call: PluginCall) {
// Need to make sure the player service has been started
Log.d(tag, "CAST REQUEST SESSION PLUGIN")
call.resolve()
castManager.requestSession(playerNotificationService, object : CastManager.RequestSessionCallback() {
override fun onError(errorCode: Int) {
Log.e(tag, "CAST REQUEST SESSION CALLBACK ERROR $errorCode")
}
override fun onCancel() {
Log.d(tag, "CAST REQUEST SESSION ON CANCEL")
}
override fun onJoin(jsonSession: JSONObject?) {
Log.d(tag, "CAST REQUEST SESSION ON JOIN")
}
})
}
@PluginMethod
fun getIsCastAvailable(call: PluginCall) {
val jsobj = JSObject()
jsobj.put("value", isCastAvailable)
call.resolve(jsobj)
}
}
@@ -0,0 +1,291 @@
package com.audiobookshelf.app.data
import android.util.Log
import com.audiobookshelf.app.MainActivity
import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.server.ApiHandler
import com.fasterxml.jackson.core.json.JsonReadFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.getcapacitor.*
import com.getcapacitor.annotation.CapacitorPlugin
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import org.json.JSONObject
@CapacitorPlugin(name = "AbsDatabase")
class AbsDatabase : Plugin() {
val tag = "AbsDatabase"
var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
lateinit var mainActivity: MainActivity
lateinit var apiHandler: ApiHandler
data class LocalMediaProgressPayload(val value:List<LocalMediaProgress>)
data class LocalLibraryItemsPayload(val value:List<LocalLibraryItem>)
data class LocalFoldersPayload(val value:List<LocalFolder>)
override fun load() {
mainActivity = (activity as MainActivity)
apiHandler = ApiHandler(mainActivity)
DeviceManager.dbManager.cleanLocalMediaProgress()
DeviceManager.dbManager.cleanLocalLibraryItems()
}
@PluginMethod
fun getDeviceData(call:PluginCall) {
GlobalScope.launch(Dispatchers.IO) {
var deviceData = DeviceManager.dbManager.getDeviceData()
call.resolve(JSObject(jacksonMapper.writeValueAsString(deviceData)))
}
}
@PluginMethod
fun getLocalFolders(call:PluginCall) {
GlobalScope.launch(Dispatchers.IO) {
var folders = DeviceManager.dbManager.getAllLocalFolders()
call.resolve(JSObject(jacksonMapper.writeValueAsString(LocalFoldersPayload(folders))))
}
}
@PluginMethod
fun getLocalFolder(call:PluginCall) {
var folderId = call.getString("folderId", "").toString()
GlobalScope.launch(Dispatchers.IO) {
DeviceManager.dbManager.getLocalFolder(folderId)?.let {
var folderObj = jacksonMapper.writeValueAsString(it)
call.resolve(JSObject(folderObj))
} ?: call.resolve()
}
}
@PluginMethod
fun getLocalLibraryItem(call:PluginCall) {
var id = call.getString("id", "").toString()
GlobalScope.launch(Dispatchers.IO) {
var localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(id)
if (localLibraryItem == null) {
call.resolve()
} else {
call.resolve(JSObject(jacksonMapper.writeValueAsString(localLibraryItem)))
}
}
}
@PluginMethod
fun getLocalLibraryItemByLLId(call:PluginCall) {
var libraryItemId = call.getString("libraryItemId", "").toString()
GlobalScope.launch(Dispatchers.IO) {
var localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLLId(libraryItemId)
if (localLibraryItem == null) {
call.resolve()
} else {
call.resolve(JSObject(jacksonMapper.writeValueAsString(localLibraryItem)))
}
}
}
@PluginMethod
fun getLocalLibraryItems(call:PluginCall) {
var mediaType = call.getString("mediaType", "").toString()
GlobalScope.launch(Dispatchers.IO) {
var localLibraryItems = DeviceManager.dbManager.getLocalLibraryItems(mediaType)
call.resolve(JSObject(jacksonMapper.writeValueAsString(LocalLibraryItemsPayload(localLibraryItems))))
}
}
@PluginMethod
fun getLocalLibraryItemsInFolder(call:PluginCall) {
var folderId = call.getString("folderId", "").toString()
GlobalScope.launch(Dispatchers.IO) {
var localLibraryItems = DeviceManager.dbManager.getLocalLibraryItemsInFolder(folderId)
call.resolve(JSObject(jacksonMapper.writeValueAsString(LocalLibraryItemsPayload(localLibraryItems))))
}
}
@PluginMethod
fun setCurrentServerConnectionConfig(call:PluginCall) {
var serverConnectionConfigId = call.getString("id", "").toString()
var serverConnectionConfig = DeviceManager.deviceData.serverConnectionConfigs.find { it.id == serverConnectionConfigId }
var userId = call.getString("userId", "").toString()
var username = call.getString("username", "").toString()
var token = call.getString("token", "").toString()
GlobalScope.launch(Dispatchers.IO) {
if (serverConnectionConfig == null) { // New Server Connection
var serverAddress = call.getString("address", "").toString()
// Create new server connection config
var sscId = DeviceManager.getBase64Id("$serverAddress@$username")
var sscIndex = DeviceManager.deviceData.serverConnectionConfigs.size
serverConnectionConfig = ServerConnectionConfig(sscId, sscIndex, "$serverAddress ($username)", serverAddress, userId, username, token)
// Add and save
DeviceManager.deviceData.serverConnectionConfigs.add(serverConnectionConfig!!)
DeviceManager.deviceData.lastServerConnectionConfigId = serverConnectionConfig?.id
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
} else {
var shouldSave = false
if (serverConnectionConfig?.username != username || serverConnectionConfig?.token != token) {
serverConnectionConfig?.userId = userId
serverConnectionConfig?.username = username
serverConnectionConfig?.name = "${serverConnectionConfig?.address} (${serverConnectionConfig?.username})"
serverConnectionConfig?.token = token
shouldSave = true
}
// Set last connection config
if (DeviceManager.deviceData.lastServerConnectionConfigId != serverConnectionConfigId) {
DeviceManager.deviceData.lastServerConnectionConfigId = serverConnectionConfigId
shouldSave = true
}
if (shouldSave) DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
}
DeviceManager.serverConnectionConfig = serverConnectionConfig
call.resolve(JSObject(jacksonMapper.writeValueAsString(DeviceManager.serverConnectionConfig)))
}
}
@PluginMethod
fun removeServerConnectionConfig(call:PluginCall) {
GlobalScope.launch(Dispatchers.IO) {
var serverConnectionConfigId = call.getString("serverConnectionConfigId", "").toString()
DeviceManager.deviceData.serverConnectionConfigs = DeviceManager.deviceData.serverConnectionConfigs.filter { it.id != serverConnectionConfigId } as MutableList<ServerConnectionConfig>
if (DeviceManager.deviceData.lastServerConnectionConfigId == serverConnectionConfigId) {
DeviceManager.deviceData.lastServerConnectionConfigId = null
}
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
if (DeviceManager.serverConnectionConfig?.id == serverConnectionConfigId) {
DeviceManager.serverConnectionConfig = null
}
call.resolve()
}
}
@PluginMethod
fun logout(call:PluginCall) {
GlobalScope.launch(Dispatchers.IO) {
DeviceManager.serverConnectionConfig = null
DeviceManager.deviceData.lastServerConnectionConfigId = null
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
call.resolve()
}
}
@PluginMethod
fun getAllLocalMediaProgress(call:PluginCall) {
GlobalScope.launch(Dispatchers.IO) {
var localMediaProgress = DeviceManager.dbManager.getAllLocalMediaProgress()
call.resolve(JSObject(jacksonMapper.writeValueAsString(LocalMediaProgressPayload(localMediaProgress))))
}
}
@PluginMethod
fun removeLocalMediaProgress(call:PluginCall) {
var localMediaProgressId = call.getString("localMediaProgressId", "").toString()
DeviceManager.dbManager.removeLocalMediaProgress(localMediaProgressId)
call.resolve()
}
@PluginMethod
fun syncLocalMediaProgressWithServer(call:PluginCall) {
if (DeviceManager.serverConnectionConfig == null) {
Log.e(tag, "syncLocalMediaProgressWithServer not connected to server")
return call.resolve()
}
apiHandler.syncMediaProgress {
call.resolve(JSObject(jacksonMapper.writeValueAsString(it)))
}
}
@PluginMethod
fun updateLocalMediaProgressFinished(call:PluginCall) {
var localMediaProgressId = call.getString("localMediaProgressId", "").toString()
var isFinished = call.getBoolean("isFinished", false) == true
Log.d(tag, "updateLocalMediaProgressFinished $localMediaProgressId | Is Finished:$isFinished")
var localMediaProgress = DeviceManager.dbManager.getLocalMediaProgress(localMediaProgressId)
if (localMediaProgress == null) {
Log.e(tag, "updateLocalMediaProgressFinished Local Media Progress not found $localMediaProgressId")
call.resolve(JSObject("{\"error\":\"Progress not found\"}"))
} else {
localMediaProgress.updateIsFinished(isFinished)
var lmpstring = jacksonMapper.writeValueAsString(localMediaProgress)
Log.d(tag, "updateLocalMediaProgressFinished: Local Media Progress String $lmpstring")
// Send update to server media progress is linked to a server and user is logged into that server
localMediaProgress.serverConnectionConfigId?.let { configId ->
if (DeviceManager.serverConnectionConfigId == configId) {
var libraryItemId = localMediaProgress.libraryItemId ?: ""
var episodeId = localMediaProgress.episodeId ?: ""
var updatePayload = JSObject()
updatePayload.put("isFinished", isFinished)
apiHandler.updateMediaProgress(libraryItemId,episodeId,updatePayload) {
Log.d(tag, "updateLocalMediaProgressFinished: Updated media progress isFinished on server")
var jsobj = JSObject()
jsobj.put("local", true)
jsobj.put("server", true)
jsobj.put("localMediaProgress", JSObject(lmpstring))
call.resolve(jsobj)
// call.resolve(JSObject("{\"local\":true,\"server\":true,\"localMediaProgress\":$lmpstring}"))
}
}
}
if (localMediaProgress.serverConnectionConfigId == null || DeviceManager.serverConnectionConfigId != localMediaProgress.serverConnectionConfigId) {
// call.resolve(JSObject("{\"local\":true,\"localMediaProgress\":$lmpstring}}"))
var jsobj = JSObject()
jsobj.put("local", true)
jsobj.put("server", false)
jsobj.put("localMediaProgress", JSObject(lmpstring))
call.resolve(jsobj)
}
}
}
@PluginMethod
fun updateLocalTrackOrder(call:PluginCall) {
var localLibraryItemId = call.getString("localLibraryItemId", "") ?: ""
var localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
if (localLibraryItem == null) {
call.resolve()
return
}
var audioTracks = localLibraryItem.media.getAudioTracks() as MutableList
var tracks:JSArray = call.getArray("tracks") ?: JSArray()
Log.d(tag, "updateLocalTrackOrder $tracks")
var index = 1
var hasUpdates = false
for (i in 0 until tracks.length()) {
var track = tracks.getJSONObject(i)
var localFileId = track.getString("localFileId")
var existingTrack = audioTracks.find{ it.localFileId == localFileId }
if (existingTrack != null) {
Log.d(tag, "Found existing track ${existingTrack.localFileId} that has index ${existingTrack.index} should be index $index")
if (existingTrack.index != index) hasUpdates = true
existingTrack.index = index++
} else {
Log.e(tag, "Audio track with local file id not found")
}
}
if (hasUpdates) {
Log.d(tag, "Save library item track orders")
localLibraryItem.media.setAudioTracks(audioTracks)
DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem)
call.resolve(JSObject(jacksonMapper.writeValueAsString(localLibraryItem)))
} else {
Log.d(tag, "No tracks need to be updated")
call.resolve()
}
}
}
@@ -0,0 +1,400 @@
package com.audiobookshelf.app.plugins
import android.app.DownloadManager
import android.content.Context
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.util.Log
import androidx.documentfile.provider.DocumentFile
import com.anggrayudi.storage.callback.FileCallback
import com.anggrayudi.storage.file.*
import com.anggrayudi.storage.media.FileDescription
import com.audiobookshelf.app.MainActivity
import com.audiobookshelf.app.data.*
import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.device.FolderScanner
import com.audiobookshelf.app.server.ApiHandler
import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.core.json.JsonReadFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.getcapacitor.JSObject
import com.getcapacitor.Plugin
import com.getcapacitor.PluginCall
import com.getcapacitor.PluginMethod
import com.getcapacitor.annotation.CapacitorPlugin
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.io.File
@CapacitorPlugin(name = "AbsDownloader")
class AbsDownloader : Plugin() {
private val tag = "AbsDownloader"
var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
lateinit var mainActivity: MainActivity
lateinit var downloadManager: DownloadManager
lateinit var apiHandler: ApiHandler
lateinit var folderScanner: FolderScanner
data class DownloadItemPart(
val id: String,
val filename: String,
val finalDestinationPath:String,
val itemTitle: String,
val serverPath: String,
val localFolderName: String,
val localFolderUrl: String,
val localFolderId: String,
val audioTrack: AudioTrack?,
val episode:PodcastEpisode?,
var completed:Boolean,
var moved:Boolean,
var failed:Boolean,
@JsonIgnore val uri: Uri,
@JsonIgnore val destinationUri: Uri,
@JsonIgnore val finalDestinationUri: Uri,
var downloadId: Long?,
var progress: Long
) {
companion object {
fun make(filename:String, destinationFile:File, finalDestinationFile:File, itemTitle:String, serverPath:String, localFolder:LocalFolder, audioTrack:AudioTrack?, episode:PodcastEpisode?) :DownloadItemPart {
val destinationUri = Uri.fromFile(destinationFile)
val finalDestinationUri = Uri.fromFile(finalDestinationFile)
var downloadUrl = "${DeviceManager.serverAddress}${serverPath}?token=${DeviceManager.token}"
if (serverPath.endsWith("/cover")) downloadUrl += "&format=jpeg" // For cover images force to jpeg
val downloadUri = Uri.parse(downloadUrl)
Log.d("DownloadItemPart", "Audio File Destination Uri: $destinationUri | Final Destination Uri: $finalDestinationUri | Download URI $downloadUri")
return DownloadItemPart(
id = DeviceManager.getBase64Id(finalDestinationFile.absolutePath),
filename = filename, finalDestinationFile.absolutePath,
itemTitle = itemTitle,
serverPath = serverPath,
localFolderName = localFolder.name,
localFolderUrl = localFolder.contentUrl,
localFolderId = localFolder.id,
audioTrack = audioTrack,
episode = episode,
completed = false,
moved = false,
failed = false,
uri = downloadUri,
destinationUri = destinationUri,
finalDestinationUri = finalDestinationUri,
downloadId = null,
progress = 0
)
}
}
@JsonIgnore
fun getDownloadRequest(): DownloadManager.Request {
val dlRequest = DownloadManager.Request(uri)
dlRequest.setTitle(filename)
dlRequest.setDescription("Downloading to $localFolderName for book $itemTitle")
dlRequest.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
dlRequest.setDestinationUri(destinationUri)
return dlRequest
}
}
data class DownloadItem(
val id: String,
val libraryItemId:String,
val episodeId:String?,
val userMediaProgress:MediaProgress?,
val serverConnectionConfigId:String,
val serverAddress:String,
val serverUserId:String,
val mediaType: String,
val itemFolderPath:String,
val localFolder: LocalFolder,
val itemTitle: String,
val media:MediaType,
val downloadItemParts: MutableList<DownloadItemPart>
)
var downloadQueue: MutableList<DownloadItem> = mutableListOf()
override fun load() {
mainActivity = (activity as MainActivity)
downloadManager = activity.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
folderScanner = FolderScanner(mainActivity)
apiHandler = ApiHandler(mainActivity)
Log.d(tag, "Build SDK ${Build.VERSION.SDK_INT}")
}
@PluginMethod
fun downloadLibraryItem(call: PluginCall) {
val libraryItemId = call.data.getString("libraryItemId").toString()
var episodeId = call.data.getString("episodeId").toString()
if (episodeId == "null") episodeId = ""
val localFolderId = call.data.getString("localFolderId").toString()
Log.d(tag, "Download library item $libraryItemId to folder $localFolderId / episode: $episodeId")
val downloadId = if (episodeId.isEmpty()) libraryItemId else "$libraryItemId-$episodeId"
if (downloadQueue.find { it.id == downloadId } != null) {
Log.d(tag, "Download already started for this media entity $downloadId")
return call.resolve(JSObject("{\"error\":\"Download already started for this media entity\"}"))
}
apiHandler.getLibraryItemWithProgress(libraryItemId, episodeId) { libraryItem ->
Log.d(tag, "Got library item from server ${libraryItem.id}")
val localFolder = DeviceManager.dbManager.getLocalFolder(localFolderId)
if (localFolder != null) {
if (episodeId.isNotEmpty() && libraryItem.mediaType != "podcast") {
Log.e(tag, "Library item is not a podcast but episode was requested")
call.resolve(JSObject("{\"error\":\"Invalid library item not a podcast\"}"))
} else if (episodeId.isNotEmpty()) {
val podcast = libraryItem.media as Podcast
val episode = podcast.episodes?.find { podcastEpisode ->
podcastEpisode.id == episodeId
}
if (episode == null) {
call.resolve(JSObject("{\"error\":\"Invalid podcast episode not found\"}"))
} else {
startLibraryItemDownload(libraryItem, localFolder, episode)
call.resolve()
}
} else {
startLibraryItemDownload(libraryItem, localFolder, null)
call.resolve()
}
} else {
call.resolve(JSObject("{\"error\":\"Local Folder Not Found\"}"))
}
}
}
// Clean folder path so it can be used in URL
private fun cleanRelPath(relPath: String): String {
val cleanedRelPath = relPath.replace("\\", "/").replace("%", "%25").replace("#", "%23")
return if (cleanedRelPath.startsWith("/")) cleanedRelPath.substring(1) else cleanedRelPath
}
// Item filenames could be the same if they are in sub-folders, this will make them unique
private fun getFilenameFromRelPath(relPath: String): String {
val cleanedRelPath = relPath.replace("\\", "_").replace("/", "_")
return if (cleanedRelPath.startsWith("_")) cleanedRelPath.substring(1) else cleanedRelPath
}
private fun startLibraryItemDownload(libraryItem: LibraryItem, localFolder: LocalFolder, episode:PodcastEpisode?) {
val tempFolderPath = mainActivity.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
if (libraryItem.mediaType == "book") {
val bookTitle = libraryItem.media.metadata.title
val tracks = libraryItem.media.getAudioTracks()
Log.d(tag, "Starting library item download with ${tracks.size} tracks")
val itemFolderPath = localFolder.absolutePath + "/" + bookTitle
val downloadItem = DownloadItem(libraryItem.id, libraryItem.id, null, libraryItem.userMediaProgress,DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, bookTitle, libraryItem.media, mutableListOf())
// Create download item part for each audio track
tracks.forEach { audioTrack ->
val serverPath = "/s/item/${libraryItem.id}/${cleanRelPath(audioTrack.relPath)}"
val destinationFilename = getFilenameFromRelPath(audioTrack.relPath)
Log.d(tag, "Audio File Server Path $serverPath | AF RelPath ${audioTrack.relPath} | LocalFolder Path ${localFolder.absolutePath} | DestName ${destinationFilename}")
val finalDestinationFile = File("$itemFolderPath/$destinationFilename")
val destinationFile = File("$tempFolderPath/$destinationFilename")
if (finalDestinationFile.exists()) {
Log.d(tag, "Audio file already exists, removing it from ${finalDestinationFile.absolutePath}")
finalDestinationFile.delete()
}
val downloadItemPart = DownloadItemPart.make(destinationFilename,destinationFile,finalDestinationFile,bookTitle,serverPath,localFolder,audioTrack,null)
downloadItem.downloadItemParts.add(downloadItemPart)
val dlRequest = downloadItemPart.getDownloadRequest()
val downloadId = downloadManager.enqueue(dlRequest)
downloadItemPart.downloadId = downloadId
}
if (downloadItem.downloadItemParts.isNotEmpty()) {
// Add cover download item
if (libraryItem.media.coverPath != null && libraryItem.media.coverPath?.isNotEmpty() == true) {
val serverPath = "/api/items/${libraryItem.id}/cover"
val destinationFilename = "cover.jpg"
val destinationFile = File("$tempFolderPath/$destinationFilename")
val finalDestinationFile = File("$itemFolderPath/$destinationFilename")
if (finalDestinationFile.exists()) {
Log.d(tag, "Cover already exists, removing it from ${finalDestinationFile.absolutePath}")
finalDestinationFile.delete()
}
val downloadItemPart = DownloadItemPart.make(destinationFilename,destinationFile,finalDestinationFile,bookTitle,serverPath,localFolder,null,null)
downloadItem.downloadItemParts.add(downloadItemPart)
val dlRequest = downloadItemPart.getDownloadRequest()
val downloadId = downloadManager.enqueue(dlRequest)
downloadItemPart.downloadId = downloadId
}
downloadQueue.add(downloadItem)
startWatchingDownloads(downloadItem)
DeviceManager.dbManager.saveDownloadItem(downloadItem)
}
} else {
// Podcast episode download
val podcastTitle = libraryItem.media.metadata.title
val audioTrack = episode?.audioTrack
Log.d(tag, "Starting podcast episode download")
val itemFolderPath = localFolder.absolutePath + "/" + podcastTitle
val downloadItemId = "${libraryItem.id}-${episode?.id}"
val downloadItem = DownloadItem(downloadItemId, libraryItem.id, episode?.id, libraryItem.userMediaProgress, DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, podcastTitle, libraryItem.media, mutableListOf())
var serverPath = "/s/item/${libraryItem.id}/${cleanRelPath(audioTrack?.relPath ?: "")}"
var destinationFilename = getFilenameFromRelPath(audioTrack?.relPath ?: "")
Log.d(tag, "Audio File Server Path $serverPath | AF RelPath ${audioTrack?.relPath} | LocalFolder Path ${localFolder.absolutePath} | DestName ${destinationFilename}")
var destinationFile = File("$tempFolderPath/$destinationFilename")
var finalDestinationFile = File("$itemFolderPath/$destinationFilename")
if (finalDestinationFile.exists()) {
Log.d(tag, "Audio file already exists, removing it from ${finalDestinationFile.absolutePath}")
finalDestinationFile.delete()
}
var downloadItemPart = DownloadItemPart.make(destinationFilename,destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,audioTrack,null)
downloadItem.downloadItemParts.add(downloadItemPart)
var dlRequest = downloadItemPart.getDownloadRequest()
var downloadId = downloadManager.enqueue(dlRequest)
downloadItemPart.downloadId = downloadId
if (libraryItem.media.coverPath != null && libraryItem.media.coverPath?.isNotEmpty() == true) {
serverPath = "/api/items/${libraryItem.id}/cover"
destinationFilename = "cover.jpg"
destinationFile = File("$tempFolderPath/$destinationFilename")
finalDestinationFile = File("$itemFolderPath/$destinationFilename")
if (finalDestinationFile.exists()) {
Log.d(tag, "Podcast cover already exists - not downloading cover again")
} else {
downloadItemPart = DownloadItemPart.make(destinationFilename,destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,audioTrack,null)
downloadItem.downloadItemParts.add(downloadItemPart)
dlRequest = downloadItemPart.getDownloadRequest()
downloadId = downloadManager.enqueue(dlRequest)
downloadItemPart.downloadId = downloadId
}
}
downloadQueue.add(downloadItem)
startWatchingDownloads(downloadItem)
DeviceManager.dbManager.saveDownloadItem(downloadItem)
}
}
private fun startWatchingDownloads(downloadItem: DownloadItem) {
GlobalScope.launch(Dispatchers.IO) {
while (downloadItem.downloadItemParts.find { !it.moved && !it.failed } != null) { // While some item is not completed
val numPartsBefore = downloadItem.downloadItemParts.size
checkDownloads(downloadItem)
// Keep database updated as item parts finish downloading
if (downloadItem.downloadItemParts.size > 0 && downloadItem.downloadItemParts.size != numPartsBefore) {
Log.d(tag, "Save download item on num parts changed from $numPartsBefore to ${downloadItem.downloadItemParts.size}")
DeviceManager.dbManager.saveDownloadItem(downloadItem)
}
notifyListeners("onItemDownloadUpdate", JSObject(jacksonMapper.writeValueAsString(downloadItem)))
delay(500)
}
val downloadItemScanResult = folderScanner.scanDownloadItem(downloadItem)
DeviceManager.dbManager.removeDownloadItem(downloadItem.id)
downloadQueue.remove(downloadItem)
Log.d(tag, "Item download complete ${downloadItem.itemTitle} | local library item id: ${downloadItemScanResult?.localLibraryItem?.id} | Items remaining in Queue ${downloadQueue.size}")
val jsobj = JSObject()
jsobj.put("libraryItemId", downloadItem.id)
jsobj.put("localFolderId", downloadItem.localFolder.id)
downloadItemScanResult?.localLibraryItem?.let { localLibraryItem ->
jsobj.put("localLibraryItem", JSObject(jacksonMapper.writeValueAsString(localLibraryItem)))
}
downloadItemScanResult?.localMediaProgress?.let { localMediaProgress ->
jsobj.put("localMediaProgress", JSObject(jacksonMapper.writeValueAsString(localMediaProgress)))
}
notifyListeners("onItemDownloadComplete", jsobj)
}
}
private fun checkDownloads(downloadItem: DownloadItem) {
val itemParts = downloadItem.downloadItemParts.map { it }
for (downloadItemPart in itemParts) {
if (downloadItemPart.downloadId != null) {
val dlid = downloadItemPart.downloadId!!
val query = DownloadManager.Query().setFilterById(dlid)
downloadManager.query(query).use {
if (it.moveToFirst()) {
val bytesColumnIndex = it.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)
val statusColumnIndex = it.getColumnIndex(DownloadManager.COLUMN_STATUS)
val bytesDownloadedColumnIndex = it.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)
val totalBytes = if (bytesColumnIndex >= 0) it.getInt(bytesColumnIndex) else 0
val downloadStatus = if (statusColumnIndex >= 0) it.getInt(statusColumnIndex) else 0
val bytesDownloadedSoFar = if (bytesDownloadedColumnIndex >= 0) it.getInt(bytesDownloadedColumnIndex) else 0
Log.d(tag, "checkDownloads Download ${downloadItemPart.filename} bytes $totalBytes | bytes dled $bytesDownloadedSoFar | downloadStatus $downloadStatus")
if (downloadStatus == DownloadManager.STATUS_SUCCESSFUL) {
// Once file download is complete move the file to the final destination
if (!downloadItemPart.completed) {
Log.d(tag, "checkDownloads Download ${downloadItemPart.filename} Done")
downloadItemPart.completed = true
val file = DocumentFileCompat.fromUri(mainActivity, downloadItemPart.destinationUri)
Log.d(tag, "DOWNLOAD: Attempt move for file at destination ${downloadItemPart.destinationUri} | ${file?.getBasePath(mainActivity)}")
val fcb = object : FileCallback() {
override fun onPrepare() {
Log.d(tag, "DOWNLOAD: PREPARING MOVE FILE")
}
override fun onFailed(errorCode:ErrorCode) {
Log.e(tag, "DOWNLOAD: FAILED TO MOVE FILE $errorCode")
downloadItemPart.failed = true
file?.delete()
}
override fun onCompleted(result:Any) {
Log.d(tag, "DOWNLOAD: FILE MOVE COMPLETED")
val resultDocFile = result as DocumentFile
Log.d(tag, "DOWNLOAD: COMPLETED FILE INFO ${resultDocFile.getAbsolutePath(mainActivity)}")
downloadItemPart.moved = true
}
}
Log.d(tag, "DOWNLOAD: Move file to final destination path: ${downloadItemPart.finalDestinationPath}")
val localFolderFile = DocumentFileCompat.fromUri(mainActivity,Uri.parse(downloadItemPart.localFolderUrl))
val mimetype = if (downloadItemPart.audioTrack != null) MimeType.AUDIO else MimeType.IMAGE
val fileDescription = FileDescription(downloadItemPart.filename, downloadItemPart.itemTitle, mimetype)
file?.moveFileTo(mainActivity,localFolderFile!!,fileDescription,fcb)
} else {
// Why is kotlin requiring an else here..
}
} else if (downloadStatus == DownloadManager.STATUS_FAILED) {
Log.d(tag, "checkDownloads Download ${downloadItemPart.filename} Failed")
downloadItem.downloadItemParts.remove(downloadItemPart)
} else {
//update progress
val percentProgress = if (totalBytes > 0) ((bytesDownloadedSoFar * 100L) / totalBytes) else 0
Log.d(tag, "checkDownloads Download ${downloadItemPart.filename} Progress = $percentProgress%")
downloadItemPart.progress = percentProgress
}
} else {
Log.d(tag, "Download ${downloadItemPart.filename} not found in dlmanager")
downloadItem.downloadItemParts.remove(downloadItemPart)
}
}
}
}
}
}
@@ -0,0 +1,233 @@
package com.audiobookshelf.app.plugins
import android.database.Cursor
import android.net.Uri
import android.os.Build
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.documentfile.provider.DocumentFile
import com.anggrayudi.storage.SimpleStorage
import com.anggrayudi.storage.callback.FolderPickerCallback
import com.anggrayudi.storage.callback.StorageAccessCallback
import com.anggrayudi.storage.file.*
import com.audiobookshelf.app.MainActivity
import com.audiobookshelf.app.data.LocalFolder
import com.audiobookshelf.app.data.LocalLibraryItem
import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.device.FolderScanner
import com.fasterxml.jackson.core.json.JsonReadFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.getcapacitor.*
import com.getcapacitor.annotation.CapacitorPlugin
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
@CapacitorPlugin(name = "AbsFileSystem")
class AbsFileSystem : Plugin() {
private val TAG = "AbsFileSystem"
private val tag = "AbsFileSystem"
var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
lateinit var mainActivity: MainActivity
override fun load() {
mainActivity = (activity as MainActivity)
mainActivity.storage.storageAccessCallback = object : StorageAccessCallback {
override fun onRootPathNotSelected(
requestCode: Int,
rootPath: String,
uri: Uri,
selectedStorageType: StorageType,
expectedStorageType: StorageType
) {
Log.d(TAG, "STORAGE ACCESS CALLBACK")
}
override fun onCanceledByUser(requestCode: Int) {
Log.d(TAG, "STORAGE ACCESS CALLBACK")
}
override fun onExpectedStorageNotSelected(requestCode: Int, selectedFolder: DocumentFile, selectedStorageType: StorageType, expectedBasePath: String, expectedStorageType: StorageType) {
Log.d(TAG, "STORAGE ACCESS CALLBACK")
}
override fun onStoragePermissionDenied(requestCode: Int) {
Log.d(TAG, "STORAGE ACCESS CALLBACK")
}
override fun onRootPathPermissionGranted(requestCode: Int, root: DocumentFile) {
Log.d(TAG, "STORAGE ACCESS CALLBACK")
}
}
}
@PluginMethod
fun selectFolder(call: PluginCall) {
var mediaType = call.data.getString("mediaType", "book").toString()
mainActivity.storage.folderPickerCallback = object : FolderPickerCallback {
override fun onFolderSelected(requestCode: Int, folder: DocumentFile) {
Log.d(TAG, "ON FOLDER SELECTED ${folder.uri} ${folder.name}")
var absolutePath = folder.getAbsolutePath(activity)
var storageType = folder.getStorageType(activity)
var simplePath = folder.getSimplePath(activity)
var basePath = folder.getBasePath(activity)
var folderId = android.util.Base64.encodeToString(folder.id.toByteArray(), android.util.Base64.DEFAULT)
var localFolder = LocalFolder(folderId, folder.name ?: "", folder.uri.toString(),basePath,absolutePath, simplePath, storageType.toString(), mediaType)
DeviceManager.dbManager.saveLocalFolder(localFolder)
call.resolve(JSObject(jacksonMapper.writeValueAsString(localFolder)))
}
override fun onStorageAccessDenied(requestCode: Int, folder: DocumentFile?, storageType: StorageType) {
Log.e(TAG, "STORAGE ACCESS DENIED")
var jsobj = JSObject()
jsobj.put("error", "Access Denied")
call.resolve(jsobj)
}
override fun onStoragePermissionDenied(requestCode: Int) {
Log.d(TAG, "STORAGE PERMISSION DENIED $requestCode")
var jsobj = JSObject()
jsobj.put("error", "Permission Denied")
call.resolve(jsobj)
}
}
mainActivity.storage.openFolderPicker(6)
}
@RequiresApi(Build.VERSION_CODES.R)
@PluginMethod
fun requestStoragePermission(call: PluginCall) {
Log.d(TAG, "Request Storage Permissions")
mainActivity.storageHelper.requestStorageAccess()
call.resolve()
}
@PluginMethod
fun checkStoragePermission(call: PluginCall) {
var res: Boolean
if (Build.VERSION.SDK_INT <= android.os.Build.VERSION_CODES.P) {
res = SimpleStorage.hasStoragePermission(context)
Log.d(TAG, "checkStoragePermission: Check Storage Access $res")
} else {
Log.d(TAG, "checkStoragePermission: Has permission on Android 10 or up")
res = true
}
var jsobj = JSObject()
jsobj.put("value", res)
call.resolve(jsobj)
}
@PluginMethod
fun checkFolderPermissions(call: PluginCall) {
var folderUrl = call.data.getString("folderUrl", "").toString()
Log.d(TAG, "Check Folder Permissions for $folderUrl")
var hasAccess = SimpleStorage.hasStorageAccess(context,folderUrl,true)
var jsobj = JSObject()
jsobj.put("value", hasAccess)
call.resolve(jsobj)
}
@PluginMethod
fun scanFolder(call: PluginCall) {
var folderId = call.data.getString("folderId", "").toString()
var forceAudioProbe = call.data.getBoolean("forceAudioProbe")
Log.d(TAG, "Scan Folder $folderId | Force Audio Probe $forceAudioProbe")
var folder: LocalFolder? = DeviceManager.dbManager.getLocalFolder(folderId)
folder?.let {
var folderScanner = FolderScanner(context)
var folderScanResult = folderScanner.scanForMediaItems(it, forceAudioProbe)
if (folderScanResult == null) {
Log.d(TAG, "NO Scan DATA")
return call.resolve(JSObject())
} else {
Log.d(TAG, "Scan DATA ${jacksonMapper.writeValueAsString(folderScanResult)}")
return call.resolve(JSObject(jacksonMapper.writeValueAsString(folderScanResult)))
}
} ?: call.resolve(JSObject())
}
@PluginMethod
fun removeFolder(call: PluginCall) {
var folderId = call.data.getString("folderId", "").toString()
DeviceManager.dbManager.removeLocalFolder(folderId)
call.resolve()
}
@PluginMethod
fun removeLocalLibraryItem(call: PluginCall) {
var localLibraryItemId = call.data.getString("localLibraryItemId", "").toString()
DeviceManager.dbManager.removeLocalLibraryItem(localLibraryItemId)
call.resolve()
}
@PluginMethod
fun scanLocalLibraryItem(call: PluginCall) {
var localLibraryItemId = call.data.getString("localLibraryItemId", "").toString()
var forceAudioProbe = call.data.getBoolean("forceAudioProbe")
Log.d(TAG, "Scan Local library item $localLibraryItemId | Force Audio Probe $forceAudioProbe")
GlobalScope.launch(Dispatchers.IO) {
var localLibraryItem: LocalLibraryItem? = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
localLibraryItem?.let {
var folderScanner = FolderScanner(context)
var scanResult = folderScanner.scanLocalLibraryItem(it, forceAudioProbe)
if (scanResult == null) {
Log.d(TAG, "NO Scan DATA")
call.resolve(JSObject())
} else {
Log.d(TAG, "Scan DATA ${jacksonMapper.writeValueAsString(scanResult)}")
call.resolve(JSObject(jacksonMapper.writeValueAsString(scanResult)))
}
} ?: call.resolve(JSObject())
}
}
@PluginMethod
fun deleteItem(call: PluginCall) {
var localLibraryItemId = call.data.getString("id", "").toString()
var absolutePath = call.data.getString("absolutePath", "").toString()
var contentUrl = call.data.getString("contentUrl", "").toString()
Log.d(tag, "deleteItem $absolutePath | $contentUrl")
var docfile = DocumentFileCompat.fromUri(mainActivity, Uri.parse(contentUrl))
var success = docfile?.delete() == true
if (success) {
DeviceManager.dbManager.removeLocalLibraryItem(localLibraryItemId)
}
call.resolve(JSObject("{\"success\":$success}"))
}
@PluginMethod
fun deleteTrackFromItem(call: PluginCall) {
var localLibraryItemId = call.data.getString("id", "").toString()
var trackLocalFileId = call.data.getString("trackLocalFileId", "").toString()
var contentUrl = call.data.getString("trackContentUrl", "").toString()
Log.d(tag, "deleteTrackFromItem $contentUrl")
var localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
if (localLibraryItem == null) {
Log.e(tag, "deleteTrackFromItem: LLI does not exist $localLibraryItemId")
return call.resolve(JSObject("{\"success\":false}"))
}
var docfile = DocumentFileCompat.fromUri(mainActivity, Uri.parse(contentUrl))
var success = docfile?.delete() == true
if (success) {
localLibraryItem.media.removeAudioTrack(trackLocalFileId)
localLibraryItem.removeLocalFile(trackLocalFileId)
DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem)
call.resolve(JSObject(jacksonMapper.writeValueAsString(localLibraryItem)))
} else {
call.resolve(JSObject("{\"success\":false}"))
}
}
}
@@ -0,0 +1,260 @@
package com.audiobookshelf.app.server
import android.content.Context
import android.content.SharedPreferences
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.util.Log
import com.audiobookshelf.app.data.*
import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.player.MediaProgressSyncData
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.core.json.JsonReadFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import com.getcapacitor.JSArray
import com.getcapacitor.JSObject
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import java.io.IOException
class ApiHandler(var ctx:Context) {
val tag = "ApiHandler"
private var client = OkHttpClient()
var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
var storageSharedPreferences: SharedPreferences? = null
data class LocalMediaProgressSyncPayload(val localMediaProgress:List<LocalMediaProgress>)
@JsonIgnoreProperties(ignoreUnknown = true)
data class MediaProgressSyncResponsePayload(val numServerProgressUpdates:Int, val localProgressUpdates:List<LocalMediaProgress>)
data class LocalMediaProgressSyncResultsPayload(var numLocalMediaProgressForServer:Int, var numServerProgressUpdates:Int, var numLocalProgressUpdates:Int)
fun getRequest(endpoint:String, cb: (JSObject) -> Unit) {
val request = Request.Builder()
.url("${DeviceManager.serverAddress}$endpoint").addHeader("Authorization", "Bearer ${DeviceManager.token}")
.build()
makeRequest(request, cb)
}
fun postRequest(endpoint:String, payload: JSObject, cb: (JSObject) -> Unit) {
val mediaType = "application/json; charset=utf-8".toMediaType()
val requestBody = payload.toString().toRequestBody(mediaType)
val request = Request.Builder().post(requestBody)
.url("${DeviceManager.serverAddress}$endpoint").addHeader("Authorization", "Bearer ${DeviceManager.token}")
.build()
makeRequest(request, cb)
}
fun patchRequest(endpoint:String, payload: JSObject, cb: (JSObject) -> Unit) {
val mediaType = "application/json; charset=utf-8".toMediaType()
val requestBody = payload.toString().toRequestBody(mediaType)
val request = Request.Builder().patch(requestBody)
.url("${DeviceManager.serverAddress}$endpoint").addHeader("Authorization", "Bearer ${DeviceManager.token}")
.build()
makeRequest(request, cb)
}
fun isOnline(): Boolean {
val connectivityManager = ctx.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val capabilities = connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork)
if (capabilities != null) {
if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) {
Log.i("Internet", "NetworkCapabilities.TRANSPORT_CELLULAR")
return true
} else if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
Log.i("Internet", "NetworkCapabilities.TRANSPORT_WIFI")
return true
} else if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)) {
Log.i("Internet", "NetworkCapabilities.TRANSPORT_ETHERNET")
return true
}
}
return false
}
fun makeRequest(request:Request, cb: (JSObject) -> Unit) {
client.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
Log.d(tag, "FAILURE TO CONNECT")
e.printStackTrace()
cb(JSObject())
}
override fun onResponse(call: Call, response: Response) {
response.use {
if (!it.isSuccessful) throw IOException("Unexpected code $response")
val bodyString = it.body!!.string()
if (bodyString == "OK") {
cb(JSObject())
} else {
var jsonObj = JSObject()
if (bodyString.startsWith("[")) {
val array = JSArray(bodyString)
jsonObj.put("value", array)
} else {
jsonObj = JSObject(bodyString)
}
cb(jsonObj)
}
}
}
})
}
fun getLibraries(cb: (List<Library>) -> Unit) {
val mapper = jacksonMapper
getRequest("/api/libraries") {
val libraries = mutableListOf<Library>()
if (it.has("value")) {
val array = it.getJSONArray("value")
for (i in 0 until array.length()) {
val library = mapper.readValue<Library>(array.get(i).toString())
libraries.add(library)
}
}
cb(libraries)
}
}
fun getLibraryItem(libraryItemId:String, cb: (LibraryItem) -> Unit) {
getRequest("/api/items/$libraryItemId?expanded=1") {
val libraryItem = jacksonMapper.readValue<LibraryItem>(it.toString())
cb(libraryItem)
}
}
fun getLibraryItemWithProgress(libraryItemId:String, episodeId:String?, cb: (LibraryItem) -> Unit) {
var requestUrl = "/api/items/$libraryItemId?expanded=1&include=progress"
if (!episodeId.isNullOrEmpty()) requestUrl += "&episode=$episodeId"
getRequest(requestUrl) {
val libraryItem = jacksonMapper.readValue<LibraryItem>(it.toString())
cb(libraryItem)
}
}
fun getLibraryItems(libraryId:String, cb: (List<LibraryItem>) -> Unit) {
getRequest("/api/libraries/$libraryId/items?limit=100&minified=1") {
val items = mutableListOf<LibraryItem>()
if (it.has("results")) {
val array = it.getJSONArray("results")
for (i in 0 until array.length()) {
val item = jacksonMapper.readValue<LibraryItem>(array.get(i).toString())
items.add(item)
}
}
cb(items)
}
}
fun getLibraryCategories(libraryId:String, cb: (List<LibraryCategory>) -> Unit) {
getRequest("/api/libraries/$libraryId/personalized") {
val items = mutableListOf<LibraryCategory>()
if (it.has("value")) {
val array = it.getJSONArray("value")
for (i in 0 until array.length()) {
val jsobj = array.get(i) as JSONObject
val type = jsobj.get("type").toString()
// Only support for podcast and book in android auto
if (type == "podcast" || type == "book") {
jsobj.put("isLocal", false)
val item = jacksonMapper.readValue<LibraryCategory>(jsobj.toString())
items.add(item)
}
}
}
cb(items)
}
}
fun playLibraryItem(libraryItemId:String, episodeId:String?, forceTranscode:Boolean, mediaPlayer:String, cb: (PlaybackSession) -> Unit) {
val payload = JSObject()
payload.put("mediaPlayer", mediaPlayer)
// Only if direct play fails do we force transcode
if (!forceTranscode) payload.put("forceDirectPlay", true)
else payload.put("forceTranscode", true)
val endpoint = if (episodeId.isNullOrEmpty()) "/api/items/$libraryItemId/play" else "/api/items/$libraryItemId/play/$episodeId"
postRequest(endpoint, payload) {
it.put("serverConnectionConfigId", DeviceManager.serverConnectionConfig?.id)
it.put("serverAddress", DeviceManager.serverAddress)
val playbackSession = jacksonMapper.readValue<PlaybackSession>(it.toString())
cb(playbackSession)
}
}
fun sendProgressSync(sessionId:String, syncData: MediaProgressSyncData, cb: () -> Unit) {
val payload = JSObject(jacksonMapper.writeValueAsString(syncData))
postRequest("/api/session/$sessionId/sync", payload) {
cb()
}
}
fun sendLocalProgressSync(playbackSession:PlaybackSession, cb: () -> Unit) {
val payload = JSObject(jacksonMapper.writeValueAsString(playbackSession))
postRequest("/api/session/local", payload) {
cb()
}
}
fun syncMediaProgress(cb: (LocalMediaProgressSyncResultsPayload) -> Unit) {
if (!isOnline()) {
Log.d(tag, "Error not online")
cb(LocalMediaProgressSyncResultsPayload(0,0,0))
return
}
// Get all local media progress connected to items on the current connected server
val localMediaProgress = DeviceManager.dbManager.getAllLocalMediaProgress().filter {
it.serverConnectionConfigId == DeviceManager.serverConnectionConfig?.id
}
val localSyncResultsPayload = LocalMediaProgressSyncResultsPayload(localMediaProgress.size,0, 0)
if (localMediaProgress.isNotEmpty()) {
Log.d(tag, "Sending sync local progress request with ${localMediaProgress.size} progress items")
val payload = JSObject(jacksonMapper.writeValueAsString(LocalMediaProgressSyncPayload(localMediaProgress)))
postRequest("/api/me/sync-local-progress", payload) {
Log.d(tag, "Media Progress Sync payload $payload - response ${it.toString()}")
if (it.toString() == "{}") {
Log.e(tag, "Progress sync received empty object")
} else {
val progressSyncResponsePayload = jacksonMapper.readValue<MediaProgressSyncResponsePayload>(it.toString())
localSyncResultsPayload.numLocalProgressUpdates = progressSyncResponsePayload.localProgressUpdates.size
localSyncResultsPayload.numServerProgressUpdates = progressSyncResponsePayload.numServerProgressUpdates
Log.d(tag, "Media Progress Sync | Local Updates: $localSyncResultsPayload")
if (progressSyncResponsePayload.localProgressUpdates.isNotEmpty()) {
// Update all local media progress
progressSyncResponsePayload.localProgressUpdates.forEach { localMediaProgress ->
DeviceManager.dbManager.saveLocalMediaProgress(localMediaProgress)
}
}
}
cb(localSyncResultsPayload)
}
} else {
Log.d(tag, "No local media progress to sync")
cb(localSyncResultsPayload)
}
}
fun updateMediaProgress(libraryItemId:String,episodeId:String?,updatePayload:JSObject, cb: () -> Unit) {
Log.d(tag, "updateMediaProgress $libraryItemId $episodeId $updatePayload")
val endpoint = if(episodeId.isNullOrEmpty()) "/api/me/progress/$libraryItemId" else "/api/me/progress/$libraryItemId/$episodeId"
patchRequest(endpoint,updatePayload) {
Log.d(tag, "updateMediaProgress patched progress")
cb()
}
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
<?xml version='1.0' encoding='utf-8'?>
<resources>
<string name="app_name">AudioBookshelf</string>
<string name="title_activity_main">AudioBookshelf</string>
<string name="app_name">audiobookshelf</string>
<string name="title_activity_main">audiobookshelf</string>
<string name="package_name">com.audiobookshelf.app</string>
<string name="custom_url_scheme">com.audiobookshelf.app</string>
</resources>
+4 -4
View File
@@ -5,11 +5,11 @@ buildscript {
repositories {
google()
jcenter()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:4.0.1'
classpath 'com.google.gms:google-services:4.3.5'
classpath 'com.google.gms:google-services:4.3.5'
classpath 'com.android.tools.build:gradle:7.2.0-beta04'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
// NOTE: Do not place your application dependencies here; they belong
@@ -22,7 +22,7 @@ apply from: "variables.gradle"
allprojects {
repositories {
google()
jcenter()
mavenCentral()
}
}
+6 -6
View File
@@ -2,23 +2,23 @@
include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
include ':capacitor-community-sqlite'
project(':capacitor-community-sqlite').projectDir = new File('../node_modules/@capacitor-community/sqlite/android')
include ':capacitor-app'
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
include ':capacitor-dialog'
project(':capacitor-dialog').projectDir = new File('../node_modules/@capacitor/dialog/android')
include ':capacitor-haptics'
project(':capacitor-haptics').projectDir = new File('../node_modules/@capacitor/haptics/android')
include ':capacitor-network'
project(':capacitor-network').projectDir = new File('../node_modules/@capacitor/network/android')
include ':capacitor-status-bar'
project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/android')
include ':capacitor-storage'
project(':capacitor-storage').projectDir = new File('../node_modules/@capacitor/storage/android')
include ':robingenz-capacitor-app-update'
project(':robingenz-capacitor-app-update').projectDir = new File('../node_modules/@robingenz/capacitor-app-update/android')
include ':capacitor-data-storage-sqlite'
project(':capacitor-data-storage-sqlite').projectDir = new File('../node_modules/capacitor-data-storage-sqlite/android')
+11 -1
View File
@@ -9,7 +9,15 @@
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx1536m
#org.gradle.jvmargs=-Xmx1536m
org.gradle.jvmargs=-Dfile.encoding=UTF-8 \
--add-opens jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED \
--add-opens jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED \
--add-opens jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED \
--add-opens jdk.compiler/com.sun.tools.javac.jvm=ALL-UNNAMED \
--add-opens jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED \
--add-opens jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED \
--add-opens jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
@@ -22,3 +30,5 @@ org.gradle.jvmargs=-Xmx1536m
android.useAndroidX=true
# Automatically convert third-party libraries to use AndroidX
android.enableJetifier=true
kapt.use.worker.api=false
+3 -2
View File
@@ -1,5 +1,6 @@
#Sun Apr 17 13:28:55 CDT 2022
distributionBase=GRADLE_USER_HOME
distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-bin.zip
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-6.5-all.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
+9 -10
View File
@@ -1,34 +1,33 @@
ext {
minSdkVersion = 23
compileSdkVersion = 30
minSdkVersion = 24
compileSdkVersion = 31
targetSdkVersion = 30
androidxActivityVersion = '1.2.0'
androidxAppCompatVersion = '1.2.0'
androidxCoordinatorLayoutVersion = '1.1.0'
androidxAppCompatVersion = '1.4.1'
androidxCoordinatorLayoutVersion = '1.2.0'
androidxCoreVersion = '1.6.0'
androidPlayCore = '1.9.0'
androidxFragmentVersion = '1.3.0'
junitVersion = '4.13.1'
androidxJunitVersion = '1.1.2'
androidxEspressoCoreVersion = '3.3.0'
cordovaAndroidVersion = '7.0.0'
androidx_app_compat_version = '1.2.0'
cordovaAndroidVersion = '10.1.1'
androidx_car_version = '1.0.0-alpha7'
androidx_core_ktx_version = '1.6.0'
androidx_media_version = '1.0.1'
androidx_core_ktx_version = '1.7.0'
androidx_media_version = '1.5.0'
androidx_preference_version = '1.1.1'
androidx_test_runner_version = '1.3.0'
arch_lifecycle_version = '2.2.0'
constraint_layout_version = '2.0.1'
espresso_version = '3.3.0'
exoplayer_version = '2.15.0'
exoplayer_version = '2.17.0'
fragment_version = '1.2.5'
glide_version = '4.11.0'
gms_strict_version_matcher_version = '1.0.3'
gradle_version = '3.1.4'
gson_version = '2.8.5'
junit_version = '4.13'
kotlin_version = '1.4.32'
kotlin_version = '1.5.30'
kotlin_coroutines_version = '1.1.0'
multidex_version = '1.0.3'
play_services_auth_version = '18.1.0'
+24 -1
View File
@@ -1,7 +1,30 @@
@import "./fonts.css";
body {
background-color: #262626;
}
.layout-wrapper {
height: calc(100vh - env(safe-area-inset-top));
min-height: calc(100vh - env(safe-area-inset-top));
max-height: calc(100vh - env(safe-area-inset-top));
margin-top: env(safe-area-inset-top);
}
#content {
height: calc(100% - 64px);
min-height: calc(100% - 64px);
max-height: calc(100% - 64px);
}
#content.playerOpen {
height: calc(100% - 164px);
min-height: calc(100% - 164px);
max-height: calc(100% - 164px);
}
#bookshelf {
min-height: calc(100vh - 48px);
height: calc(100% - 48px);
min-height: calc(100% - 48px);
}
.box-shadow-sm {
+53 -9
View File
@@ -7,17 +7,22 @@
<a v-if="showBack" @click="back" class="rounded-full h-10 w-10 flex items-center justify-center hover:bg-white hover:bg-opacity-10 mr-2 cursor-pointer">
<span class="material-icons text-3xl text-white">arrow_back</span>
</a>
<div v-if="socketConnected">
<div class="px-4 py-2 bg-bg bg-opacity-30 rounded-md flex items-center" @click="clickShowLibraryModal">
<svg 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="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
</svg>
<p class="text-lg font-book leading-4 ml-2">{{ currentLibraryName }}</p>
<div v-if="user">
<div class="pl-3 pr-4 py-2 bg-bg bg-opacity-30 rounded-md flex items-center" @click="clickShowLibraryModal">
<widgets-library-icon :icon="currentLibraryIcon" :size="4" />
<p class="text-base font-book leading-4 ml-2 mt-0.5">{{ currentLibraryName }}</p>
</div>
</div>
<div class="flex-grow" />
<nuxt-link class="h-7 mx-2" to="/search">
<widgets-download-progress-indicator />
<!-- Must be connected to a server to cast, only supports media items on server -->
<div v-show="isCastAvailable && user" class="mx-2 cursor-pointer mt-1.5">
<span class="material-icons" :class="isCasting ? 'text-success' : ''" style="font-size: 1.6rem" @click="castClick">cast</span>
</div>
<nuxt-link v-if="user" class="h-7 mx-2" to="/search">
<span class="material-icons" style="font-size: 1.75rem">search</span>
</nuxt-link>
@@ -29,11 +34,23 @@
</template>
<script>
import { AbsAudioPlayer } from '@/plugins/capacitor'
export default {
data() {
return {}
return {
onCastAvailableUpdateListener: null
}
},
computed: {
isCastAvailable: {
get() {
return this.$store.state.isCastAvailable
},
set(val) {
this.$store.commit('setCastAvailable', val)
}
},
socketConnected() {
return this.$store.state.socketConnected
},
@@ -43,7 +60,11 @@ export default {
currentLibraryName() {
return this.currentLibrary ? this.currentLibrary.name : 'Main'
},
currentLibraryIcon() {
return this.currentLibrary ? this.currentLibrary.icon : 'database'
},
showBack() {
if (!this.$route.name) return true
return this.$route.name !== 'index' && !this.$route.name.startsWith('bookshelf')
},
user() {
@@ -51,9 +72,21 @@ export default {
},
username() {
return this.user ? this.user.username : 'err'
},
isCasting() {
return this.$store.state.isCasting
}
},
methods: {
castClick() {
if (this.$store.state.playerIsLocal) {
this.$toast.warn('Cannot cast downloaded media item')
return
}
console.log('Cast Btn Click')
AbsAudioPlayer.requestSession()
},
clickShowSideDrawer() {
this.$store.commit('setShowSideDrawer', true)
},
@@ -62,9 +95,20 @@ export default {
},
back() {
window.history.back()
},
onCastAvailableUpdate(data) {
this.isCastAvailable = data && data.value
}
},
mounted() {}
mounted() {
AbsAudioPlayer.getIsCastAvailable().then((data) => {
this.isCastAvailable = data && data.value
})
this.onCastAvailableUpdateListener = AbsAudioPlayer.addListener('onCastAvailableUpdate', this.onCastAvailableUpdate)
},
beforeDestroy() {
if (this.onCastAvailableUpdateListener) this.onCastAvailableUpdateListener.remove()
}
}
</script>
+207 -277
View File
@@ -1,14 +1,14 @@
<template>
<div class="fixed top-0 bottom-0 left-0 right-0 z-50 pointer-events-none" :class="showFullscreen ? 'fullscreen' : ''">
<div v-if="playbackSession" id="streamContainer" class="fixed top-0 left-0 layout-wrapper right-0 z-50 pointer-events-none" :class="showFullscreen ? 'fullscreen' : ''">
<div v-if="showFullscreen" class="w-full h-full z-10 bg-bg absolute top-0 left-0 pointer-events-auto">
<div class="top-2 left-4 absolute cursor-pointer">
<span class="material-icons text-5xl" @click="collapseFullscreen">expand_more</span>
</div>
<div v-show="showCastBtn" class="top-3.5 right-20 absolute cursor-pointer">
<span class="material-icons text-3xl" @click="castClick">cast</span>
<span class="material-icons text-3xl" :class="isCasting ? 'text-success' : ''" @click="castClick">cast</span>
</div>
<div class="top-4 right-4 absolute cursor-pointer">
<ui-dropdown-menu :items="menuItems" @action="clickMenuAction">
<ui-dropdown-menu ref="dropdownMenu" :items="menuItems" @action="clickMenuAction">
<span class="material-icons text-3xl">more_vert</span>
</ui-dropdown-menu>
</div>
@@ -31,19 +31,22 @@
<div class="cover-wrapper absolute z-30 pointer-events-auto" :class="bookCoverAspectRatio === 1 ? 'square-cover' : ''" @click="clickContainer">
<div class="cover-container bookCoverWrapper bg-black bg-opacity-75 w-full h-full">
<covers-book-cover :audiobook="audiobook" :download-cover="downloadedCover" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<covers-book-cover v-if="libraryItem || localLibraryItemCoverSrc" :library-item="libraryItem" :download-cover="localLibraryItemCoverSrc" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</div>
</div>
<div class="title-author-texts absolute z-30 left-0 right-0 overflow-hidden">
<p class="title-text font-book truncate">{{ title }}</p>
<p class="author-text text-white text-opacity-75 truncate">by {{ authorFL }}</p>
<p class="author-text text-white text-opacity-75 truncate">by {{ authorName }}</p>
</div>
<div id="streamContainer" class="w-full z-20 bg-primary absolute bottom-0 left-0 right-0 p-2 pointer-events-auto transition-all" @click="clickContainer">
<div v-if="showFullscreen" class="absolute top-0 left-0 right-0 w-full py-3 mx-auto px-3" style="max-width: 380px">
<div class="flex items-center justify-between pointer-events-auto">
<span class="material-icons text-3xl text-white text-opacity-75 cursor-pointer" @click="$emit('showBookmarks')">{{ bookmarks.length ? 'bookmark' : 'bookmark_border' }}</span>
<span v-if="!isPodcast && !isLocalPlayMethod" class="material-icons text-3xl text-white text-opacity-75 cursor-pointer" @click="$emit('showBookmarks')">{{ bookmarks.length ? 'bookmark' : 'bookmark_border' }}</span>
<!-- hidden for podcasts but still using this as a placeholder -->
<span v-else class="material-icons text-3xl text-white text-opacity-0">bookmark</span>
<span class="font-mono text-white text-opacity-75 cursor-pointer" style="font-size: 1.35rem" @click="$emit('selectPlaybackSpeed')">{{ currentPlaybackRate }}x</span>
<svg v-if="!sleepTimerRunning" xmlns="http://www.w3.org/2000/svg" class="h-7 w-7 text-white text-opacity-75 cursor-pointer" fill="none" viewBox="0 0 24 24" stroke="currentColor" @click.stop="$emit('showSleepTimer')">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
@@ -52,25 +55,25 @@
<p class="text-xl font-mono text-success">{{ sleepTimeRemainingPretty }}</p>
</div>
<span class="material-icons text-3xl text-white cursor-pointer" :class="chapters.length ? 'text-opacity-75' : 'text-opacity-10'" @click="$emit('selectChapter')">format_list_bulleted</span>
<span class="material-icons text-3xl text-white cursor-pointer" :class="chapters.length ? 'text-opacity-75' : 'text-opacity-10'" @click="showChapterModal = true">format_list_bulleted</span>
</div>
</div>
<div id="playerControls" class="absolute right-0 bottom-0 py-2">
<div class="flex items-center justify-center">
<span v-show="showFullscreen" class="material-icons next-icon text-white text-opacity-75 cursor-pointer" :class="loading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpChapterStart">first_page</span>
<span class="material-icons jump-icon text-white cursor-pointer" :class="loading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="backward10">replay_10</span>
<span v-show="showFullscreen" class="material-icons next-icon text-white text-opacity-75 cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpChapterStart">first_page</span>
<span class="material-icons jump-icon text-white cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="backward10">replay_10</span>
<div class="play-btn cursor-pointer shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-4" :class="seekLoading ? 'animate-spin' : ''" @mousedown.prevent @mouseup.prevent @click.stop="playPauseClick">
<span v-if="!loading" class="material-icons">{{ seekLoading ? 'autorenew' : isPaused ? 'play_arrow' : 'pause' }}</span>
<span v-if="!isLoading" class="material-icons">{{ seekLoading ? 'autorenew' : !isPlaying ? 'play_arrow' : 'pause' }}</span>
<widgets-spinner-icon v-else class="h-8 w-8" />
</div>
<span class="material-icons jump-icon text-white cursor-pointer" :class="loading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="forward10">forward_10</span>
<span v-show="showFullscreen" class="material-icons next-icon text-white cursor-pointer" :class="nextChapter && !loading ? 'text-opacity-75' : 'text-opacity-10'" @click.stop="jumpNextChapter">last_page</span>
<span class="material-icons jump-icon text-white cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="forward10">forward_10</span>
<span v-show="showFullscreen" class="material-icons next-icon text-white cursor-pointer" :class="nextChapter && !isLoading ? 'text-opacity-75' : 'text-opacity-10'" @click.stop="jumpNextChapter">last_page</span>
</div>
</div>
<div id="playerTrack" class="absolute bottom-0 left-0 w-full px-3">
<div ref="track" class="h-2 w-full bg-gray-500 bg-opacity-50 relative" :class="loading ? 'animate-pulse' : ''" @click="clickTrack">
<div ref="track" class="h-2 w-full bg-gray-500 bg-opacity-50 relative" :class="isLoading ? 'animate-pulse' : ''" @click="clickTrack">
<div ref="readyTrack" class="h-full bg-gray-600 absolute top-0 left-0 pointer-events-none" />
<div ref="bufferedTrack" class="h-full bg-gray-500 absolute top-0 left-0 pointer-events-none" />
<div ref="playedTrack" class="h-full bg-gray-200 absolute top-0 left-0 pointer-events-none" />
@@ -84,74 +87,54 @@
</div>
</div>
</div>
<modals-chapters-modal v-model="showChapterModal" :current-chapter="currentChapter" :chapters="chapters" @select="selectChapter" />
</div>
</template>
<script>
import MyNativeAudio from '@/plugins/my-native-audio'
import { Capacitor } from '@capacitor/core'
import { AbsAudioPlayer } from '@/plugins/capacitor'
export default {
props: {
playing: Boolean,
audiobook: {
type: Object,
default: () => {}
},
download: {
type: Object,
default: () => {}
},
bookmarks: {
type: Array,
default: () => []
},
loading: Boolean,
sleepTimerRunning: Boolean,
sleepTimerEndTime: Number
sleepTimeRemaining: Number
},
data() {
return {
showCastBtn: false,
playbackSession: null,
showChapterModal: false,
showFullscreen: false,
totalDuration: 0,
currentPlaybackRate: 1,
currentTime: 0,
bufferedTime: 0,
isResetting: false,
initObject: null,
streamId: null,
audiobookId: null,
stateName: 'idle',
playInterval: null,
trackWidth: 0,
isPaused: true,
src: null,
isPlaying: false,
isEnded: false,
volume: 0.5,
readyTrackWidth: 0,
playedTrackWidth: 0,
seekedTime: 0,
seekLoading: false,
onPlaybackSessionListener: null,
onPlaybackClosedListener: null,
onPlayingUpdateListener: null,
onMetadataListener: null,
// noSyncUpdateTime: false,
touchStartY: 0,
touchStartTime: 0,
touchEndY: 0,
listenTimeInterval: null,
listeningTimeSinceLastUpdate: 0,
totalListeningTimeInSession: 0,
useChapterTrack: false
useChapterTrack: false,
isLoading: false
}
},
computed: {
isPlaying: {
get() {
return this.playing
},
set(val) {
this.$emit('update:playing', val)
}
},
menuItems() {
var items = []
items.push({
@@ -175,25 +158,62 @@ export default {
}
return this.showFullscreen ? 200 : 60
},
book() {
return this.audiobook.book || {}
showCastBtn() {
return this.$store.state.isCastAvailable && !this.isLocalPlayMethod
},
isCasting() {
return this.mediaPlayer === 'cast-player'
},
mediaPlayer() {
return this.playbackSession ? this.playbackSession.mediaPlayer : null
},
mediaType() {
return this.playbackSession ? this.playbackSession.mediaType : null
},
isPodcast() {
return this.mediaType === 'podcast'
},
mediaMetadata() {
return this.playbackSession ? this.playbackSession.mediaMetadata : null
},
libraryItem() {
return this.playbackSession ? this.playbackSession.libraryItem || null : null
},
localLibraryItem() {
return this.playbackSession ? this.playbackSession.localLibraryItem || null : null
},
localLibraryItemCoverSrc() {
var localItemCover = this.localLibraryItem ? this.localLibraryItem.coverContentUrl : null
if (localItemCover) return Capacitor.convertFileSrc(localItemCover)
return null
},
playMethod() {
return this.playbackSession ? this.playbackSession.playMethod : null
},
isLocalPlayMethod() {
return this.playMethod == this.$constants.PlayMethod.LOCAL
},
title() {
return this.book.title
if (this.playbackSession) return this.playbackSession.displayTitle
return this.mediaMetadata ? this.mediaMetadata.title : 'Title'
},
authorFL() {
return this.book.authorFL
authorName() {
if (this.playbackSession) return this.playbackSession.displayAuthor
return this.mediaMetadata ? this.mediaMetadata.authorName : 'Author'
},
chapters() {
return this.audiobook ? this.audiobook.chapters || [] : []
if (this.playbackSession && this.playbackSession.chapters) {
return this.playbackSession.chapters
}
return []
},
currentChapter() {
if (!this.audiobook || !this.chapters.length) return null
return this.chapters.find((ch) => Number(ch.start.toFixed(2)) <= this.currentTime && Number(ch.end.toFixed(2)) > this.currentTime)
if (!this.chapters.length) return null
return this.chapters.find((ch) => Number(Number(ch.start).toFixed(2)) <= this.currentTime && Number(Number(ch.end).toFixed(2)) > this.currentTime)
},
nextChapter() {
if (!this.chapters.length) return
return this.chapters.find((c) => Number(c.start.toFixed(2)) > this.currentTime)
return this.chapters.find((c) => Number(Number(c.start).toFixed(2)) > this.currentTime)
},
currentChapterTitle() {
return this.currentChapter ? this.currentChapter.title : ''
@@ -201,9 +221,6 @@ export default {
currentChapterDuration() {
return this.currentChapter ? this.currentChapter.end - this.currentChapter.start : this.totalDuration
},
downloadedCover() {
return this.download ? this.download.cover : null
},
totalDurationPretty() {
return this.$secondsToTimestamp(this.totalDuration)
},
@@ -236,10 +253,6 @@ export default {
if (!this.currentChapter) return 0
return this.currentChapter.end - this.currentTime
},
sleepTimeRemaining() {
if (!this.sleepTimerEndTime) return 0
return Math.max(0, this.sleepTimerEndTime / 1000 - this.currentTime)
},
sleepTimeRemainingPretty() {
if (!this.sleepTimeRemaining) return '0s'
var secondsRemaining = Math.round(this.sleepTimeRemaining)
@@ -251,65 +264,18 @@ export default {
}
},
methods: {
selectChapter(chapter) {
this.seek(chapter.start)
this.showChapterModal = false
},
castClick() {
console.log('Cast Btn Click')
MyNativeAudio.requestSession()
},
sendStreamSync(timeListened = 0) {
var syncData = {
timeListened,
currentTime: this.currentTime,
streamId: this.streamId,
audiobookId: this.audiobookId,
totalDuration: this.totalDuration
if (this.isLocalPlayMethod) {
this.$toast.warn('Cannot cast downloaded media items')
return
}
this.$emit('sync', syncData)
},
sendAddListeningTime() {
var listeningTimeToAdd = Math.floor(this.listeningTimeSinceLastUpdate)
this.listeningTimeSinceLastUpdate = Math.max(0, this.listeningTimeSinceLastUpdate - listeningTimeToAdd)
this.sendStreamSync(listeningTimeToAdd)
},
cancelListenTimeInterval() {
this.sendAddListeningTime()
clearInterval(this.listenTimeInterval)
this.listenTimeInterval = null
},
startListenTimeInterval() {
clearInterval(this.listenTimeInterval)
var lastTime = this.currentTime
var lastTick = Date.now()
var noProgressCount = 0
this.listenTimeInterval = setInterval(() => {
var timeSinceLastTick = Date.now() - lastTick
lastTick = Date.now()
var expectedAudioTime = lastTime + timeSinceLastTick / 1000
var currentTime = this.currentTime
var differenceFromExpected = expectedAudioTime - currentTime
if (currentTime === lastTime) {
noProgressCount++
if (noProgressCount > 3) {
console.error('Audio current time has not increased - cancel interval and pause player')
this.pause()
}
} else if (Math.abs(differenceFromExpected) > 0.1) {
noProgressCount = 0
console.warn('Invalid time between interval - resync last', differenceFromExpected)
lastTime = currentTime
} else {
noProgressCount = 0
var exactPlayTimeDifference = currentTime - lastTime
// console.log('Difference from expected', differenceFromExpected, 'Exact play time diff', exactPlayTimeDifference)
lastTime = currentTime
this.listeningTimeSinceLastUpdate += exactPlayTimeDifference
this.totalListeningTimeInSession += exactPlayTimeDifference
// console.log('Time since last update:', this.listeningTimeSinceLastUpdate, 'Session listening time:', this.totalListeningTimeInSession)
if (this.listeningTimeSinceLastUpdate > 5) {
this.sendAddListeningTime()
}
}
}, 1000)
AbsAudioPlayer.requestSession()
},
clickContainer() {
this.showFullscreen = true
@@ -321,21 +287,22 @@ export default {
},
collapseFullscreen() {
this.showFullscreen = false
this.forceCloseDropdownMenu()
},
jumpNextChapter() {
if (this.loading) return
if (this.isLoading) return
if (!this.nextChapter) return
this.seek(this.nextChapter.start)
},
jumpChapterStart() {
if (this.loading) return
if (this.isLoading) return
if (!this.currentChapter) {
return this.restart()
}
// If 1 second or less into current chapter, then go to previous
if (this.currentTime - this.currentChapter.start <= 1) {
var currChapterIndex = this.chapters.findIndex((ch) => ch.start <= this.currentTime && ch.end >= this.currentTime)
// If 4 seconds or less into current chapter, then go to previous
if (this.currentTime - this.currentChapter.start <= 4) {
var currChapterIndex = this.chapters.findIndex((ch) => Number(ch.start) <= this.currentTime && Number(ch.end) >= this.currentTime)
if (currChapterIndex > 0) {
var prevChapter = this.chapters[currChapterIndex - 1]
this.seek(prevChapter.start)
@@ -350,18 +317,18 @@ export default {
setPlaybackSpeed(speed) {
console.log(`[AudioPlayer] Set Playback Rate: ${speed}`)
this.currentPlaybackRate = speed
MyNativeAudio.setPlaybackSpeed({ speed: speed })
AbsAudioPlayer.setPlaybackSpeed({ value: speed })
},
restart() {
this.seek(0)
},
backward10() {
if (this.loading) return
MyNativeAudio.seekBackward({ amount: '10000' })
if (this.isLoading) return
AbsAudioPlayer.seekBackward({ value: 10 })
},
forward10() {
if (this.loading) return
MyNativeAudio.seekForward({ amount: '10000' })
if (this.isLoading) return
AbsAudioPlayer.seekForward({ value: 10 })
},
setStreamReady() {
this.readyTrackWidth = this.trackWidth
@@ -431,6 +398,7 @@ export default {
this.updateTrack()
},
updateTrack() {
// Update progress track UI
var percentDone = this.currentTime / this.totalDuration
var totalPercentDone = percentDone
var bufferedPercent = this.bufferedTime / this.totalDuration
@@ -455,7 +423,7 @@ export default {
}
},
seek(time) {
if (this.loading) return
if (this.isLoading) return
if (this.seekLoading) {
console.error('Already seek loading', this.seekedTime)
return
@@ -463,7 +431,8 @@ export default {
this.seekedTime = time
this.seekLoading = true
MyNativeAudio.seekPlayer({ timeMs: String(Math.floor(time * 1000)) })
AbsAudioPlayer.seek({ value: Math.floor(time) })
if (this.$refs.playedTrack) {
var perc = time / this.totalDuration
@@ -476,7 +445,7 @@ export default {
}
},
clickTrack(e) {
if (this.loading) return
if (this.isLoading) return
if (!this.showFullscreen) {
// Track not clickable on mini-player
return
@@ -497,172 +466,37 @@ export default {
}
this.seek(time)
},
playPauseClick() {
if (this.loading) return
if (this.isPaused) {
console.log('playPause PLAY')
this.play()
} else {
console.log('playPause PAUSE')
this.pause()
}
},
calcSeekBackTime(lastUpdate) {
var time = Date.now() - lastUpdate
var seekback = 0
if (time < 3000) seekback = 0
else if (time < 60000) seekback = time / 6
else if (time < 300000) seekback = 15000
else if (time < 1800000) seekback = 20000
else if (time < 3600000) seekback = 25000
else seekback = 29500
return seekback
},
async set(audiobookStreamData, stream, fromAppDestroy) {
this.isResetting = false
this.bufferedTime = 0
this.streamId = stream ? stream.id : null
this.audiobookId = audiobookStreamData.audiobookId
this.initObject = { ...audiobookStreamData }
async playPauseClick() {
if (this.isLoading) return
var init = true
if (!!stream) {
//console.log(JSON.stringify(stream))
var data = await MyNativeAudio.getStreamSyncData()
console.log('getStreamSyncData', JSON.stringify(data))
console.log('lastUpdate', stream.lastUpdate || 0)
//Same audiobook
if (data.id == stream.id && (data.isPlaying || data.lastPauseTime >= (stream.lastUpdate || 0))) {
console.log('Same audiobook')
this.isPaused = !data.isPlaying
this.currentTime = Number((data.currentTime / 1000).toFixed(2))
this.totalDuration = Number((data.duration / 1000).toFixed(2))
this.$emit('setTotalDuration', this.totalDuration)
this.timeupdate()
if (data.isPlaying) {
console.log('playing - continue')
if (fromAppDestroy) this.startPlayInterval()
} else console.log('paused and newer')
if (!fromAppDestroy) return
init = false
this.initObject.startTime = String(Math.floor(this.currentTime * 1000))
}
//new audiobook stream or sync from other client
else if (stream.clientCurrentTime > 0) {
console.log('new audiobook stream or sync from other client')
if (!!stream.lastUpdate) {
var backTime = this.calcSeekBackTime(stream.lastUpdate)
var currentTime = Math.floor(stream.clientCurrentTime * 1000)
if (backTime >= currentTime) backTime = currentTime - 500
console.log('SeekBackTime', backTime)
this.initObject.startTime = String(Math.floor(currentTime - backTime))
}
}
}
this.currentPlaybackRate = this.initObject.playbackSpeed
console.log(`[AudioPlayer] Set Stream Playback Rate: ${this.currentPlaybackRate}`)
if (init)
MyNativeAudio.initPlayer(this.initObject).then((res) => {
if (res && res.success) {
console.log('Success init audio player')
} else {
console.error('Failed to init audio player')
}
})
if (audiobookStreamData.isLocal) {
this.setStreamReady()
}
},
setFromObj() {
if (!this.initObject) {
console.error('Cannot set from obj')
return
}
this.isResetting = false
MyNativeAudio.initPlayer(this.initObject).then((res) => {
if (res && res.success) {
console.log('Success init audio player')
} else {
console.error('Failed to init audio player')
}
})
if (audiobookStreamData.isLocal) {
this.setStreamReady()
}
this.isPlaying = !!((await AbsAudioPlayer.playPause()) || {}).playing
this.isEnded = false
},
play() {
MyNativeAudio.playPlayer()
AbsAudioPlayer.playPlayer()
this.startPlayInterval()
this.isPlaying = true
},
pause() {
MyNativeAudio.pausePlayer()
AbsAudioPlayer.pausePlayer()
this.stopPlayInterval()
this.isPlaying = false
},
startPlayInterval() {
this.startListenTimeInterval()
clearInterval(this.playInterval)
this.playInterval = setInterval(async () => {
var data = await MyNativeAudio.getCurrentTime()
this.currentTime = Number((data.value / 1000).toFixed(2))
this.bufferedTime = Number((data.bufferedTime / 1000).toFixed(2))
var data = await AbsAudioPlayer.getCurrentTime()
this.currentTime = Number(data.value.toFixed(2))
this.bufferedTime = Number(data.bufferedTime.toFixed(2))
console.log('[AudioPlayer] Got Current Time', this.currentTime)
this.timeupdate()
}, 1000)
},
stopPlayInterval() {
this.cancelListenTimeInterval()
clearInterval(this.playInterval)
},
resetStream(startTime) {
var _time = String(Math.floor(startTime * 1000))
if (!this.initObject) {
console.error('Terminate stream when no init object is set...')
return
}
this.isResetting = true
this.initObject.currentTime = _time
this.terminateStream()
},
terminateStream() {
MyNativeAudio.terminateStream()
},
onPlayingUpdate(data) {
this.isPaused = !data.value
if (!this.isPaused) {
this.startPlayInterval()
} else {
this.stopPlayInterval()
}
},
onMetadata(data) {
this.totalDuration = Number((data.duration / 1000).toFixed(2))
this.$emit('setTotalDuration', this.totalDuration)
this.currentTime = Number((data.currentTime / 1000).toFixed(2))
this.stateName = data.stateName
if (this.stateName === 'ended' && this.isResetting) {
this.setFromObj()
}
this.timeupdate()
},
async init() {
this.useChapterTrack = await this.$localStore.getUseChapterTrack()
this.onPlayingUpdateListener = MyNativeAudio.addListener('onPlayingUpdate', this.onPlayingUpdate)
this.onMetadataListener = MyNativeAudio.addListener('onMetadata', this.onMetadata)
if (this.$refs.track) {
this.trackWidth = this.$refs.track.clientWidth
} else {
console.error('Track not loaded', this.$refs)
}
this.closePlayback()
},
handleGesture() {
var touchDistance = this.touchEndY - this.touchStartY
@@ -702,8 +536,95 @@ export default {
})
this.$localStore.setUseChapterTrack(this.useChapterTrack)
} else if (action === 'close') {
this.$emit('close')
this.closePlayback()
}
},
forceCloseDropdownMenu() {
if (this.$refs.dropdownMenu && this.$refs.dropdownMenu.closeMenu) {
this.$refs.dropdownMenu.closeMenu()
}
},
closePlayback() {
this.endPlayback()
AbsAudioPlayer.closePlayback()
},
endPlayback() {
this.$store.commit('setPlayerItem', null)
this.showFullscreen = false
this.isEnded = false
this.isLoading = false
this.playbackSession = null
},
//
// Listeners from audio AbsAudioPlayer
//
onPlayingUpdate(data) {
console.log('onPlayingUpdate', JSON.stringify(data))
this.isPlaying = !!data.value
this.$store.commit('setPlayerPlaying', this.isPlaying)
if (this.isPlaying) {
this.startPlayInterval()
} else {
this.stopPlayInterval()
}
},
onMetadata(data) {
console.log('onMetadata', JSON.stringify(data))
this.totalDuration = Number(data.duration.toFixed(2))
this.currentTime = Number(data.currentTime.toFixed(2))
// Done loading
if (data.playerState !== 'BUFFERING' && data.playerState !== 'IDLE') {
this.isLoading = false
}
if (data.playerState === 'ENDED') {
console.log('[AudioPlayer] Playback ended')
}
this.isEnded = data.playerState === 'ENDED'
console.log('received metadata update', data)
if (data.currentRate && data.currentRate > 0) this.playbackSpeed = data.currentRate
this.timeupdate()
},
// When a playback session is started the native android/ios will send the session
onPlaybackSession(playbackSession) {
console.log('onPlaybackSession received', JSON.stringify(playbackSession))
this.playbackSession = playbackSession
this.isEnded = false
this.isLoading = true
this.$store.commit('setPlayerItem', this.playbackSession)
// Set track width
this.$nextTick(() => {
if (this.$refs.track) {
this.trackWidth = this.$refs.track.clientWidth
} else {
console.error('Track not loaded', this.$refs)
}
})
},
onPlaybackClosed() {
console.log('Received onPlaybackClosed evt')
this.endPlayback()
},
onPlaybackFailed(data) {
console.log('Received onPlaybackFailed evt')
var errorMessage = data.value || 'Unknown Error'
this.$toast.error(`Playback Failed: ${errorMessage}`)
this.endPlayback()
},
async init() {
this.useChapterTrack = await this.$localStore.getUseChapterTrack()
this.onPlaybackSessionListener = AbsAudioPlayer.addListener('onPlaybackSession', this.onPlaybackSession)
this.onPlaybackClosedListener = AbsAudioPlayer.addListener('onPlaybackClosed', this.onPlaybackClosed)
this.onPlaybackFailedListener = AbsAudioPlayer.addListener('onPlaybackFailed', this.onPlaybackFailed)
this.onPlayingUpdateListener = AbsAudioPlayer.addListener('onPlayingUpdate', this.onPlayingUpdate)
this.onMetadataListener = AbsAudioPlayer.addListener('onMetadata', this.onMetadata)
}
},
mounted() {
@@ -713,11 +634,20 @@ export default {
this.$nextTick(this.init)
},
beforeDestroy() {
if (this.playbackSession) {
console.log('[AudioPlayer] Before destroy closing playback')
this.closePlayback()
}
this.forceCloseDropdownMenu()
document.body.removeEventListener('touchstart', this.touchstart)
document.body.removeEventListener('touchend', this.touchend)
if (this.onPlayingUpdateListener) this.onPlayingUpdateListener.remove()
if (this.onMetadataListener) this.onMetadataListener.remove()
if (this.onPlaybackSessionListener) this.onPlaybackSessionListener.remove()
if (this.onPlaybackClosedListener) this.onPlaybackClosedListener.remove()
if (this.onPlaybackFailedListener) this.onPlaybackFailedListener.remove()
clearInterval(this.playInterval)
}
}
+98 -355
View File
@@ -1,59 +1,38 @@
<template>
<div>
<div v-if="audiobook" id="streamContainer">
<app-audio-player
ref="audioPlayer"
:playing.sync="isPlaying"
:audiobook="audiobook"
:download="download"
:loading="isLoading"
:bookmarks="bookmarks"
:sleep-timer-running="isSleepTimerRunning"
:sleep-timer-end-time="sleepTimerEndTime"
@close="cancelStream"
@sync="sync"
@setTotalDuration="setTotalDuration"
@selectPlaybackSpeed="showPlaybackSpeedModal = true"
@selectChapter="clickChapterBtn"
@updateTime="(t) => (currentTime = t)"
@showSleepTimer="showSleepTimer"
@showBookmarks="showBookmarks"
@hook:mounted="audioPlayerMounted"
/>
</div>
<app-audio-player ref="audioPlayer" :bookmarks="bookmarks" :sleep-timer-running="isSleepTimerRunning" :sleep-time-remaining="sleepTimeRemaining" @selectPlaybackSpeed="showPlaybackSpeedModal = true" @updateTime="(t) => (currentTime = t)" @showSleepTimer="showSleepTimer" @showBookmarks="showBookmarks" />
<modals-playback-speed-modal v-model="showPlaybackSpeedModal" :playback-rate.sync="playbackSpeed" @update:playbackRate="updatePlaybackSpeed" @change="changePlaybackSpeed" />
<modals-chapters-modal v-model="showChapterModal" :current-chapter="currentChapter" :chapters="chapters" @select="selectChapter" />
<modals-sleep-timer-modal v-model="showSleepTimerModal" :current-time="sleepTimeRemaining" :sleep-timer-running="isSleepTimerRunning" :current-end-of-chapter-time="currentEndOfChapterTime" @change="selectSleepTimeout" @cancel="cancelSleepTimer" @increase="increaseSleepTimer" @decrease="decreaseSleepTimer" />
<modals-bookmarks-modal v-model="showBookmarksModal" :audiobook-id="audiobookId" :bookmarks="bookmarks" :current-time="currentTime" @select="selectBookmark" />
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :current-time="currentTime" :library-item-id="serverLibraryItemId" @select="selectBookmark" />
</div>
</template>
<script>
import { AbsAudioPlayer } from '@/plugins/capacitor'
import { Dialog } from '@capacitor/dialog'
import MyNativeAudio from '@/plugins/my-native-audio'
export default {
data() {
return {
isPlaying: false,
audioPlayerReady: false,
stream: null,
download: null,
lastProgressTimeUpdate: 0,
showPlaybackSpeedModal: false,
showBookmarksModal: false,
showSleepTimerModal: false,
playbackSpeed: 1,
showChapterModal: false,
currentTime: 0,
isSleepTimerRunning: false,
sleepTimerEndTime: 0,
sleepTimeRemaining: 0,
onLocalMediaProgressUpdateListener: null,
onSleepTimerEndedListener: null,
onSleepTimerSetListener: null,
onMediaPlayerChangedListener: null,
sleepInterval: null,
currentEndOfChapterTime: 0,
totalDuration: 0
serverLibraryItemId: null
}
},
watch: {
@@ -65,94 +44,12 @@ export default {
}
},
computed: {
userToken() {
return this.$store.getters['user/getToken']
},
userAudiobook() {
if (!this.audiobookId) return
return this.$store.getters['user/getUserAudiobookData'](this.audiobookId)
},
bookmarks() {
if (!this.userAudiobook) return []
return this.userAudiobook.bookmarks || []
},
currentChapter() {
if (!this.audiobook || !this.chapters.length) return null
return this.chapters.find((ch) => ch.start <= this.currentTime && ch.end > this.currentTime)
if (!this.serverLibraryItemId) return []
return this.$store.getters['user/getUserBookmarksForItem'](this.serverLibraryItemId)
},
socketConnected() {
return this.$store.state.socketConnected
},
isLoading() {
if (this.playingDownload) return false
if (!this.streamAudiobook) return false
return !this.stream || this.streamAudiobook.id !== this.stream.audiobook.id
},
playingDownload() {
return this.$store.state.playingDownload
},
audiobook() {
if (this.playingDownload) return this.playingDownload.audiobook
return this.streamAudiobook
},
audiobookId() {
return this.audiobook ? this.audiobook.id : null
},
streamAudiobook() {
return this.$store.state.streamAudiobook
},
book() {
return this.audiobook ? this.audiobook.book || {} : {}
},
title() {
return this.book ? this.book.title : ''
},
author() {
return this.book ? this.book.author : ''
},
cover() {
return this.book ? this.book.cover : ''
},
series() {
return this.book ? this.book.series : ''
},
chapters() {
return this.audiobook ? this.audiobook.chapters || [] : []
},
volumeNumber() {
return this.book ? this.book.volumeNumber : ''
},
seriesTxt() {
if (!this.series) return ''
if (!this.volumeNumber) return this.series
return `${this.series} #${this.volumeNumber}`
},
duration() {
return this.audiobook ? this.audiobook.duration || 0 : 0
},
coverForNative() {
if (!this.cover) {
return `${this.$store.state.serverUrl}/Logo.png`
}
if (this.cover.startsWith('http')) return this.cover
var coverSrc = this.$store.getters['audiobooks/getBookCoverSrc'](this.audiobook)
return coverSrc
},
tracksForCast() {
if (!this.audiobook || !this.audiobook.tracks) {
return []
}
var abpath = this.audiobook.path
var tracks = this.audiobook.tracks.map((t) => {
var trelpath = t.path.replace(abpath, '')
if (trelpath.startsWith('/')) trelpath = trelpath.substr(1)
return `${this.$store.state.serverUrl}/s/book/${this.audiobook.id}/${trelpath}?token=${this.userToken}`
})
return tracks
},
sleepTimeRemaining() {
if (!this.sleepTimerEndTime) return 0
return Math.max(0, this.sleepTimerEndTime / 1000 - this.currentTime)
}
},
methods: {
@@ -172,23 +69,23 @@ export default {
if (currentPosition) {
console.log('Sleep Timer Ended Current Position: ' + currentPosition)
var currentTime = Math.floor(currentPosition / 1000)
this.updateTime(currentTime)
// TODO: Was syncing to the server here before
}
},
onSleepTimerSet({ value: sleepTimerEndTime }) {
console.log('SLEEP TIMER SET', sleepTimerEndTime)
if (sleepTimerEndTime === 0) {
onSleepTimerSet({ value: sleepTimeRemaining }) {
console.log('SLEEP TIMER SET', sleepTimeRemaining)
if (sleepTimeRemaining === 0) {
console.log('Sleep timer canceled')
this.isSleepTimerRunning = false
} else {
this.isSleepTimerRunning = true
}
this.sleepTimerEndTime = sleepTimerEndTime
this.sleepTimeRemaining = sleepTimeRemaining
},
showSleepTimer() {
if (this.currentChapter) {
this.currentEndOfChapterTime = Math.floor(this.currentChapter.end)
if (this.$refs.audioPlayer && this.$refs.audioPlayer.currentChapter) {
this.currentEndOfChapterTime = Math.floor(this.$refs.audioPlayer.currentChapter.end)
} else {
this.currentEndOfChapterTime = 0
}
@@ -196,101 +93,24 @@ export default {
},
async selectSleepTimeout({ time, isChapterTime }) {
console.log('Setting sleep timer', time, isChapterTime)
var res = await MyNativeAudio.setSleepTimer({ time: String(time), isChapterTime })
var res = await AbsAudioPlayer.setSleepTimer({ time: String(time), isChapterTime })
if (!res.success) {
return this.$toast.error('Sleep timer did not set, invalid time')
}
},
increaseSleepTimer() {
// Default time to increase = 5 min
MyNativeAudio.increaseSleepTime({ time: '300000' })
AbsAudioPlayer.increaseSleepTime({ time: '300000' })
},
decreaseSleepTimer() {
MyNativeAudio.decreaseSleepTime({ time: '300000' })
AbsAudioPlayer.decreaseSleepTime({ time: '300000' })
},
async cancelSleepTimer() {
console.log('Canceling sleep timer')
await MyNativeAudio.cancelSleepTimer()
await AbsAudioPlayer.cancelSleepTimer()
},
clickChapterBtn() {
if (!this.chapters.length) return
this.showChapterModal = true
},
selectChapter(chapter) {
if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.seek(chapter.start)
}
this.showChapterModal = false
},
async cancelStream() {
this.currentTime = 0
if (this.download) {
if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.terminateStream()
}
this.download = null
this.$store.commit('setPlayingDownload', null)
this.$localStore.setCurrent(null)
} else {
const { value } = await Dialog.confirm({
title: 'Confirm',
message: 'Cancel this stream?'
})
if (value) {
this.$server.socket.emit('close_stream')
this.$store.commit('setStreamAudiobook', null)
this.$server.stream = null
if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.terminateStream()
}
}
}
},
sync(syncData) {
var diff = syncData.currentTime - this.lastServerUpdateSentSeconds
if (Math.abs(diff) < 1 && !syncData.timeListened) {
// No need to sync
return
}
if (this.stream) {
this.$server.socket.emit('stream_sync', syncData)
} else {
var progressUpdate = {
audiobookId: syncData.audiobookId,
currentTime: syncData.currentTime,
totalDuration: syncData.totalDuration,
progress: syncData.totalDuration ? Number((syncData.currentTime / syncData.totalDuration).toFixed(3)) : 0,
lastUpdate: Date.now(),
isRead: false
}
if (this.$server.connected) {
this.$server.socket.emit('progress_update', progressUpdate)
} else {
this.$store.dispatch('user/updateUserAudiobookData', progressUpdate)
}
}
},
updateTime(currentTime) {
this.sync({
currentTime,
audiobookId: this.audiobookId,
streamId: this.stream ? this.stream.id : null,
timeListened: 0,
totalDuration: this.totalDuration || 0
})
},
setTotalDuration(duration) {
this.totalDuration = duration
},
streamClosed(audiobookId) {
streamClosed() {
console.log('Stream Closed')
if (this.stream.audiobook.id === audiobookId || audiobookId === 'n/a') {
this.$store.commit('setStreamAudiobook', null)
}
},
streamProgress(data) {
if (!data.numSegments) return
@@ -306,126 +126,13 @@ export default {
}
},
streamReset({ streamId, startTime }) {
console.log('received stream reset', streamId, startTime)
if (this.$refs.audioPlayer) {
if (this.stream && this.stream.id === streamId) {
this.$refs.audioPlayer.resetStream(startTime)
}
}
},
async getDownloadStartTime() {
var userAudiobook = this.$store.getters['user/getUserAudiobookData'](this.audiobookId)
if (!userAudiobook) {
console.log('[StreamContainer] getDownloadStartTime no user audiobook record found')
return 0
}
return userAudiobook.currentTime
},
async playDownload() {
if (this.stream) {
if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.terminateStream()
}
this.stream = null
}
this.lastProgressTimeUpdate = 0
console.log('[StreamContainer] Playing local', this.playingDownload)
if (!this.$refs.audioPlayer) {
console.error('No Audio Player Mini')
return
}
var playOnLoad = this.$store.state.playOnLoad
if (playOnLoad) this.$store.commit('setPlayOnLoad', false)
var currentTime = await this.getDownloadStartTime()
if (isNaN(currentTime) || currentTime === null) currentTime = 0
this.currentTime = currentTime
// Update local current time
this.$localStore.setCurrent({
audiobookId: this.download.id,
lastUpdate: Date.now()
})
var audiobookStreamData = {
id: 'download',
title: this.title,
author: this.author,
playWhenReady: !!playOnLoad,
startTime: String(Math.floor(currentTime * 1000)),
playbackSpeed: this.playbackSpeed || 1,
cover: this.download.coverUrl || null,
duration: String(Math.floor(this.duration * 1000)),
series: this.seriesTxt,
token: this.userToken,
contentUrl: this.playingDownload.contentUrl,
isLocal: true,
audiobookId: this.download.id
}
this.$refs.audioPlayer.set(audiobookStreamData, null, false)
},
streamOpen(stream) {
if (this.download) {
if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.terminateStream()
}
this.download = null
}
this.lastProgressTimeUpdate = 0
console.log('[StreamContainer] Stream Open: ' + this.title)
if (!this.$refs.audioPlayer) {
console.error('No Audio Player Mini')
return
}
// Update local remove current
this.$localStore.setCurrent(null)
var playlistUrl = stream.clientPlaylistUri
var currentTime = stream.clientCurrentTime || 0
this.currentTime = currentTime
var playOnLoad = this.$store.state.playOnLoad
if (playOnLoad) this.$store.commit('setPlayOnLoad', false)
var audiobookStreamData = {
id: stream.id,
title: this.title,
author: this.author,
playWhenReady: !!playOnLoad,
startTime: String(Math.floor(currentTime * 1000)),
playbackSpeed: this.playbackSpeed || 1,
cover: this.coverForNative,
duration: String(Math.floor(this.duration * 1000)),
series: this.seriesTxt,
playlistUrl: this.$server.url + playlistUrl,
token: this.userToken,
audiobookId: this.audiobookId,
tracks: this.tracksForCast
}
this.$refs.audioPlayer.set(audiobookStreamData, stream, !this.stream)
this.stream = stream
},
audioPlayerMounted() {
console.log('Audio Player Mounted', this.$server.stream)
this.audioPlayerReady = true
if (this.playingDownload) {
console.log('[StreamContainer] Play download on audio mount')
if (!this.download) {
this.download = { ...this.playingDownload }
}
this.playDownload()
} else if (this.$server.stream) {
console.log('[StreamContainer] Open stream on audio mount')
this.streamOpen(this.$server.stream)
}
},
updatePlaybackSpeed(speed) {
if (this.$refs.audioPlayer) {
console.log(`[AudioPlayerContainer] Update Playback Speed: ${speed}`)
@@ -444,66 +151,102 @@ export default {
this.$refs.audioPlayer.setPlaybackSpeed(this.playbackSpeed)
}
},
streamUpdated(type, data) {
if (type === 'download') {
if (data) {
this.download = { ...data }
if (this.audioPlayerReady) {
this.playDownload()
}
} else if (this.download) {
this.cancelStream()
}
}
},
setListeners() {
if (!this.$server.socket) {
console.error('Invalid server socket not set')
return
}
this.$server.socket.on('stream_open', this.streamOpen)
this.$server.socket.on('stream_closed', this.streamClosed)
this.$server.socket.on('stream_progress', this.streamProgress)
this.$server.socket.on('stream_ready', this.streamReady)
this.$server.socket.on('stream_reset', this.streamReset)
// if (!this.$server.socket) {
// console.error('Invalid server socket not set')
// return
// }
// this.$server.socket.on('stream_open', this.streamOpen)
// this.$server.socket.on('stream_closed', this.streamClosed)
// this.$server.socket.on('stream_progress', this.streamProgress)
// this.$server.socket.on('stream_ready', this.streamReady)
// this.$server.socket.on('stream_reset', this.streamReset)
},
closeStreamOnly() {
// If user logs out or disconnects from server, close audio if streaming
if (!this.download) {
this.$store.commit('setStreamAudiobook', null)
if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.terminateStream()
// If user logs out or disconnects from server and not playing local
if (this.$refs.audioPlayer && !this.$refs.audioPlayer.isLocalPlayMethod) {
this.$refs.audioPlayer.closePlayback()
}
},
async playLibraryItem(payload) {
var libraryItemId = payload.libraryItemId
var episodeId = payload.episodeId
if (libraryItemId.startsWith('local') && this.$store.state.isCasting) {
const { value } = await Dialog.confirm({
title: 'Warning',
message: `Cannot cast downloaded media items. Confirm to close cast and play on your device.`
})
if (!value) {
return
}
}
this.serverLibraryItemId = null
var playbackRate = 1
if (this.$refs.audioPlayer) {
playbackRate = this.$refs.audioPlayer.currentPlaybackRate || 1
}
console.log('Called playLibraryItem', libraryItemId)
AbsAudioPlayer.prepareLibraryItem({ libraryItemId, episodeId, playWhenReady: true, playbackRate })
.then((data) => {
console.log('Library item play response', JSON.stringify(data))
if (!libraryItemId.startsWith('local')) {
this.serverLibraryItemId = libraryItemId
}
})
.catch((error) => {
console.error('Failed', error)
})
},
pauseItem() {
if (this.$refs.audioPlayer && !this.$refs.audioPlayer.isPaused) {
this.$refs.audioPlayer.pause()
}
},
onLocalMediaProgressUpdate(localMediaProgress) {
console.log('Got local media progress update', localMediaProgress.progress, JSON.stringify(localMediaProgress))
this.$store.commit('globals/updateLocalMediaProgress', localMediaProgress)
},
onMediaPlayerChanged(data) {
var mediaPlayer = data.value
this.$store.commit('setMediaPlayer', mediaPlayer)
}
},
mounted() {
this.onSleepTimerEndedListener = MyNativeAudio.addListener('onSleepTimerEnded', this.onSleepTimerEnded)
this.onSleepTimerSetListener = MyNativeAudio.addListener('onSleepTimerSet', this.onSleepTimerSet)
this.onLocalMediaProgressUpdateListener = AbsAudioPlayer.addListener('onLocalMediaProgressUpdate', this.onLocalMediaProgressUpdate)
this.onSleepTimerEndedListener = AbsAudioPlayer.addListener('onSleepTimerEnded', this.onSleepTimerEnded)
this.onSleepTimerSetListener = AbsAudioPlayer.addListener('onSleepTimerSet', this.onSleepTimerSet)
this.onMediaPlayerChangedListener = AbsAudioPlayer.addListener('onMediaPlayerChanged', this.onMediaPlayerChanged)
this.playbackSpeed = this.$store.getters['user/getUserSetting']('playbackRate')
console.log(`[AudioPlayerContainer] Init Playback Speed: ${this.playbackSpeed}`)
this.setListeners()
this.$eventBus.$on('close_stream', this.closeStreamOnly)
this.$eventBus.$on('play-item', this.playLibraryItem)
this.$eventBus.$on('pause-item', this.pauseItem)
this.$eventBus.$on('close-stream', this.closeStreamOnly)
this.$store.commit('user/addSettingsListener', { id: 'streamContainer', meth: this.settingsUpdated })
this.$store.commit('setStreamListener', this.streamUpdated)
},
beforeDestroy() {
if (this.onLocalMediaProgressUpdateListener) this.onLocalMediaProgressUpdateListener.remove()
if (this.onSleepTimerEndedListener) this.onSleepTimerEndedListener.remove()
if (this.onSleepTimerSetListener) this.onSleepTimerSetListener.remove()
if (this.onMediaPlayerChangedListener) this.onMediaPlayerChangedListener.remove()
if (this.$server.socket) {
this.$server.socket.off('stream_open', this.streamOpen)
this.$server.socket.off('stream_closed', this.streamClosed)
this.$server.socket.off('stream_progress', this.streamProgress)
this.$server.socket.off('stream_ready', this.streamReady)
this.$server.socket.off('stream_reset', this.streamReset)
}
this.$eventBus.$off('close_stream', this.closeStreamOnly)
// if (this.$server.socket) {
// this.$server.socket.off('stream_open', this.streamOpen)
// this.$server.socket.off('stream_closed', this.streamClosed)
// this.$server.socket.off('stream_progress', this.streamProgress)
// this.$server.socket.off('stream_ready', this.streamReady)
// this.$server.socket.off('stream_reset', this.streamReset)
// }
this.$eventBus.$off('play-item', this.playLibraryItem)
this.$eventBus.$off('pause-item', this.pauseItem)
this.$eventBus.$off('close-stream', this.closeStreamOnly)
this.$store.commit('user/removeSettingsListener', 'streamContainer')
this.$store.commit('removeStreamListener')
}
}
</script>
+47 -28
View File
@@ -1,26 +1,33 @@
<template>
<div class="fixed top-0 left-0 right-0 bottom-0 w-full h-full z-50 overflow-hidden pointer-events-none">
<div class="fixed top-0 left-0 right-0 layout-wrapper w-full z-50 overflow-hidden pointer-events-none">
<div class="absolute top-0 left-0 w-full h-full bg-black transition-opacity duration-200" :class="show ? 'bg-opacity-60 pointer-events-auto' : 'bg-opacity-0'" @click="clickBackground" />
<div class="absolute top-0 right-0 w-64 h-full bg-primary transform transition-transform py-6 pointer-events-auto" :class="show ? '' : 'translate-x-64'" @click.stop>
<div class="px-6 mb-4">
<p v-if="socketConnected" class="text-base">
Welcome, <strong>{{ username }}</strong>
<p v-if="user" class="text-base">
Welcome,
<strong>{{ username }}</strong>
</p>
</div>
<div class="w-full overflow-y-auto">
<template v-for="item in navItems">
<nuxt-link :to="item.to" :key="item.text" class="w-full hover:bg-bg hover:bg-opacity-60 flex items-center py-3 px-6 text-gray-300">
<nuxt-link :to="item.to" :key="item.text" class="w-full hover:bg-bg hover:bg-opacity-60 flex items-center py-3 px-6 text-gray-300" :class="currentRoutePath.startsWith(item.to) ? 'bg-bg bg-opacity-60' : ''">
<span class="text-lg" :class="item.iconOutlined ? 'material-icons-outlined' : 'material-icons'">{{ item.icon }}</span>
<p class="pl-4">{{ item.text }}</p>
</nuxt-link>
</template>
</div>
<div class="absolute bottom-0 left-0 w-full flex items-center py-6 px-6 text-gray-300">
<p class="text-xs">{{ $config.version }}</p>
<div class="flex-grow" />
<div v-if="socketConnected" class="flex items-center" @click="logout">
<p class="text-xs pr-2">Logout</p>
<span class="material-icons text-sm">logout</span>
<div class="absolute bottom-0 left-0 w-full py-6 px-6 text-gray-300">
<div v-if="serverConnectionConfig" class="mb-4 flex justify-center">
<p class="text-xs">{{ serverConnectionConfig.address }}</p>
</div>
<div class="flex items-center">
<p class="text-xs">{{ $config.version }}</p>
<div class="flex-grow" />
<div v-if="user" class="flex items-center" @click="logout">
<p class="text-xs pr-2">Logout</p>
<span class="material-icons text-sm">logout</span>
</div>
</div>
</div>
</div>
@@ -61,6 +68,9 @@ export default {
user() {
return this.$store.state.user.user
},
serverConnectionConfig() {
return this.$store.state.user.serverConnectionConfig
},
username() {
return this.user ? this.user.username : ''
},
@@ -73,25 +83,9 @@ export default {
icon: 'home',
text: 'Home',
to: '/bookshelf'
},
{
icon: 'person',
text: 'Account',
to: '/account'
},
{
icon: 'folder',
iconOutlined: true,
text: 'Downloads',
to: '/downloads'
}
// {
// icon: 'settings',
// text: 'Settings',
// to: '/config'
// }
]
if (!this.socketConnected) {
if (!this.serverConnectionConfig) {
items = [
{
icon: 'cloud_off',
@@ -99,8 +93,31 @@ export default {
to: '/connect'
}
].concat(items)
} else {
items.push({
icon: 'person',
text: 'Account',
to: '/account'
})
}
if (this.$platform !== 'ios') {
items.push({
icon: 'folder',
iconOutlined: true,
text: 'Local Media',
to: '/localMedia/folders'
})
// items.push({
// icon: 'settings',
// text: 'Settings',
// to: '/settings'
// })
}
return items
},
currentRoutePath() {
return this.$route.path
}
},
methods: {
@@ -111,7 +128,9 @@ export default {
await this.$axios.$post('/logout').catch((error) => {
console.error(error)
})
this.$server.logout()
this.$socket.logout()
await this.$db.logout()
this.$store.commit('user/logout')
this.$router.push('/connect')
},
touchstart(e) {
+110 -131
View File
@@ -1,8 +1,8 @@
<template>
<div id="bookshelf" class="w-full max-w-full h-full">
<template v-for="shelf in totalShelves">
<div :key="shelf" class="w-full px-2 bookshelfRow relative" :id="`shelf-${shelf - 1}`" :style="{ height: shelfHeight + 'px' }">
<div class="bookshelfDivider w-full absolute bottom-0 left-0 z-30" style="min-height: 16px" :class="`h-${shelfDividerHeightIndex}`" />
<div :key="shelf" class="w-full px-2 relative" :class="showBookshelfListView ? '' : 'bookshelfRow'" :id="`shelf-${shelf - 1}`" :style="{ height: shelfHeight + 'px' }">
<div v-if="!showBookshelfListView" class="bookshelfDivider w-full absolute bottom-0 left-0 z-30" style="min-height: 16px" :class="`h-${shelfDividerHeightIndex}`" />
</div>
</template>
@@ -28,9 +28,8 @@ export default {
bookshelfWidth: 0,
bookshelfMarginLeft: 0,
shelvesPerPage: 0,
entitiesPerShelf: 8,
entitiesPerShelf: 2,
currentPage: 0,
currentBookWidth: 0,
booksPerFetch: 20,
initialized: false,
currentSFQueryString: null,
@@ -42,12 +41,18 @@ export default {
entityIndexesMounted: [],
pagesLoaded: {},
isFirstInit: false,
pendingReset: false
pendingReset: false,
localLibraryItems: []
}
},
watch: {
showBookshelfListView(newVal) {
this.resetEntities()
}
},
computed: {
isSocketConnected() {
return this.$store.state.socketConnected
user() {
return this.$store.state.user.user
},
isBookEntity() {
return this.entityName === 'books' || this.entityName === 'series-books'
@@ -56,21 +61,18 @@ export default {
if (this.isBookEntity) return 4
return 6
},
bookshelfListView() {
return this.$store.state.globals.bookshelfListView
},
showBookshelfListView() {
return this.isBookEntity && this.bookshelfListView
},
entityName() {
return this.page
},
bookshelfView() {
return this.$store.state.bookshelfView
},
hasFilter() {
return this.filterBy !== 'all'
},
isListView() {
return this.bookshelfView === 'list'
},
books() {
return this.$store.getters['downloads/getAudiobooks']
},
orderBy() {
return this.$store.getters['user/getUserSetting']('mobileOrderBy')
},
@@ -90,8 +92,9 @@ export default {
return this.isCoverSquareAspectRatio ? 1 : 1.6
},
bookWidth() {
// var coverSize = this.$store.getters['user/getUserSetting']('bookshelfCoverSize')
var coverSize = 100
if (window.innerWidth <= 375) coverSize = 90
if (this.isCoverSquareAspectRatio) return coverSize * 1.6
return coverSize
},
@@ -100,33 +103,28 @@ export default {
return this.bookWidth * 1.6
},
entityWidth() {
if (this.showBookshelfListView) return this.bookshelfWidth - 16
if (this.isBookEntity) return this.bookWidth
return this.bookWidth * 2
},
entityHeight() {
if (this.showBookshelfListView) return 88
return this.bookHeight
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
currentLibraryMediaType() {
return this.$store.getters['libraries/getCurrentLibraryMediaType']
},
shelfHeight() {
if (this.showBookshelfListView) return this.entityHeight
return this.entityHeight + 40
},
totalEntityCardWidth() {
if (this.showBookshelfListView) return this.entityWidth
// Includes margin
return this.entityWidth + 24
},
downloads() {
return this.$store.getters['downloads/getDownloads']
},
downloadedBooks() {
return this.downloads.map((dl) => {
var download = { ...dl }
var ab = { ...download.audiobook }
delete download.audiobook
ab.download = download
return ab
})
}
},
methods: {
@@ -143,19 +141,16 @@ export default {
if (!this.initialized) {
this.currentSFQueryString = this.buildSearchParams()
}
var entityPath = this.entityName === 'books' ? `books/all` : this.entityName
var entityPath = this.entityName === 'books' || this.entityName === 'series-books' ? `items` : this.entityName
var sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : ''
var queryString = `?${sfQueryString}&limit=${this.booksPerFetch}&page=${page}`
var fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1`
if (this.entityName === 'series-books') {
entityPath = `series/${this.seriesId}`
queryString = ''
}
var payload = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/${entityPath}${queryString}`).catch((error) => {
var payload = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/${entityPath}${fullQueryString}`).catch((error) => {
console.error('failed to fetch books', error)
return null
})
this.isFetchingEntities = false
if (this.pendingReset) {
this.pendingReset = false
@@ -173,25 +168,26 @@ export default {
}
for (let i = 0; i < payload.results.length; i++) {
if (this.entityName === 'books' || this.entityName === 'series-books') {
// Check if has download and append download obj
var download = this.downloads.find((dl) => dl.id === payload.results[i].id)
if (download) {
var dl = { ...download }
delete dl.audiobook
payload.results[i].download = dl
}
}
var index = i + startIndex
this.entities[index] = payload.results[i]
if (this.entityComponentRefs[index]) {
this.entityComponentRefs[index].setEntity(this.entities[index])
if (this.isBookEntity) {
var localLibraryItem = this.localLibraryItems.find((lli) => lli.libraryItemId == this.entities[index].id)
if (localLibraryItem) {
this.entityComponentRefs[index].setLocalLibraryItem(localLibraryItem)
}
}
}
}
}
},
async loadPage(page) {
if (!this.currentLibraryId) {
console.error('[LazyBookshelf] loadPage current library id not set')
return
}
this.pagesLoaded[page] = true
await this.fetchEntities(page)
},
@@ -223,6 +219,7 @@ export default {
this.loadPage(lastBookPage)
}
// Remove entities out of view
this.entityIndexesMounted = this.entityIndexesMounted.filter((_index) => {
if (_index < firstBookIndex || _index >= lastBookIndex) {
var el = document.getElementById(`book-card-${_index}`)
@@ -242,7 +239,7 @@ export default {
},
setDownloads() {
if (this.entityName === 'books') {
this.entities = this.downloadedBooks
this.entities = []
// TOOD: Sort and filter here
this.totalEntities = this.entities.length
this.totalShelves = Math.ceil(this.totalEntities / this.entitiesPerShelf)
@@ -270,14 +267,12 @@ export default {
this.initialized = false
this.initSizeData()
if (this.isSocketConnected) {
if (this.user) {
await this.loadPage(0)
var lastBookIndex = Math.min(this.totalEntities, this.shelvesPerPage * this.entitiesPerShelf)
this.mountEntites(0, lastBookIndex)
} else {
this.setDownloads()
this.mountEntites(0, this.totalEntities - 1)
// Local only
}
},
remountEntities() {
@@ -303,12 +298,11 @@ export default {
var { clientHeight, clientWidth } = bookshelf
this.bookshelfHeight = clientHeight
this.bookshelfWidth = clientWidth
this.entitiesPerShelf = Math.floor((this.bookshelfWidth - 16) / this.totalEntityCardWidth)
this.entitiesPerShelf = this.showBookshelfListView ? 1 : Math.floor((this.bookshelfWidth - 16) / this.totalEntityCardWidth)
this.shelvesPerPage = Math.ceil(this.bookshelfHeight / this.shelfHeight) + 2
this.bookshelfMarginLeft = (this.bookshelfWidth - this.entitiesPerShelf * this.totalEntityCardWidth) / 2
this.currentBookWidth = this.bookWidth
if (this.totalEntities) {
this.totalShelves = Math.ceil(this.totalEntities / this.entitiesPerShelf)
}
@@ -316,43 +310,43 @@ export default {
},
async init() {
if (this.isFirstInit) return
this.localLibraryItems = await this.$db.getLocalLibraryItems(this.currentLibraryMediaType)
console.log('Local library items loaded for lazy bookshelf', this.localLibraryItems.length)
this.isFirstInit = true
this.initSizeData()
await this.loadPage(0)
var lastBookIndex = Math.min(this.totalEntities, this.shelvesPerPage * this.entitiesPerShelf)
this.mountEntites(0, lastBookIndex)
},
initDownloads() {
this.initSizeData()
this.setDownloads()
this.$nextTick(() => {
console.log('Mounting downloads', this.totalEntities, 'total shelves', this.totalShelves)
this.mountEntites(0, this.totalEntities)
})
},
scroll(e) {
if (!e || !e.target) return
if (!this.isSocketConnected) return // Offline books are all mounted at once
if (!this.user) return
var { scrollTop } = e.target
this.handleScroll(scrollTop)
},
socketInit(isConnected) {
if (isConnected) {
this.init()
} else {
this.isFirstInit = false
this.resetEntities()
}
},
buildSearchParams() {
let searchParams = new URLSearchParams()
if (this.filterBy && this.filterBy !== 'all') {
searchParams.set('filter', this.filterBy)
if (this.page === 'search' || this.page === 'series' || this.page === 'collections') {
return ''
}
if (this.orderBy) {
searchParams.set('sort', this.orderBy)
searchParams.set('desc', this.orderDesc ? 1 : 0)
let searchParams = new URLSearchParams()
if (this.page === 'series-books') {
searchParams.set('filter', `series.${this.$encode(this.seriesId)}`)
searchParams.set('sort', 'book.volumeNumber')
searchParams.set('desc', 0)
} else {
if (this.filterBy && this.filterBy !== 'all') {
searchParams.set('filter', this.filterBy)
}
if (this.orderBy) {
searchParams.set('sort', this.orderBy)
searchParams.set('desc', this.orderDesc ? 1 : 0)
}
if (this.collapseSeries) {
searchParams.set('collapseseries', 1)
}
}
return searchParams.toString()
},
@@ -385,47 +379,49 @@ export default {
this.resetEntities()
}
},
downloadsLoaded() {
if (!this.isSocketConnected) {
this.resetEntities()
}
},
audiobookAdded(audiobook) {
console.log('Audiobook added', audiobook)
// TODO: Check if audiobook would be on this shelf
libraryItemAdded(libraryItem) {
console.log('libraryItem added', libraryItem)
// TODO: Check if item would be on this shelf
this.resetEntities()
},
audiobookUpdated(audiobook) {
console.log('Audiobook updated', audiobook)
libraryItemUpdated(libraryItem) {
console.log('Item updated', libraryItem)
if (this.entityName === 'books' || this.entityName === 'series-books') {
var indexOf = this.entities.findIndex((ent) => ent && ent.id === audiobook.id)
var indexOf = this.entities.findIndex((ent) => ent && ent.id === libraryItem.id)
if (indexOf >= 0) {
this.entities[indexOf] = audiobook
this.entities[indexOf] = libraryItem
if (this.entityComponentRefs[indexOf]) {
this.entityComponentRefs[indexOf].setEntity(audiobook)
this.entityComponentRefs[indexOf].setEntity(libraryItem)
if (this.isBookEntity) {
var localLibraryItem = this.localLibraryItems.find((lli) => lli.libraryItemId == libraryItem.id)
if (localLibraryItem) {
this.entityComponentRefs[indexOf].setLocalLibraryItem(localLibraryItem)
}
}
}
}
}
},
audiobookRemoved(audiobook) {
libraryItemRemoved(libraryItem) {
if (this.entityName === 'books' || this.entityName === 'series-books') {
var indexOf = this.entities.findIndex((ent) => ent && ent.id === audiobook.id)
var indexOf = this.entities.findIndex((ent) => ent && ent.id === libraryItem.id)
if (indexOf >= 0) {
this.entities = this.entities.filter((ent) => ent.id !== audiobook.id)
this.entities = this.entities.filter((ent) => ent.id !== libraryItem.id)
this.totalEntities = this.entities.length
this.$eventBus.$emit('bookshelf-total-entities', this.totalEntities)
this.remountEntities()
this.executeRebuild()
}
}
},
audiobooksAdded(audiobooks) {
console.log('audiobooks added', audiobooks)
// TODO: Check if audiobook would be on this shelf
libraryItemsAdded(libraryItems) {
console.log('items added', libraryItems)
// TODO: Check if item would be on this shelf
this.resetEntities()
},
audiobooksUpdated(audiobooks) {
audiobooks.forEach((ab) => {
this.audiobookUpdated(ab)
libraryItemsUpdated(libraryItems) {
libraryItems.forEach((ab) => {
this.libraryItemUpdated(ab)
})
},
initListeners() {
@@ -433,54 +429,37 @@ export default {
if (bookshelf) {
bookshelf.addEventListener('scroll', this.scroll)
}
// this.$eventBus.$on('bookshelf-clear-selection', this.clearSelectedEntities)
// this.$eventBus.$on('bookshelf-select-all', this.selectAllEntities)
// this.$eventBus.$on('bookshelf-keyword-filter', this.updateKeywordFilter)
this.$eventBus.$on('library-changed', this.libraryChanged)
this.$eventBus.$on('downloads-loaded', this.downloadsLoaded)
this.$store.commit('user/addSettingsListener', { id: 'lazy-bookshelf', meth: this.settingsUpdated })
if (this.$server.socket) {
this.$server.socket.on('audiobook_updated', this.audiobookUpdated)
this.$server.socket.on('audiobook_added', this.audiobookAdded)
this.$server.socket.on('audiobook_removed', this.audiobookRemoved)
this.$server.socket.on('audiobooks_updated', this.audiobooksUpdated)
this.$server.socket.on('audiobooks_added', this.audiobooksAdded)
} else {
console.error('Bookshelf - Socket not initialized')
}
this.$socket.$on('item_updated', this.libraryItemUpdated)
this.$socket.$on('item_added', this.libraryItemAdded)
this.$socket.$on('item_removed', this.libraryItemRemoved)
this.$socket.$on('items_updated', this.libraryItemsUpdated)
this.$socket.$on('items_added', this.libraryItemsAdded)
},
removeListeners() {
var bookshelf = document.getElementById('bookshelf-wrapper')
if (bookshelf) {
bookshelf.removeEventListener('scroll', this.scroll)
}
this.$eventBus.$off('library-changed', this.libraryChanged)
this.$eventBus.$off('downloads-loaded', this.downloadsLoaded)
this.$store.commit('user/removeSettingsListener', 'lazy-bookshelf')
if (this.$server.socket) {
this.$server.socket.off('audiobook_updated', this.audiobookUpdated)
this.$server.socket.off('audiobook_added', this.audiobookAdded)
this.$server.socket.off('audiobook_removed', this.audiobookRemoved)
this.$server.socket.off('audiobooks_updated', this.audiobooksUpdated)
this.$server.socket.off('audiobooks_added', this.audiobooksAdded)
} else {
console.error('Bookshelf - Socket not initialized')
}
this.$socket.$off('item_updated', this.libraryItemUpdated)
this.$socket.$off('item_added', this.libraryItemAdded)
this.$socket.$off('item_removed', this.libraryItemRemoved)
this.$socket.$off('items_updated', this.libraryItemsUpdated)
this.$socket.$off('items_added', this.libraryItemsAdded)
}
},
mounted() {
if (this.$server.initialized) {
this.init()
} else {
this.initDownloads()
}
this.$server.on('initialized', this.socketInit)
this.init()
this.initListeners()
},
beforeDestroy() {
this.$server.off('initialized', this.socketInit)
this.removeListeners()
}
}
+3 -1
View File
@@ -2,8 +2,10 @@
<div class="w-full relative">
<div class="bookshelfRow flex items-end px-3 max-w-full overflow-x-auto" :style="{ height: shelfHeight + 'px' }">
<template v-for="(entity, index) in entities">
<cards-lazy-book-card v-if="type === 'books'" :key="entity.id" :index="index" :book-mount="entity" :width="bookWidth" :height="entityHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" is-categorized class="mx-2 relative" />
<cards-lazy-book-card v-if="type === 'book' || type === 'podcast'" :key="entity.id" :index="index" :book-mount="entity" :width="bookWidth" :height="entityHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" is-categorized class="mx-2 relative" />
<cards-lazy-book-card v-if="type === 'episode'" :key="entity.recentEpisode.id" :index="index" :book-mount="entity" :width="bookWidth" :height="entityHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" is-categorized class="mx-2 relative" />
<cards-lazy-series-card v-else-if="type === 'series'" :key="entity.id" :index="index" :series-mount="entity" :width="bookWidth * 2" :height="entityHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" is-categorized class="mx-2 relative" />
<cards-author-card v-else-if="type === 'authors'" :key="entity.id" :width="bookWidth / 1.25" :height="bookWidth" :author="entity" :size-multiplier="1" class="mx-2" />
</template>
</div>
+81
View File
@@ -0,0 +1,81 @@
<template>
<div>
<div :style="{ width: width + 'px', height: height + 'px' }" class="bg-primary box-shadow-book rounded-md relative overflow-hidden">
<!-- Image or placeholder -->
<covers-author-image :author="author" />
<!-- Author name & num books overlay -->
<div v-show="!searching && !nameBelow" class="absolute bottom-0 left-0 w-full py-1 bg-black bg-opacity-60 px-2">
<p class="text-center font-semibold truncate" :style="{ fontSize: sizeMultiplier * 0.75 + 'rem' }">{{ name }}</p>
<p class="text-center text-gray-200" :style="{ fontSize: sizeMultiplier * 0.65 + 'rem' }">{{ numBooks }} Book{{ numBooks === 1 ? '' : 's' }}</p>
</div>
<!-- Loading spinner -->
<div v-show="searching" class="absolute top-0 left-0 z-10 w-full h-full bg-black bg-opacity-50 flex items-center justify-center">
<widgets-loading-spinner size="" />
</div>
</div>
<div v-show="nameBelow" class="w-full py-1 px-2">
<p class="text-center font-semibold truncate text-gray-200" :style="{ fontSize: sizeMultiplier * 0.75 + 'rem' }">{{ name }}</p>
</div>
</div>
</template>
<script>
export default {
props: {
author: {
type: Object,
default: () => {}
},
width: Number,
height: Number,
sizeMultiplier: {
type: Number,
default: 1
},
nameBelow: Boolean
},
data() {
return {
searching: false
}
},
computed: {
userToken() {
return this.$store.getters['user/getToken']
},
_author() {
return this.author || {}
},
authorId() {
return this._author.id
},
name() {
return this._author.name || ''
},
numBooks() {
return this._author.numBooks || 0
}
},
methods: {
async searchAuthor() {
this.searching = true
var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, { q: this.name }).catch((error) => {
console.error('Failed', error)
return null
})
if (!response) {
this.$toast.error('Author not found')
} else if (response.updated) {
if (response.author.imagePath) this.$toast.success('Author was updated')
else this.$toast.success('Author was updated (no image found)')
} else {
this.$toast.info('No updates were made for Author')
}
this.searching = false
}
},
mounted() {}
}
</script>
+19 -5
View File
@@ -1,6 +1,13 @@
<template>
<div class="flex h-full px-1 overflow-hidden shadow-sm">
<!-- <img src="/icons/NoUserPhoto.png" class="w-40 h-40 max-h-40 object-contain" style="max-height: 40px; max-width: 40px" /> -->
<div class="flex h-full px-1 overflow-hidden">
<div class="overflow-hidden bg-primary rounded" style="height: 50px; width: 40px">
<covers-author-image :author="author" />
</div>
<div class="flex-grow px-2 authorSearchCardContent h-full">
<p class="truncate text-sm">{{ name }}</p>
</div>
</div>
<!-- <div class="flex h-full px-1 overflow-hidden shadow-sm">
<div style="max-height: 48px; max-width: 48px" class="w-12 h-12 bg-primary overflow-hidden rounded">
<svg width="140%" height="140%" style="margin-left: -20%; margin-top: -20%; opacity: 0.6" viewBox="0 0 177 266" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill="white" d="M40.7156 165.47C10.2694 150.865 -31.5407 148.629 -38.0532 155.529L63.3191 204.159L76.9443 190.899C66.828 181.394 54.006 171.846 40.7156 165.47Z" stroke="white" stroke-width="4" transform="translate(-2 -1)" />
@@ -19,18 +26,25 @@
<div class="flex-grow px-2 authorSearchCardContent h-full">
<p class="truncate text-sm">{{ author }}</p>
</div>
</div>
</div> -->
</template>
<script>
export default {
props: {
author: String
author: {
type: Object,
default: () => {}
}
},
data() {
return {}
},
computed: {},
computed: {
name() {
return this.author.name
}
},
methods: {},
mounted() {}
}
@@ -1,16 +1,16 @@
<template>
<div class="flex h-full px-1 overflow-hidden">
<covers-book-cover :audiobook="audiobook" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<div class="flex-grow px-2 h-full audiobookSearchCardContent">
<covers-book-cover :library-item="libraryItem" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<div class="flex-grow px-2 audiobookSearchCardContent">
<p v-if="matchKey !== 'title'" class="truncate text-sm">{{ title }}</p>
<p v-else class="truncate text-sm" v-html="matchHtml" />
<p v-if="matchKey === 'subtitle'" class="truncate text-xs text-gray-300">{{ matchHtml }}</p>
<p v-if="matchKey !== 'authorFL'" class="text-xs text-gray-200 truncate">by {{ authorFL }}</p>
<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'" class="m-0 p-0 truncate" 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>
</div>
</template>
@@ -18,7 +18,7 @@
<script>
export default {
props: {
audiobook: {
libraryItem: {
type: Object,
default: () => {}
},
@@ -37,17 +37,27 @@ export default {
if (this.bookCoverAspectRatio === 1) return 50 * 1.2
return 50
},
book() {
return this.audiobook ? this.audiobook.book || {} : {}
media() {
return this.libraryItem ? this.libraryItem.media || {} : {}
},
mediaMetadata() {
return this.media.metadata || {}
},
mediaType() {
return this.libraryItem ? this.libraryItem.mediaType : null
},
isPodcast() {
return this.mediaType === 'podcast'
},
title() {
return this.book ? this.book.title : 'No Title'
return this.mediaMetadata.title || 'No Title'
},
subtitle() {
return this.book ? this.book.subtitle : ''
return this.mediaMetadata.subtitle
},
authorFL() {
return this.book ? this.book.authorFL : 'Unknown'
authorName() {
if (this.isPodcast) return this.mediaMetadata.author
return this.mediaMetadata.authorName
},
matchHtml() {
if (!this.matchText || !this.search) return ''
@@ -69,7 +79,9 @@ export default {
html += lastPart
if (this.matchKey === 'tags') return `<p class="truncate">Tags: ${html}</p>`
if (this.matchKey === 'authorFL') return `by ${html}`
if (this.matchKey === 'authors') return `by ${html}`
if (this.matchKey === 'isbn') return `<p class="truncate">ISBN: ${html}</p>`
if (this.matchKey === 'asin') return `<p class="truncate">ASIN: ${html}</p>`
if (this.matchKey === 'series') return `<p class="truncate">Series: ${html}</p>`
return `${html}`
}
+294 -75
View File
@@ -1,16 +1,27 @@
<template>
<div ref="card" :id="`book-card-${index}`" :style="{ width: width + 'px', minWidth: width + 'px', height: height + 'px' }" class="rounded-sm z-10 bg-primary cursor-pointer box-shadow-book" @click="clickCard">
<div ref="card" :id="`book-card-${index}`" :style="{ minWidth: width + 'px', maxWidth: width + 'px', height: height + 'px' }" class="rounded-sm z-10 bg-primary cursor-pointer box-shadow-book" @click="clickCard">
<!-- When cover image does not fill -->
<div v-show="showCoverBg" class="absolute top-0 left-0 w-full h-full overflow-hidden rounded-sm bg-primary">
<div class="absolute cover-bg" ref="coverBg" />
</div>
<!-- Alternative bookshelf title/author/sort -->
<!-- <div v-if="isAlternativeBookshelfView" class="absolute left-0 z-50 w-full" :style="{ bottom: `-${titleDisplayBottomOffset}rem` }">
<p class="truncate" :style="{ fontSize: 0.9 * sizeMultiplier + 'rem' }">
{{ displayTitle }}
</p>
<p class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displayAuthor }}</p>
<p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
</div> -->
<div v-if="booksInSeries" class="absolute z-20 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">{{ booksInSeries }}</div>
<div class="w-full h-full absolute top-0 left-0 rounded overflow-hidden z-10">
<div v-show="audiobook && !imageReady" class="absolute top-0 left-0 w-full h-full flex items-center justify-center" :style="{ padding: sizeMultiplier * 0.5 + 'rem' }">
<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>
</div>
<img v-show="audiobook" ref="cover" :src="bookCoverSrc" class="w-full h-full transition-opacity duration-300" :class="hasCover ? 'object-contain' : 'object-fill'" @load="imageLoaded" :style="{ opacity: imageReady ? 1 : 0 }" />
<img v-show="libraryItem" ref="cover" :src="bookCoverSrc" class="w-full h-full transition-opacity duration-300" :class="showCoverBg ? 'object-contain' : 'object-fill'" @load="imageLoaded" :style="{ opacity: imageReady ? 1 : 0 }" />
<!-- 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' }">
@@ -23,25 +34,43 @@
</div>
</div>
<!-- Downloaded indicator icon -->
<div v-if="hasDownload" class="absolute z-10" :style="{ top: 0.5 * sizeMultiplier + 'rem', right: 0.5 * sizeMultiplier + 'rem' }">
<span class="material-icons text-success" :style="{ fontSize: 1.1 * sizeMultiplier + 'rem' }">download_done</span>
<!-- Play/pause button for podcast episode -->
<div v-if="recentEpisode" class="absolute z-10 top-0 left-0 bottom-0 right-0 m-auto flex items-center justify-center w-12 h-12 rounded-full bg-white bg-opacity-70">
<span class="material-icons text-6xl text-black text-opacity-80">{{ streamIsPlaying ? 'pause_circle' : 'play_circle_filled' }}</span>
</div>
<!-- Progress bar -->
<div class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="userIsRead ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div>
<!-- No progress shown for collapsed series in library -->
<div v-if="!collapsedSeries && (!isPodcast || recentEpisode)" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div>
<div v-if="localLibraryItem || isLocal" class="absolute top-0 right-0 z-20" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
<span class="material-icons text-2xl text-success">{{ isLocalOnly ? 'task' : 'download_done' }}</span>
</div>
<!-- Error widget -->
<div v-if="showError" :style="{ height: 1.5 * sizeMultiplier + 'rem', width: 2.5 * sizeMultiplier + 'rem' }" class="bg-error rounded-r-full shadow-md flex items-center justify-end border-r border-b border-red-300">
<span class="material-icons text-red-100 pr-1" :style="{ fontSize: 0.875 * sizeMultiplier + 'rem' }">priority_high</span>
</div>
<div v-if="volumeNumber && showVolumeNumber && !isHovering && !isSelectionMode" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ volumeNumber }}</p>
<!-- Series sequence -->
<div v-if="seriesSequence && showSequence && !isSelectionMode" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ seriesSequence }}</p>
</div>
<!-- Podcast Episode # -->
<div v-if="recentEpisodeNumber && !isSelectionMode" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">Episode #{{ recentEpisodeNumber }}</p>
</div>
<!-- Podcast Num Episodes -->
<div v-else-if="numEpisodes && !isSelectionMode" class="absolute rounded-full bg-black bg-opacity-90 box-shadow-md z-10 flex items-center justify-center" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', width: 1.25 * sizeMultiplier + 'rem', height: 1.25 * sizeMultiplier + 'rem' }">
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">{{ numEpisodes }}</p>
</div>
</div>
</template>
<script>
import { Capacitor } from '@capacitor/core'
export default {
props: {
index: Number,
@@ -54,119 +83,225 @@ export default {
default: 192
},
bookCoverAspectRatio: Number,
showVolumeNumber: Boolean,
showSequence: Boolean,
bookshelfView: Number,
bookMount: {
// Book can be passed as prop or set with setEntity()
type: Object,
default: () => null
}
},
orderBy: String,
filterBy: String,
sortingIgnorePrefix: Boolean
},
data() {
return {
isHovering: false,
isMoreMenuOpen: false,
isProcessingReadUpdate: false,
audiobook: null,
libraryItem: null,
imageReady: false,
rescanning: false,
selected: false,
isSelectionMode: false,
showCoverBg: false
showCoverBg: false,
localLibraryItem: null
}
},
watch: {
bookMount: {
handler(newVal) {
if (newVal) {
this.libraryItem = newVal
}
}
}
},
computed: {
_audiobook() {
return this.audiobook || {}
showExperimentalFeatures() {
return this.store.state.showExperimentalFeatures
},
_libraryItem() {
return this.libraryItem || {}
},
isLocal() {
return !!this._libraryItem.isLocal
},
isLocalOnly() {
// Local item with no server match
return this.isLocal && !this._libraryItem.libraryItemId
},
media() {
return this._libraryItem.media || {}
},
mediaMetadata() {
return this.media.metadata || {}
},
mediaType() {
return this._libraryItem.mediaType
},
isPodcast() {
return this.mediaType === 'podcast'
},
placeholderUrl() {
return '/book_placeholder.jpg'
},
hasDownload() {
return !!this._audiobook.download
},
downloadedCover() {
if (!this._audiobook.download) return null
return this._audiobook.download.cover
},
bookCoverSrc() {
if (this.downloadedCover) return this.downloadedCover
return this.store.getters['audiobooks/getBookCoverSrc'](this._audiobook, this.placeholderUrl)
if (this.isLocal) {
if (this.libraryItem.coverContentUrl) return Capacitor.convertFileSrc(this.libraryItem.coverContentUrl)
return this.placeholderUrl
}
return this.store.getters['globals/getLibraryItemCoverSrc'](this._libraryItem, this.placeholderUrl)
},
audiobookId() {
return this._audiobook.id
libraryItemId() {
return this._libraryItem.id
},
libraryId() {
return this._libraryItem.libraryId
},
hasEbook() {
return this._audiobook.numEbooks
return this.media.ebookFile
},
hasTracks() {
return this._audiobook.numTracks
numTracks() {
return this.media.numTracks
},
book() {
return this._audiobook.book || {}
numEpisodes() {
return this.media.numEpisodes
},
processingBatch() {
return this.store.state.processingBatch
},
booksInSeries() {
// Only added to audiobook object when collapseSeries is enabled
return this._libraryItem.booksInSeries
},
hasCover() {
return !!this.book.cover
return !!this.media.coverPath
},
squareAspectRatio() {
return this.bookCoverAspectRatio === 1
},
sizeMultiplier() {
var baseSize = this.squareAspectRatio ? 160 : 100
var baseSize = this.squareAspectRatio ? 192 : 120
return this.width / baseSize
},
title() {
return this.book.title || ''
return this.mediaMetadata.title || ''
},
playIconFontSize() {
return Math.max(2, 3 * this.sizeMultiplier)
},
authors() {
return this.mediaMetadata.authors || []
},
author() {
return this.book.author
},
authorFL() {
return this.book.authorFL || this.author
if (this.isPodcast) return this.mediaMetadata.author
return this.mediaMetadata.authorName
},
authorLF() {
return this.book.authorLF || this.author
return this.mediaMetadata.authorNameLF
},
volumeNumber() {
return this.book.volumeNumber || null
series() {
// Only included when filtering by series or collapse series
return this.mediaMetadata.series
},
seriesSequence() {
return this.series ? this.series.sequence : null
},
recentEpisode() {
// Only added to item when getting currently listening podcasts
return this._libraryItem.recentEpisode
},
recentEpisodeNumber() {
if (!this.recentEpisode) return null
if (this.recentEpisode.episode) {
return this.recentEpisode.episode.replace(/^#/, '')
}
return this.recentEpisode.index
},
collapsedSeries() {
// Only added to item object when collapseSeries is enabled
return this._libraryItem.collapsedSeries
},
booksInSeries() {
// Only added to item object when collapseSeries is enabled
return this.collapsedSeries ? this.collapsedSeries.numBooks : 0
},
displayTitle() {
if (this.orderBy === 'media.metadata.title' && this.sortingIgnorePrefix && this.title.toLowerCase().startsWith('the ')) {
return this.title.substr(4) + ', The'
}
return this.title
},
displayAuthor() {
if (this.orderBy === 'media.metadata.authorNameLF') return this.authorLF
return this.author
},
displaySortLine() {
if (this.orderBy === 'mtimeMs') return 'Modified ' + this.$formatDate(this._libraryItem.mtimeMs)
if (this.orderBy === 'birthtimeMs') return 'Born ' + this.$formatDate(this._libraryItem.birthtimeMs)
if (this.orderBy === 'addedAt') return 'Added ' + this.$formatDate(this._libraryItem.addedAt)
if (this.orderBy === 'duration') return 'Duration: ' + this.$elapsedPrettyExtended(this.media.duration, false)
if (this.orderBy === 'size') return 'Size: ' + this.$bytesPretty(this._libraryItem.size)
return null
},
episodeProgress() {
// Only used on home page currently listening podcast shelf
if (!this.recentEpisode) return null
if (this.isLocal) return this.store.getters['globals/getLocalMediaProgressById'](this.libraryItemId, this.recentEpisode.id)
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId, this.recentEpisode.id)
},
userProgress() {
return this.store.getters['user/getUserAudiobook'](this.audiobookId)
if (this.episodeProgress) return this.episodeProgress
if (this.isLocal) return this.store.getters['globals/getLocalMediaProgressById'](this.libraryItemId)
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId)
},
userProgressPercent() {
return this.userProgress ? this.userProgress.progress || 0 : 0
},
userIsRead() {
return this.userProgress ? !!this.userProgress.isRead : false
itemIsFinished() {
return this.userProgress ? !!this.userProgress.isFinished : false
},
showError() {
return this.hasMissingParts || this.hasInvalidParts || this.isMissing || this.isInvalid
return this.numMissingParts || this.isMissing || this.isInvalid
},
playerIsLocal() {
return !!this.$store.state.playerIsLocal
},
localLibraryItemId() {
if (this.isLocal) return this.libraryItemId
return this.localLibraryItem ? this.localLibraryItem.id : null
},
isStreaming() {
return this.store.getters['getAudiobookIdStreaming'] === this.audiobookId
if (this.isPodcast) {
if (this.playerIsLocal) {
// Check is streaming local version of this episode
return false // episode cards not implemented for local yet
}
return this.$store.getters['getIsEpisodeStreaming'](this.libraryItemId, this.recentEpisode.id)
} else {
return false // not yet necessary for books
}
},
streamIsPlaying() {
return this.$store.state.playerIsPlaying && this.isStreaming
},
isMissing() {
return this._audiobook.isMissing
return this._libraryItem.isMissing
},
isInvalid() {
return this._audiobook.isInvalid
return this._libraryItem.isInvalid
},
hasMissingParts() {
return this._audiobook.hasMissingParts
numMissingParts() {
if (this.isPodcast) return 0
return this.media.numMissingParts
},
hasInvalidParts() {
return this._audiobook.hasInvalidParts
},
errorText() {
if (this.isMissing) return 'Audiobook directory is missing!'
else if (this.isInvalid) return 'Audiobook has no audio tracks & ebook'
var txt = ''
if (this.hasMissingParts) {
txt = `${this.hasMissingParts} missing parts.`
overlayWrapperClasslist() {
var classes = []
if (this.isSelectionMode) classes.push('bg-opacity-60')
else classes.push('bg-opacity-40')
if (this.selected) {
classes.push('border-2 border-yellow-400')
}
if (this.hasInvalidParts) {
if (this.hasMissingParts) txt += ' '
txt += `${this.hasInvalidParts} invalid parts.`
}
return txt || 'Unknown Error'
return classes
},
store() {
return this.$store || this.$nuxt.$store
@@ -206,11 +341,11 @@ export default {
return this.title
},
authorCleaned() {
if (!this.authorFL) return ''
if (this.authorFL.length > 30) {
return this.authorFL.slice(0, 27) + '...'
if (!this.author) return ''
if (this.author.length > 30) {
return this.author.slice(0, 27) + '...'
}
return this.authorFL
return this.author
}
},
methods: {
@@ -218,22 +353,102 @@ export default {
this.isSelectionMode = val
if (!val) this.selected = false
},
setEntity(audiobook) {
this.audiobook = audiobook
setEntity(libraryItem) {
this.libraryItem = libraryItem
},
setLocalLibraryItem(localLibraryItem) {
// Server books may have a local library item
this.localLibraryItem = localLibraryItem
},
clickCard(e) {
if (this.isSelectionMode) {
e.stopPropagation()
e.preventDefault()
this.selectBtnClick()
} else if (this.recentEpisode) {
var eventBus = this.$eventBus || this.$nuxt.$eventBus
if (this.streamIsPlaying) eventBus.$emit('pause-item')
else eventBus.$emit('play-item', { libraryItemId: this.libraryItemId, episodeId: this.recentEpisode.id })
} else {
var router = this.$router || this.$nuxt.$router
if (router) router.push(`/audiobook/${this.audiobookId}`)
if (router) {
if (this.collapsedSeries) router.push(`/library/${this.libraryId}/series/${this.collapsedSeries.id}`)
else router.push(`/item/${this.libraryItemId}`)
}
}
},
editClick() {
this.$emit('edit', this.libraryItem)
},
toggleFinished() {
var updatePayload = {
isFinished: !this.itemIsFinished
}
this.isProcessingReadUpdate = true
var toast = this.$toast || this.$nuxt.$toast
var axios = this.$axios || this.$nuxt.$axios
axios
.$patch(`/api/me/progress/${this.libraryItemId}`, updatePayload)
.then(() => {
this.isProcessingReadUpdate = false
toast.success(`Item marked as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
})
.catch((error) => {
console.error('Failed', error)
this.isProcessingReadUpdate = false
toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
})
},
rescan() {
this.rescanning = true
this.$axios
.$get(`/api/items/${this.libraryItemId}/scan`)
.then((data) => {
this.rescanning = false
var result = data.result
if (!result) {
this.$toast.error(`Re-Scan Failed for "${this.title}"`)
} else if (result === 'UPDATED') {
this.$toast.success(`Re-Scan complete item was updated`)
} else if (result === 'UPTODATE') {
this.$toast.success(`Re-Scan complete item was up to date`)
} else if (result === 'REMOVED') {
this.$toast.error(`Re-Scan complete item was removed`)
}
})
.catch((error) => {
console.error('Failed to scan library item', error)
this.$toast.error('Failed to scan library item')
this.rescanning = false
})
},
showEditModalTracks() {
// More menu func
this.store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'tracks' })
},
showEditModalMatch() {
// More menu func
this.store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'match' })
},
showEditModalDownload() {
// More menu func
this.store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'download' })
},
openCollections() {
this.store.commit('setSelectedLibraryItem', this.libraryItem)
this.store.commit('globals/setShowUserCollectionsModal', true)
},
clickReadEBook() {
this.store.commit('showEReader', this.libraryItem)
},
selectBtnClick() {
if (this.processingBatch) return
this.selected = !this.selected
this.$emit('select', this.audiobook)
this.$emit('select', this.libraryItem)
},
play() {
var eventBus = this.$eventBus || this.$nuxt.$eventBus
eventBus.$emit('play-item', { libraryItemId: this.libraryItemId })
},
destroy() {
// destroy the vue listeners, etc
@@ -272,6 +487,10 @@ export default {
mounted() {
if (this.bookMount) {
this.setEntity(this.bookMount)
if (this.bookMount.localLibraryItem) {
this.setLocalLibraryItem(this.bookMount.localLibraryItem)
}
}
}
}
+447
View File
@@ -0,0 +1,447 @@
<template>
<div ref="card" :id="`book-card-${index}`" :style="{ minWidth: width + 'px', maxWidth: width + 'px', height: height + 'px' }" class="rounded-sm z-10 cursor-pointer py-1" @click="clickCard">
<div class="h-full flex relative">
<div class="w-20 h-20 relative" style="min-width: 80px; max-width: 80px">
<!-- When cover image does not fill -->
<div v-show="showCoverBg" class="absolute top-0 left-0 w-full h-full overflow-hidden rounded-sm bg-primary">
<div class="absolute cover-bg" ref="coverBg" />
</div>
<div class="w-full h-full absolute top-0 left-0 bg-primary">
<img v-show="libraryItem" ref="cover" :src="bookCoverSrc" class="w-full h-full transition-opacity duration-300" :class="showCoverBg ? 'object-contain' : 'object-fill'" @load="imageLoaded" :style="{ opacity: imageReady ? 1 : 0 }" />
</div>
<!-- No progress shown for collapsed series or podcasts in library -->
<div v-if="!isPodcast && !collapsedSeries" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: 80 * userProgressPercent + 'px' }"></div>
</div>
<div class="flex-grow px-2">
<p class="whitespace-normal" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">
{{ displayTitle }}<span v-if="seriesSequence">&nbsp;#{{ seriesSequence }}</span>
</p>
<p class="truncate text-gray-400" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">by {{ displayAuthor }}</p>
<p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
</div>
<div v-if="localLibraryItem || isLocal" class="absolute top-0 right-0 z-20" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
<span class="material-icons text-2xl text-success">{{ isLocalOnly ? 'task' : 'download_done' }}</span>
</div>
</div>
</div>
</template>
<script>
import { Capacitor } from '@capacitor/core'
export default {
props: {
index: Number,
width: {
type: Number,
default: 120
},
height: {
type: Number,
default: 192
},
bookCoverAspectRatio: Number,
showSequence: Boolean,
bookshelfView: Number,
bookMount: {
// Book can be passed as prop or set with setEntity()
type: Object,
default: () => null
},
orderBy: String,
filterBy: String,
sortingIgnorePrefix: Boolean
},
data() {
return {
isProcessingReadUpdate: false,
libraryItem: null,
imageReady: false,
rescanning: false,
selected: false,
isSelectionMode: false,
showCoverBg: false,
localLibraryItem: null
}
},
watch: {
bookMount: {
handler(newVal) {
if (newVal) {
this.libraryItem = newVal
}
}
}
},
computed: {
showExperimentalFeatures() {
return this.store.state.showExperimentalFeatures
},
_libraryItem() {
return this.libraryItem || {}
},
isLocal() {
return !!this._libraryItem.isLocal
},
isLocalOnly() {
// Local item with no server match
return this.isLocal && !this._libraryItem.libraryItemId
},
media() {
return this._libraryItem.media || {}
},
mediaMetadata() {
return this.media.metadata || {}
},
mediaType() {
return this._libraryItem.mediaType
},
isPodcast() {
return this.mediaType === 'podcast'
},
placeholderUrl() {
return '/book_placeholder.jpg'
},
bookCoverSrc() {
if (this.isLocal) {
if (this.libraryItem.coverContentUrl) return Capacitor.convertFileSrc(this.libraryItem.coverContentUrl)
return this.placeholderUrl
}
return this.store.getters['globals/getLibraryItemCoverSrc'](this._libraryItem, this.placeholderUrl)
},
libraryItemId() {
return this._libraryItem.id
},
series() {
return this.mediaMetadata.series
},
libraryId() {
return this._libraryItem.libraryId
},
hasEbook() {
return this.media.ebookFile
},
numTracks() {
return this.media.numTracks
},
processingBatch() {
return this.store.state.processingBatch
},
booksInSeries() {
// Only added to audiobook object when collapseSeries is enabled
return this._libraryItem.booksInSeries
},
hasCover() {
return !!this.media.coverPath
},
squareAspectRatio() {
return this.bookCoverAspectRatio === 1
},
sizeMultiplier() {
return Math.min(1, this.width / 364)
},
title() {
return this.mediaMetadata.title || ''
},
playIconFontSize() {
return Math.max(2, 3 * this.sizeMultiplier)
},
author() {
if (this.isPodcast) return this.mediaMetadata.author || 'Unknown'
return this.mediaMetadata.authorName || 'Unknown'
},
authorLF() {
return this.mediaMetadata.authorNameLF || 'Unknown'
},
series() {
// Only included when filtering by series or collapse series
return this.mediaMetadata.series
},
seriesSequence() {
return this.series ? this.series.sequence : null
},
collapsedSeries() {
// Only added to item object when collapseSeries is enabled
return this._libraryItem.collapsedSeries
},
booksInSeries() {
// Only added to item object when collapseSeries is enabled
return this.collapsedSeries ? this.collapsedSeries.numBooks : 0
},
displayTitle() {
if (this.orderBy === 'media.metadata.title' && this.sortingIgnorePrefix && this.title.toLowerCase().startsWith('the ')) {
return this.title.substr(4) + ', The'
}
return this.title
},
displayAuthor() {
if (this.isPodcast) return this.author
if (this.orderBy === 'media.metadata.authorNameLF') return this.authorLF
return this.author
},
displaySortLine() {
if (this.orderBy === 'mtimeMs') return 'Modified ' + this.$formatDate(this._libraryItem.mtimeMs)
if (this.orderBy === 'birthtimeMs') return 'Born ' + this.$formatDate(this._libraryItem.birthtimeMs)
if (this.orderBy === 'addedAt') return 'Added ' + this.$formatDate(this._libraryItem.addedAt)
if (this.orderBy === 'size') return 'Size: ' + this.$bytesPretty(this._libraryItem.size)
return null
},
userProgress() {
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId)
},
userProgressPercent() {
return this.userProgress ? this.userProgress.progress || 0 : 0
},
itemIsFinished() {
return this.userProgress ? !!this.userProgress.isFinished : false
},
showError() {
return this.hasMissingParts || this.hasInvalidParts || this.isMissing || this.isInvalid
},
isStreaming() {
return this.store.getters['getlibraryItemIdStreaming'] === this.libraryItemId
},
showReadButton() {
return !this.isSelectionMode && this.showExperimentalFeatures && !this.showPlayButton && this.hasEbook
},
showPlayButton() {
return !this.isSelectionMode && !this.isMissing && !this.isInvalid && this.numTracks && !this.isStreaming
},
showSmallEBookIcon() {
return !this.isSelectionMode && this.showExperimentalFeatures && this.hasEbook
},
isMissing() {
return this._libraryItem.isMissing
},
isInvalid() {
return this._libraryItem.isInvalid
},
hasMissingParts() {
return this._libraryItem.hasMissingParts
},
hasInvalidParts() {
return this._libraryItem.hasInvalidParts
},
errorText() {
if (this.isMissing) return 'Item directory is missing!'
else if (this.isInvalid) return 'Item has no media files'
var txt = ''
if (this.hasMissingParts) {
txt = `${this.hasMissingParts} missing parts.`
}
if (this.hasInvalidParts) {
if (this.hasMissingParts) txt += ' '
txt += `${this.hasInvalidParts} invalid parts.`
}
return txt || 'Unknown Error'
},
overlayWrapperClasslist() {
var classes = []
if (this.isSelectionMode) classes.push('bg-opacity-60')
else classes.push('bg-opacity-40')
if (this.selected) {
classes.push('border-2 border-yellow-400')
}
return classes
},
store() {
return this.$store || this.$nuxt.$store
},
userCanUpdate() {
return this.store.getters['user/getUserCanUpdate']
},
userCanDelete() {
return this.store.getters['user/getUserCanDelete']
},
userCanDownload() {
return this.store.getters['user/getUserCanDownload']
},
userIsRoot() {
return this.store.getters['user/getIsRoot']
},
_socket() {
return this.$root.socket || this.$nuxt.$root.socket
},
titleFontSize() {
return 0.75 * this.sizeMultiplier
},
authorFontSize() {
return 0.6 * this.sizeMultiplier
},
placeholderCoverPadding() {
return 0.8 * this.sizeMultiplier
},
authorBottom() {
return 0.75 * this.sizeMultiplier
},
titleCleaned() {
if (!this.title) return ''
if (this.title.length > 60) {
return this.title.slice(0, 57) + '...'
}
return this.title
},
authorCleaned() {
if (!this.author) return ''
if (this.author.length > 30) {
return this.author.slice(0, 27) + '...'
}
return this.author
},
isAlternativeBookshelfView() {
return false
// var constants = this.$constants || this.$nuxt.$constants
// return this.bookshelfView === constants.BookshelfView.TITLES
},
titleDisplayBottomOffset() {
if (!this.isAlternativeBookshelfView) return 0
else if (!this.displaySortLine) return 3 * this.sizeMultiplier
return 4.25 * this.sizeMultiplier
}
},
methods: {
setSelectionMode(val) {
this.isSelectionMode = val
if (!val) this.selected = false
},
setEntity(libraryItem) {
this.libraryItem = libraryItem
},
setLocalLibraryItem(localLibraryItem) {
// Server books may have a local library item
this.localLibraryItem = localLibraryItem
},
clickCard(e) {
if (this.isSelectionMode) {
e.stopPropagation()
e.preventDefault()
this.selectBtnClick()
} else {
var router = this.$router || this.$nuxt.$router
if (router) {
if (this.collapsedSeries) router.push(`/library/${this.libraryId}/series/${this.collapsedSeries.id}`)
else router.push(`/item/${this.libraryItemId}`)
}
}
},
editClick() {
this.$emit('edit', this.libraryItem)
},
toggleFinished() {
var updatePayload = {
isFinished: !this.itemIsFinished
}
this.isProcessingReadUpdate = true
var toast = this.$toast || this.$nuxt.$toast
var axios = this.$axios || this.$nuxt.$axios
axios
.$patch(`/api/me/progress/${this.libraryItemId}`, updatePayload)
.then(() => {
this.isProcessingReadUpdate = false
toast.success(`Item marked as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
})
.catch((error) => {
console.error('Failed', error)
this.isProcessingReadUpdate = false
toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
})
},
rescan() {
this.rescanning = true
this.$axios
.$get(`/api/items/${this.libraryItemId}/scan`)
.then((data) => {
this.rescanning = false
var result = data.result
if (!result) {
this.$toast.error(`Re-Scan Failed for "${this.title}"`)
} else if (result === 'UPDATED') {
this.$toast.success(`Re-Scan complete item was updated`)
} else if (result === 'UPTODATE') {
this.$toast.success(`Re-Scan complete item was up to date`)
} else if (result === 'REMOVED') {
this.$toast.error(`Re-Scan complete item was removed`)
}
})
.catch((error) => {
console.error('Failed to scan library item', error)
this.$toast.error('Failed to scan library item')
this.rescanning = false
})
},
showEditModalTracks() {
// More menu func
this.store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'tracks' })
},
showEditModalMatch() {
// More menu func
this.store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'match' })
},
showEditModalDownload() {
// More menu func
this.store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'download' })
},
openCollections() {
this.store.commit('setSelectedLibraryItem', this.libraryItem)
this.store.commit('globals/setShowUserCollectionsModal', true)
},
clickReadEBook() {
this.store.commit('showEReader', this.libraryItem)
},
selectBtnClick() {
if (this.processingBatch) return
this.selected = !this.selected
this.$emit('select', this.libraryItem)
},
play() {
var eventBus = this.$eventBus || this.$nuxt.$eventBus
eventBus.$emit('play-item', { libraryItemId: this.libraryItemId })
},
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()
}
},
setCoverBg() {
if (this.$refs.coverBg) {
this.$refs.coverBg.style.backgroundImage = `url("${this.bookCoverSrc}")`
}
},
imageLoaded() {
this.imageReady = true
if (this.$refs.cover && this.bookCoverSrc !== this.placeholderUrl) {
var { naturalWidth, naturalHeight } = this.$refs.cover
var aspectRatio = naturalHeight / naturalWidth
var arDiff = Math.abs(aspectRatio - this.bookCoverAspectRatio)
// If image aspect ratio is <= 1.45 or >= 1.75 then use cover bg, otherwise stretch to fit
if (arDiff > 0.15) {
this.showCoverBg = true
this.$nextTick(this.setCoverBg)
} else {
this.showCoverBg = false
}
}
}
},
mounted() {
if (this.bookMount) {
this.setEntity(this.bookMount)
if (this.bookMount.localLibraryItem) {
this.setLocalLibraryItem(this.bookMount.localLibraryItem)
}
}
}
}
</script>
+1 -1
View File
@@ -56,7 +56,7 @@ export default {
return this.store.state.libraries.currentLibraryId
},
seriesId() {
return this.series ? this.$encode(this.series.id) : null
return this.series ? this.series.id : null
},
hasValidCovers() {
var validCovers = this.books.map((bookItem) => bookItem.book.cover)
+9 -3
View File
@@ -1,8 +1,8 @@
<template>
<div class="flex h-full px-1 overflow-hidden">
<covers-group-cover :name="series" :book-items="bookItems" :width="80" :height="60" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<covers-group-cover :name="name" :book-items="bookItems" :width="80" :height="60" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<div class="flex-grow px-2 seriesSearchCardContent h-full">
<p class="truncate text-sm">{{ series }}</p>
<p class="truncate text-sm">{{ name }}</p>
</div>
</div>
</template>
@@ -10,7 +10,10 @@
<script>
export default {
props: {
series: String,
series: {
type: Object,
default: () => {}
},
bookItems: {
type: Array,
default: () => []
@@ -22,6 +25,9 @@ export default {
computed: {
bookCoverAspectRatio() {
return this.$store.getters['getBookCoverAspectRatio']
},
name() {
return this.series.name
}
},
methods: {},
+314
View File
@@ -0,0 +1,314 @@
<template>
<div class="w-full max-w-md mx-auto px-4 sm:px-6 lg:px-8 z-10">
<div v-show="!loggedIn" class="mt-8 bg-primary overflow-hidden shadow rounded-lg p-6 w-full">
<template v-if="!showForm">
<div v-for="config in serverConnectionConfigs" :key="config.id" class="flex items-center py-4 my-1 border-b border-white border-opacity-10 relative" @click="connectToServer(config)">
<span class="material-icons-outlined text-xl text-gray-300">dns</span>
<p class="pl-3 pr-6 text-base text-gray-200">{{ config.name }}</p>
<div class="absolute top-0 right-0 h-full px-4 flex items-center" @click.stop="editServerConfig(config)">
<span class="material-icons text-lg text-gray-300">more_vert</span>
</div>
</div>
<div class="my-1 py-4 w-full">
<ui-btn class="w-full" @click="newServerConfigClick">Add New Server</ui-btn>
</div>
</template>
<div v-else class="w-full">
<form v-show="!showAuth" @submit.prevent="submit" novalidate class="w-full">
<h2 class="text-lg leading-7 mb-2">Server address</h2>
<ui-text-input v-model="serverConfig.address" :disabled="processing || !networkConnected || serverConfig.id" placeholder="http://55.55.55.55:13378" type="url" class="w-full sm:w-72 h-10" />
<div class="flex justify-end">
<ui-btn :disabled="processing || !networkConnected" type="submit" :padding-x="3" class="h-10 mt-4">{{ networkConnected ? 'Submit' : 'No Internet' }}</ui-btn>
</div>
</form>
<template v-if="showAuth">
<div v-if="serverConfig.id" class="flex items-center mb-4" @click="showServerList">
<span class="material-icons text-gray-300">arrow_back</span>
</div>
<div class="flex items-center">
<p class="text-gray-300">{{ serverConfig.address }}</p>
<div class="flex-grow" />
<span v-if="!serverConfig.id" class="material-icons" style="font-size: 1.1rem" @click="editServerAddress">edit</span>
</div>
<div class="w-full h-px bg-white bg-opacity-10 my-2" />
<form @submit.prevent="submitAuth" class="pt-3">
<ui-text-input v-model="serverConfig.username" :disabled="processing" placeholder="username" class="w-full mb-2 text-lg" />
<ui-text-input v-model="password" type="password" :disabled="processing" placeholder="password" class="w-full mb-2 text-lg" />
<div class="flex items-center pt-2">
<ui-icon-btn v-if="serverConfig.id" small bg-color="error" icon="delete" @click="removeServerConfigClick" />
<div class="flex-grow" />
<ui-btn :disabled="processing || !networkConnected" type="submit" class="mt-1 h-10">{{ networkConnected ? 'Submit' : 'No Internet' }}</ui-btn>
</div>
</form>
</template>
</div>
<div v-show="error" class="w-full rounded-lg bg-red-600 bg-opacity-10 border border-error border-opacity-50 py-3 px-2 flex items-center mt-4">
<span class="material-icons mr-2 text-error" style="font-size: 1.1rem">warning</span>
<p class="text-error">{{ error }}</p>
</div>
</div>
<div :class="processing ? 'opacity-100' : 'opacity-0 pointer-events-none'" class="fixed w-full h-full top-0 left-0 bg-black bg-opacity-75 flex items-center justify-center z-30 transition-opacity duration-500">
<div>
<div class="absolute top-0 left-0 w-full p-6 flex items-center flex-col justify-center z-0 short:hidden">
<img src="/Logo.png" class="h-20 w-20 mb-2" />
</div>
<svg class="animate-spin w-16 h-16" viewBox="0 0 24 24">
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
</svg>
</div>
</div>
</div>
</template>
<script>
import { Dialog } from '@capacitor/dialog'
export default {
props: {},
data() {
return {
deviceData: null,
loggedIn: false,
showAuth: false,
processing: false,
serverConfig: {
address: null,
username: null
},
password: null,
error: null,
showForm: false
}
},
computed: {
networkConnected() {
return this.$store.state.networkConnected
},
serverConnectionConfigs() {
return this.deviceData ? this.deviceData.serverConnectionConfigs || [] : []
},
lastServerConnectionConfigId() {
return this.deviceData ? this.deviceData.lastServerConnectionConfigId : null
},
lastServerConnectionConfig() {
if (!this.lastServerConnectionConfigId || !this.serverConnectionConfigs.length) return null
return this.serverConnectionConfigs.find((s) => s.id == this.lastServerConnectionConfigId)
}
},
methods: {
showServerList() {
this.showForm = false
this.showAuth = false
this.error = null
this.serverConfig = {
address: null,
userId: null,
username: null
}
},
async connectToServer(config) {
console.log('[ServerConnectForm] connectToServer', config.address)
this.processing = true
this.serverConfig = {
...config
}
this.showForm = true
var success = await this.pingServerAddress(config.address)
this.processing = false
console.log(`[ServerConnectForm] pingServer result ${success}`)
if (!success) {
this.showForm = false
this.showAuth = false
console.log(`[ServerConnectForm] showForm ${this.showForm}`)
return
}
this.error = null
var payload = await this.authenticateToken()
if (payload) {
this.setUserAndConnection(payload.user, payload.userDefaultLibraryId)
} else {
this.showAuth = true
}
},
async removeServerConfigClick() {
if (!this.serverConfig.id) return
const { value } = await Dialog.confirm({
title: 'Confirm',
message: `Remove this server config?`
})
if (value) {
this.processing = true
await this.$db.removeServerConnectionConfig(this.serverConfig.id)
this.deviceData.serverConnectionConfigs = this.deviceData.serverConnectionConfigs.filter((scc) => scc.id != this.serverConfig.id)
this.serverConfig = {
address: null,
userId: null,
username: null
}
this.password = null
this.processing = false
this.showAuth = false
this.showForm = !this.serverConnectionConfigs.length
}
},
editServerConfig(serverConfig) {
this.serverConfig = {
...serverConfig
}
this.showForm = true
this.showAuth = true
console.log('Edit server config', serverConfig)
},
newServerConfigClick() {
this.serverConfig = {
address: '',
userId: '',
username: ''
}
this.showForm = true
this.showAuth = false
this.error = null
},
editServerAddress() {
this.error = null
this.showAuth = false
},
validateServerUrl(url) {
try {
var urlObject = new URL(url)
var address = `${urlObject.protocol}//${urlObject.hostname}`
if (urlObject.port) address += ':' + urlObject.port
return address
} catch (error) {
console.error('Invalid URL', error)
return null
}
},
pingServerAddress(address) {
return this.$axios
.$get(`${address}/ping`, { timeout: 3000 })
.then((data) => data.success)
.catch((error) => {
console.error('Server check failed', error)
this.error = 'Failed to ping server'
return false
})
},
requestServerLogin() {
return this.$axios
.$post(`${this.serverConfig.address}/login`, { username: this.serverConfig.username, password: this.password })
.then((data) => {
if (!data.user) {
console.error(data.error)
this.error = data.error || 'Unknown Error'
return false
}
return data
})
.catch((error) => {
console.error('Server auth failed', error)
var errorMsg = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
this.error = errorMsg
return false
})
},
async submit() {
if (!this.networkConnected) return
if (!this.serverConfig.address) return
if (!this.serverConfig.address.startsWith('http')) {
this.serverConfig.address = 'http://' + this.serverConfig.address
}
var validServerAddress = this.validateServerUrl(this.serverConfig.address)
if (!validServerAddress) {
this.error = 'Invalid server address'
return
}
this.serverConfig.address = validServerAddress
this.processing = true
this.error = null
var success = await this.pingServerAddress(this.serverConfig.address)
this.processing = false
if (success) this.showAuth = true
},
async submitAuth() {
if (!this.networkConnected) return
if (!this.serverConfig.username) {
this.error = 'Invalid username'
return
}
this.error = null
this.processing = true
var payload = await this.requestServerLogin()
this.processing = false
if (payload) {
this.setUserAndConnection(payload.user, payload.userDefaultLibraryId)
}
},
async setUserAndConnection(user, userDefaultLibraryId) {
if (!user) return
console.log('Successfully logged in', JSON.stringify(user))
// Set library - Use last library if set and available fallback to default user library
var lastLibraryId = await this.$localStore.getLastLibraryId()
if (lastLibraryId && (!user.librariesAccessible.length || user.librariesAccessible.includes(lastLibraryId))) {
this.$store.commit('libraries/setCurrentLibrary', lastLibraryId)
} else if (userDefaultLibraryId) {
this.$store.commit('libraries/setCurrentLibrary', userDefaultLibraryId)
}
this.serverConfig.userId = user.id
this.serverConfig.token = user.token
var serverConnectionConfig = await this.$db.setServerConnectionConfig(this.serverConfig)
this.$store.commit('user/setUser', user)
this.$store.commit('user/setServerConnectionConfig', serverConnectionConfig)
this.$socket.connect(this.serverConfig.address, this.serverConfig.token)
this.$router.replace('/bookshelf')
},
async authenticateToken() {
if (!this.networkConnected) return
if (!this.serverConfig.token) {
this.error = 'No token'
return
}
this.error = null
this.processing = true
var authRes = await this.$axios.$post(`${this.serverConfig.address}/api/authorize`, null, { headers: { Authorization: `Bearer ${this.serverConfig.token}` } }).catch((error) => {
console.error('[Server] Server auth failed', error)
var errorMsg = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
this.error = errorMsg
return false
})
this.processing = false
return authRes
},
async init() {
this.deviceData = await this.$db.getDeviceData()
if (this.lastServerConnectionConfig) {
this.connectToServer(this.lastServerConnectionConfig)
} else {
this.showForm = !this.serverConnectionConfigs.length
}
}
},
mounted() {
this.init()
}
}
</script>
+90
View File
@@ -0,0 +1,90 @@
<template>
<nuxt-link :to="`/bookshelf/library?filter=authors.${$encode(authorId)}`" ref="wrapper" :class="`rounded-${rounded}`" class="w-full h-full bg-primary overflow-hidden">
<svg v-if="!imagePath" width="140%" height="140%" style="margin-left: -20%; margin-top: -20%; opacity: 0.6" viewBox="0 0 177 266" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill="white" d="M40.7156 165.47C10.2694 150.865 -31.5407 148.629 -38.0532 155.529L63.3191 204.159L76.9443 190.899C66.828 181.394 54.006 171.846 40.7156 165.47Z" stroke="white" stroke-width="4" transform="translate(-2 -1)" />
<path d="M-38.0532 155.529C-31.5407 148.629 10.2694 150.865 40.7156 165.47C54.006 171.846 66.828 181.394 76.9443 190.899L95.0391 173.37C80.6681 159.403 64.7526 149.155 51.5747 142.834C21.3549 128.337 -46.2471 114.563 -60.6897 144.67L-71.5489 167.307L44.5864 223.019L63.3191 204.159L-38.0532 155.529Z" fill="white" />
<path
d="M105.87 29.6508C80.857 17.6515 50.8784 28.1923 38.879 53.2056C26.8797 78.219 37.4205 108.198 62.4338 120.197C87.4472 132.196 117.426 121.656 129.425 96.6422C141.425 71.6288 130.884 41.6502 105.87 29.6508ZM106.789 85.783C112.761 73.3329 107.461 58.2599 95.0112 52.2874C82.5611 46.3148 67.4881 51.6147 61.5156 64.0648C55.543 76.5149 60.8429 91.5879 73.293 97.5604C85.7431 103.533 100.816 98.2331 106.789 85.783Z"
fill="white"
/>
<path
d="M151.336 159.01L159.048 166.762L82.7048 242.703L74.973 242.683L74.9934 234.951L151.336 159.01ZM181.725 108.497C179.624 108.491 177.436 109.326 175.835 110.918L160.415 126.257L191.848 157.856L207.268 142.517C210.554 139.248 210.568 133.954 207.299 130.667L187.685 110.95C186.009 109.264 183.91 108.502 181.725 108.497ZM151.399 135.226L58.2034 227.931L58.1203 259.447L89.6359 259.53L182.831 166.825L151.399 135.226Z"
fill="white"
/>
<path d="M151.336 159.01L159.048 166.762L82.7048 242.703L74.973 242.683L74.9934 234.951L151.336 159.01Z" fill="white" stroke="white" stroke-width="10px" />
</svg>
<div v-else class="w-full h-full relative">
<div v-if="showCoverBg" class="cover-bg absolute" :style="{ backgroundImage: `url(${imgSrc})` }" />
<img ref="img" :src="imgSrc" @load="imageLoaded" class="absolute top-0 left-0 h-full w-full" :class="coverContain ? 'object-contain' : 'object-cover'" />
</div>
</nuxt-link>
</template>
<script>
export default {
props: {
author: {
type: Object,
default: () => {}
},
rounded: {
type: String,
default: 'lg'
}
},
data() {
return {
showCoverBg: false,
coverContain: true
}
},
computed: {
userToken() {
return this.$store.getters['user/getToken']
},
_author() {
return this.author || {}
},
authorId() {
return this._author.id
},
imagePath() {
return this._author.imagePath
},
updatedAt() {
return this._author.updatedAt
},
serverAddres() {
return this.$store.getters['user/getServerAddress']
},
imgSrc() {
if (!this.imagePath || !this.serverAddres) return null
if (process.env.NODE_ENV !== 'production') {
// Testing
return `http://localhost:3333/api/authors/${this.authorId}/image?token=${this.userToken}&ts=${this.updatedAt}`
}
return `${this.serverAddres}/api/authors/${this.authorId}/image?token=${this.userToken}&ts=${this.updatedAt}`
}
},
methods: {
imageLoaded() {
var aspectRatio = 1.25
if (this.$refs.wrapper) {
aspectRatio = this.$refs.wrapper.clientHeight / this.$refs.wrapper.clientWidth
}
if (this.$refs.img) {
var { naturalWidth, naturalHeight } = this.$refs.img
var imgAr = naturalHeight / naturalWidth
var arDiff = Math.abs(imgAr - aspectRatio)
if (arDiff > 0.15) {
this.showCoverBg = true
} else {
this.showCoverBg = false
this.coverContain = false
}
}
}
},
mounted() {}
}
</script>
+38 -240
View File
@@ -5,20 +5,12 @@
<div class="absolute cover-bg" ref="coverBg" />
</div>
<img v-if="audiobook" 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 && audiobook" class="absolute top-0 left-0 h-full w-full flex items-center justify-center">
<img v-if="fullCoverUrl" 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 && !hasCover ? '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>
<div class="absolute top-2 right-2">
<div class="la-ball-spin-clockwise la-sm">
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
</div>
<widgets-loading-spinner />
</div>
</div>
</div>
@@ -32,23 +24,24 @@
<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 font-book truncate leading-none origin-center" style="color: rgb(247 223 187); font-size: 0.8rem" :style="{ transform: `scale(${sizeMultiplier})` }">{{ 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 font-book truncate leading-none origin-center" style="color: rgb(247 223 187); opacity: 0.75; font-size: 0.6rem" :style="{ transform: `scale(${sizeMultiplier})` }">{{ authorCleaned }}</p>
</div>
</div>
</template>
<script>
import { Capacitor } from '@capacitor/core'
export default {
props: {
audiobook: {
libraryItem: {
type: Object,
default: () => {}
},
authorOverride: String,
width: {
type: Number,
default: 120
@@ -70,18 +63,28 @@ export default {
}
},
computed: {
isLocal() {
if (!this.libraryItem) return false
return this.libraryItem.isLocal
},
localCover() {
return this.libraryItem ? this.libraryItem.coverContentUrl : null
},
squareAspectRatio() {
return this.bookCoverAspectRatio === 1
},
height() {
return this.width * this.bookCoverAspectRatio
},
book() {
if (!this.audiobook) return {}
return this.audiobook.book || {}
media() {
if (!this.libraryItem) return {}
return this.libraryItem.media || {}
},
mediaMetadata() {
return this.media.metadata || {}
},
title() {
return this.book.title || 'No Title'
return this.mediaMetadata.title || 'No Title'
},
titleCleaned() {
if (this.title.length > 60) {
@@ -89,9 +92,11 @@ export default {
}
return this.title
},
authors() {
return this.mediaMetadata.authors || []
},
author() {
if (this.authorOverride) return this.authorOverride
return this.book.author || 'Unknown'
return this.authors.map((au) => au.name).join(', ')
},
authorCleaned() {
if (this.author.length > 30) {
@@ -103,19 +108,23 @@ export default {
return '/book_placeholder.jpg'
},
fullCoverUrl() {
if (this.isLocal) {
if (this.localCover) return Capacitor.convertFileSrc(this.localCover)
return this.placeholderUrl
}
if (this.downloadCover) return this.downloadCover
if (!this.audiobook) return null
if (!this.libraryItem) return null
var store = this.$store || this.$nuxt.$store
return store.getters['audiobooks/getBookCoverSrc'](this.audiobook, this.placeholderUrl)
return store.getters['globals/getLibraryItemCoverSrc'](this.libraryItem, this.placeholderUrl)
},
cover() {
return this.book.cover || this.placeholderUrl
return this.media.coverPath || this.placeholderUrl
},
hasCover() {
return !!this.book.cover
return !!this.media.coverPath || this.localCover || this.downloadCover
},
sizeMultiplier() {
var baseSize = this.squareAspectRatio ? 192 : 120
var baseSize = this.squareAspectRatio ? 128 : 96
return this.width / baseSize
},
titleFontSize() {
@@ -125,7 +134,8 @@ export default {
return 0.6 * this.sizeMultiplier
},
placeholderCoverPadding() {
return 0.8 * this.sizeMultiplier
if (this.sizeMultiplier < 0.5) return 0
return this.sizeMultiplier
},
authorBottom() {
return 0.75 * this.sizeMultiplier
@@ -140,7 +150,6 @@ export default {
this.$refs.coverBg.style.backgroundImage = `url("${this.fullCoverUrl}")`
}
},
hideCoverBg() {},
imageLoaded() {
this.loading = false
this.$nextTick(() => {
@@ -170,214 +179,3 @@ export default {
}
</script>
<style>
/*!
* Load Awesome v1.1.0 (http://github.danielcardoso.net/load-awesome/)
* Copyright 2015 Daniel Cardoso <@DanielCardoso>
* Licensed under MIT
*/
.la-ball-spin-clockwise,
.la-ball-spin-clockwise > div {
position: relative;
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
}
.la-ball-spin-clockwise {
display: block;
font-size: 0;
color: #fff;
}
.la-ball-spin-clockwise.la-dark {
color: #262626;
}
.la-ball-spin-clockwise > div {
display: inline-block;
float: none;
background-color: currentColor;
border: 0 solid currentColor;
}
.la-ball-spin-clockwise {
width: 32px;
height: 32px;
}
.la-ball-spin-clockwise > div {
position: absolute;
top: 50%;
left: 50%;
width: 8px;
height: 8px;
margin-top: -4px;
margin-left: -4px;
border-radius: 100%;
-webkit-animation: ball-spin-clockwise 1s infinite ease-in-out;
-moz-animation: ball-spin-clockwise 1s infinite ease-in-out;
-o-animation: ball-spin-clockwise 1s infinite ease-in-out;
animation: ball-spin-clockwise 1s infinite ease-in-out;
}
.la-ball-spin-clockwise > div:nth-child(1) {
top: 5%;
left: 50%;
-webkit-animation-delay: -0.875s;
-moz-animation-delay: -0.875s;
-o-animation-delay: -0.875s;
animation-delay: -0.875s;
}
.la-ball-spin-clockwise > div:nth-child(2) {
top: 18.1801948466%;
left: 81.8198051534%;
-webkit-animation-delay: -0.75s;
-moz-animation-delay: -0.75s;
-o-animation-delay: -0.75s;
animation-delay: -0.75s;
}
.la-ball-spin-clockwise > div:nth-child(3) {
top: 50%;
left: 95%;
-webkit-animation-delay: -0.625s;
-moz-animation-delay: -0.625s;
-o-animation-delay: -0.625s;
animation-delay: -0.625s;
}
.la-ball-spin-clockwise > div:nth-child(4) {
top: 81.8198051534%;
left: 81.8198051534%;
-webkit-animation-delay: -0.5s;
-moz-animation-delay: -0.5s;
-o-animation-delay: -0.5s;
animation-delay: -0.5s;
}
.la-ball-spin-clockwise > div:nth-child(5) {
top: 94.9999999966%;
left: 50.0000000005%;
-webkit-animation-delay: -0.375s;
-moz-animation-delay: -0.375s;
-o-animation-delay: -0.375s;
animation-delay: -0.375s;
}
.la-ball-spin-clockwise > div:nth-child(6) {
top: 81.8198046966%;
left: 18.1801949248%;
-webkit-animation-delay: -0.25s;
-moz-animation-delay: -0.25s;
-o-animation-delay: -0.25s;
animation-delay: -0.25s;
}
.la-ball-spin-clockwise > div:nth-child(7) {
top: 49.9999750815%;
left: 5.0000051215%;
-webkit-animation-delay: -0.125s;
-moz-animation-delay: -0.125s;
-o-animation-delay: -0.125s;
animation-delay: -0.125s;
}
.la-ball-spin-clockwise > div:nth-child(8) {
top: 18.179464974%;
left: 18.1803700518%;
-webkit-animation-delay: 0s;
-moz-animation-delay: 0s;
-o-animation-delay: 0s;
animation-delay: 0s;
}
.la-ball-spin-clockwise.la-sm {
width: 16px;
height: 16px;
}
.la-ball-spin-clockwise.la-sm > div {
width: 4px;
height: 4px;
margin-top: -2px;
margin-left: -2px;
}
.la-ball-spin-clockwise.la-2x {
width: 64px;
height: 64px;
}
.la-ball-spin-clockwise.la-2x > div {
width: 16px;
height: 16px;
margin-top: -8px;
margin-left: -8px;
}
.la-ball-spin-clockwise.la-3x {
width: 96px;
height: 96px;
}
.la-ball-spin-clockwise.la-3x > div {
width: 24px;
height: 24px;
margin-top: -12px;
margin-left: -12px;
}
/*
* Animation
*/
@-webkit-keyframes ball-spin-clockwise {
0%,
100% {
opacity: 1;
-webkit-transform: scale(1);
transform: scale(1);
}
20% {
opacity: 1;
}
80% {
opacity: 0;
-webkit-transform: scale(0);
transform: scale(0);
}
}
@-moz-keyframes ball-spin-clockwise {
0%,
100% {
opacity: 1;
-moz-transform: scale(1);
transform: scale(1);
}
20% {
opacity: 1;
}
80% {
opacity: 0;
-moz-transform: scale(0);
transform: scale(0);
}
}
@-o-keyframes ball-spin-clockwise {
0%,
100% {
opacity: 1;
-o-transform: scale(1);
transform: scale(1);
}
20% {
opacity: 1;
}
80% {
opacity: 0;
-o-transform: scale(0);
transform: scale(0);
}
}
@keyframes ball-spin-clockwise {
0%,
100% {
opacity: 1;
-webkit-transform: scale(1);
-moz-transform: scale(1);
-o-transform: scale(1);
transform: scale(1);
}
20% {
opacity: 1;
}
80% {
opacity: 0;
-webkit-transform: scale(0);
-moz-transform: scale(0);
-o-transform: scale(0);
transform: scale(0);
}
}
</style>
+2 -2
View File
@@ -9,8 +9,8 @@
<div v-else-if="books.length" class="flex 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 :audiobook="books[0]" :width="width / 2" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<covers-book-cover v-if="books.length > 1" :audiobook="books[1]" :width="width / 2" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<covers-book-cover :library-item="books[0]" :width="width / 2" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<covers-book-cover v-if="books.length > 1" :library-item="books[1]" :width="width / 2" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</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" />
+28 -46
View File
@@ -17,6 +17,7 @@ export default {
},
width: Number,
height: Number,
groupTo: String,
bookCoverAspectRatio: Number
},
data() {
@@ -31,7 +32,6 @@ export default {
isFannedOut: false,
isDetached: false,
isAttaching: false,
windowWidth: 0,
isInit: false
}
},
@@ -48,8 +48,11 @@ export default {
},
computed: {
sizeMultiplier() {
if (this.bookCoverAspectRatio === 1) return this.width / (100 * 1.6 * 2)
return this.width / 200
if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6 * 2)
return this.width / 240
},
showExperimentalFeatures() {
return this.store.state.showExperimentalFeatures
},
store() {
return this.$store || this.$nuxt.$store
@@ -59,44 +62,8 @@ export default {
}
},
methods: {
detchCoverWrapper() {
if (!this.coverWrapperEl || !this.$refs.wrapper || this.isDetached) return
this.coverWrapperEl.remove()
this.isDetached = true
document.body.appendChild(this.coverWrapperEl)
this.coverWrapperEl.addEventListener('mouseleave', this.mouseleaveCover)
this.coverWrapperEl.style.position = 'absolute'
this.coverWrapperEl.style.zIndex = 40
this.updatePosition()
},
attachCoverWrapper() {
if (!this.coverWrapperEl || !this.$refs.wrapper || !this.isDetached) return
this.coverWrapperEl.remove()
this.coverWrapperEl.style.position = 'relative'
this.coverWrapperEl.style.left = 'unset'
this.coverWrapperEl.style.top = 'unset'
this.coverWrapperEl.style.width = this.$refs.wrapper.clientWidth + 'px'
this.$refs.wrapper.appendChild(this.coverWrapperEl)
this.isDetached = false
},
updatePosition() {
var rect = this.$refs.wrapper.getBoundingClientRect()
this.coverWrapperEl.style.top = rect.top + window.scrollY + 'px'
this.coverWrapperEl.style.left = rect.left + window.scrollX + 4 + 'px'
this.coverWrapperEl.style.height = rect.height + 'px'
this.coverWrapperEl.style.width = rect.width + 'px'
},
getCoverUrl(book) {
return this.store.getters['audiobooks/getBookCoverSrc'](book, '')
return this.store.getters['globals/getLibraryItemCoverSrc'](book, '')
},
async buildCoverImg(coverData, bgCoverWidth, offsetLeft, zIndex, forceCoverBg = false) {
var src = coverData.coverUrl
@@ -156,6 +123,22 @@ export default {
imgdiv.appendChild(img)
return imgdiv
},
createSeriesNameCover(offsetLeft) {
var imgdiv = document.createElement('div')
imgdiv.style.height = this.height + 'px'
imgdiv.style.width = this.height / this.bookCoverAspectRatio + 'px'
imgdiv.style.left = offsetLeft + 'px'
imgdiv.className = 'absolute top-0 box-shadow-book transition-transform flex items-center justify-center'
imgdiv.style.boxShadow = '4px 0px 4px #11111166'
imgdiv.style.backgroundColor = '#111'
var innerP = document.createElement('p')
innerP.textContent = this.name
innerP.className = 'text-sm font-book text-white'
imgdiv.appendChild(innerP)
return imgdiv
},
async init() {
if (this.isInit) return
this.isInit = true
@@ -168,7 +151,6 @@ export default {
.map((bookItem) => {
return {
id: bookItem.id,
volumeNumber: bookItem.book ? bookItem.book.volumeNumber : null,
coverUrl: this.getCoverUrl(bookItem)
}
})
@@ -179,6 +161,8 @@ export default {
}
this.noValidCovers = false
validCovers = validCovers.slice(0, 10)
var coverWidth = this.width
var widthPer = this.width
if (validCovers.length > 1) {
@@ -189,7 +173,7 @@ export default {
this.offsetIncrement = widthPer
var outerdiv = document.createElement('div')
outerdiv.id = `group-cover-${this.id || this.$encode(this.name)}`
outerdiv.id = `group-cover-${this.id}`
this.coverWrapperEl = outerdiv
outerdiv.className = 'w-full h-full relative box-shadow-book'
@@ -211,9 +195,7 @@ export default {
}
}
},
mounted() {
this.windowWidth = window.innerWidth
},
mounted() {},
beforeDestroy() {
if (this.coverWrapperEl) this.coverWrapperEl.remove()
if (this.coverImageEls && this.coverImageEls.length) {
@@ -222,4 +204,4 @@ export default {
if (this.coverDiv) this.coverDiv.remove()
}
}
</script>
</script>
+46 -11
View File
@@ -1,17 +1,8 @@
<template>
<div class="w-full h-9 bg-bg relative">
<div id="bookshelf-navbar" class="absolute z-10 top-0 left-0 w-full h-full flex bg-secondary text-gray-200">
<nuxt-link to="/bookshelf" class="w-1/4 h-full flex items-center justify-center" :class="routeName === 'bookshelf' ? 'bg-primary' : 'text-gray-400'">
<p>Home</p>
</nuxt-link>
<nuxt-link to="/bookshelf/library" class="w-1/4 h-full flex items-center justify-center" :class="routeName === 'bookshelf-library' ? 'bg-primary' : 'text-gray-400'">
<p>Library</p>
</nuxt-link>
<nuxt-link to="/bookshelf/series" class="w-1/4 h-full flex items-center justify-center" :class="routeName === 'bookshelf-series' ? 'bg-primary' : 'text-gray-400'">
<p>Series</p>
</nuxt-link>
<nuxt-link to="/bookshelf/collections" class="w-1/4 h-full flex items-center justify-center" :class="routeName === 'bookshelf-collections' ? 'bg-primary' : 'text-gray-400'">
<p>Collections</p>
<nuxt-link v-for="item in items" :key="item.to" :to="item.to" class="h-full flex items-center justify-center" :style="{ width: isPodcast ? '50%' : '25%' }" :class="routeName === item.routeName ? 'bg-primary' : 'text-gray-400'">
<p>{{ item.text }}</p>
</nuxt-link>
</div>
</div>
@@ -23,8 +14,52 @@ export default {
return {}
},
computed: {
items() {
if (this.isPodcast) {
return [
{
to: '/bookshelf',
routeName: 'bookshelf',
text: 'Home'
},
{
to: '/bookshelf/library',
routeName: 'bookshelf-library',
text: 'Library'
}
]
}
return [
{
to: '/bookshelf',
routeName: 'bookshelf',
text: 'Home'
},
{
to: '/bookshelf/library',
routeName: 'bookshelf-library',
text: 'Library'
},
{
to: '/bookshelf/series',
routeName: 'bookshelf-series',
text: 'Series'
},
{
to: '/bookshelf/collections',
routeName: 'bookshelf-collections',
text: 'Collections'
}
]
},
routeName() {
return this.$route.name
},
isPodcast() {
return this.libraryMediaType == 'podcast'
},
libraryMediaType() {
return this.$store.getters['libraries/getCurrentLibraryMediaType']
}
},
methods: {},
+20 -18
View File
@@ -8,8 +8,8 @@
<p v-show="!selectedSeriesName" class="font-book pt-1">{{ totalEntities }} {{ entityTitle }}</p>
<p v-show="selectedSeriesName" class="ml-2 font-book pt-1">{{ selectedSeriesName }} ({{ totalEntities }})</p>
<div class="flex-grow" />
<span v-if="page == 'library' || seriesBookPage" class="material-icons px-2" @click="bookshelfListView = !bookshelfListView">{{ bookshelfListView ? 'view_list' : 'grid_view' }}</span>
<template v-if="page === 'library'">
<!-- <span class="material-icons px-2" @click="changeView">{{ viewIcon }}</span> -->
<div class="relative flex items-center px-2">
<span class="material-icons" @click="showFilterModal = true">filter_alt</span>
<div v-show="hasFilters" class="absolute top-0 right-2 w-2 h-2 rounded-full bg-success border border-green-300 shadow-sm z-10 pointer-events-none" />
@@ -31,11 +31,19 @@ export default {
showSortModal: false,
showFilterModal: false,
settings: {},
isListView: false,
totalEntities: 0
}
},
computed: {
bookshelfListView: {
get() {
return this.$store.state.globals.bookshelfListView
},
set(val) {
this.$localStore.setBookshelfListView(val)
this.$store.commit('globals/setBookshelfListView', val)
}
},
hasFilters() {
return this.$store.getters['user/getUserSetting']('mobileFilterBy') !== 'all'
},
@@ -43,10 +51,14 @@ export default {
var routeName = this.$route.name || ''
return routeName.split('-')[1]
},
seriesBookPage() {
return this.$route.name == 'bookshelf-series-id'
},
routeQuery() {
return this.$route.query || {}
},
entityTitle() {
if (this.isPodcast) return 'Podcasts'
if (this.page === 'library') return 'Books'
else if (this.page === 'series') {
return 'Series'
@@ -56,23 +68,16 @@ export default {
return ''
},
selectedSeriesName() {
if (this.page === 'series' && this.$route.params.id) {
return this.$decode(this.$route.params.id)
if (this.page === 'series' && this.$route.params.id && this.$store.state.globals.series) {
return this.$store.state.globals.series.name
}
return null
},
viewIcon() {
return this.isListView ? 'grid_view' : 'view_stream'
isPodcast() {
return this.$store.getters['libraries/getCurrentLibraryMediaType'] === 'podcast'
}
},
methods: {
changeView() {
this.isListView = !this.isListView
var bookshelfView = this.isListView ? 'list' : 'grid'
this.$localStore.setBookshelfView(bookshelfView)
this.$store.commit('setBookshelfView', bookshelfView)
},
updateOrder() {
this.saveSettings()
},
@@ -81,15 +86,12 @@ export default {
},
saveSettings() {
this.$store.commit('user/setSettings', this.settings) // Immediate update
this.$store.dispatch('user/updateUserSettings', this.settings)
this.$store.dispatch('user/updateUserSettings', this.settings) // TODO: No need to update settings on server...
},
async init() {
this.bookshelfListView = await this.$localStore.getBookshelfListView()
this.settings = { ...this.$store.state.user.settings }
var bookshelfView = await this.$localStore.getBookshelfView()
this.isListView = bookshelfView === 'list'
this.bookshelfReady = true
this.$store.commit('setBookshelfView', bookshelfView)
},
settingsUpdated(settings) {
for (const key in settings) {
+23
View File
@@ -0,0 +1,23 @@
<template>
<svg fill="currentColor" class="h-full w-full p-px" viewBox="0 0 1978.03 2349.44">
<path
d="M2519.5,1438.39c-12.13-10.1-31-25-56.57-42.62V1197.31c0-505.94-410.15-916.09-916.1-916.09h0c-505.94,0-916.09,410.15-916.09,916.09v198.46c-25.57,17.66-44.44,32.52-56.57,42.62a45.45,45.45,0,0,0-16.35,34.95v237.74a45.45,45.45,0,0,0,16.35,35c28.28,23.54,93.18,72.92,194.22,123.55v23.11c0,62.32,40.21,112.85,89.8,112.85h0c49.59,0,89.8-50.53,89.8-112.85V1322.51c0-62.33-40.21-112.86-89.8-112.86h0c-47.51,0-86.4,46.38-89.58,105.07l-.22.11V1197.31c0-429.92,348.52-778.43,778.44-778.43h0c429.92,0,778.44,348.51,778.44,778.43v117.52l-.22-.11c-3.18-58.69-42.06-105.07-89.58-105.07h0c-49.59,0-89.79,50.53-89.79,112.86v570.18c0,62.32,40.2,112.85,89.79,112.85h0c49.6,0,89.8-50.53,89.8-112.85v-23.11c101.05-50.63,165.95-100,194.23-123.55a45.48,45.48,0,0,0,16.35-35V1473.34A45.48,45.48,0,0,0,2519.5,1438.39Z"
transform="translate(-557.82 -281.22)"
/>
<path d="M1227.4,2429.63a108.47,108.47,0,0,0,108.47-108.47V1106.56A108.47,108.47,0,0,0,1227.4,998.08H1115.33a108.48,108.48,0,0,0-108.48,108.48v1214.6a108.47,108.47,0,0,0,108.48,108.47ZM1047.75,1289.38H1295v25.83H1047.75Z" transform="translate(-557.82 -281.22)" />
<path d="M1602.87,2429.63a108.47,108.47,0,0,0,108.47-108.47V1106.56a108.47,108.47,0,0,0-108.47-108.48H1490.8a108.48,108.48,0,0,0-108.48,108.48v1214.6a108.47,108.47,0,0,0,108.48,108.47ZM1423.22,1289.38h247.22v25.83H1423.22Z" transform="translate(-557.82 -281.22)" />
<path d="M1978.34,2429.63a108.47,108.47,0,0,0,108.47-108.47V1106.56a108.47,108.47,0,0,0-108.47-108.48H1866.27a108.48,108.48,0,0,0-108.48,108.48v1214.6a108.47,108.47,0,0,0,108.48,108.47ZM1798.69,1289.38h247.22v25.83H1798.69Z" transform="translate(-557.82 -281.22)" />
<rect x="180.05" y="2185.95" width="1617.93" height="163.49" rx="81.74" />
</svg>
</template>
<script>
export default {
data() {
return {}
},
computed: {},
methods: {},
mounted() {}
}
</script>
+16
View File
@@ -0,0 +1,16 @@
<template>
<svg fill="currentColor" viewBox="0 0 24 24">
<path fill="currentColor" d="M9 3V18H12V3H9M12 5L16 18L19 17L15 4L12 5M5 5V18H8V5H5M3 19V21H21V19H3Z" />
</svg>
</template>
<script>
export default {
data() {
return {}
},
computed: {},
methods: {},
mounted() {}
}
</script>
+16
View File
@@ -0,0 +1,16 @@
<template>
<svg viewBox="0 0 24 24">
<path fill="currentColor" d="M6,19L9,15.14L11.14,17.72L14.14,13.86L18,19H6M6,4H11V12L8.5,10.5L6,12M18,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V4A2,2 0 0,0 18,2Z" />
</svg>
</template>
<script>
export default {
data() {
return {}
},
computed: {},
methods: {},
mounted() {}
}
</script>
+16
View File
@@ -0,0 +1,16 @@
<template>
<svg class="p-px" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
</svg>
</template>
<script>
export default {
data() {
return {}
},
computed: {},
methods: {},
mounted() {}
}
</script>
+19
View File
@@ -0,0 +1,19 @@
<template>
<svg class="p-px" viewBox="0 0 122.877 120.596">
<path
fill="currentColor"
d="M68.925,69.906v50.689H53.953V69.906c-4.918-2.662-8.259-7.867-8.259-13.854 c0-8.694,7.05-15.744,15.745-15.744c8.694,0,15.745,7.05,15.745,15.744C77.184,62.039,73.843,67.244,68.925,69.906L68.925,69.906z M39.32,11.165c2.916-1.438,4.111-4.969,2.673-7.882c-1.438-2.914-4.966-4.111-7.88-2.674C22.213,6.479,12.958,16.19,7.11,27.625 c-4.32,8.445-6.783,17.842-7.08,27.325c-0.299,9.563,1.587,19.223,5.973,28.114c5.401,10.953,14.558,20.695,28.039,27.592 c2.889,1.477,6.429,0.33,7.905-2.559c1.477-2.889,0.331-6.428-2.558-7.904c-11.037-5.645-18.486-13.525-22.833-22.334 c-3.506-7.111-5.014-14.857-4.774-22.539c0.243-7.757,2.256-15.442,5.79-22.348C22.304,23.721,29.76,15.879,39.32,11.165 L39.32,11.165z M88.765,0.608c-2.914-1.438-6.443-0.24-7.881,2.674c-1.438,2.914-0.242,6.445,2.674,7.882 c9.561,4.715,17.017,12.556,21.747,21.808c3.533,6.905,5.547,14.59,5.789,22.348c0.24,7.682-1.268,15.428-4.773,22.539 c-4.347,8.809-11.796,16.689-22.833,22.334c-2.889,1.477-4.034,5.016-2.558,7.904c1.476,2.889,5.016,4.035,7.905,2.559 c13.48-6.896,22.638-16.639,28.039-27.592c4.386-8.891,6.272-18.551,5.973-28.114c-0.297-9.483-2.76-18.88-7.079-27.325 C109.919,16.19,100.665,6.479,88.765,0.608L88.765,0.608z M82.791,26.505c-2.195-1.581-5.256-1.082-6.837,1.113 c-1.58,2.195-1.082,5.256,1.113,6.837c0.885,0.637,1.753,1.352,2.604,2.134c4.971,4.583,7.919,10.694,8.538,17.16 c0.626,6.524-1.111,13.437-5.518,19.552c-0.748,1.039-1.61,2.092-2.585,3.15c-1.835,1.992-1.708,5.098,0.287,6.932 c1.994,1.834,5.099,1.705,6.933-0.287c1.18-1.279,2.286-2.641,3.315-4.072c5.862-8.139,8.166-17.4,7.322-26.197 c-0.848-8.853-4.871-17.208-11.648-23.457C85.249,28.387,84.074,27.431,82.791,26.505L82.791,26.505z M45.81,34.458 c2.195-1.581,2.694-4.642,1.113-6.837c-1.581-2.195-4.642-2.694-6.837-1.114c-1.284,0.926-2.458,1.882-3.524,2.864 c-6.778,6.25-10.801,14.604-11.649,23.457c-0.844,8.796,1.46,18.06,7.323,26.199c1.031,1.43,2.136,2.791,3.315,4.07 c1.834,1.992,4.939,2.121,6.932,0.287c1.996-1.834,2.123-4.939,0.288-6.932c-0.975-1.059-1.837-2.111-2.585-3.15 c-4.406-6.115-6.144-13.027-5.518-19.551c0.619-6.465,3.567-12.577,8.538-17.16C44.058,35.81,44.926,35.095,45.81,34.458 L45.81,34.458z"
/>
</svg>
</template>
<script>
export default {
data() {
return {}
},
computed: {},
methods: {},
mounted() {}
}
</script>
+60 -21
View File
@@ -45,6 +45,7 @@
</template>
<script>
import { Dialog } from '@capacitor/dialog'
export default {
props: {
value: Boolean,
@@ -56,7 +57,7 @@ export default {
type: Number,
default: 0
},
audiobookId: String
libraryItemId: String
},
data() {
return {
@@ -96,40 +97,78 @@ export default {
this.newBookmarkTitle = bm.title
this.showBookmarkTitleInput = true
},
deleteBookmark(bm) {
var bookmark = { ...bm, audiobookId: this.audiobookId }
this.$server.socket.emit('delete_bookmark', bookmark)
async deleteBookmark(bm) {
const { value } = await Dialog.confirm({
title: 'Remove Bookmark',
message: `Are you sure you want to remove bookmark?`
})
if (!value) return
this.$axios
.$delete(`/api/me/item/${this.libraryItemId}/bookmark/${bm.time}`)
.then(() => {
this.$toast.success('Bookmark removed')
})
.catch((error) => {
this.$toast.error(`Failed to remove bookmark`)
console.error(error)
})
this.show = false
},
clickBookmark(bm) {
this.$emit('select', bm)
},
submitUpdateBookmark(updatedBookmark) {
var bookmark = { ...updatedBookmark }
this.$axios
.$patch(`/api/me/item/${this.libraryItemId}/bookmark`, bookmark)
.then(() => {
this.$toast.success('Bookmark updated')
})
.catch((error) => {
this.$toast.error(`Failed to update bookmark`)
console.error(error)
})
this.show = false
},
submitCreateBookmark() {
if (!this.newBookmarkTitle) {
this.newBookmarkTitle = this.$formatDate(Date.now(), 'MMM dd, yyyy HH:mm')
}
var bookmark = {
title: this.newBookmarkTitle,
time: Math.floor(this.currentTime)
}
this.$axios
.$post(`/api/me/item/${this.libraryItemId}/bookmark`, bookmark)
.then(() => {
this.$toast.success('Bookmark added')
})
.catch((error) => {
this.$toast.error(`Failed to create bookmark`)
console.error(error)
})
this.newBookmarkTitle = ''
this.showBookmarkTitleInput = false
this.show = false
},
createBookmark() {
this.selectedBookmark = null
this.newBookmarkTitle = this.$formatDate(Date.now(), 'MMM dd, yyyy HH:mm')
this.showBookmarkTitleInput = true
},
submitBookmark() {
console.log(`[BookmarksModal] Submit Bookmark ${this.newBookmarkTitle}/${this.audiobookId}`)
if (this.selectedBookmark) {
if (this.selectedBookmark.title !== this.newBookmarkTitle) {
var bookmark = { ...this.selectedBookmark }
bookmark.audiobookId = this.audiobookId
bookmark.title = this.newBookmarkTitle
console.log(`[BookmarksModal] Update Bookmark ${JSON.stringify(bookmark)}`)
this.$server.socket.emit('update_bookmark', bookmark)
var updatePayload = {
...this.selectedBookmark,
title: this.newBookmarkTitle
}
this.submitUpdateBookmark(updatePayload)
} else {
var bookmark = {
audiobookId: this.audiobookId,
title: this.newBookmarkTitle,
time: this.currentTime
}
console.log(`[BookmarksModal] Create Bookmark ${JSON.stringify(bookmark)}`)
this.$server.socket.emit('create_bookmark', bookmark)
this.submitCreateBookmark()
}
this.newBookmarkTitle = ''
this.showBookmarkTitleInput = false
this.show = false
}
},
mounted() {}
+55
View File
@@ -0,0 +1,55 @@
<template>
<modals-modal v-model="show" :width="300" height="100%">
<template #outer>
<div v-if="title" class="absolute top-7 left-4 z-40" style="max-width: 80%">
<p class="text-white text-lg truncate">{{ title }}</p>
</div>
</template>
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
<div ref="container" class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<template v-for="item in items">
<li :key="item.value" class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption(item.value)">
<div class="relative flex items-center px-3">
<p class="font-normal block truncate text-base text-white text-opacity-80">{{ item.text }}</p>
</div>
</li>
</template>
</ul>
</div>
</div>
</modals-modal>
</template>
<script>
export default {
props: {
value: Boolean,
title: String,
items: {
type: Array,
default: () => []
}
},
data() {
return {}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
}
},
methods: {
clickedOption(action) {
this.$emit('action', action)
}
},
mounted() {}
}
</script>
-254
View File
@@ -1,254 +0,0 @@
<template>
<modals-modal v-model="show" width="100%" height="100%">
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
<p class="absolute top-6 left-2 text-2xl">Downloads</p>
<div class="absolute top-16 left-0 right-0 w-full px-2 py-1" :class="hasStoragePermission ? '' : 'text-error'">
<div class="flex items-center">
<span class="material-icons" @click="changeDownloadFolderClick">{{ hasStoragePermission ? 'folder' : 'error' }}</span>
<p v-if="hasStoragePermission" class="text-sm px-4" @click="changeDownloadFolderClick">{{ downloadFolderSimplePath || 'No Download Folder Selected' }}</p>
<p v-else class="text-sm px-4" @click="changeDownloadFolderClick">No Storage Permissions. Click here</p>
</div>
<!-- <p v-if="hasStoragePermission" class="text-xs text-gray-400 break-all max-w-full">{{ downloadFolderUri }}</p> -->
</div>
<div v-if="totalSize" class="absolute bottom-0 left-0 right-0 w-full py-3 text-center">
<p class="text-sm text-center text-gray-300">Total: {{ $bytesPretty(totalSize) }}</p>
</div>
<div v-if="downloadFolder && hasStoragePermission" class="w-full relative mt-10" @click.stop>
<div class="w-full h-10 relative">
<div class="absolute top-px left-0 z-10 w-full h-full flex">
<div class="flex-grow h-full bg-primary rounded-t-md mr-px" @click="showingDownloads = true">
<div class="flex items-center justify-center rounded-t-md border-t border-l border-r border-white border-opacity-20 h-full" :class="showingDownloads ? 'text-gray-100' : 'border-b bg-black bg-opacity-20 text-gray-400'">
<p>Downloads</p>
</div>
</div>
<div class="flex-grow h-full bg-primary rounded-t-md ml-px" @click="showingDownloads = false">
<div class="flex items-center justify-center h-full rounded-t-md border-t border-l border-r border-white border-opacity-20" :class="!showingDownloads ? 'text-gray-100' : 'border-b bg-black bg-opacity-20 text-gray-400'">
<p>Files</p>
</div>
</div>
</div>
</div>
<div class="list-content-body relative w-full overflow-x-hidden overflow-y-auto bg-primary rounded-b-lg border border-white border-opacity-20" style="max-height: 70vh; height: 70vh">
<template v-if="showingDownloads">
<div v-if="!totalDownloads" class="flex items-center justify-center h-40">
<p>No Downloads</p>
</div>
<ul v-else class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<template v-for="download in downloadsDownloading">
<li :key="download.id" class="text-gray-400 select-none relative px-4 py-5 border-b border-white border-opacity-10 bg-black bg-opacity-10">
<div class="flex items-center justify-center">
<div class="w-3/4">
<span class="text-xs">({{ downloadingProgress[download.id] || 0 }}%) {{ download.isPreparing ? 'Preparing' : 'Downloading' }}...</span>
<p class="font-normal truncate text-sm">{{ download.audiobook.book.title }}</p>
</div>
<div class="flex-grow" />
<div class="shadow-sm text-white flex items-center justify-center rounded-full animate-spin">
<span class="material-icons">refresh</span>
</div>
</div>
</li>
</template>
<template v-for="download in downloadsReady">
<li :key="download.id" class="text-gray-50 select-none relative pr-4 pl-2 py-5 border-b border-white border-opacity-10" @click="jumpToAudiobook(download)">
<modals-downloads-download-item :download="download" @play="playDownload" @delete="clickDeleteDownload" />
</li>
</template>
</ul>
</template>
<template v-else>
<div class="w-full h-full">
<div class="w-full flex justify-around py-4 px-2">
<ui-btn small @click="searchFolder">Re-Scan</ui-btn>
<ui-btn small @click="changeDownloadFolderClick">Change Folder</ui-btn>
<ui-btn small color="error" @click="resetFolder">Reset</ui-btn>
</div>
<p v-if="isScanning" class="text-center my-8">Scanning Folder..</p>
<p v-else-if="!mediaScanResults" class="text-center my-8">No Files Found</p>
<template v-else>
<template v-for="mediaFolder in mediaScanResults.folders">
<div :key="mediaFolder.uri" class="w-full px-2 py-2">
<div class="flex items-center">
<span class="material-icons text-base text-white text-opacity-50">folder</span>
<p class="ml-1 py-0.5">{{ mediaFolder.name }}</p>
</div>
<div v-for="mediaFile in mediaFolder.files" :key="mediaFile.uri" class="ml-3 flex items-center">
<span class="material-icons text-base text-white text-opacity-50">{{ mediaFile.isAudio ? 'music_note' : 'image' }}</span>
<p class="ml-1 py-0.5">{{ mediaFile.name }}</p>
</div>
</div>
</template>
<template v-for="mediaFile in mediaScanResults.files">
<div :key="mediaFile.uri" class="w-full px-2 py-2">
<div class="flex items-center">
<span class="material-icons text-base text-white text-opacity-50">{{ mediaFile.isAudio ? 'music_note' : 'image' }}</span>
<p class="ml-1 py-0.5">{{ mediaFile.name }}</p>
</div>
</div>
</template>
</template>
</div>
</template>
</div>
</div>
<div v-else class="list-content-body relative w-full overflow-x-hidden overflow-y-auto bg-primary rounded-b-lg border border-white border-opacity-20 py-8 px-4" @click.stop>
<ui-btn class="w-full" color="info" @click="changeDownloadFolderClick">Select Folder</ui-btn>
</div>
</div>
</modals-modal>
</template>
<script>
import { Dialog } from '@capacitor/dialog'
import AudioDownloader from '@/plugins/audio-downloader'
import StorageManager from '@/plugins/storage-manager'
export default {
data() {
return {
downloadingProgress: {},
totalSize: 0,
showingDownloads: true,
isScanning: false
}
},
watch: {
async show(newValue) {
if (newValue) {
await this.$localStore.getDownloadFolder()
this.setTotalSize()
}
}
},
computed: {
show: {
get() {
return this.$store.state.downloads.showModal
},
set(val) {
this.$store.commit('downloads/setShowModal', val)
}
},
hasStoragePermission() {
return this.$store.state.hasStoragePermission
},
downloadFolder() {
return this.$store.state.downloadFolder
},
downloadFolderSimplePath() {
return this.downloadFolder ? this.downloadFolder.simplePath : null
},
downloadFolderUri() {
return this.downloadFolder ? this.downloadFolder.uri : null
},
totalDownloads() {
return this.downloadsReady.length + this.downloadsDownloading.length
},
downloadsDownloading() {
return this.downloads.filter((d) => d.isDownloading || d.isPreparing)
},
downloadsReady() {
return this.downloads.filter((d) => !d.isDownloading && !d.isPreparing)
},
downloads() {
return this.$store.state.downloads.downloads
},
mediaScanResults() {
return this.$store.state.downloads.mediaScanResults
}
},
methods: {
setTotalSize() {
var totalSize = 0
this.downloadsReady.forEach((dl) => {
totalSize += dl.size && !isNaN(dl.size) ? Number(dl.size) : 0
})
this.totalSize = totalSize
},
async changeDownloadFolderClick() {
if (!this.hasStoragePermission) {
console.log('Requesting Storage Permission')
StorageManager.requestStoragePermission()
} else {
var folderObj = await StorageManager.selectFolder()
if (folderObj.error) {
return this.$toast.error(`Error: ${folderObj.error || 'Unknown Error'}`)
}
var permissionsGood = await StorageManager.checkFolderPermissions({ folderUrl: folderObj.uri })
console.log('Storage Permission check folder ' + permissionsGood)
if (!permissionsGood) {
this.$toast.error('Folder permissions failed')
return
} else {
this.$toast.success('Folder permission success')
}
await this.$localStore.setDownloadFolder(folderObj)
this.searchFolder()
}
},
async searchFolder() {
this.isScanning = true
var response = await StorageManager.searchFolder({ folderUrl: this.downloadFolderUri })
var searchResults = response
searchResults.folders = JSON.parse(searchResults.folders)
searchResults.files = JSON.parse(searchResults.files)
if (searchResults.folders.length) {
console.log('Search results folders length', searchResults.folders.length)
searchResults.folders = searchResults.folders.map((sr) => {
if (sr.files) {
sr.files = JSON.parse(sr.files)
}
return sr
})
this.$store.commit('downloads/setMediaScanResults', searchResults)
} else {
this.$toast.warning('No audio or image files found')
}
this.isScanning = false
},
async resetFolder() {
await this.$localStore.setDownloadFolder(null)
this.$store.commit('downloads/setMediaScanResults', {})
this.$toast.info('Unlinked Folder')
},
updateDownloadProgress({ audiobookId, progress }) {
this.$set(this.downloadingProgress, audiobookId, progress)
},
jumpToAudiobook(download) {
this.show = false
this.$router.push(`/audiobook/${download.id}`)
},
async clickDeleteDownload(download) {
const { value } = await Dialog.confirm({
title: 'Confirm',
message: 'Delete this download?'
})
if (value) {
this.$emit('deleteDownload', download)
}
},
playDownload(download) {
this.$store.commit('setPlayOnLoad', true)
this.$store.commit('setPlayingDownload', download)
this.show = false
}
},
mounted() {}
}
</script>
<style>
.list-content-body {
max-height: calc(75% - 40px);
}
</style>
+35 -5
View File
@@ -56,7 +56,7 @@ export default {
data() {
return {
sublist: null,
items: [
bookItems: [
{
text: 'All',
value: 'all'
@@ -96,6 +96,22 @@ export default {
value: 'issues',
sublist: false
}
],
podcastItems: [
{
text: 'All',
value: 'all'
},
{
text: 'Genre',
value: 'genres',
sublist: true
},
{
text: 'Tag',
value: 'tags',
sublist: true
}
]
}
},
@@ -124,6 +140,13 @@ export default {
this.$emit('update:filterBy', val)
}
},
isPodcast() {
return this.$store.getters['libraries/getCurrentLibraryMediaType'] === 'podcast'
},
items() {
if (this.isPodcast) return this.podcastItems
return this.bookItems
},
selectedItemSublist() {
return this.selected && this.selected.includes('.') ? this.selected.split('.')[0] : false
},
@@ -143,13 +166,20 @@ export default {
return this.filterData.narrators || []
},
progress() {
return ['Read', 'Unread', 'In Progress']
return ['Finished', 'In Progress', 'Not Started']
},
sublistItems() {
return (this[this.sublist] || []).map((item) => {
return {
text: item,
value: this.$encode(item)
if (typeof item === 'string') {
return {
text: item,
value: this.$encode(item)
}
} else {
return {
text: item.name,
value: this.$encode(item.id)
}
}
})
},
+2 -5
View File
@@ -13,10 +13,7 @@
<li :key="library.id" class="text-gray-50 select-none relative py-3 cursor-pointer hover:bg-black-400" :class="currentLibraryId === library.id ? 'bg-bg bg-opacity-80' : ''" role="option" @click="clickedOption(library)">
<div v-show="currentLibraryId === library.id" class="absolute top-0 left-0 w-0.5 bg-warning h-full" />
<div class="flex items-center px-3">
<svg 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="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
</svg>
<widgets-library-icon :icon="library.icon" />
<span class="font-normal block truncate text-lg ml-4">{{ library.name }}</span>
</div>
</li>
@@ -55,7 +52,7 @@ export default {
this.show = false
await this.$store.dispatch('libraries/fetch', lib.id)
this.$eventBus.$emit('library-changed', lib.id)
this.$localStore.setCurrentLibrary(lib)
this.$localStore.setLastLibraryId(lib.id)
}
},
mounted() {}
+39 -10
View File
@@ -26,38 +26,60 @@ export default {
},
data() {
return {
items: [
bookItems: [
{
text: 'Title',
value: 'book.title'
value: 'media.metadata.title'
},
{
text: 'Author (First Last)',
value: 'book.authorFL'
value: 'media.metadata.authorName'
},
{
text: 'Author (Last, First)',
value: 'book.authorLF'
value: 'media.metadata.authorNameLF'
},
{
text: 'Added At',
value: 'addedAt'
},
{
text: 'Volume #',
value: 'book.volumeNumber'
text: 'Size',
value: 'size'
},
{
text: 'Duration',
value: 'duration'
text: 'File Birthtime',
value: 'birthtimeMs'
},
{
text: 'File Modified',
value: 'mtimeMs'
}
],
podcastItems: [
{
text: 'Title',
value: 'media.metadata.title'
},
{
text: 'Author',
value: 'media.metadata.author'
},
{
text: 'Added At',
value: 'addedAt'
},
{
text: 'Size',
value: 'size'
},
{
text: 'Last Read',
value: 'recent'
text: 'File Birthtime',
value: 'birthtimeMs'
},
{
text: 'File Modified',
value: 'mtimeMs'
}
]
}
@@ -86,6 +108,13 @@ export default {
set(val) {
this.$emit('update:descending', val)
}
},
isPodcast() {
return this.$store.getters['libraries/getCurrentLibraryMediaType'] === 'podcast'
},
items() {
if (this.isPodcast) return this.podcastItems
return this.bookItems
}
},
methods: {
@@ -0,0 +1,62 @@
<template>
<modals-modal v-model="show" :width="300" height="100%">
<template #outer>
<div class="absolute top-7 left-4 z-40" style="max-width: 80%">
<p class="text-white text-lg truncate">Select Local Folder</p>
</div>
</template>
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
<div ref="container" class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<template v-for="folder in localFolders">
<li :key="folder.id" :id="`folder-${folder.id}`" class="text-gray-50 select-none relative py-4" role="option" @click="clickedOption(folder)">
<div class="relative flex items-center pl-3" style="padding-right: 4.5rem">
<p class="font-normal block truncate text-sm text-white text-opacity-80">{{ folder.name }}</p>
</div>
</li>
</template>
</ul>
</div>
</div>
</modals-modal>
</template>
<script>
export default {
props: {
value: Boolean,
mediaType: String
},
data() {
return {
localFolders: []
}
},
watch: {
value(newVal) {
this.$nextTick(this.init)
}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
}
},
methods: {
clickedOption(folder) {
this.$emit('select', folder)
},
async init() {
var localFolders = (await this.$db.getLocalFolders()) || []
this.localFolders = localFolders.filter((lf) => lf.mediaType == this.mediaType)
}
},
mounted() {}
}
</script>
+5 -5
View File
@@ -1,11 +1,11 @@
<template>
<div :key="bookmark.id" :id="`bookmark-row-${bookmark.id}`" class="flex items-center px-4 py-4 justify-start cursor-pointer hover:bg-bg relative" :class="highlight ? 'bg-bg bg-opacity-60' : ' bg-opacity-20'" @click="click">
<span class="material-icons" :class="highlight ? 'text-success' : 'text-white text-opacity-60'">{{ highlight ? 'bookmark' : 'bookmark_border' }}</span>
<div :key="bookmark.id" :id="`bookmark-row-${bookmark.id}`" class="flex items-center px-3 py-4 justify-start cursor-pointer hover:bg-bg relative" :class="highlight ? 'bg-bg bg-opacity-60' : ' bg-opacity-20'" @click="click">
<span class="material-icons text-xl" :class="highlight ? 'text-success' : 'text-white text-opacity-60'">{{ highlight ? 'bookmark' : 'bookmark_border' }}</span>
<div class="flex-grow overflow-hidden">
<p class="pl-2 pr-2 truncate">{{ bookmark.title }}</p>
<p class="pl-2 pr-2 truncate text-sm">{{ bookmark.title }}</p>
</div>
<div class="h-full flex items-center w-16 justify-end">
<span class="font-mono text-sm text-gray-300">{{ $secondsToTimestamp(bookmark.time) }}</span>
<div class="h-full flex items-center w-14 justify-end">
<span class="font-mono text-xs text-gray-300">{{ $secondsToTimestamp(bookmark.time) }}</span>
</div>
<div class="h-full flex items-center justify-end transform w-16">
<span class="material-icons text-lg mr-2 text-gray-200 hover:text-yellow-400" @click.stop="editClick">edit</span>
+4 -4
View File
@@ -8,8 +8,8 @@
<div v-show="showInfoMenu" v-click-outside="clickOutside" class="pagemenu absolute top-20 right-0 rounded-md overflow-y-auto bg-bg shadow-lg z-20 border border-gray-400 w-full" style="top: 72px">
<div v-for="key in comicMetadataKeys" :key="key" class="w-full px-2 py-1">
<p class="text-xs">
<strong>{{ key }}</strong
>: {{ comicMetadata[key] }}
<strong>{{ key }}</strong>
: {{ comicMetadata[key] }}
</p>
</div>
</div>
@@ -210,10 +210,10 @@ export default {
<style scoped>
#comic-reader {
height: calc(100vh - 32px);
height: calc(100% - 32px);
}
.pagemenu {
max-height: calc(100vh - 80px);
max-height: calc(100% - 80px);
}
.comicimg {
height: 100%;
+48 -33
View File
@@ -13,8 +13,6 @@
export default {
data() {
return {
ebookType: null,
ebookUrl: null,
touchstartX: 0,
touchendX: 0
}
@@ -23,7 +21,6 @@ export default {
show: {
handler(newVal) {
if (newVal) {
this.init()
this.registerListeners()
} else {
this.unregisterListeners()
@@ -41,10 +38,16 @@ export default {
}
},
title() {
return this.selectedBook ? this.selectedBook.book.title : null
return this.mediaMetadata.title || 'No Title'
},
selectedBook() {
return this.$store.state.selectedBook
selectedLibraryItem() {
return this.$store.state.selectedLibraryItem
},
media() {
return this.selectedLibraryItem ? this.selectedLibraryItem.media : null
},
mediaMetadata() {
return this.media ? this.media.metadata || {} : {}
},
readerComponentName() {
if (this.ebookType === 'epub') return 'readers-epub-reader'
@@ -52,40 +55,52 @@ export default {
else if (this.ebookType === 'comic') return 'readers-comic-reader'
return null
},
ebook() {
if (!this.selectedBook || !this.selectedBook.ebooks || !this.selectedBook.ebooks.length) return null
return this.selectedBook.ebooks[0]
},
ebookPath() {
return this.ebook ? this.ebook.path : null
},
folderId() {
return this.selectedBook ? this.selectedBook.folderId : null
return this.selectedLibraryItem ? this.selectedLibraryItem.folderId : null
},
libraryId() {
return this.selectedBook ? this.selectedBook.libraryId : null
return this.selectedLibraryItem ? this.selectedLibraryItem.libraryId : null
},
ebookRelPath() {
return `/ebook/${this.libraryId}/${this.folderId}/${this.ebookPath}`
ebookFile() {
return this.media ? this.media.ebookFile : null
},
ebookFormat() {
if (!this.ebookFile) return null
return this.ebookFile.ebookFormat
},
ebookType() {
if (this.isMobi) return 'mobi'
else if (this.isEpub) return 'epub'
else if (this.isPdf) return 'pdf'
else if (this.isComic) return 'comic'
return null
},
isEpub() {
return this.ebookFormat == 'epub'
},
isMobi() {
return this.ebookFormat == 'mobi' || this.ebookFormat == 'azw3'
},
isPdf() {
return this.ebookFormat == 'pdf'
},
isComic() {
return this.ebookFormat == 'cbz' || this.ebookFormat == 'cbr'
},
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)
var serverAddress = this.$store.getters['user/getServerAddress']
return `${serverAddress}/ebook/${this.libraryId}/${this.folderId}/${itemRelPath}/${relPath}`
}
},
methods: {
init() {
if (!this.ebook) {
console.error('No ebook for book', this.selectedBook)
return
}
if (this.ebook.ext === '.epub') {
this.ebookType = 'epub'
} else if (this.ebook.ext === '.mobi' || this.ebook.ext === '.azw3') {
this.ebookType = 'mobi'
} else if (this.ebook.ext === '.cbr' || this.ebook.ext === '.cbz') {
this.ebookType = 'comic'
}
var serverUrl = this.$store.state.serverUrl
this.ebookUrl = `${serverUrl}${this.ebookRelPath}`
},
next() {
if (this.$refs.readerComponent && this.$refs.readerComponent.next) {
this.$refs.readerComponent.next()
+22 -70
View File
@@ -2,12 +2,12 @@
<div class="w-full px-2 py-2 overflow-hidden relative">
<div v-if="book" class="flex h-20">
<div class="h-full relative" :style="{ width: bookWidth + 'px' }">
<covers-book-cover :audiobook="book" :width="bookWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<covers-book-cover :library-item="book" :width="bookWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</div>
<div class="w-80 h-full px-2 flex items-center">
<div>
<nuxt-link :to="`/audiobook/${book.id}`" class="truncate hover:underline">{{ bookTitle }}</nuxt-link>
<nuxt-link :to="`/bookshelf/library?filter=authors.${$encode(bookAuthor)}`" class="truncate block text-gray-400 text-sm hover:underline">{{ bookAuthor }}</nuxt-link>
<nuxt-link :to="`/item/${book.id}`" class="truncate text-sm">{{ bookTitle }}</nuxt-link>
<p class="truncate block text-gray-400 text-xs">{{ bookAuthor }}</p>
</div>
</div>
</div>
@@ -29,15 +29,25 @@ export default {
processingRemove: false
}
},
watch: {
userIsRead: {
immediate: true,
handler(newVal) {
this.isRead = newVal
}
}
},
computed: {
media() {
return this.book.media || {}
},
mediaMetadata() {
return this.media.metadata || {}
},
tracks() {
return this.media.tracks || []
},
bookTitle() {
return this.mediaMetadata.title || ''
},
bookAuthor() {
return this.mediaMetadata.authorName || ''
},
bookDuration() {
return this.$secondsToTimestamp(this.media.duration)
},
bookCoverAspectRatio() {
return this.$store.getters['getBookCoverAspectRatio']
},
@@ -45,18 +55,6 @@ export default {
if (this.bookCoverAspectRatio === 1) return 80
return 50
},
_book() {
return this.book.book || {}
},
bookTitle() {
return this._book.title || ''
},
bookAuthor() {
return this._book.authorFL || ''
},
bookDuration() {
return this.$secondsToTimestamp(this.book.duration)
},
isMissing() {
return this.book.isMissing
},
@@ -67,61 +65,15 @@ export default {
return this.book.numTracks
},
isStreaming() {
return this.$store.getters['getAudiobookIdStreaming'] === this.book.id
return this.$store.getters['getIsItemStreaming'](this.book.id)
},
showPlayBtn() {
return !this.isMissing && !this.isIncomplete && !this.isStreaming && this.numTracks
},
userAudiobooks() {
return this.$store.state.user.user ? this.$store.state.user.user.audiobooks || {} : {}
},
userAudiobook() {
return this.userAudiobooks[this.book.id] || null
},
userIsRead() {
return this.userAudiobook ? !!this.userAudiobook.isRead : false
}
},
methods: {
playClick() {
// this.$store.commit('setStreamAudiobook', this.book)
// this.$root.socket.emit('open_stream', this.book.id)
},
clickEdit() {
this.$emit('edit', this.book)
},
toggleRead() {
var updatePayload = {
isRead: !this.isRead
}
this.isProcessingReadUpdate = true
this.$axios
.$patch(`/api/me/audiobook/${this.book.id}`, updatePayload)
.then(() => {
this.isProcessingReadUpdate = false
this.$toast.success(`"${this.bookTitle}" Marked as ${updatePayload.isRead ? 'Read' : 'Not Read'}`)
})
.catch((error) => {
console.error('Failed', error)
this.isProcessingReadUpdate = false
this.$toast.error(`Failed to mark as ${updatePayload.isRead ? 'Read' : 'Not Read'}`)
})
},
removeClick() {
this.processingRemove = true
this.$axios
.$delete(`/api/collections/${this.collectionId}/book/${this.book.id}`)
.then((updatedCollection) => {
console.log(`Book removed from collection`, updatedCollection)
this.$toast.success('Book removed from collection')
this.processingRemove = false
})
.catch((error) => {
console.error('Failed to remove book from collection', error)
this.$toast.error('Failed to remove book from collection')
this.processingRemove = false
})
}
},
mounted() {}
+251
View File
@@ -0,0 +1,251 @@
<template>
<div class="w-full px-0 py-4 overflow-hidden relative border-b border-white border-opacity-10">
<div v-if="episode" class="w-full px-1">
<!-- Help debug for testing -->
<!-- <template>
<p class="text-xs mb-1">{{ isLocal ? 'LOCAL' : 'NOT LOCAL' }}</p>
<p class="text-xs mb-4">Lid:{{ libraryItemId }}<br />Eid:{{ episode.id }}<br />LLid:{{ localLibraryItemId }}<br />LEid:{{ localEpisodeId }}</p>
<p v-if="itemProgress">Server Media Progress {{ Math.round(itemProgress.progress * 100) }}</p>
<p v-else>No Server Media Progress</p>
<p v-if="localMediaProgress">Local Media Progress {{ Math.round(localMediaProgress.progress * 100) }}</p>
<p v-else>No Local Media Progress</p>
</template> -->
<p v-if="publishedAt" class="text-xs text-gray-400 mb-1">Published {{ $formatDate(publishedAt, 'MMM do, yyyy') }}</p>
<p class="text-sm font-semibold">
{{ title }}
</p>
<p class="text-sm text-gray-200 episode-subtitle mt-1.5 mb-0.5">
{{ description }}
</p>
<div class="flex items-center pt-2">
<div class="h-8 px-4 border border-white border-opacity-20 hover:bg-white hover:bg-opacity-10 rounded-full flex items-center justify-center cursor-pointer" :class="userIsFinished ? 'text-white text-opacity-40' : ''" @click="playClick">
<span class="material-icons" :class="streamIsPlaying ? '' : 'text-success'">{{ streamIsPlaying ? 'pause' : 'play_arrow' }}</span>
<p class="pl-2 pr-1 text-sm font-semibold">{{ timeRemaining }}</p>
</div>
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" borderless class="mx-1 mt-0.5" @click="toggleFinished" />
<span v-if="isLocal" class="material-icons-outlined px-2 text-success text-lg">audio_file</span>
<span v-else-if="!localEpisode" class="material-icons px-2" :class="downloadItem ? 'animate-bounce text-warning text-opacity-75 text-xl' : 'text-gray-300 text-xl'" @click="downloadClick">{{ downloadItem ? 'downloading' : 'download' }}</span>
<span v-else class="material-icons px-2 text-success text-xl">download_done</span>
</div>
</div>
<div v-if="!userIsFinished" class="absolute bottom-0 left-0 h-0.5 bg-warning" :style="{ width: itemProgressPercent * 100 + '%' }" />
</div>
</template>
<script>
import { Dialog } from '@capacitor/dialog'
import { AbsDownloader } from '@/plugins/capacitor'
export default {
props: {
libraryItemId: String,
episode: {
type: Object,
default: () => {}
},
localLibraryItemId: String,
localEpisode: {
type: Object,
default: () => {}
},
isLocal: Boolean
},
data() {
return {
isProcessingReadUpdate: false
}
},
computed: {
mediaType() {
return 'podcast'
},
audioFile() {
return this.episode.audioFile
},
title() {
return this.episode.title || ''
},
description() {
if (this.episode.subtitle) return this.episode.subtitle
var desc = this.episode.description || ''
return desc
},
duration() {
return this.$secondsToTimestamp(this.episode.duration)
},
isStreaming() {
if (this.playerIsLocal && this.localLibraryItemId && this.localEpisode) {
// Check is streaming local version of this episode
return this.$store.getters['getIsEpisodeStreaming'](this.localLibraryItemId, this.localEpisode.id)
}
return this.$store.getters['getIsEpisodeStreaming'](this.libraryItemId, this.episode.id)
},
playerIsLocal() {
return !!this.$store.state.playerIsLocal
},
streamIsPlaying() {
return this.$store.state.playerIsPlaying && this.isStreaming
},
itemProgress() {
if (this.isLocal) return this.$store.getters['globals/getLocalMediaProgressById'](this.libraryItemId, this.episode.id)
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId, this.episode.id)
},
localMediaProgress() {
if (this.isLocal) return this.$store.getters['globals/getLocalMediaProgressById'](this.libraryItemId, this.episode.id)
else if (this.localLibraryItemId && this.localEpisode) {
return this.$store.getters['globals/getLocalMediaProgressById'](this.localLibraryItemId, this.localEpisode.id)
} else {
return null
}
},
itemProgressPercent() {
return this.itemProgress ? this.itemProgress.progress : 0
},
userIsFinished() {
return this.itemProgress ? !!this.itemProgress.isFinished : false
},
timeRemaining() {
if (this.streamIsPlaying) return 'Playing'
if (!this.itemProgressPercent) return this.$elapsedPretty(this.episode.duration)
if (this.userIsFinished) return 'Finished'
var remaining = Math.floor(this.itemProgress.duration - this.itemProgress.currentTime)
return `${this.$elapsedPretty(remaining)} left`
},
publishedAt() {
return this.episode.publishedAt
},
downloadItem() {
return this.$store.getters['globals/getDownloadItem'](this.libraryItemId, this.episode.id)
},
localEpisodeId() {
return this.localEpisode ? this.localEpisode.id : null
}
},
methods: {
selectFolder() {
this.$toast.error('Folder selector not implemented for podcasts yet')
},
downloadClick() {
if (this.downloadItem) return
this.download()
},
async download(selectedLocalFolder = null) {
var localFolder = selectedLocalFolder
if (!localFolder) {
var localFolders = (await this.$db.getLocalFolders()) || []
console.log('Local folders loaded', localFolders.length)
var foldersWithMediaType = localFolders.filter((lf) => {
console.log('Checking local folder', lf.mediaType)
return lf.mediaType == this.mediaType
})
console.log('Folders with media type', this.mediaType, foldersWithMediaType.length)
if (!foldersWithMediaType.length) {
// No local folders or no local folders with this media type
localFolder = await this.selectFolder()
} else if (foldersWithMediaType.length == 1) {
console.log('Only 1 local folder with this media type - auto select it')
localFolder = foldersWithMediaType[0]
} else {
console.log('Multiple folders with media type')
// this.showSelectLocalFolder = true
return
}
if (!localFolder) {
return this.$toast.error('Invalid download folder')
}
}
console.log('Local folder', JSON.stringify(localFolder))
var startDownloadMessage = `Start download for "${this.title}" to folder ${localFolder.name}?`
const { value } = await Dialog.confirm({
title: 'Confirm',
message: startDownloadMessage
})
if (value) {
this.startDownload(localFolder)
}
},
async startDownload(localFolder) {
var downloadRes = await AbsDownloader.downloadLibraryItem({ libraryItemId: this.libraryItemId, localFolderId: localFolder.id, episodeId: this.episode.id })
if (downloadRes && downloadRes.error) {
var errorMsg = downloadRes.error || 'Unknown error'
console.error('Download error', errorMsg)
this.$toast.error(errorMsg)
}
},
playClick() {
if (this.streamIsPlaying) {
this.$eventBus.$emit('pause-item')
} else {
if (this.localEpisode && this.localLibraryItemId) {
console.log('Play local episode', this.localEpisode.id, this.localLibraryItemId)
this.$eventBus.$emit('play-item', {
libraryItemId: this.localLibraryItemId,
episodeId: this.localEpisode.id
})
} else {
this.$eventBus.$emit('play-item', {
libraryItemId: this.libraryItemId,
episodeId: this.episode.id
})
}
}
},
async toggleFinished() {
this.isProcessingReadUpdate = true
if (this.isLocal || this.localEpisode) {
var isFinished = !this.userIsFinished
var localLibraryItemId = this.isLocal ? this.libraryItemId : this.localLibraryItemId
var localEpisodeId = this.isLocal ? this.episode.id : this.localEpisode.id
var localMediaProgressId = `${localLibraryItemId}-${localEpisodeId}`
console.log('toggleFinished local media progress id', localMediaProgressId, isFinished)
var payload = await this.$db.updateLocalMediaProgressFinished({ localMediaProgressId, isFinished })
console.log('toggleFinished payload', JSON.stringify(payload))
if (!payload || payload.error) {
var errorMsg = payload ? payload.error : 'Unknown error'
this.$toast.error(errorMsg)
} else {
var localMediaProgress = payload.localMediaProgress
console.log('toggleFinished localMediaProgress', JSON.stringify(localMediaProgress))
if (localMediaProgress) {
this.$store.commit('globals/updateLocalMediaProgress', localMediaProgress)
}
var lmp = this.$store.getters['globals/getLocalMediaProgressById'](this.libraryItemId, this.episode.id)
console.log('toggleFinished Check LMP', this.libraryItemId, this.episode.id, JSON.stringify(lmp))
var serverUpdated = payload.server
if (serverUpdated) {
this.$toast.success(`Local & Server Item marked as ${isFinished ? 'Finished' : 'Not Finished'}`)
} else {
this.$toast.success(`Local Item marked as ${isFinished ? 'Finished' : 'Not Finished'}`)
}
}
this.isProcessingReadUpdate = false
} else {
var updatePayload = {
isFinished: !this.userIsFinished
}
this.$axios
.$patch(`/api/me/progress/${this.libraryItemId}/${this.episode.id}`, updatePayload)
.then(() => {
this.isProcessingReadUpdate = false
this.$toast.success(`Item marked as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
})
.catch((error) => {
console.error('Failed', error)
this.isProcessingReadUpdate = false
this.$toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
})
}
}
}
}
</script>
@@ -0,0 +1,44 @@
<template>
<div class="w-full">
<p class="text-lg mb-1 font-semibold">Episodes ({{ episodes.length }})</p>
<template v-for="episode in episodes">
<tables-podcast-episode-row :episode="episode" :local-episode="localEpisodeMap[episode.id]" :library-item-id="libraryItemId" :local-library-item-id="localLibraryItemId" :is-local="isLocal" :key="episode.id" />
</template>
</div>
</template>
<script>
export default {
props: {
libraryItemId: String,
episodes: {
type: Array,
default: () => []
},
localLibraryItemId: String,
localEpisodes: {
type: Array,
default: () => []
},
isLocal: Boolean // If is local then episodes and libraryItemId are local, otherwise local is passed in localLibraryItemId and localEpisodes
},
data() {
return {}
},
computed: {
// Map of local episodes where server episode id is key
localEpisodeMap() {
var epmap = {}
this.localEpisodes.forEach((localEp) => {
if (localEp.serverEpisodeId) {
epmap[localEp.serverEpisodeId] = localEp
}
})
return epmap
}
},
methods: {},
mounted() {}
}
</script>
+15 -4
View File
@@ -1,8 +1,15 @@
<template>
<button class="btn outline-none rounded-md shadow-md relative border border-gray-600" :disabled="disabled || loading" :type="type" :class="classList" @click="click">
<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">
<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>
</div>
</nuxt-link>
<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>
@@ -13,6 +20,7 @@
<script>
export default {
props: {
to: String,
color: {
type: String,
default: 'primary'
@@ -46,6 +54,9 @@ export default {
if (this.paddingX !== undefined) {
list.push(`px-${this.paddingX}`)
}
if (this.disabled) {
list.push('cursor-not-allowed')
}
return list
}
},
@@ -59,7 +70,7 @@ export default {
</script>
<style>
button.btn::before {
.btn::before {
content: '';
position: absolute;
border-radius: 6px;
@@ -70,7 +81,7 @@ button.btn::before {
background-color: rgba(255, 255, 255, 0);
transition: all 0.1s ease-in-out;
}
button.btn:hover:not(:disabled)::before {
.btn:hover:not(:disabled)::before {
background-color: rgba(255, 255, 255, 0.1);
}
button:disabled::before {
+71
View File
@@ -0,0 +1,71 @@
<template>
<label class="flex justify-start items-center" :class="!disabled ? 'cursor-pointer' : ''">
<div class="border-2 rounded flex flex-shrink-0 justify-center items-center" :class="wrapperClass">
<input v-model="selected" :disabled="disabled" type="checkbox" class="opacity-0 absolute" :class="!disabled ? 'cursor-pointer' : ''" />
<svg v-if="selected" class="fill-current pointer-events-none" :class="svgClass" viewBox="0 0 20 20"><path d="M0 11l2-2 5 5L18 3l2 2L7 18z" /></svg>
</div>
<div v-if="label" class="select-none text-gray-100" :class="labelClassname">{{ label }}</div>
</label>
</template>
<script>
export default {
props: {
value: Boolean,
label: String,
small: Boolean,
checkboxBg: {
type: String,
default: 'white'
},
borderColor: {
type: String,
default: 'gray-400'
},
checkColor: {
type: String,
default: 'green-500'
},
labelClass: {
type: String,
default: ''
},
disabled: Boolean
},
data() {
return {}
},
computed: {
selected: {
get() {
return this.value
},
set(val) {
this.$emit('input', !!val)
}
},
wrapperClass() {
var classes = [`bg-${this.checkboxBg} border-${this.borderColor}`]
if (this.small) classes.push('w-4 h-4')
else classes.push('w-6 h-6')
return classes.join(' ')
},
labelClassname() {
if (this.labelClass) return this.labelClass
var classes = ['pl-1']
if (this.small) classes.push('text-xs md:text-sm')
return classes.join(' ')
},
svgClass() {
var classes = [`text-${this.checkColor}`]
if (this.small) classes.push('w-3 h-3')
else classes.push('w-4 h-4')
return classes.join(' ')
}
},
methods: {},
mounted() {}
}
</script>
+94
View File
@@ -0,0 +1,94 @@
<template>
<div class="relative w-full" v-click-outside="clickOutsideObj">
<p class="text-sm font-semibold" :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">
<span class="flex items-center" :class="!selectedText ? 'text-gray-300' : 'text-white'">
<span class="block truncate" :class="small ? 'text-sm' : ''">{{ selectedText || placeholder || '' }}</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>
</button>
<transition name="menu">
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full bg-primary border border-gray-600 shadow-lg max-h-56 rounded-b-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox">
<template v-for="item in items">
<li :key="item.value" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="clickedOption(item.value)">
<div class="flex items-center">
<span class="font-normal ml-3 block truncate font-sans text-sm">{{ item.text }}</span>
</div>
</li>
</template>
</ul>
</transition>
</div>
</template>
<script>
export default {
props: {
value: [String, Number],
label: {
type: String,
default: ''
},
items: {
type: Array,
default: () => []
},
disabled: Boolean,
small: Boolean,
placeholder: String
},
data() {
return {
clickOutsideObj: {
handler: this.clickedOutside,
events: ['mousedown'],
isActive: true
},
showMenu: false
}
},
computed: {
selected: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
selectedItem() {
return this.items.find((i) => i.value === this.selected)
},
selectedText() {
return this.selectedItem ? this.selectedItem.text : ''
},
buttonClass() {
var classes = []
if (this.small) classes.push('h-9')
else classes.push('h-10')
if (this.disabled) classes.push('cursor-not-allowed border-gray-600 bg-primary bg-opacity-70 border-opacity-70 text-gray-400')
else classes.push('cursor-pointer border-gray-600 bg-primary text-gray-100')
return classes.join(' ')
}
},
methods: {
clickShowMenu() {
if (this.disabled) return
this.showMenu = !this.showMenu
},
clickedOutside() {
this.showMenu = false
},
clickedOption(itemValue) {
this.selected = itemValue
this.showMenu = false
}
},
mounted() {}
}
</script>

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