Compare commits

..
Author SHA1 Message Date
advplyr 8bc5932889 Version bump v0.9.76-beta 2024-08-13 10:26:08 -05:00
advplyrandGitHub 3cd597d71e Merge pull request #1286 from weblate/weblate-audiobookshelf-abs-mobile-app
Translations update from Hosted Weblate
2024-08-13 10:19:04 -05:00
Fredrik LindqvistandHosted Weblate 48d5ad462e Translated using Weblate (Swedish)
Currently translated at 81.1% (249 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/sv/
2024-08-13 17:18:39 +02:00
advplyr 40cab87640 Merge branch 'master' of https://github.com/advplyr/audiobookshelf-app 2024-08-13 10:18:28 -05:00
advplyr f405947ad8 Add:Random library sort #1285 2024-08-13 10:18:23 -05:00
advplyr fd550c15a5 iOS version bump 0.9.75 2024-08-12 17:38:44 -05:00
advplyr d18537cf04 Version bump v0.9.75-beta & targetSdkVersion bump to 34 2024-08-12 17:24:05 -05:00
advplyrandGitHub f01f0229a8 Merge pull request #1278 from weblate/weblate-audiobookshelf-abs-mobile-app
Translations update from Hosted Weblate
2024-08-12 16:40:47 -05:00
CharlieandHosted Weblate b369eb2d40 Translated using Weblate (French)
Currently translated at 100.0% (307 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/fr/
2024-08-12 23:34:46 +02:00
CharlieandHosted Weblate f08c65c5bf Translated using Weblate (French)
Currently translated at 100.0% (307 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/fr/
2024-08-12 23:34:46 +02:00
tonttulaandHosted Weblate 1043f4ecd7 Translated using Weblate (Finnish)
Currently translated at 72.3% (222 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/fi/
2024-08-12 23:34:46 +02:00
SunSpringandHosted Weblate 4f17a32373 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (307 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/zh_Hans/
2024-08-12 23:34:46 +02:00
CharlieandHosted Weblate 2203aae6e6 Translated using Weblate (French)
Currently translated at 100.0% (307 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/fr/
2024-08-12 23:34:46 +02:00
advplyrandGitHub 7b60c7b6c5 Merge pull request #1279 from nichwall/release_close_issue
Add: workflow to automatically close issues on release
2024-08-12 16:34:41 -05:00
Nicholas Wallace be98e06efc Add: workflow to automatically close issues on release 2024-08-06 21:02:27 -07:00
advplyrandGitHub 45239bca34 Merge pull request #1258 from weblate/weblate-audiobookshelf-abs-mobile-app
Translations update from Hosted Weblate
2024-08-04 10:09:21 -05:00
burghy86andHosted Weblate 2493ce7ed7 Translated using Weblate (Italian)
Currently translated at 100.0% (307 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/it/
2024-07-26 04:45:02 +00:00
advplyr 2b1e53b371 Remove references to matrix server 2024-07-23 16:22:24 -05:00
weblate.user.1274andHosted Weblate d643a1769f Translated using Weblate (Norwegian Bokmål)
Currently translated at 84.0% (258 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/nb_NO/
2024-07-23 23:19:53 +02:00
MarioandHosted Weblate a63b7a188c Translated using Weblate (German)
Currently translated at 100.0% (307 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/de/
2024-07-23 23:19:53 +02:00
gallegonovatoandHosted Weblate f9c0162b8a Translated using Weblate (Spanish)
Currently translated at 100.0% (307 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/es/
2024-07-23 23:19:52 +02:00
Petteri HjortandHosted Weblate cda3cb3512 Translated using Weblate (Finnish)
Currently translated at 49.8% (153 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/fi/
2024-07-23 23:19:52 +02:00
Marcin MartelaandHosted Weblate 766f6938a7 Translated using Weblate (Polish)
Currently translated at 96.0% (295 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/pl/
2024-07-23 23:19:51 +02:00
tonttulaandHosted Weblate 8e1ccb517b Translated using Weblate (Finnish)
Currently translated at 48.2% (148 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/fi/
2024-07-23 23:19:51 +02:00
MarioandHosted Weblate 540aab6f83 Translated using Weblate (German)
Currently translated at 100.0% (307 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/de/
2024-07-23 23:19:50 +02:00
CharlieandHosted Weblate d1969d7897 Translated using Weblate (French)
Currently translated at 100.0% (307 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/fr/
2024-07-23 23:19:50 +02:00
AhetekandHosted Weblate b69e8fd649 Translated using Weblate (Polish)
Currently translated at 93.4% (287 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/pl/
2024-07-23 23:19:49 +02:00
Lode SmetsandHosted Weblate 2e95cf908d Translated using Weblate (Dutch)
Currently translated at 64.8% (199 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/nl/
2024-07-23 23:19:49 +02:00
MarioandHosted Weblate b589bae2b0 Translated using Weblate (German)
Currently translated at 100.0% (307 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/de/
2024-07-23 23:19:48 +02:00
AhetekandHosted Weblate e7e4891d82 Translated using Weblate (Polish)
Currently translated at 93.4% (287 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/pl/
2024-07-23 23:19:48 +02:00
advplyr 03a46decba Fix:Server connect form press enter to submit #1158 2024-07-08 17:21:31 -05:00
advplyrandGitHub d80482f443 Merge pull request #1250 from weblate/weblate-audiobookshelf-abs-mobile-app
Translations update from Hosted Weblate
2024-06-28 15:50:19 -05:00
J. LavoieandHosted Weblate 735cce7614 Translated using Weblate (Finnish)
Currently translated at 39.0% (120 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/fi/
2024-06-27 04:45:17 +00:00
J. LavoieandHosted Weblate 8ea35a0b36 Translated using Weblate (Portuguese (Brazil))
Currently translated at 99.3% (305 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/pt_BR/
2024-06-27 04:45:16 +00:00
J. LavoieandHosted Weblate ad90bd3d05 Translated using Weblate (Norwegian Bokmål)
Currently translated at 81.1% (249 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/nb_NO/
2024-06-27 04:45:15 +00:00
J. LavoieandHosted Weblate fa3856e845 Translated using Weblate (Italian)
Currently translated at 100.0% (307 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/it/
2024-06-27 04:45:15 +00:00
dex girlandHosted Weblate 752d4ddc8c Translated using Weblate (Croatian)
Currently translated at 69.0% (212 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/hr/
2024-06-27 04:45:05 +00:00
J. LavoieandHosted Weblate fd2d7a05d1 Translated using Weblate (French)
Currently translated at 100.0% (307 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/fr/
2024-06-27 04:45:04 +00:00
J. LavoieandHosted Weblate ccf4675867 Translated using Weblate (Spanish)
Currently translated at 100.0% (307 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/es/
2024-06-27 04:45:03 +00:00
J. LavoieandHosted Weblate bc043c73ca Translated using Weblate (German)
Currently translated at 100.0% (307 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/de/
2024-06-27 04:45:02 +00:00
Allan NordhøyandHosted Weblate 291ab247bc Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (307 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/zh_Hans/
2024-06-25 16:34:50 +02:00
Allan NordhøyandHosted Weblate d901f470a1 Translated using Weblate (Vietnamese)
Currently translated at 85.6% (263 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/vi/
2024-06-25 16:34:50 +02:00
Allan NordhøyandHosted Weblate 0ebb01e39d Translated using Weblate (Ukrainian)
Currently translated at 100.0% (307 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/uk/
2024-06-25 16:34:50 +02:00
Allan NordhøyandHosted Weblate 20ac99bf8d Translated using Weblate (Swedish)
Currently translated at 59.2% (182 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/sv/
2024-06-25 16:34:50 +02:00
Allan NordhøyandHosted Weblate 90e86e1575 Translated using Weblate (Russian)
Currently translated at 99.3% (305 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/ru/
2024-06-25 16:34:50 +02:00
Allan NordhøyandHosted Weblate 28652031e7 Translated using Weblate (Portuguese (Brazil))
Currently translated at 99.3% (305 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/pt_BR/
2024-06-25 16:34:50 +02:00
Allan NordhøyandHosted Weblate cd8be40cc8 Translated using Weblate (Polish)
Currently translated at 90.2% (277 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/pl/
2024-06-25 16:34:50 +02:00
Allan NordhøyandHosted Weblate 52fc937f96 Translated using Weblate (Norwegian Bokmål)
Currently translated at 81.1% (249 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/nb_NO/
2024-06-25 16:34:49 +02:00
Allan NordhøyandHosted Weblate 68e4722387 Translated using Weblate (Dutch)
Currently translated at 57.0% (175 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/nl/
2024-06-25 16:34:49 +02:00
Allan NordhøyandHosted Weblate 5f5b38b2f9 Translated using Weblate (Lithuanian)
Currently translated at 59.2% (182 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/lt/
2024-06-25 16:34:49 +02:00
Allan NordhøyandHosted Weblate 65f8d3ce81 Translated using Weblate (Italian)
Currently translated at 96.4% (296 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/it/
2024-06-25 16:34:49 +02:00
Allan NordhøyandHosted Weblate ac571fd63d Translated using Weblate (Hungarian)
Currently translated at 91.5% (281 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/hu/
2024-06-25 16:34:49 +02:00
Allan NordhøyandHosted Weblate ea2d6e9f31 Translated using Weblate (Croatian)
Currently translated at 46.5% (143 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/hr/
2024-06-25 16:34:49 +02:00
Allan NordhøyandHosted Weblate 80b999d648 Translated using Weblate (Hindi)
Currently translated at 24.1% (74 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/hi/
2024-06-25 16:34:49 +02:00
Allan NordhøyandHosted Weblate 54720039e4 Translated using Weblate (Gujarati)
Currently translated at 23.4% (72 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/gu/
2024-06-25 16:34:49 +02:00
Allan NordhøyandHosted Weblate 8c6717c201 Translated using Weblate (French)
Currently translated at 99.6% (306 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/fr/
2024-06-25 16:34:49 +02:00
Allan NordhøyandHosted Weblate 6610754e25 Translated using Weblate (Spanish)
Currently translated at 100.0% (307 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/es/
2024-06-25 16:34:49 +02:00
Allan NordhøyandHosted Weblate d38622d8a2 Translated using Weblate (German)
Currently translated at 99.6% (306 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/de/
2024-06-25 16:34:49 +02:00
Allan NordhøyandHosted Weblate cd2750a489 Translated using Weblate (Danish)
Currently translated at 64.1% (197 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/da/
2024-06-25 16:34:48 +02:00
Allan NordhøyandHosted Weblate 30140629ae Translated using Weblate (Czech)
Currently translated at 96.0% (295 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/cs/
2024-06-25 16:34:48 +02:00
Allan NordhøyandHosted Weblate 0ce29337ef Translated using Weblate (Norwegian Bokmål)
Currently translated at 69.7% (214 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/nb_NO/
2024-06-25 15:36:41 +02:00
Allan NordhøyandHosted Weblate 6715f29ebb Translated using Weblate (Norwegian Bokmål)
Currently translated at 68.7% (211 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/nb_NO/
2024-06-25 13:32:12 +00:00
phewiandHosted Weblate b4a8dc9e26 Translated using Weblate (Finnish)
Currently translated at 36.4% (112 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/fi/
2024-06-25 13:32:12 +00:00
advplyrandGitHub c349a0bf00 Merge pull request #1248 from weblate/weblate-audiobookshelf-abs-mobile-app
Translations update from Hosted Weblate
2024-06-25 08:32:05 -05:00
SunSpringandHosted Weblate 0dc6c142be Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (307 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/zh_Hans/
2024-06-21 04:45:03 +00:00
PlazecandHosted Weblate e46c1d9120 Translated using Weblate (Czech)
Currently translated at 96.4% (296 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/cs/
2024-06-20 04:45:02 +00:00
advplyrandGitHub 27ad554ea1 Merge pull request #1245 from weblate/weblate-audiobookshelf-abs-mobile-app
Translations update from Hosted Weblate
2024-06-19 15:18:59 -05:00
CharlieandHosted Weblate 5660772b46 Translated using Weblate (French)
Currently translated at 100.0% (307 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/fr/
2024-06-19 06:45:03 +02:00
kuci-JKandHosted Weblate dab80642ab Translated using Weblate (Czech)
Currently translated at 95.7% (294 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/cs/
2024-06-19 06:45:02 +02:00
Nicholas WandHosted Weblate da46191208 Translated using Weblate (Bengali)
Currently translated at 57.6% (177 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/bn/
2024-06-18 22:01:23 +02:00
JLandHosted Weblate 6484c4de79 Translated using Weblate (Danish)
Currently translated at 64.1% (197 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/da/
2024-06-18 22:01:23 +02:00
kuci-JKandHosted Weblate 6716b6042a Translated using Weblate (Czech)
Currently translated at 94.1% (289 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/cs/
2024-06-18 22:01:23 +02:00
PlazecandHosted Weblate 0686859fa7 Translated using Weblate (Czech)
Currently translated at 94.1% (289 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/cs/
2024-06-18 22:01:23 +02:00
advplyr 4180862c3a Fix: Sleep Timer modal defaults to Custom Time during first access #1224 2024-06-17 16:44:06 -05:00
PlazecandHosted Weblate 7aa20d3211 Translated using Weblate (Czech)
Currently translated at 92.8% (285 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/cs/
2024-06-17 23:42:10 +02:00
pmangroandHosted Weblate b694b55710 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (307 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/pt_BR/
2024-06-17 23:42:10 +02:00
pmangroandHosted Weblate 024b613063 Translated using Weblate (Portuguese (Brazil))
Currently translated at 93.8% (288 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/pt_BR/
2024-06-17 23:42:10 +02:00
SmoukusandHosted Weblate 8768d11a20 Translated using Weblate (Croatian)
Currently translated at 46.5% (143 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/hr/
2024-06-17 23:42:10 +02:00
ValentinandHosted Weblate 5898303ba4 Translated using Weblate (German)
Currently translated at 100.0% (307 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/de/
2024-06-17 23:42:10 +02:00
advplyr e79d09941c Update:Editor config/vscode settings/prettierrc 2024-06-16 09:49:05 -05:00
advplyr c52a60fe77 Add:Finnish language option 2024-06-16 09:44:09 -05:00
advplyrandGitHub e64f9aa427 Merge pull request #1239 from weblate/weblate-audiobookshelf-abs-mobile-app
Translations update from Hosted Weblate
2024-06-16 09:40:59 -05:00
Petteri HjortandHosted Weblate ee343f78ef Translated using Weblate (Finnish)
Currently translated at 27.3% (84 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/fi/
2024-06-16 06:45:04 +02:00
Vito0912andHosted Weblate 1f20d5821f Translated using Weblate (German)
Currently translated at 100.0% (307 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/de/
2024-06-16 06:45:03 +02:00
Nicholas WandHosted Weblate b0c512e946 Added translation using Weblate (Finnish) 2024-06-15 16:32:35 +02:00
CharlieandHosted Weblate 0d13b7abd1 Translated using Weblate (French)
Currently translated at 100.0% (307 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/fr/
2024-06-15 14:31:50 +00:00
SunSpringandHosted Weblate 43fd8285c9 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (307 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/zh_Hans/
2024-06-15 06:45:09 +02:00
Illia PyshniakandHosted Weblate 2f7e8d618a Translated using Weblate (Ukrainian)
Currently translated at 100.0% (307 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/uk/
2024-06-15 06:45:08 +02:00
DmitryandHosted Weblate a0b910deeb Translated using Weblate (Russian)
Currently translated at 100.0% (307 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/ru/
2024-06-15 06:45:08 +02:00
Petras ŠukysandHosted Weblate e9dc45613c Translated using Weblate (Lithuanian)
Currently translated at 59.2% (182 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/lt/
2024-06-15 06:45:07 +02:00
Pierrick GuillaumeandHosted Weblate b5e90ec3d2 Translated using Weblate (French)
Currently translated at 87.9% (270 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/fr/
2024-06-15 06:45:06 +02:00
DiamondtipDRandHosted Weblate 3d9f42c1e1 Translated using Weblate (Spanish)
Currently translated at 100.0% (307 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/es/
2024-06-15 06:45:06 +02:00
SunSpringandHosted Weblate e62f430f37 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (307 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/zh_Hans/
2024-06-14 11:19:29 +02:00
burghy86andHosted Weblate 9c53564c46 Translated using Weblate (Italian)
Currently translated at 96.7% (297 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/it/
2024-06-14 11:19:29 +02:00
Daniel SchosserandHosted Weblate f7add6f6cd Translated using Weblate (German)
Currently translated at 99.6% (306 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/de/
2024-06-14 11:19:29 +02:00
MarioandHosted Weblate 7786e6a827 Translated using Weblate (German)
Currently translated at 99.6% (306 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/de/
2024-06-14 11:19:29 +02:00
apineiro97andHosted Weblate b213e2aabf Translated using Weblate (Spanish)
Currently translated at 99.6% (306 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/es/
2024-06-14 06:45:03 +02:00
Vito0912andHosted Weblate ebca0fafca Translated using Weblate (German)
Currently translated at 95.1% (292 of 307 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/de/
2024-06-14 06:45:02 +02:00
advplyrandGitHub ac8a2ca985 Merge pull request #1235 from mfcar/mf/downloadTweaks
Tweak the download page (add library size and play episode)
2024-06-12 18:14:35 -05:00
advplyr 6380d7cc02 Fix local episode play-item event, set PodcastEpisode size and AudioTrack FileMetadata for downloads 2024-06-12 18:01:44 -05:00
advplyrandGitHub 9056ba6b8d Merge pull request #1234 from nichwall/localization_workflow_update
Update i18n workflow to 1.3.0
2024-06-11 17:03:21 -05:00
advplyrandGitHub 23801363c4 Merge pull request #1233 from weblate/weblate-audiobookshelf-abs-mobile-app
Translations update from Hosted Weblate
2024-06-11 17:03:01 -05:00
Marcos Carvalho 1cdcd7327f Add play downloaded episode and library size 2024-06-11 19:31:10 +01:00
Nicholas Wallace 50ec50003e Update i18n workflow to 1.3.0 2024-06-10 19:25:34 -07:00
gallegonovatoandHosted Weblate 5ff4db1a3f Translated using Weblate (Spanish)
Currently translated at 100.0% (304 of 304 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/es/
2024-06-10 21:56:03 +00:00
advplyrandGitHub 394b44c343 Merge pull request #1222 from BimBimSalaBim/master
Update font settings in EpubReader and Reader components
2024-06-10 16:55:55 -05:00
advplyr eb2aa6f773 Map locally stored ebook settings onto defaults 2024-06-10 16:38:29 -05:00
advplyr 81130e04e5 Merge master 2024-06-10 16:32:09 -05:00
advplyrandGitHub c0ec0758f4 Merge pull request #1228 from weblate/weblate-audiobookshelf-test-abs-mobile-app
Translations update from Hosted Weblate
2024-06-10 16:23:07 -05:00
Nicholas WandHosted Weblate e21f7c545e Translated using Weblate (Chinese (Simplified))
Currently translated at 96.0% (292 of 304 strings)

Translation: Audiobookshelf/ABS Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf-test/abs-mobile-app/zh_Hans/
2024-06-09 21:51:48 +02:00
Nicholas WandHosted Weblate c16dd2fcf6 Translated using Weblate (Vietnamese)
Currently translated at 86.5% (263 of 304 strings)

Translation: Audiobookshelf/ABS Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf-test/abs-mobile-app/vi/
2024-06-09 21:51:48 +02:00
Nicholas WandHosted Weblate 90974e6d73 Translated using Weblate (Ukrainian)
Currently translated at 95.7% (291 of 304 strings)

Translation: Audiobookshelf/ABS Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf-test/abs-mobile-app/uk/
2024-06-09 21:51:48 +02:00
Nicholas WandHosted Weblate 8e4f75d85b Translated using Weblate (Swedish)
Currently translated at 59.5% (181 of 304 strings)

Translation: Audiobookshelf/ABS Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf-test/abs-mobile-app/sv/
2024-06-09 21:51:48 +02:00
Nicholas WandHosted Weblate 792ee2adb8 Translated using Weblate (Russian)
Currently translated at 94.0% (286 of 304 strings)

Translation: Audiobookshelf/ABS Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf-test/abs-mobile-app/ru/
2024-06-09 21:51:47 +02:00
Nicholas WandHosted Weblate 2c57489c07 Translated using Weblate (Portuguese (Brazil))
Currently translated at 94.0% (286 of 304 strings)

Translation: Audiobookshelf/ABS Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf-test/abs-mobile-app/pt_BR/
2024-06-09 21:51:47 +02:00
Nicholas WandHosted Weblate e3321770fd Translated using Weblate (Polish)
Currently translated at 91.4% (278 of 304 strings)

Translation: Audiobookshelf/ABS Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf-test/abs-mobile-app/pl/
2024-06-09 21:51:47 +02:00
Nicholas WandHosted Weblate 848673f028 Translated using Weblate (Norwegian Bokmål)
Currently translated at 57.8% (176 of 304 strings)

Translation: Audiobookshelf/ABS Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf-test/abs-mobile-app/nb_NO/
2024-06-09 21:51:47 +02:00
Nicholas WandHosted Weblate e6d3003ecf Translated using Weblate (Dutch)
Currently translated at 57.5% (175 of 304 strings)

Translation: Audiobookshelf/ABS Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf-test/abs-mobile-app/nl/
2024-06-09 21:51:47 +02:00
Nicholas WandHosted Weblate 90551b7ce7 Translated using Weblate (Lithuanian)
Currently translated at 59.5% (181 of 304 strings)

Translation: Audiobookshelf/ABS Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf-test/abs-mobile-app/lt/
2024-06-09 21:51:46 +02:00
Nicholas WandHosted Weblate aabac5da13 Translated using Weblate (Italian)
Currently translated at 92.4% (281 of 304 strings)

Translation: Audiobookshelf/ABS Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf-test/abs-mobile-app/it/
2024-06-09 21:51:46 +02:00
Nicholas WandHosted Weblate 4fad77f9c1 Translated using Weblate (Hungarian)
Currently translated at 92.7% (282 of 304 strings)

Translation: Audiobookshelf/ABS Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf-test/abs-mobile-app/hu/
2024-06-09 21:51:46 +02:00
Nicholas WandHosted Weblate 0fb6d746eb Translated using Weblate (Croatian)
Currently translated at 45.7% (139 of 304 strings)

Translation: Audiobookshelf/ABS Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf-test/abs-mobile-app/hr/
2024-06-09 21:51:46 +02:00
Nicholas WandHosted Weblate 7fc712d19b Translated using Weblate (Hindi)
Currently translated at 24.3% (74 of 304 strings)

Translation: Audiobookshelf/ABS Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf-test/abs-mobile-app/hi/
2024-06-09 21:51:46 +02:00
Nicholas WandHosted Weblate d6018cfe10 Translated using Weblate (Gujarati)
Currently translated at 23.3% (71 of 304 strings)

Translation: Audiobookshelf/ABS Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf-test/abs-mobile-app/gu/
2024-06-09 21:51:45 +02:00
Nicholas WandHosted Weblate 5b8f231a98 Translated using Weblate (French)
Currently translated at 86.8% (264 of 304 strings)

Translation: Audiobookshelf/ABS Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf-test/abs-mobile-app/fr/
2024-06-09 21:51:45 +02:00
Nicholas WandHosted Weblate cb503f84e1 Translated using Weblate (Spanish)
Currently translated at 93.0% (283 of 304 strings)

Translation: Audiobookshelf/ABS Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf-test/abs-mobile-app/es/
2024-06-09 21:51:45 +02:00
Nicholas WandHosted Weblate a34f9b80ac Translated using Weblate (Danish)
Currently translated at 58.5% (178 of 304 strings)

Translation: Audiobookshelf/ABS Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf-test/abs-mobile-app/da/
2024-06-09 21:51:44 +02:00
Nicholas WandHosted Weblate 3e6696b516 Translated using Weblate (Czech)
Currently translated at 93.0% (283 of 304 strings)

Translation: Audiobookshelf/ABS Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf-test/abs-mobile-app/cs/
2024-06-09 21:51:44 +02:00
Nicholas WandHosted Weblate 9cd8bd439a Translated using Weblate (English)
Currently translated at 20.3% (62 of 304 strings)

Translation: Audiobookshelf/ABS Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf-test/abs-mobile-app/en/
2024-06-09 21:51:44 +02:00
Nicholas WandHosted Weblate ca5b99e23d Added translation using Weblate (Bengali) 2024-06-09 21:44:05 +02:00
Nicholas WandHosted Weblate c44fd74f5b Translated using Weblate (German)
Currently translated at 99.0% (301 of 304 strings)

Translation: Audiobookshelf/ABS Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf-test/abs-mobile-app/de/
2024-06-09 21:40:09 +02:00
advplyrandGitHub f82c4834c3 Merge pull request #1225 from lukasitaly/patch-1
Update de.json
2024-06-08 16:03:45 -05:00
advplyrandGitHub dda95bd69c Merge pull request #1217 from mfcar/mf/networkStatusSwift
Implement network monitoring on iOS
2024-06-08 16:02:53 -05:00
LukasandGitHub a623cb57a7 Update de.json
Changed "Mobilfunknetz" to "mobile Daten", but I wouldn't really change the remaining strings with "Mobilfunknetz" just for the sake of uniformity, since "Mobilfunknetz" definitely fits better than "mobile Daten".
2024-06-08 17:01:49 +00:00
LukasandGitHub e798d930fb Update de.json
Translation of not yet translated strings
2024-06-07 08:42:05 +02:00
advplyrandGitHub f91b0c9e2f Merge pull request #1210 from mfcar/mf/limitCellularData
Add settings to limit Cellular Data (Download/Streaming)
2024-06-04 16:56:01 -05:00
advplyr b6824cf242 Fix:Latest podcast episodes to play local 2024-06-04 16:50:55 -05:00
advplyr 41477fdac1 Merge translation strings 2024-06-04 16:28:35 -05:00
advplyr d4e8afc2f1 Map translation strings 2024-06-04 16:23:23 -05:00
advplyr d42cbfe3e4 Revert podcast episode local playback event 2024-06-04 16:21:28 -05:00
advplyr c7c3ef52e6 Update issue templates to 0.9.73 and 0.9.74 2024-06-04 10:25:01 -05:00
advplyr 92344325b6 Add 0.9.64 to issue templates 2024-06-04 10:23:21 -05:00
Faizan Zafar a8172fb3f9 Update font settings in EpubReader and Reader components to include a "Font Boldness" slider which applies a stroke to the text #3020 on main repo 2024-06-03 20:25:11 -07:00
advplyrandGitHub a73ec783d5 Merge pull request #1221 from c-franco/update-spanish-translation
Update Spanish translation
2024-06-03 17:20:00 -05:00
Christian Franco 5c9fc4c9e4 Update Spanish translation 2024-06-03 16:31:40 +01:00
advplyr 494ff38ef3 Fix:Epub rendition resize on screen orientation change #1213 2024-06-02 16:20:41 -05:00
advplyr b7e6cbf1ab Fix:PDF ereader flickering 2024-06-02 15:54:50 -05:00
advplyrandGitHub b33623b74f Merge pull request #1215 from Dalabad/patch-1
Update de.json
2024-05-28 17:24:57 -05:00
Marcos Carvalho 4f939ce6fc Implement network monitoring on iOS 2024-05-27 22:16:49 +01:00
Daniel SchosserandGitHub 4f45f360df Update de.json 2024-05-27 22:50:11 +02:00
Marcos Carvalho 4ad54f34fe Improvements and fixes on the cellular permissions 2024-05-26 22:08:53 +01:00
Marcos Carvalho 574dda5286 Fix android build 2024-05-24 08:10:25 +01:00
Marcos Carvalho d788623509 Initial work 2024-05-23 22:33:06 +01:00
advplyrandGitHub f047f6a23b Merge pull request #1208 from mfcar/mf/detectableTimestamp
feat: Add Time Marker Detection in Episode Descriptions
2024-05-21 17:11:49 -05:00
advplyr 48b30f6b03 iOS version bump 0.9.74 2024-05-20 09:42:43 -05:00
Marcos Carvalho 67d32164d9 Update the parse description to identify time marker in anchor links 2024-05-20 08:33:02 +01:00
advplyr 6672a21049 Version bump v0.9.74-beta 2024-05-19 16:58:20 -05:00
Marcos Carvalho b833019a14 Change href link to span, to avoid change the page and improve code 2024-05-19 14:14:47 +01:00
Marcos Carvalho 00eeeb2a5a Add detectable timestamp 2024-05-19 10:40:04 +01:00
advplyrandGitHub 06a0a68143 Merge pull request #1205 from indiefan/command-target-duplication-fix
Fix bug iOS AudioPlayer where multiple calls to setupRemoteTransportC…
2024-05-17 17:05:14 -05:00
advplyrandGitHub 1a7ae468bd Merge pull request #1206 from cor-bee/patch-1
Update uk.json
2024-05-17 16:50:49 -05:00
Illia PyshniakandGitHub e8f74d7358 Update uk.json 2024-05-17 14:00:58 +03:00
Marke Hallowell 984f01da23 Fix bug iOS AudioPlayer where multiple calls to setupRemoteTransportControls resulted in duplicate MPRemoteCommand targets being applied for the same action. 2024-05-16 19:11:38 -07:00
advplyrandGitHub d6e75410a5 Merge pull request #1201 from nichwall/bug_report
App bug report format
2024-05-12 12:41:45 -05:00
Nicholas Wallace 7627e1b58c App bug report format 2024-05-11 18:24:38 -07:00
advplyrandGitHub 5fb31b18ae Merge pull request #1199 from nichwall/feature_request
Add: feature request
2024-05-10 17:55:03 -05:00
Nicholas WandGitHub 20903e8217 App selection allows for multiple 2024-05-09 07:12:02 -07:00
Nicholas WandGitHub 8fe348d4eb Update feature_request.yaml 2024-05-09 06:40:06 -07:00
Nicholas Wallace 24cda8a179 Add: feature request 2024-05-08 23:30:13 -07:00
advplyr fc954a968b Remove old missing parts references 2024-04-28 16:38:40 -05:00
advplyr aae53a54cd Update:UI/UX: Add explicit indicator next to titles #1181 2024-04-28 16:34:31 -05:00
advplyr cfedcffc98 Add:No series filter option for book libraries #1184 2024-04-28 16:00:57 -05:00
advplyrandGitHub 0e74c74a6c Merge pull request #1179 from indiefan/rate-manager
Refactor AudioPlayer to use a Rate Manager protocol.
2024-04-27 16:49:27 -05:00
advplyr fb86c45e78 iOS add defaultRate fixing merge 2024-04-27 16:41:29 -05:00
advplyrandGitHub 124300b215 Merge branch 'master' into rate-manager 2024-04-27 16:06:20 -05:00
advplyrandGitHub 6746c0627b Merge pull request #1178 from indiefan/master
Update Now Playing info with correct default rate.
2024-04-27 16:01:06 -05:00
Marke Hallowell 0f2cd3b132 Update Now Playing info with correct default rate so that third party apps don't mistake current status as scrubbing. 2024-04-21 15:39:37 -07:00
advplyr e94d6fdc1e Update:Android notification image to not resize #657 2024-04-19 16:32:16 -05:00
advplyr e301b3071c Update:Add 1.7x playback speed to modal #1180 2024-04-19 14:39:00 -05:00
Marke Hallowell 3d3dad5e4a Adding new sources to project file. 2024-04-14 16:47:38 -07:00
Marke Hallowell f8a9033738 Add an AudioPlayerRateManager implementation for iOS 16+. 2024-04-14 16:12:55 -07:00
Marke Hallowell edb25f5bcd Refactor AudioPlayer to use an AudioPlayerRateManager protocol for managing rate/defaultRate state management. Implements a LegacyAudioPlayerRateManager implementation with existing (pre-iOS 16) behavior. 2024-04-14 11:45:03 -07:00
Marke Hallowell bcb239efd1 Update Now Playing info with correct default rate so that third party apps don't mistake current status as scrubbing. 2024-04-13 23:10:03 -07:00
advplyrandGitHub d208211c6f Merge pull request #1176 from Fymyte/patch-4
Update French Translation
2024-04-12 15:22:06 -05:00
Pierrick GuillaumeandGitHub 0b20b72241 Update fr.json 2024-04-12 20:32:47 +02:00
Pierrick GuillaumeandGitHub 5c29459af2 Update fr.json 2024-04-12 20:31:15 +02:00
advplyrandGitHub e54fd38784 Merge pull request #1174 from nichwall/i18n-update
Update i18n workflow to use 1.2.0
2024-04-11 17:17:23 -05:00
Nicholas Wallace 94774f13df Update i18n workflow to use 1.2.0 2024-04-10 18:38:11 -07:00
advplyr 43ec35fe67 Fix:Android device info client version 2024-04-05 16:39:24 -05:00
advplyrandGitHub b2645dc444 Merge pull request #1162 from springsunx/patch-1
Update zh-cn.json
2024-04-03 18:16:14 -05:00
SunXandGitHub c79624a82c Update zh-cn.json 2024-04-03 09:06:13 +08:00
advplyr 8201f3c9a6 Fix:Close sleep timer custom timer sub modal on sleep timer close #1112 2024-04-01 17:35:22 -05:00
advplyrandGitHub e0ac6a1c38 Merge pull request #1160 from pmangro/master
[PR-BR] Go to Web Client
2024-04-01 17:18:33 -05:00
pmangroandGitHub 400da5566d [PR-BR] Go to Web Client 2024-03-31 19:50:45 -03:00
advplyr 9b67037774 Update:Support for comic files with webp images https://github.com/advplyr/audiobookshelf/issues/2792 2024-03-27 16:13:13 -05:00
advplyr 48596542c7 Merge branch 'master' of https://github.com/advplyr/audiobookshelf-app 2024-03-25 16:33:36 -05:00
advplyr 9715d65d88 Update:Go to web client buttons visible to all users #1079 2024-03-25 16:33:30 -05:00
advplyrandGitHub 2e14b31a69 Merge pull request #1153 from burghy86/patch-4
Update it.json
2024-03-25 14:51:30 -05:00
burghy86andGitHub 1c8ce6c3db Update it.json
monthly translation of new strings
2024-03-25 16:29:07 +01:00
advplyrandGitHub dc74a6213d Merge pull request #1150 from nichwall/translation_faq_link
Translation guide link added
2024-03-24 05:06:47 -05:00
Nicholas Wallace e9a66bb7a7 Translation guide link added 2024-03-23 20:08:41 -07:00
advplyr 3c6d4b5f19 Add:Nav button in side drawer and item more menu to go to web client #1079 2024-03-22 17:54:56 -05:00
advplyrandGitHub f04eebe0d8 Merge pull request #1142 from nichwall/translation_workflow_check
Add: i18n workflow check
2024-03-20 16:43:26 -05:00
advplyrandGitHub 63b5227ccc Merge pull request #1140 from cor-bee/master
Update Ukrainian translation
2024-03-20 16:31:00 -05:00
Nicholas Wallace 934d9b1df4 Fixed release number of action 2024-03-19 21:19:38 -07:00
Nicholas Wallace 8994cb3a6e Added newline to force check 2024-03-19 21:12:52 -07:00
Nicholas Wallace 4a32de482b Example: German keys alphabetized 2024-03-19 21:09:49 -07:00
Nicholas Wallace 95f47793d2 Example: German strings out of order 2024-03-19 21:08:43 -07:00
Nicholas Wallace f27048618d Add: newline to end of file 2024-03-19 21:05:53 -07:00
Nicholas Wallace c90ad419e8 Add: i18n workflow check 2024-03-19 21:05:09 -07:00
Illia PyshniakandGitHub 2cdc3ecb9d Update uk.json 2024-03-19 23:09:35 +02:00
advplyr 9f89fcd968 Fix:Podcast library playlists page incorrect label in toolbar #1135 2024-03-19 12:52:14 -05:00
advplyrandGitHub 7c2d51f811 Merge pull request #1136 from cor-bee/master
Ukrainian translation
2024-03-18 17:44:49 -05:00
advplyr 41fe7a29cd Merge 2024-03-18 17:41:13 -05:00
advplyrandGitHub 54f3eec303 Merge pull request #1127 from nichwall/vietnamese_translations
Vietnamese translations
2024-03-18 17:36:58 -05:00
advplyr cd91013f69 Update Vietnamese datefnslocale code 2024-03-18 17:37:04 -05:00
advplyrandGitHub 0af16ef05f Merge pull request #1139 from pmangro/master
[PT-BR] Remove from Playlist
2024-03-18 11:57:25 -05:00
pmangroandGitHub 5ba830d56e [PT-BR] Remove from Playlist 2024-03-18 09:45:46 -03:00
Illia PyshniakandGitHub 81919e963a Add Ukrainian language to i18n.js 2024-03-15 22:28:00 +02:00
Illia PyshniakandGitHub 8a1d4bb958 Ukrainian app translation 2024-03-15 22:26:52 +02:00
Nicholas Wallace eabac0e709 Add: Vietnamese to language code map 2024-03-09 16:23:25 -07:00
Nicholas Wallace d90783d7c8 Fixed whitespace 2024-03-09 16:20:24 -07:00
Nicholas Wallace faf8211e5c Add: Vietnamese app translations 2024-03-09 16:20:00 -07:00
advplyr 65d90947c5 Fix:Remove from playlist button #987 2024-03-04 14:56:58 -06:00
advplyr 752e918d26 iOS version bump 0.9.73 & update dependencies 2024-03-03 16:28:55 -06:00
advplyr ad08cc69f4 Version bump 0.9.73-beta 2024-03-03 14:15:32 -06:00
advplyr da3882fd5e Fix:Playlist tab not shown after adding first playlist #1092 2024-03-03 13:29:58 -06:00
advplyr c04c7a55d9 Fix:iOS progress bar overflowing edge of screen 2024-03-03 12:52:17 -06:00
advplyr 3318846b43 Add:Remove from playlist option in more menu for items on playlist page #987 2024-03-02 15:25:18 -06:00
advplyrandGitHub e0860feeb9 Merge pull request #1109 from megamegax/hu
feat(i18n): add Hungarian localization
2024-03-01 15:39:15 -06:00
Hunyady Mihály c0253a69b9 feat(i18n): replace NoItemsFound translation to a more general one 2024-03-01 22:09:33 +01:00
Hunyady Mihály 9242c7e6e0 feat(i18n): small grammar adjustments 2024-03-01 22:04:09 +01:00
Hunyady Mihály 0bfc579ae4 feat(i18n): adjust translation 2024-03-01 21:39:06 +01:00
megamegax 5fad06756b feat(i18n): add Hungarian localization 2024-03-01 20:03:21 +01:00
advplyrandGitHub 5be6afa6c1 Merge pull request #1106 from pmangro/0.9.72.a-PT-BR
[PT-BR] Translated LabelAllowSeekingOnMediaControls
2024-02-27 15:58:36 -06:00
pmangroandGitHub 5ba3b8be7a [PT-BR] Translated LabelAllowSeekingOnMediaControls 2024-02-27 14:39:21 -03:00
advplyr 0eb62f541d Fix:Local media item page long titles overflowing screen pushing play & more menu button offscreen #1103 2024-02-25 15:35:23 -06:00
advplyrandGitHub 6baff881c7 Merge pull request #1094 from fidoriel/disable-widget-ios-scrubing
Disable ios/android scrubbing in MPRemoteCommandCenter/MediaBrowserServiceCompat
2024-02-25 15:29:52 -06:00
fidoriel 8c23c1e795 german translation for LabelAllowSeekingOnMediaControls 2024-02-25 22:25:17 +01:00
advplyr 9706c66a27 iOS Update Realm schema version for new setting, update remote transport controls on device settings updated 2024-02-25 15:04:54 -06:00
advplyr f4f7252537 Update wording for enable seeking on media controls settings, remove restart required help text, map translations 2024-02-25 14:44:40 -06:00
advplyr 430d200151 Update android disable media control seek setting to update in realtime 2024-02-25 14:11:28 -06:00
advplyr 7be82d03f3 Merge branch 'master' into disable-widget-ios-scrubing 2024-02-25 12:51:58 -06:00
advplyr 15aaef742d Fix:Ignore swiping for audio player when modal is open #1099 2024-02-24 15:00:29 -06:00
advplyrandGitHub e443ba09da Merge pull request #1095 from pmangro/0.9.72.a-PT-BR
Added PT-BR Strings
2024-02-24 14:49:35 -06:00
luca 8235d1f1f7 reorder setting and add info label 2024-02-22 20:27:33 +01:00
pmangroandGitHub 9a39dcceee Update i18n.js to add pt-br 2024-02-21 08:55:45 -03:00
pmangroandGitHub a707b7fc86 Typos corrected 2024-02-21 08:53:34 -03:00
fidoriel b6fea85ef4 fix compile android 2024-02-21 10:16:47 +01:00
advplyr 7e3b3f55ae Fix:Android auto not loading server items #1081 2024-02-20 17:05:07 -06:00
fidoriel d2d3bbe8a1 fix default false 2024-02-20 22:08:47 +01:00
fidoriel 41a65c0f9d add setting for widget scrubbing 2024-02-20 21:58:31 +01:00
pmangroandGitHub 165ddd58a6 Update pt-br.json - initial file 2024-02-20 17:29:03 -03:00
pmangroandGitHub 09dc04c0de Created pt-br.json 2024-02-20 15:40:29 -03:00
advplyrandGitHub 57d5dbc0d0 Merge pull request #1082 from arcao/patch-1
Translate cs.json
2024-02-14 18:42:25 -06:00
Martin SloupandGitHub 11af75e127 Translate cs.json
Translate untranslated texts to Czech and fix already translated texts.
2024-02-14 02:10:33 +01:00
advplyr f8d82a3020 iOS version bump 0.9.72 2024-02-11 08:54:54 -06:00
advplyr 81d7b1cfd7 Version bump 0.9.72-beta 2024-02-10 16:35:01 -06:00
advplyr e15c1bcb32 Fix:Continue listening shelves show on initial failed connection #1038 2024-02-09 17:52:24 -06:00
advplyr 826d414d9f Update:iOS do not resume playback after app suspended. Attempted fix for #973 2024-02-07 18:02:25 -06:00
advplyr 0da6fca727 Fix:iOS 0 second listening sessions #970 and include device info with offline sessions 2024-02-05 19:04:11 -06:00
advplyr 2327bc1705 Update playMethod to fallback to 0 2024-02-04 18:14:24 -06:00
advplyr 3bb76cecac Fix:Android respect offline mode on app restart 2024-02-04 17:46:56 -06:00
advplyr e3f82bb237 Fix:Include device info with sync local-all request 2024-02-04 17:27:57 -06:00
advplyr fcd6270f2d Fix:Hide library list button from app bar when no network connection 2024-02-04 15:53:05 -06:00
advplyr d5411703ca Update:Add 10 second timeout when loading home page shelves 2024-02-04 15:48:37 -06:00
advplyr 8fc2058db8 Update:Handle navigating to library item pages when server connection goes down
- Added loading indicator to library item page on initial request to server
- Redirect to downloaded item page if server item request fails
2024-02-04 15:46:26 -06:00
advplyr d03949f5d1 Update:Audio player title scrolls when overflow #740
- increase width of the title on minimized player to scale with screen width
- mini player shows media title and chapter title
2024-02-03 17:31:53 -06:00
advplyr ff01786063 Remove unused fastSort.js 2024-02-03 13:28:41 -06:00
advplyr bb117ab4da Update:Authors page increase size to fill width with 2 per row #1026 2024-02-02 18:33:23 -06:00
advplyr f5ec247b47 Update:Minimized audio player allow swipe up to fullscreen #1046 2024-02-01 18:20:10 -06:00
advplyr 6e6dd00523 Update:Allow swiping down anywhere on fullscreen audio player to minimize #1046 2024-02-01 17:51:10 -06:00
advplyr c9fb98f033 Fix:Android crash on startup due to SimpleStorage #1040
- Update SimpleStorage to 1.5.5-SNAPSHOT
2024-01-29 16:46:39 -06:00
advplyr 75ecff5e6d Remove @nuxtjs/tailwindcss dependency and replace with standalone tailwindcss 2024-01-25 17:39:22 -06:00
advplyrandGitHub 91a9f918fb Merge pull request #1052 from bloodscript/issue-1051-german-localization-improvement
[issue-1051] German Localization
2024-01-24 14:18:17 -06:00
Steffen Meiselbach 03305f90ca corrected ereader based on current Duden entry 2024-01-23 12:38:31 +01:00
Steffen Meiselbach 9428bc07c5 reverted to uniformly uncapitalized pronouns 2024-01-23 12:37:03 +01:00
Steffen Meiselbach f38f9c3502 ebook written as used in Duden and uniformly capitalized version of Du 2024-01-23 12:33:01 +01:00
Steffen Meiselbach 64a796aece standardized addressing (du), missing translations and uniformly starting with capital character 2024-01-23 07:24:40 +01:00
advplyr 63039fe8c9 Fix pl.json formatting 2024-01-22 17:34:59 -06:00
Steffen Meiselbach 3f2b723d0f (sleep) timer is typically used in german parlance 2024-01-22 22:55:28 +01:00
advplyr ffd7fb1cf9 Update workflows to use node 20 2024-01-21 15:56:24 -06:00
advplyrandGitHub b049b206cf Merge pull request #1049 from Bogumio/patch-2
Update pl.json
2024-01-21 15:31:27 -06:00
advplyr 35f56dc8a9 Merge branch 'master' of https://github.com/advplyr/audiobookshelf-app 2024-01-21 15:32:13 -06:00
advplyr a71cd326cc Update readme node version to 20 2024-01-21 15:32:06 -06:00
BogumioandGitHub cc841d1c7c Update pl.json
The rest of the translations have been completed. Minor corrections and unifications.
2024-01-21 12:04:06 +01:00
advplyrandGitHub 16ecdcf96e Merge pull request #1042 from Bogumio/patch-1
Update pl.json
2024-01-14 11:15:48 -06:00
BogumioandGitHub 96ec56d1ad Update pl.json
Most of the missing phrases have been completed. I will try to translate the rest soon.
2024-01-14 18:07:55 +01:00
advplyr 9b15bafc9f Fix:Settings page button/toggle touch area #1021 2024-01-11 17:27:41 -06:00
advplyr aa02f2e465 Update readme for iOS beta full 2024-01-06 14:15:19 -06:00
advplyrandGitHub 15464617ef Merge pull request #1032 from burghy86/patch-3
Update it.json
2024-01-05 10:21:43 -06:00
burghy86andGitHub 60ecceb59d Update it.json
all string translate
2024-01-05 10:18:48 +01:00
advplyr 3fe943e989 Add:Player setting to scale elapsed time by playback speed #1028
- Moved player settings to new playerSettings key in storage preferences
2024-01-04 15:28:25 -06:00
advplyr 5e7d039454 iOS version bump 0.9.71 2024-01-01 13:55:13 -06:00
advplyr f928df146a Version bump 0.9.71-beta 2024-01-01 12:56:12 -06:00
advplyr 6e57cb49c4 Update:Move modal titles down to not overlap with ios status bar 2024-01-01 12:24:41 -06:00
advplyr 2f8494b013 Add:Tags to item page & allow wrapping to multiple lines #1014 2024-01-01 12:08:15 -06:00
advplyr 6f1b93bd9f Update:Default to alt view so that wooden shelf does not show on initial load 2024-01-01 11:55:57 -06:00
advplyr 19b928d7f6 Fix:Download marker overlapping series sequence on book cover #996 2024-01-01 11:53:28 -06:00
advplyr b849be6f5e Add:Epub reader setting to force single page #1018 2024-01-01 11:31:45 -06:00
advplyrandGitHub 86096df1a4 Merge pull request #1003 from benonymity/playerSwipeBack
Disable iOS swipeBack in fullscreen audio player
2024-01-01 10:52:35 -06:00
advplyrandGitHub 98a465c7b4 Update components/app/AudioPlayer.vue 2024-01-01 10:51:46 -06:00
advplyr 3315f61ae4 Merge branch 'master' into playerSwipeBack 2024-01-01 10:46:01 -06:00
advplyrandGitHub f977445d7a Merge pull request #1004 from benonymity/statusBarTweaks
iOS Status Bar Tweaks
2024-01-01 10:31:58 -06:00
advplyr 095d5e1cec Fix item page gradient for light mode 2024-01-01 10:26:06 -06:00
advplyr 69fbfa4b33 Update nuxtjs/tailwindcss and update tailwind config 2024-01-01 10:19:55 -06:00
advplyr 1c89d33726 Update item page gradient to be absolute and below cover image, add transitions 2024-01-01 09:57:51 -06:00
advplyr acee907932 Merge branch 'master' into statusBarTweaks 2024-01-01 09:00:47 -06:00
advplyrandGitHub 08ad216fe2 Merge pull request #1002 from benonymity/iosChapterTrack
iOS Now Playing Chapter Track (updated #372)
2024-01-01 08:51:59 -06:00
advplyr 3cc7a0937c Fix seeking in NowPlaying to adjust for chapter start time 2024-01-01 08:49:39 -06:00
advplyr 322cad5548 Move AbsAudioPlayer.setChapterTrack to AudioPlayer instead of localStore 2024-01-01 08:22:20 -06:00
advplyr 73d70dd480 Merge branch 'master' into iosChapterTrack 2024-01-01 08:11:59 -06:00
advplyrandGitHub 56bc569d4a Merge pull request #1016 from KaiStarkk/hotfix-pr-1005
[Fix] Prevent crash from force unwrapping nil value in PR-1005
2023-12-31 16:06:50 -06:00
advplyr 981ae58f6a Update:Podfile for capacitor filesharer 2023-12-31 16:02:29 -06:00
Kieran Hannigan b2bfa4c002 [Fix] Prevent crash from force unwrapping nil value in PR #1005 2023-12-30 11:16:10 +08:00
advplyrandGitHub 8b6003c5e3 Merge pull request #1005 from benonymity/playPauseFix
Add togglePlayPauseCommand route to iOS
2023-12-29 15:53:20 -06:00
advplyr 017b6237ca Fix:Filter dialog clear filter button overflow notch on ios #999 2023-12-27 17:23:55 -06:00
advplyr d5496c2e9a Fix:Library list view long titles overflowing, clamped to 2 lines #1013 2023-12-27 17:10:57 -06:00
advplyr 5ef8961c33 Fix:Comma separated links on item page 2023-12-27 17:03:36 -06:00
advplyr e8bea48577 Map shelf translation strings 2023-12-26 16:40:28 -06:00
advplyrandGitHub 6cae78f5c2 Merge pull request #1007 from Jafeyyu/master
Update zh-cn.json
2023-12-26 16:34:01 -06:00
JafeyyuandGitHub d9d31a0620 Update zh-cn.json 2023-12-26 18:09:17 +08:00
advplyr 3682c5fca2 Merge branch 'master' of https://github.com/advplyr/audiobookshelf-app 2023-12-25 15:35:34 -06:00
advplyr 14b04a14b6 Add:Year in review cards to stats page 2023-12-25 15:35:29 -06:00
advplyrandGitHub f218d90bfb Merge pull request #997 from Jafeyyu/master
Update zh-cn.json
2023-12-24 12:02:17 -06:00
advplyrandGitHub 020f936ad4 Merge pull request #991 from Hallo951/master
Update de.json
2023-12-24 12:01:58 -06:00
benonymity 224e5efe48 add: togglePlayPause route 2023-12-23 01:51:37 -05:00
benonymity 439ab2c8c0 fix: item page background conflict 2023-12-23 01:45:40 -05:00
benonymity 26d0209763 fix: ios status bar mirror theme 2023-12-23 00:00:50 -05:00
benonymity 29086fc783 fix: no swipeback when audio player fullscreen 2023-12-22 22:34:24 -05:00
benonymity bb656a265f fix: broken isIos 2023-12-22 21:36:49 -05:00
benonymity b11ad9dc5b fix: only call on ios and remove...very professional logging 2023-12-22 21:18:55 -05:00
benonymity 2b1667e532 feat: now playing chapter track 2023-12-22 21:01:37 -05:00
JafeyyuandGitHub 228a198598 Update zh-cn.json 2023-12-21 16:09:21 +08:00
Hallo951andGitHub 2843308fb8 Update de.json 2023-12-19 09:03:06 +01:00
advplyr 793f0c05f7 iOS version bump 0.9.70 2023-12-18 17:15:22 -06:00
advplyr cf7271dc57 Version bump 0.9.70 2023-12-18 17:10:07 -06:00
advplyr 54664b0c32 Merge branch 'master' of https://github.com/advplyr/audiobookshelf-app 2023-12-18 17:05:40 -06:00
advplyr ecf910dc55 Fix:Play button loading indicator when offline #990 2023-12-18 17:05:34 -06:00
advplyr 460daec72e iOS version bump 0.9.69 2023-12-16 17:21:43 -06:00
advplyr 4dd67adcab Fix:Prevent pressing download buttons twice causing crash in iOS 2023-12-16 17:21:19 -06:00
advplyr 023c83ee78 Version bump 0.9.69-beta 2023-12-16 16:24:07 -06:00
advplyr 2b8ed16616 Update:UI for playlist & rss feed modal to add more padding on top and bottom to prevent iOS cutoff 2023-12-16 15:06:49 -06:00
advplyr 6ae4d72b68 Update:Settings UI cleanup and light theme updates 2023-12-16 14:43:48 -06:00
advplyr c6bd93ef5c Light theme updates 2023-12-16 14:37:05 -06:00
advplyr e1c02ce74c Update:Show loading indicator on play buttons when starting playback 2023-12-15 17:35:37 -06:00
advplyr efc6d68403 Merge branch 'master' of https://github.com/advplyr/audiobookshelf-app 2023-12-15 16:05:53 -06:00
advplyr 7412d0899f Fix:Syncing when media is open in audio player and playing on another device #984
- Local playback session IDs have been updated to UUIDs instead of prefixing local_play_
2023-12-15 16:05:19 -06:00
advplyrandGitHub 42eb0a8384 Merge pull request #988 from burghy86/patch-2
Update it.json
2023-12-15 15:03:25 -06:00
burghy86andGitHub 27e83e8ed9 Update it.json 2023-12-15 22:02:26 +01:00
advplyr dd66042766 Update:Dont show chapter number infront of chapter title #981 2023-12-14 16:59:47 -06:00
advplyr dc790d5450 Fix light mode for new podcast page 2023-12-14 16:55:17 -06:00
advplyr 01ac844024 Fix light theme on add podcast page 2023-12-14 16:39:55 -06:00
advplyr c8064ac7bc Rename page name from search to add-podcast 2023-12-14 16:25:59 -06:00
advplyrandGitHub f5da44dc6f Merge pull request #986 from Hallo951/master
Update de.json
2023-12-14 08:15:26 -06:00
Hallo951andGitHub cbdff29684 Update de.json 2023-12-14 08:19:27 +01:00
advplyr 19d6c84113 Update:Light theme colors for track progress bar and ereader settings #916 2023-12-13 16:57:44 -06:00
advplyrandGitHub d9f4ed3d54 Merge pull request #983 from Nab0y/master
Fix Russian localization
2023-12-12 17:50:06 -06:00
advplyr fdb26b7700 Update:Set device language to match the first server connected to #448 2023-12-12 17:50:04 -06:00
Dmitry Naboychenko 94cb7a65a2 Fix Russian localization 2023-12-12 22:34:53 +03:00
advplyr 9298065934 Add:Theme option setting #916 2023-12-11 18:11:17 -06:00
advplyr 1cf01a5d12 Add home page shelf translation strings #448 2023-12-11 17:42:29 -06:00
advplyr e3aa96cbd5 Fix:Setting device language code 2023-12-11 17:27:26 -06:00
advplyr c7678da664 Update:Setup css colors for themes, add light theme colors #916 2023-12-10 17:53:27 -06:00
advplyrandGitHub 52701048ad Merge pull request #967 from tonyedwardspz/master
Feature - Add a low feedback mode
2023-12-09 15:14:52 -06:00
advplyr 776c02828f Fix latest page to show download indicator, update download button to be responsive right away 2023-12-09 14:57:44 -06:00
advplyr 69308413f6 Fix bad merge with master 2023-12-08 17:04:03 -06:00
advplyr 84e376a7cf Remove commented out toasts 2023-12-08 17:03:08 -06:00
advplyr 1822b21af8 Remove socketConnected check on local items alert 2023-12-08 16:49:12 -06:00
advplyr 8b222435e6 Fix:Load local settings before attempting server connection #976 2023-12-08 16:10:57 -06:00
advplyrandGitHub fdd251ec2c Merge pull request #975 from Jafeyyu/master
Improve Chinese translation
2023-12-08 16:01:24 -06:00
Tony Edwards 78170d4e10 Potential toasts for removal 2023-12-08 09:59:22 +00:00
Tony Edwards 837981b8b6 Removes download prompt on podcast ep page 2023-12-08 09:56:07 +00:00
Tony Edwards be23c6703f Shows media link status when connected to a server 2023-12-08 09:08:51 +00:00
Tony Edwards 0c72889917 Remove successfully connected media link message 2023-12-08 07:56:45 +00:00
Tony Edwards 02746bc35f Fixes my introduced errors 2023-12-08 07:53:35 +00:00
Qi, Xiaoqiang 85a5c33bd8 Improve Chinese translation 2023-12-08 15:33:37 +08:00
Tony Edwards 5067c0f6d7 Removes download confirmation prompt 2023-12-08 07:30:54 +00:00
Tony Edwards 89938ab120 Removes download success toast 2023-12-08 07:25:51 +00:00
Tony Edwards 9a32396daa Removes low feedback toggle 2023-12-08 07:19:23 +00:00
advplyr ddb1796891 Update serverconfig to remove version before saving 2023-12-07 17:02:50 -06:00
advplyrandGitHub 76253b7b9d Merge pull request #969 from Sapd/sso-redirect-ri
SSO: Add redirect_uri to oAuth auth request
2023-12-07 16:08:19 -06:00
advplyr 74fef1d2f4 Make backwards compatible with server v2.6.0 2023-12-07 11:07:37 -06:00
advplyrandGitHub 7306ffa709 Merge pull request #972 from JannikGohr/german_translation
Add German Translations
2023-12-06 17:45:41 -06:00
JannikGohr 6f289293cc Continue german translation in strings/de.json 2023-12-07 00:09:34 +01:00
advplyr 783f924bb9 Add:Ebook library filters #971 2023-12-05 16:14:20 -06:00
Denis Arnst 044a35aacf SSO: Removed isRest as requirement
Removed in the backend to increase compatibility with possible 3rd party oauth libraries people might use
2023-12-05 09:44:20 +01:00
Tony Edwards 78448894d2 Adds hiding audio book feedback promt/toast 2023-12-05 07:31:03 +00:00
Tony Edwards aca348e783 Adds low feedback mode to podcast downloads 2023-12-05 07:31:03 +00:00
Tony Edwards e6c1874cbb Adds download success toasts to low feedback mode 2023-12-05 07:31:03 +00:00
Tony Edwards 1b06dbd78c Adds basic low feedback mode implementation 2023-12-05 07:31:03 +00:00
advplyr a26f37aa49 Add more translation strings, clean out unused strings #448 2023-12-04 17:53:36 -06:00
Denis Arnst 8af3c09458 SSO: Add redirect_uri to oAuth auth request
Also:
- Add client_id with the name of the app (for possible future use)
- Add response_type simply to provide all required oauth2 parameters for reference
- For the URL to be opened in Browser: Forward the callback/redirect_uri URL provided by the server
- Use a hardcoded variable for HTTPS enforcement, for easier debugging
2023-12-04 22:43:14 +01:00
advplyr fed6579e08 Merge branch 'master' of https://github.com/advplyr/audiobookshelf-app 2023-12-03 17:37:14 -06:00
advplyr 6621f8b2ee Add:Language code setting and translations #448 2023-12-03 17:37:01 -06:00
advplyrandGitHub 737b6d7c26 Merge pull request #964 from Sapd/seek-fix
Fix track skipping on iOS (Fixes #686)
2023-11-30 17:06:33 -06:00
Denis Arnst 59c71b71fb Fix track skipping on iOS (Fixes #686)
When we seek into a different chapter, we will have to reset the progress of the current one before seeking to the new one.
2023-11-29 17:30:34 +01:00
advplyr f89148b92e iOS version bump 0.9.68 2023-11-25 15:03:14 -06:00
advplyr 88fbebe5c4 Fix:Error going to local library item page that is storing an old server library item id when server is now using new ids #953 2023-11-25 14:38:40 -06:00
advplyr e8abb18350 Version bump v0.9.68-beta 2023-11-25 13:26:12 -06:00
advplyr cc9dead1eb Remove unused query property 2023-11-25 13:10:46 -06:00
advplyr f9dd8188ee Update:Clicking local podcast from home page will filter by downloaded episodes #958 2023-11-25 13:09:43 -06:00
advplyr 579785d2c6 Add:Downloaded filter for podcast episodes #959
Fix:More menu button not working for podcasts with some episodes downloaded
2023-11-25 12:56:29 -06:00
advplyr 257a1ceb51 Fix:Check network connection before redirecting local item to server item #958 2023-11-25 12:28:28 -06:00
advplyr 5c60cd136c Fix password login to send empty string instead of null to support passwordless root user login 2023-11-25 11:17:35 -06:00
advplyr 2bfe548c52 Add:Monochrome app icon & capitalize app name #786 2023-11-22 14:00:55 -06:00
advplyr 164fd92a18 Fix:Android auto unable to play podcast episodes #832 2023-11-21 11:33:45 -06:00
advplyr 0036a19659 Fix:Downloaded item server user id mismatch alert to support connections with old user id #945 2023-11-21 10:55:26 -06:00
advplyr ef4d7ba1bf Fix openid path that is still expecting a trailing slash 2023-11-19 14:45:35 -06:00
advplyr 8dbffa7993 iOS version bump 0.9.67 2023-11-17 15:44:33 -06:00
advplyr 17ff2ed5f4 Version bump 0.9.67-beta 2023-11-17 15:27:02 -06:00
advplyr 6fe470cfc1 Update:Android remove folder scanning and ffmpegkit 2023-11-16 14:52:11 -06:00
advplyr 301e9b213f Update message alerts for locally downloaded media. Added alert for same server address but different user. 2023-11-16 13:28:12 -06:00
advplyr 2b533524fc Add:Tags in search results #940 2023-11-15 14:30:57 -06:00
advplyr 632b1f526b Update:Library item pages show alerts on if the item is linked to the current server
- Local library item pages redirect to server item page if connected to the same server address
2023-11-14 17:13:49 -06:00
advplyr 81b11bac93 Fix:iOS downloading not saving progress for local copy #825 2023-11-13 16:33:19 -06:00
advplyr a88e404ccf Update:Trim trailing slash from server address input 2023-11-13 15:22:15 -06:00
advplyr a7cbdbfbbb Update:iOS remove paused timer that synced with the server when current media was paused 2023-11-12 13:49:20 -06:00
advplyr ad5146e2ee Update:iOS remove unused logic of opening last active session on app launch 2023-11-12 13:36:23 -06:00
advplyr 36be91962c Update:iOS using new sync local sessions endpoint
- remove local session sync function call on starting playback
- Add User model and getCurrentUser api function
2023-11-12 13:19:36 -06:00
advplyr ff5a1bb09f Update:Server connect page shows message informing new users #912 2023-11-12 10:30:39 -06:00
advplyrandGitHub 5060554640 Merge pull request #887 from atoghyani/ios-sleep-timer-reset-after-resume
Adding a check so chapter sleep timer is set after resume only when a…
2023-11-12 08:35:44 -06:00
advplyrandGitHub 93cb319e97 Merge pull request #906 from Sapd/oauth2-support
OAuth2 Support
2023-11-11 14:49:49 -06:00
Denis Arnst a42dfa5649 Don't check version for now
- Be more explicit on error 404, to not confuse users
- Error for when there is no authCode
2023-11-11 21:30:10 +01:00
advplyr 6a938b8da1 Update button UI to be full width 2023-11-11 14:00:48 -06:00
advplyr 4f994072ad Check for duplicate config when authenticating with openid 2023-11-11 13:55:32 -06:00
advplyr 7c0ef179ec Update:Podcast search page renamed to add #936 2023-11-10 16:36:07 -06:00
Denis Arnst 5fdd0c6672 oauth2: Fix PKCE
- Send challenge to the SSO provider itself too
- Fix challenge generation
- Removed semicolons
2023-11-10 22:30:55 +01:00
advplyr e9f523d7cb Fix:Android downloading finished item not marked as finished #903 2023-11-07 16:04:01 -06:00
advplyr c1b13dcd24 Update:Hide duration on collection items that have no duration 2023-11-06 16:21:52 -06:00
Denis Arnst 0daa043f14 oauth: Customizable OpenID Buttontext and auto-launch 2023-11-05 21:35:42 +01:00
Denis Arnst 1a6b716046 oauth2: Add comments
- Move appUrlOpen to represent the correct order of flow
2023-11-05 21:01:51 +01:00
Denis Arnst 945baa24f0 Connect: Check if audiobookshelf and check for version 2023-11-05 20:32:14 +01:00
advplyr 30d4e709f0 Merge branch 'master' into oauth2-support 2023-11-05 10:58:17 -06:00
Denis Arnst 8d8782a5a9 oauth2: Implement PKCE
- Also fixed URLs, the / after ${this.serverConfig.address} had to be removed, because during Connection flow the URL is now (re)set correctly uniformly
- Removed now uneccessary callback? parameter from first auth request
2023-11-05 17:27:04 +01:00
advplyr 087e8553c4 Update:Bookmarks modal is not closed on updating/deleting and does not show a toast #882 2023-11-04 16:08:19 -05:00
Denis Arnst 2984398051 ServerConnect: Improve connect flow
* Prefer HTTPS if no protocol given
* Retry with HTTP if no protocol given and HTTPS TCP establishment was not successful
* Do validation checks, i.e. control for unexpected redirects. Don't allow for protocol downgrade
* Provide good user messages on failure
* Fix a bug where it would use the wrong protocol after connecting bc. of unchecked redirect
* Reworked getRequest(...) to get information about the resulting URL and detailed errors
2023-11-04 15:25:03 +01:00
advplyr e9251db647 Add more menu on playlist item row to show library item more menu #914 2023-11-03 15:53:38 -05:00
Denis Arnst 6c88337180 oauth2: Force HTTPS, check state
Also improve error handling
2023-11-03 21:29:37 +01:00
advplyr 74b488ad0f Update playlist table design to include progress bar on items #914 2023-11-03 13:00:45 -05:00
advplyr fc7af6d1fc Add:Send ebook to device button #909 2023-11-02 16:10:55 -05:00
advplyr 737d8f19b3 Move appUrlOpen listener to plugin and listen for eventBus event 2023-11-01 10:16:31 -05:00
advplyr d613d8954d Fix host path extra port, fix formatting 2023-11-01 09:30:52 -05:00
advplyr 04e468b43d Fix:Android seeking from notification widget on multi-track books #894 2023-10-31 16:20:28 -05:00
advplyr dc8178769b Fix:Series page sort order by name #925 2023-10-31 16:00:08 -05:00
Denis Arnst 8b631f7860 oauth2 support on Android 2023-10-13 23:14:52 +02:00
Denis Arnst e521ddfab6 OAuth2 Support
Using CapacitorHttp and in-app link
2023-10-08 18:36:28 +02:00
advplyr 6b164bdb27 Fix:Clicking series item in list view when collapsing series #877 2023-10-07 13:39:47 -05:00
Arian Toghyani 2eb399b8a7 Adding a check so chapter sleep timer is set after resume only when a chapter sleep timer was set before 2023-09-26 22:35:01 -07:00
advplyr 46f558d6e0 Update axios requests to nativeHttp, add openid connect auth button 2023-09-25 17:30:39 -05:00
advplyr 00bed39733 iOS version bump 0.9.66 2023-09-17 14:12:08 -05:00
advplyr 2048795c92 Version bump 0.9.66-beta 2023-09-17 13:35:08 -05:00
advplyr 8c9bd53934 Hide lock orientation setting on iOS because it is not supported 2023-09-17 12:53:38 -05:00
advplyr 01178d00bf Replace existing axios calls with native https calls 2023-09-17 12:43:50 -05:00
advplyr 7541e4b660 Fix:Android mp3 index seeking setting not sticking #638 2023-09-17 11:51:15 -05:00
advplyr 2366c938b5 Update:Remove non-breaking spaces from comma separated name strings 2023-09-17 11:37:31 -05:00
advplyr 59480ad114 Update:Add switch server/user button in side drawer and change button text on account page. Update logout to disconnect #628 2023-09-17 10:23:35 -05:00
advplyr ce7779607f Update:Bookmarks modal UI #876 2023-09-17 09:28:43 -05:00
advplyr 04bc74babe Update:Epub ereader show location at bottom of page #766 2023-09-16 15:12:20 -05:00
advplyr ac2b674ba5 Fix:Epub ereader swiping moving 2 pages #802 2023-09-16 09:38:20 -05:00
advplyr e53901e7a4 Fix:Incorrect line-height on epub titles #874 2023-09-16 09:30:51 -05:00
advplyr 3ede41f496 Add java version to github workflow 2023-09-16 08:18:13 -05:00
advplyr 0adeaaa04d Add java version to github workflow 2023-09-16 08:08:45 -05:00
advplyr 57d1fbfa83 Disable CapacitorHttp and add plugin to replace axios calls #781 2023-09-15 17:35:44 -05:00
advplyr 27bbe40874 Update to capacitor v5, update to latest gradle and android target 34 2023-09-14 18:05:49 -05:00
advplyr 60da6093f3 Update:Android auto include explicit indicator #611 2023-09-12 17:14:28 -05:00
advplyr 259c9cef20 Update:Podcast items show number of incomplete episodes #315 2023-09-11 17:55:59 -05:00
advplyr 6d61b2acaf Fix:Switch to library of item when clicking on title of fullscreen audio player #803 2023-09-11 17:08:15 -05:00
advplyr 72f687b079 Fix:Android auto loading podcast episodes #832 2023-09-11 12:25:51 -05:00
advplyr 0ce3c8bcbd Fix:Remove local playback sessions started offline, refresh open player timestamps when device gains focus 2023-09-10 17:51:53 -05:00
advplyr f9621d2f60 Fix:Progress lost when app in background for period of time #828 2023-09-10 15:10:26 -05:00
advplyr e634132a39 Fix:Android sync server progress to local progress #808 2023-09-10 13:37:41 -05:00
advplyr 59b428100b Sync local sessions when network reconnects 2023-09-10 12:46:57 -05:00
advplyrandGitHub 36e0c91e27 Merge pull request #851 from lkiesow/author-separator
Fix separator between authors
2023-09-10 08:39:20 -05:00
advplyr 03225957aa Update initial server connection to not block the home page 2023-09-10 08:38:11 -05:00
Lars Kiesow 77f70e48de Fix separator between authors
This patch fixes a small problem with the separator between several
authors which is `Author A , Author B`, but should be instead
`Author A, Author B` like with the other metadata fields.
2023-08-30 23:36:18 +02:00
advplyr cbdc3513c7 Fix:Comic reader not detecting file sort order when number is more than 5-digit 2023-08-29 16:06:20 -05:00
advplyr 394479bea0 Update readme, add matrix link 2023-07-27 18:19:49 -05:00
advplyrandGitHub b9592e46a7 Merge pull request #813 from casrou/master
Add missing space for authors as well #777
2023-07-26 18:02:38 -05:00
advplyr 5810a83c66 Fix:Android internal storage downloads audio files multiple times #791 2023-07-26 18:02:25 -05:00
Casper Roursgaard Christensen 389c7d23ca Revert to index as key 2023-07-27 00:56:47 +02:00
Casper Roursgaard Christensen defc7d216a Add missing space for authors as well #777 2023-07-26 23:29:54 +02:00
advplyr 852702112a Update:Enable CapacitorHttp to patch XMLHttpRequest to use native libraries, fixes cors #781 2023-07-12 17:13:57 -05:00
advplyr dd91bc6667 Fix:Item page comma separated lists space before comma #777 2023-07-12 16:56:23 -05:00
advplyr cdbbd56a58 Merge branch 'master' of https://github.com/advplyr/audiobookshelf-app 2023-06-25 14:29:09 -05:00
advplyr d6a1eba657 Fix:Android file provider to use application id 2023-06-25 14:29:02 -05:00
advplyr da899bfddd iOS version bump 0.9.65 2023-06-24 16:16:56 -05:00
advplyr b7b746fb2a Version bump 0.9.65-beta 2023-06-24 15:38:56 -05:00
advplyr c8053355df Add:Open/close RSS feed for library items 2023-06-24 14:45:25 -05:00
advplyr 1ddb6eca00 Fix:iOS local podcast episode progress #719 2023-06-24 10:37:56 -05:00
advplyr f42c624cba Update:iOS saving progress for offline ebooks 2023-06-24 09:41:14 -05:00
advplyr f8fbeef9b7 Fix:Add podcast form cutoff by open player #750 2023-06-21 17:23:51 -05:00
advplyr 46b82a81ce Update:iOS deleting downloaded item redirects to downloads page 2023-06-20 16:34:44 -05:00
advplyr 48342e5bd3 Fix:iOS offline reading and initialization 2023-06-20 16:29:56 -05:00
advplyr 147f89f870 Add:iOS offline ebooks 2023-06-19 17:42:15 -05:00
advplyr 8b39ba9c92 Fix dependency issue 2023-06-19 15:28:39 -05:00
advplyr 13d32dc1f7 Fix:Android internal file paths using file provider 2023-06-19 15:09:12 -05:00
advplyr 0d1312e497 Fix:Android auto filter out ebook only from downloaded tab 2023-06-19 14:03:29 -05:00
advplyr ff4f8324e7 Update:Syncing playback time when media item is open in player 2023-06-19 12:37:44 -05:00
advplyr b4bf10d409 Set epubjs version to 0.3.88 to work on ios, fix epub reader height 2023-06-18 17:11:22 -05:00
advplyr ae37861f7d Add:iOS support offline episode chapters #675 2023-06-18 15:34:56 -05:00
advplyr 1da03cdd65 Add:Podcast episode chapters for local eps #675 2023-06-18 15:12:07 -05:00
advplyr a7443d0992 Fix:Persist Total Track setting in audio player #663 2023-06-18 14:53:01 -05:00
advplyr 5a953464ab Update:Remove toast for mark as finished #718 2023-06-18 14:26:04 -05:00
advplyr 953bde5a42 Update:Add epub ereader settings and table of contents 2023-06-18 13:31:38 -05:00
advplyr 481d0d0285 Update:Show status bar when ereader toolbar is shown 2023-06-18 08:59:35 -05:00
advplyr 7b3a005ad6 Update:Ereader fullscreen #731 2023-06-17 17:34:08 -05:00
advplyr 4f9d341f69 Update:Delete local item/episode options in context menu #690, android: remove scan options for local server items & remove option 2023-06-17 12:49:24 -05:00
advplyr 9e7d1d1617 Add:Android setting to use mp3 index seeking #638 2023-06-17 11:27:44 -05:00
advplyr 4f03c2a35d Update:Cleanup chapters modal & increase width 2023-06-13 17:15:28 -05:00
advplyr db3c5396e9 Update:UI/UX improvements for playlists 2023-06-11 17:03:03 -05:00
advplyr 455af239a1 Update:Increasing touch size for seek bar cursor #713 2023-06-11 14:20:33 -05:00
advplyr d8bc26f5f8 Add:Ebook files table and supplementary ereader 2023-06-11 13:36:19 -05:00
advplyr 543ac209e4 Add:Progress for comics #738 2023-06-11 11:12:52 -05:00
advplyr c8b5cefeb5 Fix:Playlist items playing from server and not using local copy #734 2023-06-09 17:05:29 -05:00
advplyr f1411011e6 Update:Android include DeviceInfo with local playback session 2023-06-08 17:19:31 -05:00
advplyr f3964dda17 Update:Home screen server loading to not block local item access #708 2023-06-04 17:14:26 -05:00
advplyr d207e88e18 Update:Save last scroll position for item pages #717 2023-06-04 15:52:36 -05:00
advplyr 2643e98fc0 Fix:Back button closes ebook #709 2023-06-04 15:38:26 -05:00
advplyr 410648e316 Fix:Android auto filter out ebook only items 2023-06-04 15:12:56 -05:00
advplyr 373221703d Update:Android download to internal storage option #635 2023-06-04 14:59:55 -05:00
advplyr fbcb8620f9 Update:iOS stream and download URLs to use new API endpoints 2023-06-04 12:21:40 -05:00
advplyr a8ac4970cc Update:Android downloads to use new API endpoint 2023-06-04 11:34:34 -05:00
advplyr 195e33ae32 Update:EReaders to use new ebook API routes 2023-06-04 11:13:05 -05:00
advplyr 9acf695802 Remove file copy 2023-06-03 17:28:37 -05:00
advplyr e6aaccfc74 Update:Android support storing downloads to internal app storage #635 2023-06-03 17:24:32 -05:00
advplyr 87c74fe78b Fix:Playback speed not persisting #532 2023-06-02 16:57:17 -05:00
advplyr 7dc319c1bd Fix:Android app widget updating #658 2023-06-02 16:29:52 -05:00
advplyr c093548b71 Update:Scale chapter times with playback rate #726 2023-06-01 17:36:52 -05:00
advplyr b067a2d3d5 Fix:Libraries modal background colors #723 2023-06-01 17:22:28 -05:00
advplyr a9ae7bfdb8 Update:Fix epub3 background color, increase padding on top of ereader 2023-06-01 17:19:16 -05:00
advplyr 9dcf22614c Update:Android remove stop action when notification cancelled 2023-06-01 16:28:25 -05:00
advplyr c7564d439e Update:Android exoplayer version, target SDK to 33, remove stop button from notification 2023-05-31 17:43:54 -05:00
advplyr 157dc01673 Fix:Android close playback session on server when streaming 2023-05-27 17:20:01 -05:00
advplyr cce6e1d0ab Update:Android local media page to show ebook file 2023-05-25 17:59:24 -05:00
advplyr b935cb5cf1 Fix:Android remove local sessions after sync 2023-05-25 17:23:35 -05:00
advplyr 9ab37ac525 Add:Saving progress for PDF ebooks #712 2023-05-25 17:09:28 -05:00
advplyr 7031c895b5 iOS version bump 0.9.64 2023-05-21 18:41:18 -05:00
advplyr 7c739bf3bf Version bump 0.9.64-beta 2023-05-21 18:01:28 -05:00
advplyr facf56930e iOS add deviceId 2023-05-21 17:19:21 -05:00
advplyr 2c3dff3544 Add:EBook download and offline reading #187 #243 2023-05-21 15:02:49 -05:00
advplyr b1bf68b8cd Add:Series progress to series cards #410 2023-05-21 10:25:10 -05:00
advplyr 78aade7694 Fix:Android delete empty author folder when removing item #701 2023-05-20 18:13:19 -05:00
advplyr 83a4474375 Add:Podcast episode table sort options #376 2023-05-20 17:12:36 -05:00
advplyr 1b47c38efa Fix:Android sleep timer getting set to time left in media #659 2023-05-20 16:51:16 -05:00
advplyr 922d9f22ce Fix chapters button clickable when no chapters 2023-05-20 16:31:51 -05:00
advplyr d7feb52dd8 Fix:Discard progress for local and server media progress #646 2023-05-20 16:29:54 -05:00
advplyr fafaad8b5f Update:Play buttons on item pages show Play/Pause instead of disabled and showing Playing/Streaming #697 2023-05-20 14:49:55 -05:00
advplyr 928f33005a Update:Add play button to collections and playlist table #666 2023-05-20 14:22:53 -05:00
advplyr 0448d20583 Update:Android disable check for updated progress when playing 2023-05-20 13:22:04 -05:00
advplyrandGitHub f9d408b33f Merge pull request #692 from HStep20/master
Added Auto Rewind on Sleep Timer setting to Android
2023-05-20 12:18:17 -05:00
advplyr 9386ae95a3 Fix set sleepTimerSessionId 2023-05-20 12:17:41 -05:00
advplyr c530e0f7e3 Remove test log 2023-05-20 12:00:19 -05:00
advplyr e39a5e1ba3 Fix saveSettings function call 2023-05-20 11:58:27 -05:00
advplyr cb9e317e74 Initialize auto rewind time and update description 2023-05-20 11:56:36 -05:00
advplyr 8061ac0ced Fix auto rewind when check resetting sleep timer 2023-05-20 11:41:39 -05:00
advplyr daff8355d7 Merge branch 'master' into HStep20/master 2023-05-20 10:18:16 -05:00
advplyr 231289e9ac Update:Add podcast type, episode type, episode number, season and support html episode description #677 2023-05-19 17:57:36 -05:00
advplyr 87df022af0 Add:Narrators in search results 2023-05-19 17:18:31 -05:00
advplyr 07fdac9746 Update:Podcast episodes on home page dont show an Episode number if none is set 2023-05-19 17:03:54 -05:00
advplyr 0d482aaa42 Update:Android remove auto-open most recent listened item 2023-05-19 16:59:10 -05:00
advplyr d48d1fdde3 Update:Audio player play method color for visibility #706 2023-05-17 17:59:47 -05:00
hstephens 65b69cc020 fixed if statement syntax 2023-05-12 20:19:18 -06:00
hstephens a3794a6a6a added check before setting state 2023-04-29 17:20:04 -06:00
hstephens 695ab84940 changed behavior for first timer set 2023-04-29 16:48:43 -06:00
hstephens 5399ec67d7 Added Auto Rewind on Sleep Timer setting 2023-04-29 15:38:51 -06:00
advplyr d9a6ec65df Add deviceId to DeviceInfo for Android 2023-04-22 16:48:22 -05:00
advplyr e842de78dd Fix:Audiobook tracks table for local only books 2023-03-28 17:30:54 -05:00
advplyr 260363bf4f Fix:Android media widget initialize DB 2023-03-28 17:30:20 -05:00
advplyrandGitHub 6d7dffd4de Merge pull request #649 from maltejur/largeHeap
Add `android:largeHeap="true"` to `AndroidManifest.xml`
2023-03-27 17:02:09 -05:00
advplyr ef1d8a8eb4 Update:iOS HLS stream buffer set to 50s 2023-03-27 16:54:19 -05:00
Malte Jürgens cdd7a1a636 add android:largeHeap to AndroidManifest.xml 2023-03-27 21:00:52 +02:00
advplyr 6584cd1522 Fix:Android auto sleep timer window #648 2023-03-26 16:43:24 -05:00
advplyr d01b602306 Update epub reader media progress update 2023-03-26 13:50:21 -05:00
advplyr c90f2c136a Add:Support epub progress saving 2023-03-25 17:40:46 -05:00
advplyr 15ccc7192e Fix:Android sleep timer reset volume when shake #626 2023-03-25 16:51:24 -05:00
advplyr e4345a4438 Update:Raw cover on library item page, Android download raw cover #634 2023-03-19 17:13:33 -05:00
advplyr 3f4d564bad Fix:Add try/catch to initCastManager #480 2023-03-10 16:51:45 -06:00
advplyr 3311f66da8 Update readme screenshot 2023-03-10 16:13:04 -06:00
advplyr e711ada0b3 Fix:Center play button when locking ui #624 2023-03-10 15:37:42 -06:00
advplyr 840979d635 Fix play at start time 2023-03-06 16:59:49 -06:00
advplyr 53c9109534 iOS fix local episode duration and playback session title, version bump 0.9.63 2023-03-06 16:47:38 -06:00
advplyr 87ca6018d3 Version bump 0.9.63 2023-03-06 16:11:20 -06:00
advplyr c590087578 Fix description line clamp show more 2023-03-06 15:51:11 -06:00
advplyr 3963ffa0e2 iOS implement start time override 2023-03-06 15:46:07 -06:00
advplyrandGitHub eaa0c9914a Merge pull request #571 from lkiesow/center-title
Center title on book details
2023-03-06 15:30:15 -06:00
advplyr d9b0b8c33d Add chapters & tracks table. Clamp description to 4 lines. Move size to more info modal 2023-03-06 15:27:12 -06:00
advplyr fb4e7e6b55 Merge branch 'master' into center-title 2023-03-06 13:48:30 -06:00
advplyr 78a063a669 Fix:iOS download books with CD subfolders #620 2023-03-06 13:27:57 -06:00
advplyr cb5d32bbb9 Fix epub reader 2023-03-06 13:09:09 -06:00
advplyr f594b734c0 Adding more top padding to ereader 2023-03-06 11:40:00 -06:00
advplyr bf62b82037 Fix ereader padding and swiping 2023-03-06 10:58:08 -06:00
advplyr 39fbb0902e Removing padding above cover on item page 2023-03-05 16:47:04 -06:00
advplyr 7818500f0f Fix:Podcast latest and book authors page refresh/redirect on library change 2023-03-05 11:12:17 -06:00
advplyr 1ea984b915 Update iOS disable touch callout and user select 2023-03-04 17:15:48 -06:00
advplyr f28405081a Update:Audio player controls max width 2023-03-04 16:42:06 -06:00
advplyr 317f00e1b8 Update:Audio player drag cursor to seek #457 2023-03-04 16:26:37 -06:00
advplyr f8ce0b8355 Fix collapse sub series show series name 2023-03-04 14:15:08 -06:00
advplyr 9fd6ff9fd2 Fix sub series navigation not refreshing entities 2023-03-04 14:12:16 -06:00
advplyr 3931020c83 Update:Audio player cover rounded corners and rounded track 2023-03-04 13:59:37 -06:00
advplyr 8bdb6b0b66 Update:Fullscreen audio player track padding 2023-03-04 13:49:58 -06:00
advplyr 33bdee66a3 Add:Collapse series and collapse sub-series settings #99 2023-03-03 17:05:23 -06:00
advplyr a81f50878e Add:Authors page #618 2023-03-02 17:59:11 -06:00
advplyr 322f7d75dd Fix:Show episode title on home page shelves #616 2023-03-02 17:17:49 -06:00
advplyr 2f34da4e99 Fix:Android playback rate set on initial load #532 2023-03-01 17:47:07 -06:00
advplyr 5a9e30b08c Update:Show raw cover image in audio player #614 2023-02-27 16:30:18 -06:00
advplyr 04e233816d Android fix for playing items with no cover in development 2023-02-26 17:21:03 -06:00
advplyr 3ad1042b13 Fix:Android bluetooth controls for fast forward/rewind 2023-02-26 15:56:39 -06:00
advplyr 4fb9201823 Update:Additional padding in local library items folder page #592 2023-02-26 14:57:04 -06:00
advplyr 02089e1489 Update:Increased padding on modals 2023-02-26 14:41:18 -06:00
advplyr da442f4a17 Fix:Clicking author card on home page filter/sort #607 2023-02-26 14:26:41 -06:00
advplyr a0f8e6fb93 Fix:Initializing haptic feedback setting on app launch #603 2023-02-26 12:54:56 -06:00
advplyrandGitHub 20cf5a6132 Merge pull request #597 from KeyboardHammer/widget
Android Home Screen Widget
2023-02-26 11:30:16 -06:00
advplyr f5ca30620f Update android widget sizing, hide buttons on app close, and initialize when added 2023-02-26 11:26:34 -06:00
Lars Kiesow 72ec9d81b1 Singular & Plural 2023-02-26 13:19:23 +01:00
Lars Kiesow 29568412e6 four columns 2023-02-26 13:19:23 +01:00
Lars Kiesow f94140ec6b expand 2023-02-26 13:19:23 +01:00
Lars Kiesow 51062e7403 2023-02-26 13:19:22 +01:00
Lars Kiesow 0942bd2548 move title down 2023-02-26 13:19:22 +01:00
Lars Kiesow 5939fee66f one-big-table 2023-02-26 13:19:22 +01:00
Lars Kiesow 68560fd5f5 move series down 2023-02-26 13:19:22 +01:00
Lars Kiesow a38b826898 Center title on book details
This patch centers the title, author and series metadata on the book
details page below the cover. This makes this view look similar to the
places where the metadata are also places in the center.
2023-02-26 13:19:22 +01:00
advplyr 0158ccc4c8 Hide widget action buttons when app closes 2023-02-25 18:13:03 -06:00
Alex a5ba6c64c9 redo widget layout, hide buttons on force close 2023-02-25 18:02:20 -06:00
advplyr d67454db9a Fix:Update bookshelf on screen orientation change #602 2023-02-25 16:53:48 -06:00
advplyr fb35d8b1a0 Add:Podcast episode page and update episode lists to not show description #588 2023-02-25 16:33:06 -06:00
advplyr 960f75bfba Fix:Android cancel sleep timer resets volume #605 2023-02-25 09:28:22 -06:00
advplyr 83027a5787 Fix:Android local podcasts creating playback sessions with bad duration and displayTitle 2023-02-24 17:42:34 -06:00
Alex bc6ef96465 Android Home Screen Widget 2023-02-21 22:20:49 -06:00
advplyr ff77dbf3f8 iOS update pods 2023-02-19 16:53:43 -06:00
advplyr 3f78fb7a14 iOS version bump 0.9.62 2023-02-19 15:40:24 -06:00
advplyr 152904b7c7 Merge branch 'master' of https://github.com/advplyr/audiobookshelf-app 2023-02-19 14:51:41 -06:00
advplyr 9214fecc86 Version bump 0.9.62-beta 2023-02-19 14:51:34 -06:00
advplyrandGitHub 049397f7ab Merge pull request #565 from jramer/master
fix: [iOS] seek pauses playback
2023-02-19 14:23:45 -06:00
advplyr 0f42a411a9 Merge branch 'master' of https://github.com/advplyr/audiobookshelf-app 2023-02-19 14:17:28 -06:00
advplyr 4575c61118 Update:Android foreground service updates 2023-02-19 14:17:22 -06:00
advplyr 7575a4f883 Merge branch 'master' into jramer/master 2023-02-19 11:47:20 -06:00
advplyr 8d4c322bce Fix:iOS fallback to transcoding #553 2023-02-19 11:47:05 -06:00
advplyr 28989f536a Set up for android widget 2023-02-19 11:14:30 -06:00
Joakim Ramer 3318f3501c fix: seek when changing track 2023-02-19 17:42:45 +01:00
Joakim Ramer c9ad0c7897 Merge branch 'master' of github.com:jramer/audiobookshelf-app 2023-02-19 15:04:46 +01:00
advplyr 018a988124 Merge branch 'master' into jramer/master 2023-02-18 10:20:01 -06:00
advplyr 750c370692 Fix:Android download file with capital M4B extension 2023-02-18 10:08:10 -06:00
advplyr 08ee08c698 Update:Show loading indicator when first connecting to server 2023-02-17 17:47:46 -06:00
advplyr d899fd4d89 Android & iOS only open most recent session on first load 2023-02-17 17:14:49 -06:00
advplyr aab44d8cee Fix:Alignment of recent sessions list #587 2023-02-17 16:51:11 -06:00
advplyr 2c2fac6e34 Update:Android download 3 audio files at a time #567 2023-02-17 16:24:08 -06:00
advplyr 47ccc54749 Fix:Reorder local audio files overlapping save button 2023-02-17 16:07:37 -06:00
advplyr 05d7d0d0d8 Fix:Android re-scan folder removing/unlinking downloads 2023-02-17 15:49:53 -06:00
advplyr 210fb7b88f Merge branch 'master' of https://github.com/advplyr/audiobookshelf-app 2023-02-17 13:09:42 -06:00
advplyr 4eeb7f24d8 Fix:iOS downloads #537 2023-02-17 13:07:35 -06:00
Joakim Ramer 9496ee1d97 Merge branch 'master' of github.com:advplyr/audiobookshelf-app 2023-02-16 23:06:32 +01:00
Joakim Ramer f4b05a3236 Clarify state 2023-02-16 23:00:33 +01:00
Joakim Ramer b73763a9ec fix: Fixes streaming issues 2023-02-16 22:53:37 +01:00
advplyrandGitHub e3aad8c44f Merge pull request #582 from lkiesow/build-on-workflow-change
Build APK on Workflow Change
2023-02-14 06:27:51 -06:00
Lars Kiesow 1e7c298aa2 Build APK on Workflow Change
This patch removes a few restriction about when the Android build
workflows run. In particular, we want to run workflows…

- If the workflows themselves change because that could mean that the
  output changes.
- If the deployment page template changes because that would mean that
  the published page needs to be updated.

We could still exclude a few specific files if we wanted to, but they
should rarely change and it's probably not worth the effort of keeping
those lists updated.
2023-02-14 13:16:02 +01:00
advplyrandGitHub b9652c7cd8 Merge pull request #581 from lkiesow/test-page
Publish Android Test Page
2023-02-13 17:31:43 -06:00
advplyrandGitHub 04f00d2994 Update .github/workflows/deploy-apk.yml 2023-02-13 17:31:13 -06:00
advplyrandGitHub 3da6728f4c Add ignore paths to build-apk workflow 2023-02-13 17:27:57 -06:00
Lars Kiesow 5704ca2ae8 Publish Android Test Page
This page automatically builds the Android app from the latest commit on
the branch master, generates a test page and publishes it to GitHub
Pages as a nice way for non-developers to get the latest app version for
testing without having to build it themselves.

This page might need the pages source set to GitHub Actions at
Settings → Pages → Build and deployment → Source.
2023-02-13 21:42:54 +01:00
advplyr 6cd04e7098 Remove Gentium Book Basic font 2023-02-12 12:16:38 -06:00
advplyrandGitHub 290c3560d2 Merge pull request #574 from lkiesow/easy-continue-listening
Easy way to play current audiobook
2023-02-12 08:55:15 -06:00
advplyr 40bda3d48b Auto play podcast episode & check for local copy 2023-02-12 08:47:38 -06:00
advplyrandGitHub 63a838dc9d Merge pull request #577 from lkiesow/auto-build
Automatically test and build app
2023-02-12 08:03:43 -06:00
Lars Kiesow 127bf7d4e0 Automatic test and build app
This patch adds a GitHub Actions workflow which will automatically run
through all steps to build the Android version of the app. This is an
easy way of catching major build errors in pull requests or pushed
commits.

Additionally, this uploads the Android APK as build artifact. This
allows anyone to easily download any build for a while. That will make
it much easier for non-developers to participate in testing a new
version or a specific test since the no longer have to wait for a new
release.

The build artifacts are a bit tricky to find, but they are easy to point
out if anyone asks.
2023-02-12 01:21:16 +01:00
advplyrandGitHub 9af4947232 Merge pull request #576 from lkiesow/account-settings
Account Setting Information
2023-02-11 17:29:25 -06:00
advplyrandGitHub 3233a2a078 Merge pull request #575 from lkiesow/typeerror
Fix TypeError on Logout
2023-02-11 17:28:17 -06:00
Lars Kiesow 28ede56c7c Account Setting Information
The account setting list the connection name which is the server address
directly followed by the username. This is then followed by a field
repeating the username again.

This duplication of information is nor really helpful, which is why this
patch replaces this with two fields containing just the two information:
host address and username.
2023-02-11 23:53:01 +01:00
Lars Kiesow 5f6c1f57b0 Fix TypeError on Logout
Clicking on “logout”, you get the following TypeError:

```
TypeError: state.user is null
    getUserBookmarksForItem user.js:38
    bookmarks AudioPlayerContainer.vue:45
    VueJS 4
    render AudioPlayerContainer.vue:11
    VueJS 13
    logout user.js:115
    wrappedMutationHandler vuex.esm.js:844
    commitIterator vuex.esm.js:466
    commit vuex.esm.js:465
    _withCommit vuex.esm.js:624
    commit vuex.esm.js:464
    boundCommit vuex.esm.js:409
    _callee$ account.vue:67
    tryCatch pdf_viewer.js:1329
    invoke pdf_viewer.js:1502
    defineIteratorMethods pdf_viewer.js:1372
    Babel 10
    logout account.vue:56
    VueJS 4
    click Btn.vue:65
    VueJS 33
client.js:103
```

This patch fixes the problem by checking for `null` values.
2023-02-11 23:43:20 +01:00
Lars Kiesow ec704bf501 Easy way to play current audiobook
This patch implements a generic way of re-opening the player for the
book you last listened to. This is especially handy when first opening
the app and you can restart playback with a single click.

The player is opened only if no other book is already open in the
player. It will not overwrite or replace playbacks or player already in
progress.

The player is opened paused. No automatic playback since that could be
really annoying.

This closes #494
2023-02-11 23:14:41 +01:00
advplyrandGitHub 5e0c740f02 Merge pull request #572 from lkiesow/hyphen-auto
Improve word wrapping
2023-02-10 17:48:44 -06:00
Lars Kiesow a185c3abe9 Improve word wrapping
This patch improves word wrapping for the description text in book
details by justifying the text block and allowing for words to be
wrapped by automatically inserting hyphens if necessary.

This causes the description box to look far less ragged on the right
edge which I think helps the overall cleanliness of the look.

Unfortunately the app's/browser's aren't as good as e.g. the TeX
algorithm for hyphening and there are JavaScript libraries which seem to
reimplement that. But this is already a significant step up and far less
work for something which is not the main focus of this app.
2023-02-10 23:27:33 +01:00
advplyrandGitHub e8c6fc635f Merge pull request #569 from lkiesow/blurred-cover-bg
Blur Cover Image Background
2023-02-09 18:14:01 -06:00
advplyr f362db3855 Add prop to BookCover to not show a background 2023-02-09 18:13:30 -06:00
advplyrandGitHub 9ae584665e Merge pull request #568 from emmertex/master
Disable ATS for iOS
2023-02-09 17:51:20 -06:00
Lars Kiesow 1aa852c3dc Blur Cover Image Background
This patch adds a heavily blurred version of the cover image as
background to the cover image area in the book details.

This looks especially nice on larger devices like tablets.
2023-02-09 23:57:59 +01:00
Joakim Ramer ef46ad7a93 fix: removes some warnings 2023-02-09 02:40:21 +01:00
Joakim Ramer cbd74d08e1 fix: seek pauses playback 2023-02-09 02:38:56 +01:00
advplyrandGitHub 851d0357a0 Merge pull request #564 from lkiesow/lockfile-warning
Re-generate lock file
2023-02-08 17:09:48 -06:00
advplyrandGitHub fb5c23d190 Merge pull request #563 from lkiesow/menu-padding
Increase padding for menus
2023-02-08 16:57:29 -06:00
advplyrandGitHub 1743d0e13f Merge pull request #562 from lkiesow/book-details-menu-items
Icons for book details menu
2023-02-08 16:56:00 -06:00
advplyrandGitHub 25af0c4170 Merge pull request #561 from lkiesow/simplify-progress
Simplify progress (re)sets
2023-02-08 16:53:24 -06:00
Lars Kiesow 4bcb77250d Re-generate lock file
Running `npm ci` you always get a warning about an old lock-file format
being used:

```
npm WARN old lockfile
npm WARN old lockfile The package-lock.json file was created with an old version of npm,
npm WARN old lockfile so supplemental metadata must be fetched from the registry.
npm WARN old lockfile
npm WARN old lockfile This is a one-time fix-up, please be patient...
npm WARN old lockfile
```

This patch simply re-generates the file by removing the old one and
running`npm install`. That gets rid of the warning.
2023-02-08 23:22:54 +01:00
Lars Kiesow 6114e03cb7 Increase passing for menus
This patch slightly increases the padding for menu dialogs. No real
reason for this except that I like the look of this better ;-)
2023-02-08 23:19:37 +01:00
Lars Kiesow 9866a787bd Icons for book details menu
This patch adds icons similar to those used in other app menus to the
book details menu.
2023-02-08 23:09:52 +01:00
Andrew Frahn 20e64523b2 Disable ATS for iOS 2023-02-09 08:38:50 +11:00
Lars Kiesow 50a53ec0d1 Simplify progress (re)sets
This patch is a follow up to the discussion from #559 about potentially
simplifying progress interactions.

This patch essentially reduces the interactions to two simple options
which allow users to always marks books as finished or discard the
progress and thus mark them as not yet started with a single
interaction:

- If users have already listed to the book, regardless of the state of
  the progress (finished, half way through, …), they get an option to
  discard the progress. This replaces the additional “mark as not
  finished” option but it should still be clear to users what happens.

- If a book is not yet finished, regardless of the state of the progress
  (not yet started, half way through), users get a “mark as finished”
  option which sets the progress to 100%.

This is hopefully a bit simpler than what we have now but also doesn't
require a user to figure out that you need to first mark a book as
finished to be able to discard the progress.
2023-02-08 22:27:56 +01:00
advplyrandGitHub 40b2ff5a97 Merge pull request #560 from lkiesow/account-version-github
App info in account settings
2023-02-07 17:54:58 -06:00
Lars Kiesow 48cc000b95 App info in account settings
This patch updates the account settings view, trying to make it look a
bit cleaner by:

- Making the logout button look more like a button. The full width
  looked odd on large devices or in landscape mode.
- Removing the version number. It is already listed in the main menu
  anyway.
- Moving the hint about reporting bugs and the GitHub link to the
  bottom.

The hint is still a bit weird in this view. Maybe long-term, we want to
move all these information about the app (version, issues, …) to a
separate view. But that's something for another day.
2023-02-07 23:55:21 +01:00
advplyrandGitHub 75aaf4d784 Merge pull request #559 from lkiesow/discard-progress-menu-option
Move Discard Progress to Menu
2023-02-07 16:45:04 -06:00
advplyr 23220ecae6 Fix podcast page returning item progress 2023-02-07 16:44:23 -06:00
advplyrandGitHub 94b4dda94f Merge pull request #554 from KeyboardHammer/aaSpeed
Add playback speed adjustment for Android Auto
2023-02-07 16:38:13 -06:00
advplyr 86c5d1a3e9 Refresh media session connector actions when changing speed on app. Update rounding values for Android Auto speed icons 2023-02-07 16:10:02 -06:00
Lars Kiesow d7be01935f Move Discard Progress to Menu
This patch moves the discard progress functionality from the tiny icon
at the top right corner of the progress display to the options menu.

The reasoning for this is that resetting progress is an option which is
unlikely to be used very often and therefor does not need to be on the
main screen. In addition to that, the menu already holds the related
options to mark the book as finished and to discard the progress of a
finished book.

Finally, this removes the tiny icon which does not really match the rest
of the user interface of the app.
2023-02-07 22:42:06 +01:00
Alex b2b647e2f7 fix float conversion, rounding for icons 2023-02-06 20:41:35 -06:00
advplyr 405cd21c32 Emit playback speed change event when Android Auto changes speed 2023-02-06 17:20:39 -06:00
advplyr 17a894f5ef Update Android Auto playback speed string 2023-02-06 16:34:12 -06:00
Alex 23c90f4118 Add playback spped adjustment for Android Auto 2023-02-05 19:38:12 -06:00
advplyr f215efdcd0 Update:Syncing local sessions rewrite to support offline sessions #381 2023-02-05 16:54:46 -06:00
advplyr 2f243787ce Add:Android sleep timer setting to disable vibrate on reset #552 2023-02-04 17:52:31 -06:00
advplyr 39909a398e Add:Android sleep timer setting to disable audio fade out #320 2023-02-04 17:39:41 -06:00
advplyr 5e98a4ff2f Update:Android cancelling sleep timer when it was auto-enabled will also disable the auto sleep timer setting with alert #545 2023-02-04 16:57:55 -06:00
advplyr 8710775872 Fix:Android download freezing UI when a lot of files are downloaded #550 2023-02-04 15:52:06 -06:00
advplyr 99d60eaf9a Update:Adjust current playback time with playback speed #549 2023-02-04 13:50:54 -06:00
advplyr 55b7f05685 Fix:Android media play/pause event #429 #427 2023-02-04 11:13:19 -06:00
advplyr 944aa5de66 Fix:Android download book play times in history 2023-02-04 10:32:36 -06:00
advplyr a12eb7efdf Fix:Android sync fails when restarting playback session 2023-02-04 10:11:31 -06:00
advplyr 62a5a2d069 Update:Increase height of minimized audio player to accomadate iOS safe area #531 2023-01-31 15:30:13 -06:00
advplyr ebd65b4fa7 Fix:Back button closing modals and fullscreen player #535 2023-01-31 14:50:26 -06:00
advplyrandGitHub 152cce2e9e Merge pull request #534 from lkiesow/player-progress-position
Player Progress Bar Position
2023-01-31 14:18:07 -06:00
advplyrandGitHub ea847a62ff Update components/app/AudioPlayer.vue 2023-01-31 14:17:45 -06:00
advplyrandGitHub 2a5ec618f0 Merge pull request #533 from lkiesow/metadata-separators
Fix Metadata List Separators
2023-01-31 14:10:35 -06:00
Lars Kiesow 3fc89377b4 Player Progress Bar Position
This patch moves the progress bar slightly up for a better separation
between playback controls and seeking.

The reasoning behind this is that I (and probably others as well) often
interact with this interface in a clumsy, half-asleep state. Due to the
closeness of the progress bar and the play/pause button, I have managed
it several times to accidentally seek instead of pause. That is really
annoying since you then have to manually find the previous playback
position again.

Long term, I would love to see more of the available space being used.
In particular in the player interface. There should be enough free space
available. Especially if you have just one of the progress bars active.
But for now, this is just a quick-fix to improve the current situation.
2023-01-31 15:31:06 +01:00
Lars Kiesow 93c16e6a62 Fix Metadata List Separators
This patch fixes the separators between metadata list entries in the book
detail view. Items were separated only by comma. A space was missing.

This just adds a space to both the genre as well as to the narrator
separators.
2023-01-31 14:43:49 +01:00
advplyr 84632c8a32 iOS version bump 0.9.61 2023-01-30 18:02:52 -06:00
advplyr df717fc87f Version bump 0.9.61-beta 2023-01-30 17:16:12 -06:00
advplyr 2b9f5c866b Fix home page shelves refreshing on network changes 2023-01-30 15:38:09 -06:00
advplyr 2de81130a1 Update:Podcast latest page show cover & podcast name #529 2023-01-30 11:39:01 -06:00
advplyrandGitHub 2942d703ee Merge pull request #528 from blampe/seek
Handle skip track events on iOS
2023-01-30 08:18:01 -06:00
advplyr 3ef6c345e4 Fix Realm wrong thread crash by setting values from deviceSettings outside of commandCenter functions 2023-01-30 08:14:32 -06:00
advplyr 0a4052dec2 Fix manage local files redirect on iOS 2023-01-30 07:57:34 -06:00
Bryce Lampe ef4bb6c7a8 Handle skip track events on iOS 2023-01-29 19:54:12 -08:00
advplyr d9c38d1592 Update app bar progress indicator to not navigate to downloading on iOS 2023-01-29 17:53:03 -06:00
advplyr a7227acbc3 Update:Default to alt view and rename setting to use bookshelf view 2023-01-29 17:20:46 -06:00
advplyr bd5229941b Fix:Android sleep timer end of chapter use next chapter if within 2s of current chapter ending when reset #260 2023-01-29 17:11:15 -06:00
advplyr 0583ce617e Add:Continue listening shelf for offline books/episodes #112 2023-01-29 16:04:03 -06:00
advplyr 7462eb3bc4 Fix:iOS play audiobook tracks inside subfolders #483 2023-01-29 14:30:09 -06:00
advplyr 18e15ff056 Remove left & right padding of audio player timestamps on iOS 2023-01-29 14:12:14 -06:00
advplyrandGitHub 4f6468c23a Merge pull request #519 from benonymity/playerUpdates
iOS Player Updates
2023-01-29 14:08:29 -06:00
advplyr 31531197a2 Add:Android auto sleep timer #260 2023-01-29 13:19:06 -06:00
advplyr 479de5f067 Android cleanup 2023-01-29 08:53:10 -06:00
advplyr 03240415b8 Update:Android sleep timer resets on play #260 2023-01-28 17:46:27 -06:00
advplyr f8a6b6a936 Update:Android sleep timer start fading out audio when 60s remaining on timer #320 #260 2023-01-28 17:33:15 -06:00
advplyr 28e50dd461 Add:Android device setting to adjust shake sensitivity for sleep timer #260 2023-01-28 17:31:52 -06:00
advplyr f06afbd08c Update:Sleep timer shake to reset anytime while timer is running #260 2023-01-28 16:07:03 -06:00
advplyr 860c7aac80 Fix:Refreshing server media progress after local sync 2023-01-28 14:20:00 -06:00
advplyr 8bab4ae383 Update:More accurate progress percentage using bytes, download 1 audio file at a time & currently downloading page #251 #360 #515 #274 2023-01-28 11:58:16 -06:00
advplyr 69171e5732 Update:Book downloads will go in author/title subfolders #194 2023-01-28 08:16:16 -06:00
BenandGitHub 31abaa019f Merge branch 'advplyr:master' into playerUpdates 2023-01-26 19:16:21 -05:00
advplyr ad57a90e4d Update iOS deployment target to 14 2023-01-26 17:35:37 -06:00
advplyr 925e8fd17e Update item page bg gradient 2023-01-26 17:14:06 -06:00
advplyr 7b857f04d4 Fix appbar z-index 2023-01-26 17:02:06 -06:00
advplyr 453744a637 Remove log 2023-01-25 17:48:31 -06:00
advplyr 062d4dc069 Fix:Render bookshelf on small screens 2023-01-25 17:32:52 -06:00
BenandGitHub 21bd98b367 Merge branch 'advplyr:master' into playerUpdates 2023-01-24 23:24:51 -05:00
advplyr ccd206e95d Fix cover height overflow on fullscreen audio player 2023-01-24 17:52:28 -06:00
benonymity bca5421dbc update: higher sync interval on low power mode 2023-01-24 17:35:12 -05:00
benonymity 20085c5eed update: increase update intervals 2023-01-24 17:34:53 -05:00
advplyr a8c66ff808 Update capacitor version, kotlin version, android dependencies, refactor some folders 2023-01-22 17:26:08 -06:00
advplyr b2d3edca81 Update:Android hide download notification when finished #495 2023-01-20 16:22:00 -06:00
advplyr 4b7fa7a543 Fix:Clearing filter refreshes library 2023-01-17 17:47:29 -06:00
advplyr a018dcf085 Update:Underline links on library item page 2023-01-17 17:38:48 -06:00
advplyrandGitHub 091c1bcb9f Merge pull request #514 from lkiesow/toggle-total-track
Chapter and Total Track in Player
2023-01-17 15:50:59 -06:00
Lars Kiesow d89c97ba1a Chapter and Total Track in Player
This patch allows user to not only add a chapter track but also remove
the total track. This means that you can have only the total track or
only the chapter track.

This patch does not allow user to have no track at all. Disabling one
track will automatically enable the other one, if that one is already
disabled.

The reasoning behind this is that for long-ish audio books, users will
not very often look at the overall progress. In fact, I find the
progress bar mostly useful only for quickly seeking to a position when
jumping back a few seconds is not sufficient.

In the seldom occasion that I want the overall progress, I can easily
get it from the book details page.
2023-01-17 00:54:17 +01:00
advplyr 56fe350104 Update:Search input clearable #509 2023-01-16 17:44:32 -06:00
advplyr eb552170e3 Increase max playback speed to 10x 2023-01-16 17:34:42 -06:00
advplyrandGitHub 29d022adcc Merge pull request #513 from lkiesow/player-menu-unified-style
Make Player Menu Items Look Alike
2023-01-16 17:04:02 -06:00
advplyrandGitHub d9afdaf56f Update components/modals/Dialog.vue 2023-01-16 17:03:07 -06:00
Lars Kiesow 699976dcd8 Make Player Menu Items Look Alike
Instead of handling some items of the player menu as special cases,
adding icons and colors, this patch allows menu items to have icons in
general, allowing for the removal of the special cases.

This also makes it easy to actually use a homogeneous style for all of
the player menu items, letting them all have an icon and aligning them
visually.
2023-01-16 23:54:23 +01:00
advplyr acf85f4f09 Implement start time override 2023-01-15 16:33:51 -06:00
advplyr 8f6dd72df2 Local media sync events 2023-01-15 14:58:26 -06:00
advplyr 297eca6a86 MediaItemHistory and history page 2023-01-14 18:01:12 -06:00
advplyr b1805875b9 Update:Play/action button updates, show confirm dialog when mark as finished with progress 2023-01-13 17:30:48 -06:00
advplyr 16b06b5eb8 Fix bg gradient 2023-01-13 16:46:42 -06:00
advplyr dbbb5faa78 Fix button opacity 2023-01-12 17:12:14 -06:00
advplyr 5482b30324 Update:Play button color and increase cover size on item page 2023-01-12 17:04:47 -06:00
advplyr 5bae5411c8 Update:Library item page use bg gradient of primary cover image color 2023-01-11 18:00:05 -06:00
advplyr f75a8dfb86 Update:Redirect when changing libraries 2023-01-11 17:29:49 -06:00
advplyr 716d07c74e Fix:Open audio player covering up add new podcast button #502 2023-01-11 17:17:53 -06:00
advplyr 7de0304a3d Update:Audio player UI use primary cover image color as background gradient 2023-01-11 17:02:18 -06:00
advplyr df54094221 Fix:Android auto play downloaded media if available #385 2023-01-08 17:30:57 -06:00
advplyr 3207841384 Merge branch 'master' of https://github.com/advplyr/audiobookshelf-app 2023-01-08 16:04:20 -06:00
advplyrandGitHub ae571d6b79 Merge pull request #499 from lkiesow/metadata-table
Metadata Table Alignment
2023-01-08 16:03:51 -06:00
advplyr f6ee0bf8df Fix:Lock orientation setting clicking on toggle button not working #465 2023-01-08 15:38:34 -06:00
advplyr d59f3ae0b6 Add:Haptic feedback device setting off/light/medium/heavy #472 2023-01-08 15:32:15 -06:00
advplyr 89041c4141 Fix:Show play button playing on local and non-local item pages #487 2023-01-08 14:50:21 -06:00
advplyr aac77949b4 Update:Audio player fullscreen move track above play button #408 #418 2023-01-08 14:31:37 -06:00
advplyr eb40f6e6e9 Fix:Library list view cover size #474 2023-01-08 14:07:48 -06:00
advplyr 34b3910ff8 Fix:Cover image for 1:1.6 covers in collapsed audio player #474 2023-01-08 13:50:18 -06:00
advplyr 83fdf4fd65 Update:Increase cover size on fullscreen audio player #491 2023-01-08 13:27:09 -06:00
Lars Kiesow ea6bb0a847 Metadata Table Alignment
This patch updates the metadata table in the book details view, turning
it into a CSS grid layout. This fixes a few issues with the current
layout:

- Keys and values are not vertically aligned with the value being
  displayed a little bit above the baseline of the key.
- There is no horizontal alignment of the table columns. In fact, it is
  technically not a table despite the CSS being tuned to almost look
  like one. Just turning this into a real grid looks cleaner.
- The code for making multi-line values not wrap is broken. This fixes
  the problem.

This also renames “Publish Year” to “Published”. Mainly because it is
shorter.
2023-01-08 17:17:59 +01:00
advplyrandGitHub d85158bd70 Merge pull request #493 from lkiesow/chepter-idx
Make chapters identifiable
2023-01-05 16:57:02 -06:00
Lars Kiesow 22c5fee48e Make chapters identifiable
When long chapter names are being used, it may easily happen that parts
of the name is cut off. If this part identifies the latest chapter name
or number, it may make chapters indistinguishable from one another. This
makes it near imposible to select a specific chapter if the book has
many chapters.

This patch addresses the issue by starting each item in the list of
chapters with an index number.

This fixes #479
2023-01-05 23:07:11 +01:00
advplyr 0890a44c0b Update:Remove call to server for settings filter/sort/playback rate 2022-12-17 14:48:56 -06:00
advplyr 4fd4cc5604 iOS version bump 0.9.60 2022-12-11 17:11:37 -06:00
advplyr c73b71345a Version bump 0.9.60 & android target sdk version to 31 2022-12-11 17:03:56 -06:00
advplyr 11f0af1ebd Update:Android Auto play next podcast episode after finishing one 2022-12-11 16:09:00 -06:00
advplyr a532f988b0 Update:Local covers for queue items 2022-12-11 14:00:27 -06:00
advplyr bec2796771 Update cast icon position in app bar 2022-12-11 12:18:10 -06:00
advplyr feec7f7399 Add:Sync local media progress button on local media page 2022-12-11 12:01:54 -06:00
advplyr 067c2f84dd Fix:Bookmarks for downloaded books linked to server #163 2022-12-11 11:09:50 -06:00
advplyr 69996a4346 Fix:Changing orientation update audio player progress bar 2022-12-11 10:49:05 -06:00
advplyr 80cda129d2 Fix:Progress bar width on item cover image 2022-12-11 10:29:43 -06:00
advplyr dd0ff04155 Fix:Android Auto downloaded item cover image #141, Update:Lockscreen display title/subtitle #431 2022-12-10 18:09:27 -06:00
advplyr c69558aa7f Fix:Chapters modal overflowing title/time #460 2022-12-10 14:55:33 -06:00
advplyr 8fefea0d94 Fix:Library icons min width 2022-12-10 14:34:21 -06:00
advplyr 0ddee6f44e Fix:Show Manage Local Files & Add to Playlist in more menu correctly 2022-12-10 14:26:58 -06:00
advplyr feec1ab55a Update:Local books/podcasts shelves on home page 2022-12-10 14:17:18 -06:00
advplyr 49500aecd6 Fix network flag 2022-12-10 11:35:16 -06:00
advplyr 894bf100d2 Update:Disable pod search when no network connection #225 2022-12-10 10:44:21 -06:00
advplyr 7ba810adb8 Update:Show error when searching for episodes with no network connection 2022-12-10 10:32:16 -06:00
advplyr d10f33362e Add:Option to delete episodes from server #225 2022-12-10 10:29:56 -06:00
advplyr 82e8fbba2b Download episodes to server & see downloads queued/in progress #225 2022-12-10 10:12:58 -06:00
advplyr 64325fe2a6 Add:Feed podcast episodes select modal #225 2022-12-09 18:03:17 -06:00
advplyrandGitHub 99217fee48 Merge pull request #459 from benonymity/hapticFeedback
Add haptic feedback
2022-12-09 17:08:17 -06:00
advplyrandGitHub f12e64990c Update components/home/BookshelfToolbar.vue 2022-12-09 17:08:00 -06:00
advplyrandGitHub ef7bb9a9d8 Update pages/settings.vue 2022-12-09 17:07:50 -06:00
advplyrandGitHub 5ac925e151 Update pages/settings.vue 2022-12-09 17:07:45 -06:00
benonymity acc5bde33a fix: use native haptic plugin and remove semicolons 2022-12-08 19:09:46 -05:00
advplyr b521f37ec1 Update:Only show podcast search page for admin or up #225 2022-12-08 17:56:58 -06:00
advplyr 379fa21571 Add:Podcast create form #225 2022-12-08 17:54:56 -06:00
benonymity 1aa6a441f3 feat: add haptic feedback to all action buttons 2022-12-08 00:28:28 -05:00
advplyr 705bbaccc1 Add:Podcast search page #225 2022-12-07 17:57:42 -06:00
advplyrandGitHub 8365e06762 Merge pull request #458 from benonymity/noTextSelect
Prevent selecting of text in the app
2022-12-07 16:37:47 -06:00
advplyrandGitHub 139a0b3412 Merge pull request #456 from benonymity/listeningStatsFix
Fix Listening Stats
2022-12-07 16:28:37 -06:00
advplyrandGitHub ea6b21169e Merge pull request #455 from benonymity/vertical-bounce
Don't allow webview to scroll out of bounds vertically
2022-12-07 16:13:17 -06:00
advplyr a262efe9da Fix lockOrientation setting and add Realm db migration; 2022-12-07 16:12:09 -06:00
benonymity ed5fb09007 fix: prevent selecting of text in the app 2022-12-07 15:41:32 -05:00
benonymity d3c6429fd6 fix: accurate time for streamed sessions 2022-12-07 14:40:50 -05:00
benonymity 371fcacc87 Fix: don't allow webview to scroll out of bounds vertically 2022-12-07 13:31:15 -05:00
advplyr 75666e523f Add:PDF EReader #420 2022-12-06 17:07:27 -06:00
advplyr 592ea7c533 Fix:EReader for ebooks in library folder root 2022-12-06 16:21:12 -06:00
advplyr b860dfc791 Fix:Library filter by progress #451 2022-12-05 17:30:15 -06:00
advplyr 96dde8cf31 Fix:Android seeking to invalid times 2022-12-04 16:57:03 -06:00
advplyr b4a37fed28 Update:Showing fullscreen cover uses raw cover 2022-12-04 16:30:18 -06:00
advplyr ad3fee8907 Fix:Save track order button hidden when player is open #380 2022-12-04 15:04:46 -06:00
advplyr 94db55598a Fix:Changing track sort order (Android) #380 2022-12-04 14:49:24 -06:00
advplyr 9687f47b6b Add:Device setting for locking screen orientation #449 2022-12-04 10:41:09 -06:00
advplyr af216d9b38 Update:Media session media button events for fast forward and rewind #423 2022-12-04 09:34:39 -06:00
advplyr c7879c2bc0 Update:Audio player UI updates #418 #408 2022-12-04 09:19:50 -06:00
advplyr 7db503a2cf Fix:EReader when player is open #442 2022-12-04 08:29:55 -06:00
advplyr d72295a0e6 Fix filter urls on items page 2022-12-03 17:58:22 -06:00
advplyr 638da4400f Update:Item page UI 2022-12-03 17:43:47 -06:00
advplyr af9a7aafae Add:Podcast latest page 2022-12-03 17:24:24 -06:00
advplyr ab9f7fed64 Add:Playlists management 2022-12-03 17:05:43 -06:00
advplyr b62ce27487 Add:Playlists 2022-12-03 15:20:27 -06:00
advplyr 6d9e902fe7 Update:Abs icon font and navbar to use icons 2022-12-03 12:06:24 -06:00
advplyr a0faf3f7d4 Update get libraries API call to support updated response payload 2022-12-02 17:39:45 -06:00
advplyr 32dfb3b40a Fix:Series books page sort order #443 2022-11-17 17:08:30 -06:00
advplyr 6bbcd300e4 iOS version bump 0.9.59 2022-11-05 16:41:45 -05:00
advplyr c54d9b3c5f Version bump 0.9.59 2022-11-05 16:29:30 -05:00
advplyr 9ab03fbcd4 fix & update comic reader for comics with subfolders and better sorting 2022-11-05 15:13:52 -05:00
advplyr a852114e9c Update:Use cover aspect ratio from library setting 2022-10-22 08:59:10 -05:00
advplyr 3c9b8c1c6f Fix read btn color 2022-10-21 16:29:46 -05:00
advplyr 4735b51dc0 Update:New library icons 2022-10-19 10:56:31 -05:00
advplyrandGitHub 23927a1d59 Merge pull request #413 from wnhrt/master
Colored status and navigationbar
2022-10-16 12:08:55 -05:00
Niklas Weinhart 40b592f7c1 Colored status and navigationbar 2022-10-16 05:37:35 +02:00
advplyrandGitHub 4da31f5850 Merge pull request #391 from ronaldheft/fix-ios-pause-during-seek
fix: Player sometimes pauses during seek on iOS
2022-09-23 15:37:50 -05:00
advplyrandGitHub f79e96cbfe Merge pull request #397 from ronaldheft/ios-player-seek-back-time-fixes
feat: Groundwork for testable iOS swift code
2022-09-23 15:36:59 -05:00
ronaldheft 2dbce1e3bb fix: Negative currentTime after seek back 2022-09-21 20:16:29 -04:00
ronaldheft cdfbbcbf8a refactor: Use refactored seek back time class 2022-09-20 18:34:43 -04:00
ronaldheft 890852bef5 refactor: Pull out seek back calculation into testable class 2022-09-20 18:32:28 -04:00
advplyrandGitHub 3b285db21a Merge pull request #393 from ronaldheft/fix-on-ready-multiple-calls
fix: onReady called multiple times
2022-09-19 16:46:00 -05:00
advplyrandGitHub d37be88977 Merge pull request #392 from ronaldheft/fix-ios-chapter-sleep-timer
fix: iOS chapter sleep timer issues
2022-09-19 16:45:09 -05:00
ronaldheft e6d27a1487 fix: onReady called multiple times 2022-09-18 14:38:32 -04:00
ronaldheft 5d23c17d30 fix: iOS chapter sleep timer issues 2022-09-18 14:21:41 -04:00
ronaldheft 9c9c1c3b94 fix: Player sometimes pauses during seek 2022-09-18 13:38:16 -04:00
advplyrandGitHub 4e94fd6ad0 Merge pull request #390 from ronaldheft/fix-ios-seek-back-time
fix: iOS seek back time not winding back
2022-09-18 11:58:50 -05:00
advplyrandGitHub 81a8203cca Merge pull request #389 from ronaldheft/ios-bug-artwork
Fix local cover images not used on iOS
2022-09-18 11:57:12 -05:00
ronaldheft b6b206c288 fix: Seek back time not winding back 2022-09-18 12:44:48 -04:00
advplyrandGitHub ed2310a2e7 Merge pull request #387 from ronaldheft/ios-realm-crash
fix: iOS 16 realm crash
2022-09-18 11:29:52 -05:00
ronaldheft bae5d97087 fix: Local artwork not loading 2022-09-18 12:24:19 -04:00
ronaldheft 3e12ff139b fix: Local artwork not used 2022-09-18 11:43:38 -04:00
ronaldheft 7ccd8db085 fix: iOS 16 realm crash 2022-09-17 17:47:18 -04:00
advplyrandGitHub fdc829d06c Merge pull request #386 from ronaldheft/ios-general-fixes
Fix various iOS app startup issues
2022-09-16 16:14:44 -05:00
ronaldheft e7f61e34e8 Fix player automatically starting after WebKit reload 2022-09-15 20:49:25 -04:00
ronaldheft 141877f111 Fix Pods with iOS target change 2022-09-14 20:24:27 -04:00
ronaldheft b71a976e61 Merge remote-tracking branch 'upstream/master' into ios-general-fixes 2022-09-14 19:55:42 -04:00
advplyrandGitHub 68c95ed6d5 Merge pull request #371 from ronaldheft/ios-logging
Improve iOS Logging
2022-09-14 17:54:31 -05:00
ronaldheft 7a68cc99af Convert remain NSLog statement to new logging framework 2022-09-13 21:16:55 -04:00
ronaldheft 7294bc7033 Audio player modifications should be on main queue 2022-09-13 20:47:07 -04:00
ronaldheft f8836be147 fix: Debug logging level does not persist to disk 2022-09-08 20:09:35 -04:00
ronaldheft 713999eb83 Fix deprecated and early window access warnings 2022-09-08 19:51:35 -04:00
ronaldheft cc4c9787c0 Rename ABS to Audiobookshelf 2022-09-06 21:56:10 -04:00
ronaldheft ecc5cf3e1b Rename app to provide better logging 2022-09-06 21:42:12 -04:00
ronaldheft ba7d2be03e Fix incorrect method call 2022-09-06 21:33:24 -04:00
ronaldheft 368f349c78 Use logging framework for AudioPlayer 2022-09-06 21:32:32 -04:00
ronaldheft 639d641c07 Fix usage of this 2022-09-06 21:32:10 -04:00
ronaldheft d9313a1654 Use os logging framework, so logs can be retrieved later 2022-09-06 21:26:48 -04:00
advplyrandGitHub 9dd532285c Merge pull request #365 from vangorra/vangorra/aa-button-cleanup
Cleanup Android Auto buttons
2022-09-04 11:51:11 -05:00
Robert Van GorkomandGitHub 92f10fdb5c Merge branch 'advplyr:master' into vangorra/aa-button-cleanup 2022-09-04 09:23:51 -07:00
advplyrandGitHub 9d0f43a664 Merge pull request #363 from vangorra/vangorra/debug-build
Add debug build to to allow prod and dev apps to work side by side.
2022-09-04 09:46:03 -05:00
Robbie Van Gorkom 453365fcd6 Fix buttons for Android Auto (and probably wear too). 2022-09-03 18:16:12 -07:00
Robbie Van Gorkom 6231c5fcc4 Add debug build to to allow prod and dev apps to work side by side. 2022-09-03 15:06:28 -07:00
382 changed files with 34387 additions and 28413 deletions
+1 -6
View File
@@ -1,13 +1,8 @@
# editorconfig.org
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false
trim_trailing_whitespace = true
-37
View File
@@ -1,37 +0,0 @@
---
name: Bug report
about: Create a report to help us improve
labels: bug
---
### Steps to reproduce
1.
2.
3.
### Expected behaviour
- Tell us what should happen
### Actual behaviour
- Tell us what happens
### Environment data
Audiobookshelf Version:
- [ ] Android App?
- [ ] iOS App?
#### Android Issue
Android version:
Device model:
Stock or customized system:
#### iOS Issue
iOS Version:
iPhone model:
+88
View File
@@ -0,0 +1,88 @@
name: 🐞 ABS App Bug Report
description: File a bug/issue and help us improve the Audiobookshelf mobile apps.
title: "[Bug]: "
labels: ["bug", "triage"]
body:
- type: markdown
attributes:
value: "## App Bug Description"
- type: markdown
attributes:
value: "Thank you for filing a bug report! 🐛"
- type: markdown
attributes:
value: "Join the [discord server](https://discord.gg/HQgCbd6E75) for questions or if you are not sure about a bug."
- type: textarea
id: what-happened
attributes:
label: What was the Problem?
description: Please help us understand the problem.
placeholder: Describe the problem you saw or experienced in detail! Include a screenshot or video, if applicable.
validations:
required: true
- type: textarea
id: steps-to-reproduce
attributes:
label: Steps to Reproduce the Issue
description: Please help us understand how we can reliably reproduce the issue.
placeholder: "1. Go to the library page of a Podcast library and..."
validations:
required: true
- type: textarea
id: what-was-expected
attributes:
label: What was Expected?
description: Please help us understand what should have happened.
placeholder: Please help us visualize by including a doodle or screenshot.
validations:
required: true
- type: markdown
attributes:
value: "## Mobile Environment"
- type: input
id: phone-model
attributes:
label: Phone Model
description: What kind of phone are you using?
placeholder: e.g. Pixel 6, iPhone 14, Samusung Galaxy s23, etc
validations:
required: true
- type: input
id: phone-os
attributes:
label: Phone OS
description: What operating system? If Android, is it stock or a custom system?
placeholder: e.g. Android 14, iOS 17.4, etc
validations:
required: true
- type: dropdown
id: app-version
attributes:
label: Audiobookshelf App Version
description: Please ensure your app is up to date. *If you are using a 3rd-party app, please reach out to them directly.*
multiple: true
options:
- Android App - 0.9.74
- iOS App - 0.9.74
- Android App - 0.9.73
- iOS App - 0.9.73
validations:
required: true
- type: dropdown
id: install-source
attributes:
label: Installation Source
multiple: true
options:
- Google Play Store
- Testflight
- SideStore
- Other (List in "Additional Notes")
validations:
required: true
- type: textarea
id: additional-notes
attributes:
label: Additional Notes
description: Anything else you want to add?
placeholder: "e.g. I have tried X, Y, and Z."
-18
View File
@@ -1,18 +0,0 @@
---
name: Feature request
about: Suggest an idea for this project
labels: enhancement
---
### Is your feature request related to a problem? Please describe.
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
### Describe the solution you'd like
A clear and concise description of what you want to happen.
### Describe alternatives you've considered
A clear and concise description of any alternative solutions or features you've considered.
### Additional context
Add any other context or screenshots about the feature request here.
@@ -0,0 +1,57 @@
name: 🚀 App Feature Request
description: Request a feature/enhancement
title: "[Enhancement]: "
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: "## App Feature Request Description"
- type: markdown
attributes:
value: "Please first search in both issues & discussions for your enhancement and make sure your app is up to date."
- type: textarea
id: describe
attributes:
label: Describe the Feature/Enhancement
description: Please help us understand what you want.
placeholder: What is your vision?
validations:
required: true
- type: textarea
id: the-why
attributes:
label: Why would this be helpful?
description: Please help us understand why this would enhance your experience.
placeholder: Explain the "why" or "use case".
validations:
required: true
- type: textarea
id: image
attributes:
label: Future Implementation (Screenshot)
description: Please help us visualize by including a doodle or screenshot.
placeholder: How could this look or where could it go?
validations:
required: true
- type: markdown
attributes:
value: "## App Current Implementation"
- type: dropdown
id: version
attributes:
label: Audiobookshelf App Version
description: Please ensure your app is up to date. *If you are using a 3rd-party app, please reach out to them directly.*
multiple: true
options:
- Android App - 0.9.74
- iOS App - 0.9.74
- Android App - 0.9.73
- iOS App - 0.9.73
validations:
required: true
- type: textarea
id: current-image
attributes:
label: Current Implementation (Screenshot)
description: What screen were you looking at when you thought of this enhancement?
placeholder: If an image is not applicable, please explain why.
+81
View File
@@ -0,0 +1,81 @@
<!DOCTYPE html>
<html lang=en>
<head>
<meta http-equiv=content-type content="text/html; charset=utf-8" />
<meta name=viewport content="width=device-width, initial-scale=1">
<title>Audiobookshelf :: Testers</title>
<style>
body {
background-color: rgb(55, 56, 56);
font-family: Sans;
margin: 40px auto;
max-width: 500px;
color: #ddd;
text-align: center;
padding: 5px;
}
img {
width: 100px;
}
h1 {
font-weight: 100;
}
a {
color: #fff;
}
#apk {
display: grid;
grid-template-columns: auto auto;
justify-content: center;
column-gap: 15px;
text-align: left;
font-family: monospace;
background-color: #222;
padding: 20px;
border-radius: 5px;
}
</style>
</head>
<body>
<a href="https://audiobookshelf.org">
<img src=logo.png />
</a>
<h1>Audiobookshelf :: Testers</h1>
<p>
Here you can download the latest version of Audiobookshelf for Android for <b>testing</b>.
The app is automatically built for every new patch added to the project.
</p>
<div id=apk>
<div>Built on</div>
<div>__DATE__</div>
<div>Git commit</div>
<a href="https://github.com/advplyr/audiobookshelf-app/commit/__COMMIT__">__COMMIT__</a>
<div>Download</div>
<a href="__APK__">__APK__</a>
</div>
<p>
Using this requires some technical knowledge to install the APK manually.
Regular users should use the releases published on the <a href="https://play.google.com/store/apps/details?id=com.audiobookshelf.app">Play Store</a>.
</p>
<hr />
<p>Report bugs, request features, provide feedback, and contribute on <a href="https://github.com/advplyr/audiobookshelf-app/issues">GitHub</a></p>
<p>
<a href="https://github.com/advplyr/audiobookshelf-app/issues">
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" width=32 height=32 viewBox="0 0 24 24">
<path
d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"
/>
</svg>
</a>
</div>
</body>
</html>
+53
View File
@@ -0,0 +1,53 @@
name: Build APK
on:
push:
paths-ignore:
- 'ios/**'
- 'readme.md'
pull_request:
paths-ignore:
- 'ios/**'
- 'readme.md'
jobs:
main:
runs-on: ubuntu-latest
steps:
- name: checkout sources
uses: actions/checkout@v3
- name: use Node.js 20.x
uses: actions/setup-node@v3
with:
node-version: 20
- name: Set up Java
uses: actions/setup-java@v2
with:
distribution: "temurin"
java-version: 17
- name: install dependencies
run: npm ci
- name: build Nuxt project
run: npm run generate
- name: copy to Android project
run: npx cap sync
- name: build Android app
run: ./android/gradlew assembleDebug -p android --no-daemon
- name: rename apk
working-directory: android/app/build/outputs/apk/debug/
run: |
build="$(date +%Y%m%d-%H%M%S)-$(git rev-parse --short HEAD)"
name="audiobookshelf-${build}.apk"
mv -v app-debug.apk "${name}"
- name: upload app
uses: actions/upload-artifact@v3
with:
name: audiobookshelf-apk
path: android/app/build/outputs/apk/debug/*.apk
@@ -0,0 +1,20 @@
name: Close fixed issues on release.
on:
release:
types: [published]
permissions:
contents: read
issues: write
jobs:
comment:
runs-on: ubuntu-latest
steps:
- name: Close issues marked as fixed upon a release.
uses: gcampbell-msft/fixed-pending-release@7fa1b75a0c04bcd4b375110522878e5f6100cff5
with:
label: 'awaiting release'
removeLabel: true
applyToAll: true
message: Fixed in [${releaseTag}](${releaseUrl}).
+82
View File
@@ -0,0 +1,82 @@
name: Publish Test App
on:
push:
branches:
- master
paths-ignore:
- 'ios/**'
- 'readme.md'
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: checkout sources
uses: actions/checkout@v3
- name: use Node.js 20.x
uses: actions/setup-node@v3
with:
node-version: 20
- name: Set up Java
uses: actions/setup-java@v2
with:
distribution: "temurin"
java-version: 17
- name: install dependencies
run: npm ci
- name: build Nuxt project
run: npm run generate
- name: copy to Android project
run: npx cap sync
- name: build Android app
run: ./android/gradlew assembleDebug -p android --no-daemon
- name: rename apk
working-directory: android/app/build/outputs/apk/debug/
run: |
build="$(date +%Y%m%d-%H%M%S)-$(git rev-parse --short HEAD)"
name="audiobookshelf-${build}.apk"
mv -v app-debug.apk "${name}"
- name: prepare test page ressources
run: |
mkdir ghpages
cp android/app/build/outputs/apk/debug/*apk ghpages/
cp static/Logo.png ghpages/logo.png
cp .github/testing-page-template.html ghpages/index.html
- name: build test page
working-directory: ghpages
run: |
sed -i "s/__DATE__/$(date)/g" index.html
sed -i "s/__COMMIT__/$(git rev-parse --short HEAD)/g" index.html
sed -i "s/__APK__/$(ls *apk)/g" index.html
- name: upload test page artifact
uses: actions/upload-pages-artifact@v1
with:
path: ./ghpages
deploy:
needs: build
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- name: deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v1
+28
View File
@@ -0,0 +1,28 @@
name: Verify all i18n files are alphabetized
on:
push:
pull_request:
paths:
- strings/** # Should only check if any strings changed
jobs:
update_translations:
runs-on: ubuntu-latest
steps:
# Check out the repository
- name: Checkout repository
uses: actions/checkout@v4
# Set up node to run the javascript
- name: Set up node
uses: actions/setup-node@v4
with:
node-version: '20'
# The only argument is the `directory`, which is where the i18n files are
# stored.
- name: Run Update JSON Files action
uses: audiobookshelf/audiobookshelf-i18n-updater@v1.3.0
with:
directory: 'strings/' # Adjust the directory path as needed
+17
View File
@@ -0,0 +1,17 @@
{
"semi": false,
"singleQuote": true,
"printWidth": 400,
"proseWrap": "never",
"trailingComma": "none",
"overrides": [
{
"files": ["*.html"],
"options": {
"singleQuote": false,
"wrapAttributes": false,
"sortAttributes": false
}
}
]
}
+7
View File
@@ -0,0 +1,7 @@
{
"recommendations": [
"EditorConfig.EditorConfig",
"esbenp.prettier-vscode",
"octref.vetur"
]
}
+27
View File
@@ -0,0 +1,27 @@
{
"vetur.format.defaultFormatterOptions": {
"prettier": {
"semi": false,
"singleQuote": true,
"printWidth": 400,
"proseWrap": "never",
"trailingComma": "none"
},
"prettyhtml": {
"printWidth": 400,
"singleQuote": false,
"wrapAttributes": false,
"sortAttributes": false
}
},
"editor.formatOnSave": true,
"editor.detectIndentation": true,
"editor.tabSize": 2,
"javascript.format.semicolons": "remove",
"[javascript][json][jsonc]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[vue]": {
"editor.defaultFormatter": "octref.vetur"
}
}
+18 -16
View File
@@ -21,7 +21,7 @@ kotlin {
}
android {
namespace 'com.audiobookshelf.app'
buildFeatures {
viewBinding true
}
@@ -33,9 +33,12 @@ android {
applicationId "com.audiobookshelf.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 88
versionName "0.9.57-beta"
versionCode 107
versionName "0.9.76-beta"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
manifestPlaceholders = [
"appAuthRedirectScheme": "com.audiobookshelf.app"
]
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
@@ -43,6 +46,12 @@ android {
}
}
buildTypes {
debug {
applicationIdSuffix ".debug"
versionNameSuffix "-debug"
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
@@ -55,33 +64,30 @@ repositories {
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
}
mavenCentral()
// TODO: Temporarily using SNAPSHOT version of Simple Storage that resolves https://github.com/anggrayudi/SimpleStorage/issues/133
maven { url "https://oss.sonatype.org/content/repositories/snapshots" }
}
configurations.all {
configurations.configureEach {
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 "androidx.core:core-splashscreen:$coreSplashScreenVersion"
implementation fileTree(include: ['*.jar'], dir: 'libs')
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
implementation project(':capacitor-android')
implementation 'androidx.constraintlayout:constraintlayout:2.1.3'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
// 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:$androidx_core_ktx_version"
@@ -109,22 +115,18 @@ dependencies {
// 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.14.0"
implementation "com.anggrayudi:storage:1.5.5-SNAPSHOT"
// OK HTTP
implementation 'com.squareup.okhttp3:okhttp:4.9.2'
// Jackson for JSON
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.12.2'
// FFMPEG-Kit
implementation 'com.arthenica:ffmpeg-kit-min:4.5.1'
}
apply from: 'capacitor.build.gradle'
+6 -3
View File
@@ -2,19 +2,22 @@
android {
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
}
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':byteowls-capacitor-filesharer')
implementation project(':capacitor-app')
implementation project(':capacitor-browser')
implementation project(':capacitor-clipboard')
implementation project(':capacitor-dialog')
implementation project(':capacitor-haptics')
implementation project(':capacitor-network')
implementation project(':capacitor-preferences')
implementation project(':capacitor-status-bar')
implementation project(':capacitor-storage')
}
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 879 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 685 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

@@ -0,0 +1,14 @@
<?xml version='1.0' encoding='utf-8'?>
<resources>
<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">audiobookshelf</string>
<string name="add_widget">Add widget</string>
<string name="app_widget_description">Simple widget for audiobookshelf playback</string>
<string name="action_jump_forward">Jump Forward</string>
<string name="action_jump_backward">Jump Backward</string>
<string name="action_skip_forward">Skip Forward</string>
<string name="action_skip_backward">Skip Backward</string>
<string name="action_change_speed">Change Playback Speed</string>
</resources>
+29 -15
View File
@@ -2,11 +2,11 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:dist="http://schemas.android.com/apk/distribution"
xmlns:tools="http://schemas.android.com/tools"
package="com.audiobookshelf.app"
android:installLocation="preferExternal" >
<!-- Permissions -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
@@ -16,24 +16,27 @@
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:requestLegacyExternalStorage="true"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme"
android:usesCleartextTraffic="true" >
<!-- <receiver-->
<!-- android:name=".NewAppWidget"-->
<!-- android:exported="true" >-->
<!-- <intent-filter>-->
<!-- <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />-->
<!-- </intent-filter>-->
android:usesCleartextTraffic="true"
android:largeHeap="true" >
<!-- <meta-data-->
<!-- android:name="android.appwidget.provider"-->
<!-- android:resource="@xml/new_app_widget_info" />-->
<!-- </receiver>-->
<!-- Android app widget -->
<receiver
android:name="MediaPlayerWidget"
android:exported="false">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/media_player_widget_info" />
</receiver>
<!-- Used by Android Auto -->
<meta-data
@@ -75,7 +78,7 @@
</intent-filter>
</activity>
<receiver android:name="androidx.media.session.MediaButtonReceiver" >
<receiver android:name="androidx.media.session.MediaButtonReceiver" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
@@ -84,11 +87,22 @@
<service
android:name=".player.PlayerNotificationService"
android:enabled="true"
android:exported="true" >
android:exported="true"
android:foregroundServiceType="mediaPlayback">
<intent-filter>
<action android:name="android.media.browse.MediaBrowserService" />
</intent-filter>
</service>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:grantUriPermissions="true"
android:exported="false">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
@@ -2,5 +2,10 @@
"appId": "com.audiobookshelf.app",
"appName": "audiobookshelf-app",
"webDir": "dist",
"bundledWebRuntime": false
"bundledWebRuntime": false,
"plugins": {
"CapacitorHttp": {
"enabled": false
}
}
}
@@ -1,8 +1,20 @@
[
{
"pkg": "@byteowls/capacitor-filesharer",
"classpath": "com.byteowls.capacitor.filesharer.FileSharerPlugin"
},
{
"pkg": "@capacitor/app",
"classpath": "com.capacitorjs.plugins.app.AppPlugin"
},
{
"pkg": "@capacitor/browser",
"classpath": "com.capacitorjs.plugins.browser.BrowserPlugin"
},
{
"pkg": "@capacitor/clipboard",
"classpath": "com.capacitorjs.plugins.clipboard.ClipboardPlugin"
},
{
"pkg": "@capacitor/dialog",
"classpath": "com.capacitorjs.plugins.dialog.DialogPlugin"
@@ -16,11 +28,11 @@
"classpath": "com.capacitorjs.plugins.network.NetworkPlugin"
},
{
"pkg": "@capacitor/status-bar",
"classpath": "com.capacitorjs.plugins.statusbar.StatusBarPlugin"
"pkg": "@capacitor/preferences",
"classpath": "com.capacitorjs.plugins.preferences.PreferencesPlugin"
},
{
"pkg": "@capacitor/storage",
"classpath": "com.capacitorjs.plugins.storage.StoragePlugin"
"pkg": "@capacitor/status-bar",
"classpath": "com.capacitorjs.plugins.statusbar.StatusBarPlugin"
}
]
@@ -12,10 +12,10 @@ 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.data.DbManager
import com.audiobookshelf.app.managers.DbManager
import com.audiobookshelf.app.player.PlayerNotificationService
import com.audiobookshelf.app.plugins.AbsAudioPlayer
import com.audiobookshelf.app.plugins.AbsDatabase
import com.audiobookshelf.app.plugins.AbsDownloader
import com.audiobookshelf.app.plugins.AbsFileSystem
import com.getcapacitor.BridgeActivity
@@ -51,11 +51,17 @@ class MainActivity : BridgeActivity() {
// .detectLeakedClosableObjects()
// .penaltyLog()
// .build())
DbManager.initialize(applicationContext)
registerPlugin(AbsAudioPlayer::class.java)
registerPlugin(AbsDownloader::class.java)
registerPlugin(AbsFileSystem::class.java)
registerPlugin(AbsDatabase::class.java)
super.onCreate(savedInstanceState)
Log.d(tag, "onCreate")
DbManager.initialize(applicationContext)
val permission = ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE)
if (permission != PackageManager.PERMISSION_GRANTED) {
@@ -63,11 +69,6 @@ class MainActivity : BridgeActivity() {
PERMISSIONS_ALL,
REQUEST_PERMISSIONS)
}
registerPlugin(AbsAudioPlayer::class.java)
registerPlugin(AbsDownloader::class.java)
registerPlugin(AbsFileSystem::class.java)
registerPlugin(AbsDatabase::class.java)
}
override fun onDestroy() {
@@ -102,6 +103,9 @@ class MainActivity : BridgeActivity() {
}
}
fun isPlayerNotificationServiceInitialized():Boolean {
return ::foregroundService.isInitialized
}
fun stopMyService() {
if (mBounded) {
@@ -115,6 +119,7 @@ class MainActivity : BridgeActivity() {
override fun onSaveInstanceState(outState: Bundle) {
storageHelper.onSaveInstanceState(outState)
super.onSaveInstanceState(outState)
outState.clear()
}
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
@@ -0,0 +1,103 @@
package com.audiobookshelf.app
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.net.Uri
import android.support.v4.media.session.PlaybackStateCompat
import android.util.Log
import android.view.View
import android.widget.RemoteViews
import androidx.media.session.MediaButtonReceiver
import com.audiobookshelf.app.data.PlaybackSession
import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.managers.DbManager
import com.bumptech.glide.Glide
import com.bumptech.glide.request.RequestOptions
import com.bumptech.glide.request.target.AppWidgetTarget
import com.bumptech.glide.request.transition.Transition
/**
* Implementation of App Widget functionality.
*/
class MediaPlayerWidget : AppWidgetProvider() {
val tag = "MediaPlayerWidget"
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
Log.d(tag, "onUpdate $appWidgetIds")
}
override fun onEnabled(context: Context) {
Log.i(tag, "onEnabled check context ${context.packageName}")
DbManager.initialize(context)
DeviceManager.deviceData.lastPlaybackSession?.let {
val appWidgetManager = AppWidgetManager.getInstance(context)
val componentName = ComponentName(context, MediaPlayerWidget::class.java)
val ids = appWidgetManager.getAppWidgetIds(componentName)
Log.d(tag, "Setting initial widget state with last playback session ${it.displayTitle}")
for (widgetId in ids) {
updateAppWidget(context, appWidgetManager, widgetId, it, false, true)
}
}
// Enter relevant functionality for when the first widget is created
DeviceManager.initializeWidgetUpdater(context)
}
}
internal fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int, playbackSession: PlaybackSession?, isPlaying:Boolean, isAppClosed:Boolean) {
val tag = "MediaPlayerWidget"
val views = RemoteViews(context.packageName, R.layout.media_player_widget)
Log.i(tag, "updateAppWidget ${playbackSession?.displayTitle ?: "No Title"} isPlaying=$isPlaying isAppClosed=$isAppClosed")
val wholeWidgetClickI = Intent(context, MainActivity::class.java)
wholeWidgetClickI.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK
val wholeWidgetClickPI = PendingIntent.getActivity(
context,
System.currentTimeMillis().toInt(),
wholeWidgetClickI,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val playPausePI = MediaButtonReceiver.buildMediaButtonPendingIntent(context, PlaybackStateCompat.ACTION_PLAY_PAUSE)
views.setOnClickPendingIntent(R.id.widgetPlayPauseButton, playPausePI)
val fastForwardPI = MediaButtonReceiver.buildMediaButtonPendingIntent(context, PlaybackStateCompat.ACTION_FAST_FORWARD)
views.setOnClickPendingIntent(R.id.widgetFastForwardButton, fastForwardPI)
val rewindPI = MediaButtonReceiver.buildMediaButtonPendingIntent(context, PlaybackStateCompat.ACTION_REWIND)
views.setOnClickPendingIntent(R.id.widgetRewindButton, rewindPI)
// Show/Hide button container
views.setViewVisibility(R.id.widgetButtonContainer, if (isAppClosed) View.GONE else View.VISIBLE)
views.setOnClickPendingIntent(R.id.widgetBackground, wholeWidgetClickPI)
val imageUri = playbackSession?.getCoverUri(context) ?: Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
val awt: AppWidgetTarget = object : AppWidgetTarget(context.applicationContext, R.id.widgetAlbumArt, views, appWidgetId) {
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
super.onResourceReady(resource, transition)
}
}
val artist = playbackSession?.displayAuthor ?: "Unknown"
views.setTextViewText(R.id.widgetArtistText, artist)
val title = playbackSession?.displayTitle ?: "Unknown"
views.setTextViewText(R.id.widgetMediaTitle, title)
val options = RequestOptions().override(300, 300).placeholder(R.drawable.icon).error(R.drawable.icon)
Glide.with(context.applicationContext).asBitmap().load(imageUri).apply(options).into(awt)
Log.i(tag, "Update App Widget | Is Playing=$isPlaying | isAppClosed=$isAppClosed")
val playPauseResource = if (isPlaying) androidx.mediarouter.R.drawable.ic_media_pause_dark else androidx.mediarouter.R.drawable.ic_media_play_dark
views.setImageViewResource(R.id.widgetPlayPauseButton, playPauseResource)
// Instruct the widget manager to update the widget
appWidgetManager.updateAppWidget(appWidgetId, views)
}
@@ -1,96 +0,0 @@
package com.audiobookshelf.app
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.net.Uri
import android.support.v4.media.session.PlaybackStateCompat.ACTION_PLAY_PAUSE
import android.util.Log
import android.widget.RemoteViews
import androidx.media.session.MediaButtonReceiver.buildMediaButtonPendingIntent
import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.device.WidgetEventEmitter
import com.audiobookshelf.app.player.PlayerNotificationService
import com.bumptech.glide.Glide
import com.bumptech.glide.request.RequestOptions
import com.bumptech.glide.request.target.AppWidgetTarget
import com.bumptech.glide.request.transition.Transition
/**
* Implementation of App Widget functionality.
*/
class NewAppWidget : AppWidgetProvider() {
val tag = "NewAppWidget"
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
// There may be multiple widgets active, so update all of them
for (appWidgetId in appWidgetIds) {
updateAppWidget(context, appWidgetManager, appWidgetId, null,false)
}
}
override fun onEnabled(context: Context) {
Log.w(tag, "onEnabled check context ${context.packageName}")
// Enter relevant functionality for when the first widget is created
DeviceManager.widgetUpdater = (object : WidgetEventEmitter {
override fun onPlayerChanged(pns:PlayerNotificationService) {
val isPlaying = pns.currentPlayer.isPlaying
Log.i(tag, "onPlayerChanged | Is Playing? $isPlaying")
val appWidgetManager = AppWidgetManager.getInstance(context)
val componentName = ComponentName(context, NewAppWidget::class.java)
val ids = appWidgetManager.getAppWidgetIds(componentName)
val playbackSession = pns.getCurrentPlaybackSessionCopy()
val cover = playbackSession?.getCoverUri()
for (widgetId in ids) {
updateAppWidget(context, appWidgetManager, widgetId, cover, isPlaying)
}
}
})
}
override fun onDisabled(context: Context) {
// Enter relevant functionality for when the last widget is disabled
}
}
internal fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int, coverUri:Uri?, isPlaying:Boolean) {
val views = RemoteViews(context.packageName, R.layout.new_app_widget)
val playPausePI = buildMediaButtonPendingIntent(context, ACTION_PLAY_PAUSE)
views.setOnClickPendingIntent(R.id.playPauseIcon, playPausePI)
val wholeWidgetClickI = Intent(context, MainActivity::class.java)
wholeWidgetClickI.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK
val wholeWidgetClickPI = PendingIntent.getActivity(
context,
System.currentTimeMillis().toInt(),
wholeWidgetClickI,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
views.setOnClickPendingIntent(R.id.appWidget, wholeWidgetClickPI)
val imageUri = coverUri ?: Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon)
val awt: AppWidgetTarget = object : AppWidgetTarget(context.applicationContext, R.id.imageView, views, appWidgetId) {
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
super.onResourceReady(resource, transition)
}
}
val options = RequestOptions().override(300, 300).placeholder(R.drawable.icon).error(R.drawable.icon)
Glide.with(context.applicationContext).asBitmap().load(imageUri).apply(options).into(awt)
val playPauseResource = if (isPlaying) R.drawable.ic_media_pause_dark else R.drawable.ic_media_play_dark
views.setImageViewResource(R.id.playPauseIcon, playPauseResource)
// Instruct the widget manager to update the widget
appWidgetManager.updateAppWidget(appWidgetId, views)
}
@@ -0,0 +1,34 @@
package com.audiobookshelf.app.data
import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
@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)
}
}
@@ -1,9 +1,11 @@
package com.audiobookshelf.app.data
import android.content.Context
import android.os.Bundle
import android.support.v4.media.MediaDescriptionCompat
import android.support.v4.media.MediaMetadataCompat
import androidx.media.utils.MediaConstants
import com.audiobookshelf.app.media.MediaManager
import com.fasterxml.jackson.annotation.*
// This auto-detects whether it is a Book or Podcast
@@ -22,7 +24,7 @@ open class MediaType(var metadata:MediaTypeMetadata, var coverPath:String?) {
@JsonIgnore
open fun removeAudioTrack(localFileId:String) { }
@JsonIgnore
open fun getLocalCopy():MediaType { return MediaType(MediaTypeMetadata(""),null) }
open fun getLocalCopy():MediaType { return MediaType(MediaTypeMetadata("", false),null) }
}
@@ -50,7 +52,8 @@ class Podcast(
// Add new episodes
audioTracks.forEach { at ->
if (episodes?.find{ it.audioTrack?.localFileId == at.localFileId } == null) {
val newEpisode = PodcastEpisode("local_ep_" + at.localFileId,episodes?.size ?: 0 + 1,null,null,at.title,null,null,null,at,at.duration,0, null)
val localEpisodeId = "local_ep_" + at.localFileId
val newEpisode = PodcastEpisode(localEpisodeId,(episodes?.size ?: 0) + 1,null,null,at.title,null,null,null, null, null, at,null,at.duration,0, null, localEpisodeId)
episodes?.add(newEpisode)
}
}
@@ -63,7 +66,8 @@ class Podcast(
}
@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)
val localEpisodeId = "local_ep_" + audioTrack.localFileId
val newEpisode = PodcastEpisode(localEpisodeId,(episodes?.size ?: 0) + 1,null,null,audioTrack.title,null,null,null, null, null,audioTrack,null,audioTrack.duration,0, null, localEpisodeId)
episodes?.add(newEpisode)
var index = 1
@@ -82,9 +86,17 @@ class Podcast(
index++
}
}
// 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, 0)
}
@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)
val localEpisodeId = "local_ep_" + episode.id
val newEpisode = PodcastEpisode(localEpisodeId,(episodes?.size ?: 0) + 1,episode.episode,episode.episodeType,episode.title,episode.subtitle,episode.description,null,null,null,audioTrack,episode.chapters,audioTrack.duration,episode.size, episode.id, localEpisodeId)
episodes?.add(newEpisode)
var index = 1
@@ -95,10 +107,14 @@ class Podcast(
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, 0)
fun getNextUnfinishedEpisode(libraryItemId:String, mediaManager: MediaManager):PodcastEpisode? {
val sortedEpisodes = episodes?.sortedByDescending { it.publishedAt }
val podcastEpisode = sortedEpisodes?.find { episode ->
val progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == libraryItemId && it.episodeId == episode.id }
progress == null || !progress.isFinished
}
return podcastEpisode
}
}
@@ -110,6 +126,7 @@ class Book(
var audioFiles:List<AudioFile>?,
var chapters:List<BookChapter>?,
var tracks:MutableList<AudioTrack>?,
var ebookFile: EBookFile?,
var size:Long?,
var duration:Double?,
var numTracks:Int?
@@ -122,10 +139,13 @@ class Book(
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
var currentStartOffset = 0.0
tracks?.forEach {
totalDuration += it.duration
it.startOffset = currentStartOffset
currentStartOffset += it.duration
}
duration = totalDuration
}
@@ -158,10 +178,9 @@ class Book(
}
duration = totalDuration
}
@JsonIgnore
override fun getLocalCopy(): Book {
return Book(metadata as BookMetadata,coverPath,tags, mutableListOf(),chapters,mutableListOf(),null,null, 0)
return Book(metadata as BookMetadata,coverPath,tags, mutableListOf(),chapters,mutableListOf(), ebookFile, null,null, 0)
}
}
@@ -171,7 +190,7 @@ class Book(
JsonSubTypes.Type(BookMetadata::class),
JsonSubTypes.Type(PodcastMetadata::class)
)
open class MediaTypeMetadata(var title:String) {
open class MediaTypeMetadata(var title:String, var explicit:Boolean) {
@JsonIgnore
open fun getAuthorDisplayName():String { return "Unknown" }
}
@@ -190,13 +209,13 @@ class BookMetadata(
var isbn:String?,
var asin:String?,
var language:String?,
var explicit:Boolean,
explicit:Boolean,
// In toJSONExpanded
var authorName:String?,
var authorNameLF:String?,
var narratorName:String?,
var seriesName:String?
) : MediaTypeMetadata(title) {
) : MediaTypeMetadata(title, explicit) {
@JsonIgnore
override fun getAuthorDisplayName():String { return authorName ?: "Unknown" }
}
@@ -206,8 +225,9 @@ class PodcastMetadata(
title:String,
var author:String?,
var feedUrl:String?,
var genres:MutableList<String>
) : MediaTypeMetadata(title) {
var genres:MutableList<String>,
explicit:Boolean
) : MediaTypeMetadata(title, explicit) {
@JsonIgnore
override fun getAuthorDisplayName():String { return author ?: "Unknown" }
}
@@ -228,21 +248,33 @@ data class PodcastEpisode(
var title:String?,
var subtitle:String?,
var description:String?,
var pubDate:String?,
var publishedAt:Long?,
var audioFile:AudioFile?,
var audioTrack:AudioTrack?,
var chapters:List<BookChapter>?,
var duration:Double?,
var size:Long?,
var serverEpisodeId:String? // For local podcasts to match with server podcasts
var serverEpisodeId:String?, // For local podcasts to match with server podcasts
var localEpisodeId:String? // For Android Auto server episodes with local copy
) {
@JsonIgnore
fun getMediaDescription(libraryItem:LibraryItemWrapper, progress:MediaProgressWrapper?): MediaDescriptionCompat {
val coverUri = if(libraryItem is LocalLibraryItem) {
libraryItem.getCoverUri()
fun getMediaDescription(libraryItem:LibraryItemWrapper, progress:MediaProgressWrapper?, ctx: Context): MediaDescriptionCompat {
val coverUri = if (libraryItem is LocalLibraryItem) {
libraryItem.getCoverUri(ctx)
} else {
(libraryItem as LibraryItem).getCoverUri()
}
val extras = Bundle()
if (localEpisodeId != null) {
extras.putLong(
MediaDescriptionCompat.EXTRA_DOWNLOAD_STATUS,
MediaDescriptionCompat.STATUS_DOWNLOADED
)
}
if (progress != null) {
if (progress.isFinished) {
extras.putInt(
@@ -264,22 +296,21 @@ data class PodcastEpisode(
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_NOT_PLAYED
)
}
// 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_AUTHOR, podcast.metadata.getAuthorDisplayName())
// putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, coverUri.toString())
//
// }.build()
val libraryItemDescription = libraryItem.getMediaDescription(null)
return MediaDescriptionCompat.Builder()
.setMediaId(id)
val libraryItemDescription = libraryItem.getMediaDescription(null, ctx)
val mediaId = localEpisodeId ?: id
val mediaDescriptionBuilder = MediaDescriptionCompat.Builder()
.setMediaId(mediaId)
.setTitle(title)
.setIconUri(coverUri)
.setSubtitle(libraryItemDescription.title)
.setExtras(extras)
.build()
libraryItemDescription.iconBitmap?.let {
mediaDescriptionBuilder.setIconBitmap(it)
}
return mediaDescriptionBuilder.build()
}
}
@@ -329,36 +360,6 @@ data class Folder(
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,
@@ -372,26 +373,14 @@ data class BookChapter(
val endMs get() = (end * 1000L).toLong()
}
@JsonIgnoreProperties(ignoreUnknown = true)
class MediaProgress(
var id:String,
var libraryItemId:String,
var episodeId:String?,
var duration:Double, // seconds
progress:Double, // 0 to 1
var currentTime:Double,
isFinished:Boolean,
var lastUpdate:Long,
var startedAt:Long,
var finishedAt:Long?
) : MediaProgressWrapper(isFinished, progress)
@JsonTypeInfo(use= JsonTypeInfo.Id.DEDUCTION, defaultImpl = MediaProgress::class)
@JsonSubTypes(
JsonSubTypes.Type(MediaProgress::class),
JsonSubTypes.Type(LocalMediaProgress::class)
)
open class MediaProgressWrapper(var isFinished:Boolean, var progress:Double)
open class MediaProgressWrapper(var isFinished:Boolean, var currentTime:Double, var progress:Double) {
open val mediaItemId get() = ""
}
// Helper class
data class LibraryItemWithEpisode(
@@ -1,11 +1,33 @@
package com.audiobookshelf.app.data
import android.content.Context
import android.support.v4.media.MediaDescriptionCompat
import android.util.Log
import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonSubTypes
import com.fasterxml.jackson.annotation.JsonTypeInfo
enum class LockOrientationSetting {
NONE, PORTRAIT, LANDSCAPE
}
enum class HapticFeedbackSetting {
OFF, LIGHT, MEDIUM, HEAVY
}
enum class ShakeSensitivitySetting {
VERY_LOW, LOW, MEDIUM, HIGH, VERY_HIGH
}
enum class DownloadUsingCellularSetting {
ASK, ALWAYS, NEVER
}
enum class StreamingUsingCellularSetting {
ASK, ALWAYS, NEVER
}
data class ServerConnectionConfig(
var id:String,
var index:Int,
@@ -17,46 +39,6 @@ data class ServerConnectionConfig(
var customHeaders:Map<String, String>?
)
data class DeviceSettings(
var disableAutoRewind:Boolean,
var enableAltView:Boolean,
var jumpBackwardsTime:Int,
var jumpForwardTime:Int,
var disableShakeToResetSleepTimer:Boolean
) {
companion object {
// Static method to get default device settings
fun default():DeviceSettings {
return DeviceSettings(
disableAutoRewind = false,
enableAltView = false,
jumpBackwardsTime = 10,
jumpForwardTime = 10,
disableShakeToResetSleepTimer = false
)
}
}
@get:JsonIgnore
val jumpBackwardsTimeMs get() = (jumpBackwardsTime ?: default().jumpBackwardsTime) * 1000L
@get:JsonIgnore
val jumpForwardTimeMs get() = (jumpForwardTime ?: default().jumpBackwardsTime) * 1000L
}
data class DeviceData(
var serverConnectionConfigs:MutableList<ServerConnectionConfig>,
var lastServerConnectionConfigId:String?,
var currentLocalPlaybackSession:PlaybackSession?, // Stored to open up where left off for local media
var deviceSettings:DeviceSettings?
) {
@JsonIgnore
fun getLastServerConnectionConfig():ServerConnectionConfig? {
return lastServerConnectionConfigId?.let { lsccid ->
return serverConnectionConfigs.find { it.id == lsccid }
}
}
}
@JsonIgnoreProperties(ignoreUnknown = true)
data class LocalFile(
var id:String,
@@ -74,6 +56,20 @@ data class LocalFile(
if (mimeType == "video/mp4") return true
return mimeType?.startsWith("audio") == true
}
@JsonIgnore
fun isEBookFile():Boolean {
return getEBookFormat() != null
}
@JsonIgnore
fun getEBookFormat():String? {
if (mimeType == "application/epub+zip") return "epub"
if (mimeType == "application/pdf") return "pdf"
if (mimeType == "application/x-mobipocket-ebook") return "mobi"
if (mimeType == "application/vnd.comicbook+zip") return "cbz"
if (mimeType == "application/vnd.comicbook-rar") return "cbr"
if (mimeType == "application/vnd.amazon.mobi8-ebook") return "azw3"
return null
}
}
@JsonIgnoreProperties(ignoreUnknown = true)
@@ -95,14 +91,14 @@ data class LocalFolder(
)
open class LibraryItemWrapper(var id:String) {
@JsonIgnore
open fun getMediaDescription(progress:MediaProgressWrapper?): MediaDescriptionCompat { return MediaDescriptionCompat.Builder().build() }
open fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context): MediaDescriptionCompat { return MediaDescriptionCompat.Builder().build() }
}
@JsonIgnoreProperties(ignoreUnknown = true)
data class DeviceInfo(
var deviceId:String,
var manufacturer:String,
var model:String,
var brand:String,
var sdkVersion:Int,
var clientVersion: String
)
@@ -114,3 +110,99 @@ data class PlayItemRequestPayload(
var forceTranscode:Boolean,
var deviceInfo:DeviceInfo
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class DeviceSettings(
var disableAutoRewind:Boolean,
var enableAltView:Boolean,
var allowSeekingOnMediaControls:Boolean,
var jumpBackwardsTime:Int,
var jumpForwardTime:Int,
var enableMp3IndexSeeking:Boolean,
var disableShakeToResetSleepTimer:Boolean,
var shakeSensitivity: ShakeSensitivitySetting,
var lockOrientation: LockOrientationSetting,
var hapticFeedback: HapticFeedbackSetting,
var autoSleepTimer: Boolean,
var autoSleepTimerStartTime: String,
var autoSleepTimerEndTime: String,
var autoSleepTimerAutoRewind: Boolean,
var autoSleepTimerAutoRewindTime: Long, //Time in milliseconds
var sleepTimerLength: Long, // Time in milliseconds
var disableSleepTimerFadeOut: Boolean,
var disableSleepTimerResetFeedback: Boolean,
var languageCode: String,
var downloadUsingCellular: DownloadUsingCellularSetting,
var streamingUsingCellular: StreamingUsingCellularSetting
) {
companion object {
// Static method to get default device settings
fun default():DeviceSettings {
return DeviceSettings(
disableAutoRewind = false,
enableAltView = true,
allowSeekingOnMediaControls = false,
jumpBackwardsTime = 10,
jumpForwardTime = 10,
enableMp3IndexSeeking = false,
disableShakeToResetSleepTimer = false,
shakeSensitivity = ShakeSensitivitySetting.MEDIUM,
lockOrientation = LockOrientationSetting.NONE,
hapticFeedback = HapticFeedbackSetting.LIGHT,
autoSleepTimer = false,
autoSleepTimerStartTime = "22:00",
autoSleepTimerEndTime = "06:00",
sleepTimerLength = 900000L, // 15 minutes
autoSleepTimerAutoRewind = false,
autoSleepTimerAutoRewindTime = 300000L, // 5 minutes
disableSleepTimerFadeOut = false,
disableSleepTimerResetFeedback = false,
languageCode = "en-us",
downloadUsingCellular = DownloadUsingCellularSetting.ALWAYS,
streamingUsingCellular = StreamingUsingCellularSetting.ALWAYS
)
}
}
@get:JsonIgnore
val jumpBackwardsTimeMs get() = jumpBackwardsTime * 1000L
@get:JsonIgnore
val jumpForwardTimeMs get() = jumpForwardTime * 1000L
@get:JsonIgnore
val autoSleepTimerStartHour get() = autoSleepTimerStartTime.split(":")[0].toInt()
@get:JsonIgnore
val autoSleepTimerStartMinute get() = autoSleepTimerStartTime.split(":")[1].toInt()
@get:JsonIgnore
val autoSleepTimerEndHour get() = autoSleepTimerEndTime.split(":")[0].toInt()
@get:JsonIgnore
val autoSleepTimerEndMinute get() = autoSleepTimerEndTime.split(":")[1].toInt()
@JsonIgnore
fun getShakeThresholdGravity() : Float { // Used in ShakeDetector
return if (shakeSensitivity == ShakeSensitivitySetting.VERY_HIGH) 1.2f
else if (shakeSensitivity == ShakeSensitivitySetting.HIGH) 1.4f
else if (shakeSensitivity == ShakeSensitivitySetting.MEDIUM) 1.6f
else if (shakeSensitivity == ShakeSensitivitySetting.LOW) 2f
else if (shakeSensitivity == ShakeSensitivitySetting.VERY_LOW) 2.7f
else {
Log.e("DeviceSetting", "Invalid ShakeSensitivitySetting $shakeSensitivity")
1.6f
}
}
}
data class DeviceData(
var serverConnectionConfigs:MutableList<ServerConnectionConfig>,
var lastServerConnectionConfigId:String?,
var deviceSettings: DeviceSettings?,
var lastPlaybackSession: PlaybackSession?
) {
@JsonIgnore
fun getLastServerConnectionConfig(): ServerConnectionConfig? {
return lastServerConnectionConfigId?.let { lsccid ->
return serverConnectionConfigs.find { it.id == lsccid }
}
}
}
@@ -0,0 +1,13 @@
package com.audiobookshelf.app.data
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
@JsonIgnoreProperties(ignoreUnknown = true)
data class EBookFile(
var ino:String,
var metadata:FileMetadata?,
var ebookFormat:String,
var isLocal:Boolean,
var localFileId:String?,
var contentUrl:String?
)
@@ -1,9 +1,11 @@
package com.audiobookshelf.app.data
import android.content.Context
import android.net.Uri
import android.os.Bundle
import android.support.v4.media.MediaDescriptionCompat
import androidx.media.utils.MediaConstants
import com.audiobookshelf.app.BuildConfig
import com.audiobookshelf.app.R
import com.audiobookshelf.app.device.DeviceManager
import com.fasterxml.jackson.annotation.JsonIgnore
@@ -29,7 +31,8 @@ class LibraryItem(
var mediaType:String,
var media:MediaType,
var libraryFiles:MutableList<LibraryFile>?,
var userMediaProgress:MediaProgress? // Only included when requesting library item with progress (for downloads)
var userMediaProgress:MediaProgress?, // Only included when requesting library item with progress (for downloads)
var localLibraryItemId:String? // For Android Auto
) : LibraryItemWrapper(id) {
@get:JsonIgnore
val title get() = media.metadata.title
@@ -39,7 +42,7 @@ class LibraryItem(
@JsonIgnore
fun getCoverUri(): Uri {
if (media.coverPath == null) {
return Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon)
return Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
}
return Uri.parse("${DeviceManager.serverAddress}/api/items/$id/cover?token=${DeviceManager.token}")
@@ -55,8 +58,16 @@ class LibraryItem(
}
@JsonIgnore
override fun getMediaDescription(progress:MediaProgressWrapper?): MediaDescriptionCompat {
override fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context): MediaDescriptionCompat {
val extras = Bundle()
if (localLibraryItemId != null) {
extras.putLong(
MediaDescriptionCompat.EXTRA_DOWNLOAD_STATUS,
MediaDescriptionCompat.STATUS_DOWNLOADED
)
}
if (progress != null) {
if (progress.isFinished) {
extras.putInt(
@@ -79,8 +90,13 @@ class LibraryItem(
)
}
if (media.metadata.explicit) {
extras.putLong(MediaConstants.METADATA_KEY_IS_EXPLICIT, MediaConstants.METADATA_VALUE_ATTRIBUTE_PRESENT)
}
val mediaId = localLibraryItemId ?: id
return MediaDescriptionCompat.Builder()
.setMediaId(id)
.setMediaId(mediaId)
.setTitle(title)
.setIconUri(getCoverUri())
.setSubtitle(authorName)
@@ -1,14 +1,23 @@
package com.audiobookshelf.app.data
import android.content.Context
import android.graphics.Bitmap
import android.graphics.ImageDecoder
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.MediaStore
import android.support.v4.media.MediaDescriptionCompat
import android.util.Log
import androidx.core.content.FileProvider
import androidx.core.net.toFile
import androidx.media.utils.MediaConstants
import com.audiobookshelf.app.BuildConfig
import com.audiobookshelf.app.R
import com.audiobookshelf.app.device.DeviceManager
import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.audiobookshelf.app.player.PLAYMETHOD_LOCAL
import java.util.*
@JsonIgnoreProperties(ignoreUnknown = true)
@@ -39,8 +48,11 @@ class LocalLibraryItem(
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)
fun getCoverUri(ctx:Context): Uri {
if (coverContentUrl?.startsWith("file:") == true) {
return FileProvider.getUriForFile(ctx, "${BuildConfig.APPLICATION_ID}.fileprovider", Uri.parse(coverContentUrl).toFile())
}
return if (coverContentUrl != null) Uri.parse(coverContentUrl) else Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
}
@JsonIgnore
@@ -67,27 +79,31 @@ class LocalLibraryItem(
}
@JsonIgnore
fun getPlaybackSession(episode:PodcastEpisode?):PlaybackSession {
fun getPlaybackSession(episode:PodcastEpisode?, deviceInfo:DeviceInfo):PlaybackSession {
val localEpisodeId = episode?.id
val sessionId = "play_local_${UUID.randomUUID()}"
val sessionId = "${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 chapters = if (mediaType == "book") (media as Book).chapters else mutableListOf()
var audioTracks = media.getAudioTracks() as MutableList<AudioTrack>
val authorName = mediaMetadata.getAuthorDisplayName()
val displayTitle = episode?.title ?: mediaMetadata.title
var duration = getDuration()
if (episode != null) { // Get podcast episode audio track
episode.audioTrack?.let { at -> mutableListOf(at) }?.let { tracks -> audioTracks = tracks }
chapters = episode.chapters
duration = episode.audioTrack?.duration ?: 0.0
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")
return PlaybackSession(sessionId,serverUserId,libraryItemId,episode?.serverEpisodeId, mediaType, mediaMetadata, deviceInfo,chapters ?: mutableListOf(), displayTitle, authorName,null,duration,PLAYMETHOD_LOCAL,dateNow,0L,0L, audioTracks,currentTime,null,this,localEpisodeId,serverConnectionConfigId, serverAddress, "exo-player")
}
@JsonIgnore
@@ -96,10 +112,24 @@ class LocalLibraryItem(
}
@JsonIgnore
override fun getMediaDescription(progress:MediaProgressWrapper?): MediaDescriptionCompat {
val coverUri = getCoverUri()
override fun getMediaDescription(progress:MediaProgressWrapper?, ctx:Context): MediaDescriptionCompat {
val coverUri = getCoverUri(ctx)
var bitmap:Bitmap? = null
if (coverContentUrl != null) {
bitmap = if (Build.VERSION.SDK_INT < 28) {
MediaStore.Images.Media.getBitmap(ctx.contentResolver, coverUri)
} else {
val source: ImageDecoder.Source = ImageDecoder.createSource(ctx.contentResolver, coverUri)
ImageDecoder.decodeBitmap(source)
}
}
val extras = Bundle()
extras.putLong(
MediaDescriptionCompat.EXTRA_DOWNLOAD_STATUS,
MediaDescriptionCompat.STATUS_DOWNLOADED
)
if (progress != null) {
if (progress.isFinished) {
extras.putInt(
@@ -122,12 +152,21 @@ class LocalLibraryItem(
)
}
return MediaDescriptionCompat.Builder()
if (media.metadata.explicit) {
extras.putLong(MediaConstants.METADATA_KEY_IS_EXPLICIT, MediaConstants.METADATA_VALUE_ATTRIBUTE_PRESENT)
}
val mediaDescriptionBuilder = MediaDescriptionCompat.Builder()
.setMediaId(id)
.setTitle(title)
.setIconUri(coverUri)
.setSubtitle(authorName)
.setExtras(extras)
.build()
bitmap?.let {
mediaDescriptionBuilder.setIconBitmap(bitmap)
}
return mediaDescriptionBuilder.build()
}
}
@@ -18,6 +18,7 @@ data class LocalMediaItem(
var basePath:String,
var absolutePath:String,
var audioTracks:MutableList<AudioTrack>,
var ebookFile:EBookFile?,
var localFiles:MutableList<LocalFile>,
var coverContentUrl:String?,
var coverAbsolutePath:String?
@@ -42,7 +43,7 @@ data class LocalMediaItem(
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())
PodcastMetadata(name,null,null, mutableListOf(), false)
}
}
@@ -61,7 +62,7 @@ data class LocalMediaItem(
val mediaMetadata = getMediaMetadata()
if (mediaType == "book") {
val chapters = getAudiobookChapters()
val book = Book(mediaMetadata as BookMetadata, coverAbsolutePath, mutableListOf(), mutableListOf(), chapters,audioTracks,getTotalSize(),getDuration(),audioTracks.size)
val book = Book(mediaMetadata as BookMetadata, coverAbsolutePath, mutableListOf(), mutableListOf(), chapters,audioTracks,ebookFile,getTotalSize(),getDuration(),audioTracks.size)
return LocalLibraryItem(id, folderId, basePath,absolutePath, contentUrl, false,mediaType, book, localFiles, coverContentUrl, coverAbsolutePath,true,null,null,null,null)
} else {
val podcast = Podcast(mediaMetadata as PodcastMetadata, coverAbsolutePath, mutableListOf(), mutableListOf(), false, 0)
@@ -11,8 +11,10 @@ class LocalMediaProgress(
var localEpisodeId:String?,
var duration:Double,
progress:Double, // 0 to 1
var currentTime:Double,
currentTime:Double,
isFinished:Boolean,
var ebookLocation:String?, // cfi tag
var ebookProgress:Double?, // 0 to 1
var lastUpdate:Long,
var startedAt:Long,
var finishedAt:Long?,
@@ -22,9 +24,21 @@ class LocalMediaProgress(
var serverUserId:String?,
var libraryItemId:String?,
var episodeId:String?
) : MediaProgressWrapper(isFinished, progress) {
) : MediaProgressWrapper(isFinished, currentTime, progress) {
@get:JsonIgnore
val progressPercent get() = if (progress.isNaN()) 0 else (progress * 100).roundToInt()
@get:JsonIgnore
override val mediaItemId get() = if (libraryItemId != null) {
if (episodeId.isNullOrEmpty()) libraryItemId ?: "" else "$libraryItemId-$episodeId"
} else {
if (localEpisodeId.isNullOrEmpty()) localLibraryItemId else "$localLibraryItemId-$localEpisodeId"
}
@JsonIgnore
fun isMatch(mediaProgress:MediaProgress):Boolean {
if (episodeId != null) return libraryItemId == mediaProgress.libraryItemId && episodeId == mediaProgress.episodeId
return libraryItemId == mediaProgress.libraryItemId
}
@JsonIgnore
fun updateIsFinished(finished:Boolean) {
@@ -41,17 +55,25 @@ class LocalMediaProgress(
fun updateFromPlaybackSession(playbackSession:PlaybackSession) {
currentTime = playbackSession.currentTime
progress = playbackSession.progress
lastUpdate = System.currentTimeMillis()
lastUpdate = playbackSession.updatedAt
isFinished = playbackSession.progress >= 0.99
finishedAt = if (isFinished) lastUpdate else null
}
@JsonIgnore
fun updateEbookProgress(ebookLocation:String, ebookProgress:Double) {
lastUpdate = System.currentTimeMillis()
this.ebookProgress = ebookProgress
this.ebookLocation = ebookLocation
}
@JsonIgnore
fun updateFromServerMediaProgress(serverMediaProgress:MediaProgress) {
isFinished = serverMediaProgress.isFinished
progress = serverMediaProgress.progress
currentTime = serverMediaProgress.currentTime
ebookProgress = serverMediaProgress.ebookProgress
ebookLocation = serverMediaProgress.ebookLocation
duration = serverMediaProgress.duration
lastUpdate = serverMediaProgress.lastUpdate
finishedAt = serverMediaProgress.finishedAt
@@ -0,0 +1,15 @@
package com.audiobookshelf.app.data
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
@JsonIgnoreProperties(ignoreUnknown = true)
class MediaItemEvent(
var name:String, // e.g. Play/Pause/Stop/Seek/Save
var type:String, // Playback/Info
var description:String?,
var currentTime:Number?, // Seconds
var serverSyncAttempted:Boolean?,
var serverSyncSuccess:Boolean?,
var serverSyncMessage:String?,
var timestamp: Long
)
@@ -0,0 +1,17 @@
package com.audiobookshelf.app.data
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
@JsonIgnoreProperties(ignoreUnknown = true)
class MediaItemHistory(
var id: String, // media id
var mediaDisplayTitle: String,
var libraryItemId: String,
var episodeId: String?,
var isLocal:Boolean,
var serverConnectionConfigId:String?,
var serverAddress:String?,
var serverUserId:String?,
var createdAt: Long,
var events:MutableList<MediaItemEvent>,
)
@@ -0,0 +1,24 @@
package com.audiobookshelf.app.data
import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
@JsonIgnoreProperties(ignoreUnknown = true)
class MediaProgress(
var id:String,
var libraryItemId:String,
var episodeId:String?,
var duration:Double, // seconds
progress:Double, // 0 to 1
currentTime:Double,
isFinished:Boolean,
var ebookLocation:String?, // cfi tag
var ebookProgress:Double?, // 0 to 1
var lastUpdate:Long,
var startedAt:Long,
var finishedAt:Long?
) : MediaProgressWrapper(isFinished, currentTime, progress) {
@get:JsonIgnore
override val mediaItemId get() = if (episodeId.isNullOrEmpty()) libraryItemId else "$libraryItemId-$episodeId"
}
@@ -1,10 +1,17 @@
package com.audiobookshelf.app.data
import android.content.Context
import android.graphics.ImageDecoder
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import android.support.v4.media.MediaMetadataCompat
import androidx.core.content.FileProvider
import androidx.core.net.toFile
import com.audiobookshelf.app.BuildConfig
import com.audiobookshelf.app.R
import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.player.MediaProgressSyncData
import com.audiobookshelf.app.media.MediaProgressSyncData
import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.google.android.exoplayer2.MediaItem
@@ -12,12 +19,7 @@ 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
import com.audiobookshelf.app.player.*
@JsonIgnoreProperties(ignoreUnknown = true)
class PlaybackSession(
@@ -27,6 +29,7 @@ class PlaybackSession(
var episodeId:String?,
var mediaType:String,
var mediaMetadata:MediaTypeMetadata,
var deviceInfo:DeviceInfo,
var chapters:List<BookChapter>,
var displayTitle: String?,
var displayAuthor: String?,
@@ -53,17 +56,21 @@ class PlaybackSession(
@get:JsonIgnore
val isLocal get() = playMethod == PLAYMETHOD_LOCAL
@get:JsonIgnore
val isPodcastEpisode get() = mediaType == "podcast"
@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"
val localMediaProgressId get() = if (localEpisodeId.isNullOrEmpty()) localLibraryItemId else "$localLibraryItemId-$localEpisodeId"
@get:JsonIgnore
val progress get() = currentTime / getTotalDuration()
@get:JsonIgnore
val isLocalLibraryItemOnly get() = localLibraryItemId != "" && libraryItemId == null
@get:JsonIgnore
val mediaItemId get() = if (isLocalLibraryItemOnly) localMediaProgressId else if (episodeId.isNullOrEmpty()) libraryItemId ?: "" else "$libraryItemId-$episodeId"
@JsonIgnore
fun getCurrentTrackIndex():Int {
@@ -76,12 +83,41 @@ class PlaybackSession(
return audioTracks.size - 1
}
@JsonIgnore
fun getNextTrackIndex():Int {
for (i in 0 until audioTracks.size) {
val track = audioTracks[i]
if (currentTimeMs < track.startOffsetMs) {
return i
}
}
return audioTracks.size - 1
}
@JsonIgnore
fun getChapterForTime(time:Long):BookChapter? {
if (chapters.isEmpty()) return null
return chapters.find { time >= it.startMs && it.endMs > time}
}
@JsonIgnore
fun getCurrentTrackEndTime():Long {
val currentTrack = audioTracks[this.getCurrentTrackIndex()]
return currentTrack.startOffsetMs + currentTrack.durationMs
}
@JsonIgnore
fun getNextChapterForTime(time:Long):BookChapter? {
if (chapters.isEmpty()) return null
return chapters.find { time < it.startMs } // First chapter where start time is > then time
}
@JsonIgnore
fun getNextTrackEndTime():Long {
val currentTrack = audioTracks[this.getNextTrackIndex()]
return currentTrack.startOffsetMs + currentTrack.durationMs
}
@JsonIgnore
fun getCurrentTrackTimeMs():Long {
val currentTrack = audioTracks[this.getCurrentTrackIndex()]
@@ -104,10 +140,17 @@ class PlaybackSession(
}
@JsonIgnore
fun getCoverUri(): Uri {
if (localLibraryItem?.coverContentUrl != null) return Uri.parse(localLibraryItem?.coverContentUrl) ?: Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon)
fun getCoverUri(ctx:Context): Uri {
if (localLibraryItem?.coverContentUrl != null) {
var coverUri = Uri.parse(localLibraryItem?.coverContentUrl.toString())
if (coverUri.toString().startsWith("file:")) {
coverUri = FileProvider.getUriForFile(ctx, "${BuildConfig.APPLICATION_ID}.fileprovider", coverUri.toFile())
}
if (coverPath == null) return Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon)
return coverUri ?: Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
}
if (coverPath == null) return Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
return Uri.parse("$serverAddress/api/items/$libraryItemId/cover?token=${DeviceManager.token}")
}
@@ -118,37 +161,62 @@ class PlaybackSession(
}
@JsonIgnore
fun getMediaMetadataCompat(): MediaMetadataCompat {
fun getMediaMetadataCompat(ctx: Context): MediaMetadataCompat {
val coverUri = getCoverUri(ctx)
val 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, displayAuthor)
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST, displayAuthor)
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION, displayAuthor)
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, coverUri.toString())
.putString(MediaMetadataCompat.METADATA_KEY_ART_URI, coverUri.toString())
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, coverUri.toString())
// Local covers get bitmap
if (localLibraryItem?.coverContentUrl != null) {
val bitmap = if (Build.VERSION.SDK_INT < 28) {
MediaStore.Images.Media.getBitmap(ctx.contentResolver, coverUri)
} else {
val source: ImageDecoder.Source = ImageDecoder.createSource(ctx.contentResolver, coverUri)
ImageDecoder.decodeBitmap(source)
}
metadataBuilder.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, bitmap)
metadataBuilder.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, bitmap)
}
return metadataBuilder.build()
}
@JsonIgnore
fun getExoMediaMetadata(audioTrack:AudioTrack): MediaMetadata {
fun getExoMediaMetadata(ctx:Context): MediaMetadata {
val coverUri = getCoverUri(ctx)
val metadataBuilder = MediaMetadata.Builder()
.setTitle(displayTitle)
.setDisplayTitle(displayTitle)
.setArtist(displayAuthor)
.setAlbumArtist(displayAuthor)
.setSubtitle(displayAuthor)
val contentUri = this.getContentUri(audioTrack)
metadataBuilder.setMediaUri(contentUri)
.setAlbumTitle(displayAuthor)
.setDescription(displayAuthor)
.setArtworkUri(coverUri)
.setMediaType(MediaMetadata.MEDIA_TYPE_AUDIO_BOOK)
return metadataBuilder.build()
}
@JsonIgnore
fun getMediaItems():List<MediaItem> {
fun getMediaItems(ctx:Context):List<MediaItem> {
val mediaItems:MutableList<MediaItem> = mutableListOf()
for (audioTrack in audioTracks) {
val mediaMetadata = this.getExoMediaMetadata(audioTrack)
val mediaMetadata = this.getExoMediaMetadata(ctx)
val mediaUri = this.getContentUri(audioTrack)
val mimeType = audioTrack.mimeType
@@ -169,7 +237,9 @@ class PlaybackSession(
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_ALBUM_TITLE, 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
}
@@ -194,11 +264,11 @@ class PlaybackSession(
@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)
return PlaybackSession(id,userId,libraryItemId,episodeId,mediaType,mediaMetadata,deviceInfo,chapters,displayTitle,displayAuthor,coverPath,duration,playMethod,startedAt,updatedAt,timeListening,audioTracks,currentTime,libraryItem,localLibraryItem,localEpisodeId,serverConnectionConfigId,serverAddress, mediaPlayer)
}
@JsonIgnore
fun syncData(syncData:MediaProgressSyncData) {
fun syncData(syncData: MediaProgressSyncData) {
timeListening += syncData.timeListened
updatedAt = System.currentTimeMillis()
currentTime = syncData.currentTime
@@ -206,6 +276,6 @@ class PlaybackSession(
@JsonIgnore
fun getNewLocalMediaProgress():LocalMediaProgress {
return LocalMediaProgress(localMediaProgressId,localLibraryItemId,localEpisodeId,getTotalDuration(),progress,currentTime,false,updatedAt,startedAt,null,serverConnectionConfigId,serverAddress,userId,libraryItemId,episodeId)
return LocalMediaProgress(localMediaProgressId,localLibraryItemId,localEpisodeId,getTotalDuration(),progress,currentTime,false,null,null,updatedAt,startedAt,null,serverConnectionConfigId,serverAddress,userId,libraryItemId,episodeId)
}
}
@@ -1,18 +1,27 @@
package com.audiobookshelf.app.device
import android.appwidget.AppWidgetManager
import android.content.ComponentName
import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.util.Log
import com.audiobookshelf.app.MediaPlayerWidget
import com.audiobookshelf.app.data.*
import com.audiobookshelf.app.managers.DbManager
import com.audiobookshelf.app.player.PlayerNotificationService
import com.audiobookshelf.app.updateAppWidget
interface WidgetEventEmitter {
fun onPlayerChanged(pns:PlayerNotificationService)
fun onPlayerClosed()
}
object DeviceManager {
const val tag = "DeviceManager"
val dbManager:DbManager = DbManager()
var deviceData:DeviceData = dbManager.getDeviceData()
val dbManager: DbManager = DbManager()
var deviceData: DeviceData = dbManager.getDeviceData()
var serverConnectionConfig: ServerConnectionConfig? = null
val serverConnectionConfigId get() = serverConnectionConfig?.id ?: ""
@@ -20,12 +29,38 @@ object DeviceManager {
val serverUserId get() = serverConnectionConfig?.userId ?: ""
val token get() = serverConnectionConfig?.token ?: ""
val isConnectedToServer get() = serverConnectionConfig != null
val hasLastServerConnectionConfig get() = deviceData.getLastServerConnectionConfig() != null
var widgetUpdater:WidgetEventEmitter? = null
init {
Log.d(tag, "Device Manager Singleton invoked")
// Initialize new sleep timer settings and shake sensitivity added in v0.9.61
if (deviceData.deviceSettings?.autoSleepTimerStartTime == null || deviceData.deviceSettings?.autoSleepTimerEndTime == null) {
deviceData.deviceSettings?.autoSleepTimerStartTime = "22:00"
deviceData.deviceSettings?.autoSleepTimerStartTime = "06:00"
deviceData.deviceSettings?.sleepTimerLength = 900000L
}
if (deviceData.deviceSettings?.shakeSensitivity == null) {
deviceData.deviceSettings?.shakeSensitivity = ShakeSensitivitySetting.MEDIUM
}
// Initialize auto sleep timer auto rewind added in v0.9.64
if (deviceData.deviceSettings?.autoSleepTimerAutoRewindTime == null) {
deviceData.deviceSettings?.autoSleepTimerAutoRewindTime = 300000L // 5 minutes
}
// Language added in v0.9.69
if (deviceData.deviceSettings?.languageCode == null) {
deviceData.deviceSettings?.languageCode = "en-us"
}
if (deviceData.deviceSettings?.downloadUsingCellular == null) {
deviceData.deviceSettings?.downloadUsingCellular = DownloadUsingCellularSetting.ALWAYS
}
if (deviceData.deviceSettings?.streamingUsingCellular == null) {
deviceData.deviceSettings?.streamingUsingCellular = StreamingUsingCellularSetting.ALWAYS
}
}
fun getBase64Id(id:String):String {
@@ -36,4 +71,54 @@ object DeviceManager {
if (id == null) return null
return deviceData.serverConnectionConfigs.find { it.id == id }
}
fun checkConnectivity(ctx:Context): 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 setLastPlaybackSession(playbackSession:PlaybackSession) {
deviceData.lastPlaybackSession = playbackSession
dbManager.saveDeviceData(deviceData)
}
fun initializeWidgetUpdater(context:Context) {
Log.d(tag, "Initializing widget updater")
widgetUpdater = (object : WidgetEventEmitter {
override fun onPlayerChanged(pns: PlayerNotificationService) {
val isPlaying = pns.currentPlayer.isPlaying
val appWidgetManager = AppWidgetManager.getInstance(context)
val componentName = ComponentName(context, MediaPlayerWidget::class.java)
val ids = appWidgetManager.getAppWidgetIds(componentName)
val playbackSession = pns.getCurrentPlaybackSessionCopy()
for (widgetId in ids) {
updateAppWidget(context, appWidgetManager, widgetId, playbackSession, isPlaying, PlayerNotificationService.isClosed)
}
}
override fun onPlayerClosed() {
val appWidgetManager = AppWidgetManager.getInstance(context)
val componentName = ComponentName(context, MediaPlayerWidget::class.java)
val ids = appWidgetManager.getAppWidgetIds(componentName)
for (widgetId in ids) {
updateAppWidget(context, appWidgetManager, widgetId, deviceData.lastPlaybackSession, false, PlayerNotificationService.isClosed)
}
}
})
}
}
@@ -5,20 +5,15 @@ 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.audiobookshelf.app.models.DownloadItem
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
import org.json.JSONException
import java.io.File
class FolderScanner(var ctx: Context) {
private val tag = "FolderScanner"
var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
data class DownloadItemScanResult(val localLibraryItem:LocalLibraryItem, var localMediaProgress:LocalMediaProgress?)
@@ -26,295 +21,128 @@ class FolderScanner(var ctx: Context) {
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", "application/octet-stream"))
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
val track = AudioTrack(index, startOffset, audioProbeResult?.duration ?: 0.0, filename, localFile.contentUrl, mimeType, null, true, localFileId, audioProbeResult, null)
audioTrackToAdd = track
}
startOffset += audioProbeResult?.duration ?: 0.0
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
// m4b files showing as mimeType application/octet-stream on Android 10 and earlier see #154
val filesFound = df.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4", "application/octet-stream"))
Log.d(tag, "scanDownloadItem ${filesFound.size} files found in ${downloadItem.itemFolderPath}")
private fun scanInternalDownloadItem(downloadItem:DownloadItem, cb: (DownloadItemScanResult?) -> Unit) {
val localLibraryItemId = "local_${downloadItem.libraryItemId}"
var localEpisodeId:String? = null
var localLibraryItem:LocalLibraryItem? = null
var localLibraryItem:LocalLibraryItem?
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)
localLibraryItem = LocalLibraryItem(localLibraryItemId, downloadItem.localFolder.id, downloadItem.itemFolderPath, downloadItem.itemFolderPath, "", 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)
localLibraryItem = LocalLibraryItem(localLibraryItemId, downloadItem.localFolder.id, downloadItem.itemFolderPath, downloadItem.itemFolderPath, "", false, downloadItem.mediaType, downloadItem.media.getLocalCopy(), mutableListOf(), null, null, true,downloadItem.serverConnectionConfigId,downloadItem.serverAddress,downloadItem.serverUserId,downloadItem.libraryItemId)
}
}
val audioTracks:MutableList<AudioTrack> = mutableListOf()
val audioTracks:MutableList<AudioTrack> = mutableListOf()
var foundEBookFile = false
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}")
downloadItem.downloadItemParts.forEach { downloadItemPart ->
Log.d(tag, "Scan internal storage item with finalDestinationUri=${downloadItemPart.finalDestinationUri}")
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())
val file = File(downloadItemPart.finalDestinationPath)
Log.d(tag, "Scan internal storage item created file ${file.name}")
if (file == null) {
Log.e(tag, "scanInternalDownloadItem: Null docFile for path ${downloadItemPart.finalDestinationPath}")
} else {
if (downloadItemPart.audioTrack != null) {
val audioTrackFromServer = downloadItemPart.audioTrack
Log.d(
tag,
"scanInternalDownloadItem: Audio Track from Server index = ${audioTrackFromServer.index}"
)
val localFileId = DeviceManager.getBase64Id(file.name)
Log.d(tag, "Scan internal file localFileId=$localFileId")
val localFile = LocalFile(
localFileId,
file.name,
downloadItemPart.finalDestinationUri.toString(),
file.getBasePath(ctx),
file.absolutePath,
file.getSimplePath(ctx),
file.mimeType,
file.length()
)
localLibraryItem.localFiles.add(localFile)
// TODO: Make asynchronous
val audioProbeResult = probeAudioFile(localFile.absolutePath)
val trackFileMetadata = FileMetadata(file.name, file.extension, file.absolutePath, file.getBasePath(ctx), file.length())
// Create new audio track
val track = AudioTrack(audioTrackFromServer.index, audioTrackFromServer.startOffset, audioProbeResult?.duration ?: 0.0, localFile.filename ?: "", localFile.contentUrl, localFile.mimeType ?: "", null, true, localFileId, audioProbeResult, audioTrackFromServer.index)
val track = AudioTrack(
audioTrackFromServer.index,
audioTrackFromServer.startOffset,
audioTrackFromServer.duration,
localFile.filename ?: "",
localFile.contentUrl,
localFile.mimeType ?: "",
trackFileMetadata,
true,
localFileId,
null,
audioTrackFromServer.index
)
audioTracks.add(track)
Log.d(tag, "scanDownloadItem: Created Audio Track with index ${track.index} from local file ${localFile.absolutePath}")
Log.d(
tag,
"scanInternalDownloadItem: Created Audio Track with index ${track.index} from local file ${localFile.absolutePath}"
)
// Add podcast episodes to library
itemPart.episode?.let { podcastEpisode ->
downloadItemPart.episode?.let { podcastEpisode ->
val podcast = localLibraryItem.media as Podcast
val 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}")
Log.d(
tag,
"scanInternalDownloadItem: 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())
} else if (downloadItemPart.ebookFile != null) {
foundEBookFile = true
Log.d(tag, "scanInternalDownloadItem: Ebook file found with mimetype=${file.mimeType}")
val localFileId = DeviceManager.getBase64Id(file.name)
val localFile = LocalFile(
localFileId,
file.name,
Uri.fromFile(file).toString(),
file.getBasePath(ctx),
file.absolutePath,
file.getSimplePath(ctx),
file.mimeType,
file.length()
)
localLibraryItem.localFiles.add(localFile)
val ebookFile = EBookFile(
downloadItemPart.ebookFile.ino,
downloadItemPart.ebookFile.metadata,
downloadItemPart.ebookFile.ebookFormat,
true,
localFileId,
localFile.contentUrl
)
(localLibraryItem.media as Book).ebookFile = ebookFile
Log.d(tag, "scanInternalDownloadItem: Ebook file added to lli ${localFile.contentUrl}")
} else {
val localFileId = DeviceManager.getBase64Id(file.name)
val localFile = LocalFile(localFileId,file.name,Uri.fromFile(file).toString(),file.getBasePath(ctx),file.absolutePath,file.getSimplePath(ctx),file.mimeType,file.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
if (audioTracks.isEmpty() && !foundEBookFile) {
Log.d(tag, "scanDownloadItem did not find any audio tracks or ebook file in folder for ${downloadItem.itemFolderPath}")
return cb(null)
}
// For books sort audio tracks then set
@@ -347,7 +175,9 @@ class FolderScanner(var ctx: Context) {
duration = mediaProgress.duration,
progress = mediaProgress.progress,
currentTime = mediaProgress.currentTime,
isFinished = false,
isFinished = mediaProgress.isFinished,
ebookLocation = mediaProgress.ebookLocation,
ebookProgress = mediaProgress.ebookProgress,
lastUpdate = mediaProgress.lastUpdate,
startedAt = mediaProgress.startedAt,
finishedAt = mediaProgress.finishedAt,
@@ -364,124 +194,175 @@ class FolderScanner(var ctx: Context) {
DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem)
return downloadItemScanResult
cb(downloadItemScanResult)
}
fun scanLocalLibraryItem(localLibraryItem:LocalLibraryItem, forceAudioProbe:Boolean):LocalLibraryItemScanResult? {
val df: DocumentFile? = DocumentFileCompat.fromUri(ctx, Uri.parse(localLibraryItem.contentUrl))
// Scan item after download and create local library item
fun scanDownloadItem(downloadItem: DownloadItem, cb: (DownloadItemScanResult?) -> Unit) {
// If downloading to internal storage handle separately
if (downloadItem.isInternalStorage) {
scanInternalDownloadItem(downloadItem, cb)
return
}
val folderDf = DocumentFileCompat.fromUri(ctx, Uri.parse(downloadItem.localFolder.contentUrl))
val foldersFound = folderDf?.search(true, DocumentFileType.FOLDER) ?: mutableListOf()
var itemFolderId = ""
var itemFolderUrl = ""
var itemFolderBasePath = ""
var itemFolderAbsolutePath = ""
foldersFound.forEach {
// e.g. absolute path is "storage/emulated/0/Audiobooks/Orson Scott Card/Enders Game"
// and itemSubfolder is "Orson Scott Card/Enders Game"
if (it.getAbsolutePath(ctx).endsWith(downloadItem.itemSubfolder)) {
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 cb(null)
}
val df: DocumentFile? = DocumentFileCompat.fromUri(ctx, Uri.parse(itemFolderUrl))
if (df == null) {
Log.e(tag, "Item Folder Doc File Invalid ${localLibraryItem.absolutePath}")
return null
Log.e(tag, "Folder Doc File Invalid ${downloadItem.itemFolderPath}")
return cb(null)
}
Log.d(tag, "scanLocalLibraryItem starting for ${localLibraryItem.absolutePath} | ${df.uri}")
var wasUpdated = false
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", "application/octet-stream"))
Log.d(tag, "scanLocalLibraryItem ${filesFound.size} files found in ${localLibraryItem.absolutePath}")
// m4b files showing as mimeType application/octet-stream on Android 10 and earlier see #154
val filesFound = df.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4", "application/*"))
Log.d(tag, "scanDownloadItem ${filesFound.size} files found in ${downloadItem.itemFolderPath}")
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 localEpisodeId:String? = null
var localLibraryItem:LocalLibraryItem?
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 existingAudioTracks = localLibraryItem.media.getAudioTracks()
// Remove any files no longer found in library item folder
val 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
}
}
val audioTracks:MutableList<AudioTrack> = mutableListOf()
var foundEBookFile = false
filesFound.forEach { docFile ->
val localFileId = DeviceManager.getBase64Id(docFile.id)
val existingLocalFile = localLibraryItem.localFiles.find { it.id == localFileId }
if (existingLocalFile == null || (existingLocalFile.isAudioFile() && forceAudioProbe)) {
val 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}")
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}")
if (localFile.isAudioFile()) {
// TODO: Make asynchronous
val audioProbeResult = probeAudioFile(localFile.absolutePath)
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)
val existingTrack = existingAudioTracks.find { audioTrack ->
audioTrack.localFileId == localFile.id
}
// Create new audio track
val trackFileMetadata = FileMetadata(docFile.name ?: "", docFile.extension ?: "", docFile.getAbsolutePath(ctx), docFile.getBasePath(ctx), docFile.length())
val track = AudioTrack(audioTrackFromServer.index, audioTrackFromServer.startOffset, audioTrackFromServer.duration, localFile.filename ?: "", localFile.contentUrl, localFile.mimeType ?: "", trackFileMetadata, true, localFileId, null, audioTrackFromServer.index)
audioTracks.add(track)
if (existingTrack == null) {
// Create new audio track
val lastTrack = existingAudioTracks.lastOrNull()
val startOffset = (lastTrack?.startOffset ?: 0.0) + (lastTrack?.duration ?: 0.0)
val track = AudioTrack(existingAudioTracks.size, startOffset, audioProbeResult?.duration ?: 0.0, localFile.filename ?: "", localFile.contentUrl, localFile.mimeType ?: "", null, true, localFileId, audioProbeResult, null)
localLibraryItem.media.addAudioTrack(track)
Log.d(tag, "Added New Audio Track ${track.title}")
wasUpdated = true
} else {
existingTrack.audioProbeResult = audioProbeResult
// TODO: Update data found from probe
Log.d(tag, "scanDownloadItem: Created Audio Track with index ${track.index} from local file ${localFile.absolutePath}")
Log.d(tag, "Updated Audio Track Probe Data ${existingTrack.title}")
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
}
// Add podcast episodes to library
itemPart.episode?.let { podcastEpisode ->
val podcast = localLibraryItem.media as Podcast
val 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 if (itemPart.ebookFile != null) { // Ebook
foundEBookFile = true
Log.d(tag, "scanDownloadItem: Ebook file found with mimetype=${docFile.mimeType}")
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)
val ebookFile = EBookFile(itemPart.ebookFile.ino, itemPart.ebookFile.metadata, itemPart.ebookFile.ebookFormat, true, localFileId, localFile.contentUrl)
(localLibraryItem.media as Book).ebookFile = ebookFile
Log.d(tag, "scanDownloadItem: Ebook file added to lli ${localFile.contentUrl}")
} 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 (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? {
val session = FFprobeKit.execute("-i \"${absolutePath}\" -print_format json -show_format -show_streams -select_streams a -show_chapters -loglevel quiet")
var probeObject:JSObject? = null
try {
probeObject = JSObject(session.output)
} catch(error:JSONException) {
Log.e(tag, "Failed to parse probe result $error")
if (audioTracks.isEmpty() && !foundEBookFile) {
Log.d(tag, "scanDownloadItem did not find any audio tracks or ebook file in folder for ${downloadItem.itemFolderPath}")
return cb(null)
}
Log.d(tag, "FFprobe output $probeObject")
if (probeObject == null || !probeObject.has("streams")) { // Check if output is empty
Log.d(tag, "probeAudioFile Probe audio file $absolutePath failed or invalid")
return null
} else {
val audioProbeResult = jacksonMapper.readValue<AudioProbeResult>(session.output)
Log.d(tag, "Probe Result DATA ${audioProbeResult.duration} | ${audioProbeResult.size} | ${audioProbeResult.title} | ${audioProbeResult.artist}")
return audioProbeResult
// 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 = mediaProgress.isFinished,
ebookLocation = mediaProgress.ebookLocation,
ebookProgress = mediaProgress.ebookProgress,
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)
cb(downloadItemScanResult)
}
}
@@ -1,8 +1,9 @@
package com.audiobookshelf.app.data
package com.audiobookshelf.app.managers
import android.content.Context
import android.util.Log
import com.audiobookshelf.app.plugins.AbsDownloader
import com.audiobookshelf.app.data.*
import com.audiobookshelf.app.models.DownloadItem
import io.paperdb.Paper
import java.io.File
@@ -10,7 +11,7 @@ class DbManager {
val tag = "DbManager"
companion object {
var isDbInitialized = false
private var isDbInitialized = false
fun initialize(ctx: Context) {
if (isDbInitialized) return
@@ -21,16 +22,16 @@ class DbManager {
}
fun getDeviceData(): DeviceData {
return Paper.book("device").read("data") ?: DeviceData(mutableListOf(), null, null, DeviceSettings.default())
return Paper.book("device").read("data") ?: DeviceData(mutableListOf(), null, DeviceSettings.default(), null)
}
fun saveDeviceData(deviceData:DeviceData) {
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)
val localLibraryItem: LocalLibraryItem? = Paper.book("localLibraryItems").read(it)
if (localLibraryItem != null && (mediaType.isNullOrEmpty() || mediaType == localLibraryItem.mediaType)) {
localLibraryItems.add(localLibraryItem)
}
@@ -45,16 +46,16 @@ class DbManager {
}
}
fun getLocalLibraryItemByLId(libraryItemId:String):LocalLibraryItem? {
fun getLocalLibraryItemByLId(libraryItemId:String): LocalLibraryItem? {
return getLocalLibraryItems().find { it.libraryItemId == libraryItemId }
}
fun getLocalLibraryItem(localLibraryItemId:String):LocalLibraryItem? {
fun getLocalLibraryItem(localLibraryItemId:String): LocalLibraryItem? {
return Paper.book("localLibraryItems").read(localLibraryItemId)
}
fun getLocalLibraryItemWithEpisode(podcastEpisodeId:String):LibraryItemWithEpisode? {
var podcastEpisode:PodcastEpisode? = null
fun getLocalLibraryItemWithEpisode(podcastEpisodeId:String): LibraryItemWithEpisode? {
var podcastEpisode: PodcastEpisode? = null
val localLibraryItem = getLocalLibraryItems("podcast").find { localLibraryItem ->
val podcast = localLibraryItem.media as Podcast
podcastEpisode = podcast.episodes?.find { it.id == podcastEpisodeId }
@@ -77,15 +78,15 @@ class DbManager {
}
}
fun saveLocalLibraryItem(localLibraryItem:LocalLibraryItem) {
fun saveLocalLibraryItem(localLibraryItem: LocalLibraryItem) {
Paper.book("localLibraryItems").write(localLibraryItem.id, localLibraryItem)
}
fun saveLocalFolder(localFolder:LocalFolder) {
fun saveLocalFolder(localFolder: LocalFolder) {
Paper.book("localFolders").write(localFolder.id,localFolder)
}
fun getLocalFolder(folderId:String):LocalFolder? {
fun getLocalFolder(folderId:String): LocalFolder? {
return Paper.book("localFolders").read(folderId)
}
@@ -107,7 +108,7 @@ class DbManager {
Paper.book("localFolders").delete(folderId)
}
fun saveDownloadItem(downloadItem: AbsDownloader.DownloadItem) {
fun saveDownloadItem(downloadItem: DownloadItem) {
Paper.book("downloadItems").write(downloadItem.id, downloadItem)
}
@@ -115,21 +116,21 @@ class DbManager {
Paper.book("downloadItems").delete(downloadItemId)
}
fun getDownloadItems():List<AbsDownloader.DownloadItem> {
val downloadItems:MutableList<AbsDownloader.DownloadItem> = mutableListOf()
fun getDownloadItems():List<DownloadItem> {
val downloadItems:MutableList<DownloadItem> = mutableListOf()
Paper.book("downloadItems").allKeys.forEach { downloadItemId ->
Paper.book("downloadItems").read<AbsDownloader.DownloadItem>(downloadItemId)?.let {
Paper.book("downloadItems").read<DownloadItem>(downloadItemId)?.let {
downloadItems.add(it)
}
}
return downloadItems
}
fun saveLocalMediaProgress(mediaProgress:LocalMediaProgress) {
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? {
fun getLocalMediaProgress(localMediaProgressId:String): LocalMediaProgress? {
return Paper.book("localMediaProgress").read(localMediaProgressId)
}
fun getAllLocalMediaProgress():List<LocalMediaProgress> {
@@ -154,7 +155,7 @@ class DbManager {
val localLibraryItems = getLocalLibraryItems()
localLibraryItems.forEach { lli ->
var hasUpates = false
var hasUpdates = false
// Check local files
lli.localFiles = lli.localFiles.filter { localFile ->
@@ -162,7 +163,7 @@ class DbManager {
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
hasUpdates = true
}
file.exists()
} as MutableList<LocalFile>
@@ -173,7 +174,7 @@ class DbManager {
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
hasUpdates = true
}
ep.audioTrack != null && lli.localFiles.find { lf -> lf.id == ep.audioTrack?.localFileId } != null
} as MutableList<PodcastEpisode>
@@ -182,7 +183,7 @@ class DbManager {
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
hasUpdates = true
}
lli.localFiles.find { lf -> lf.id == track.localFileId } != null
} as MutableList<AudioTrack>
@@ -196,11 +197,11 @@ class DbManager {
Log.d(tag, "cleanLocalLibraryItems: Cover $it was removed from library item ${lli.media.metadata.title}")
lli.coverAbsolutePath = null
lli.coverContentUrl = null
hasUpates = true
hasUpdates = true
}
}
if (hasUpates) {
if (hasUpdates) {
Log.d(tag, "cleanLocalLibraryItems: Saving local library item ${lli.id}")
Paper.book("localLibraryItems").write(lli.id, lli)
}
@@ -236,10 +237,26 @@ class DbManager {
}
}
fun saveLocalPlaybackSession(playbackSession:PlaybackSession) {
Paper.book("localPlaybackSession").write(playbackSession.id,playbackSession)
fun saveMediaItemHistory(mediaItemHistory: MediaItemHistory) {
Paper.book("mediaItemHistory").write(mediaItemHistory.id,mediaItemHistory)
}
fun getLocalPlaybackSession(playbackSessionId:String):PlaybackSession? {
return Paper.book("localPlaybackSession").read(playbackSessionId)
fun getMediaItemHistory(id:String): MediaItemHistory? {
return Paper.book("mediaItemHistory").read(id)
}
fun savePlaybackSession(playbackSession: PlaybackSession) {
Paper.book("playbackSession").write(playbackSession.id,playbackSession)
}
fun removePlaybackSession(playbackSessionId:String) {
Paper.book("playbackSession").delete(playbackSessionId)
}
fun getPlaybackSessions():List<PlaybackSession> {
val sessions:MutableList<PlaybackSession> = mutableListOf()
Paper.book("playbackSession").allKeys.forEach { playbackSessionId ->
Paper.book("playbackSession").read<PlaybackSession>(playbackSessionId)?.let {
sessions.add(it)
}
}
return sessions
}
}
@@ -0,0 +1,291 @@
package com.audiobookshelf.app.managers
import android.app.DownloadManager
import android.net.Uri
import android.util.Log
import androidx.documentfile.provider.DocumentFile
import com.anggrayudi.storage.callback.FileCallback
import com.anggrayudi.storage.file.DocumentFileCompat
import com.anggrayudi.storage.file.MimeType
import com.anggrayudi.storage.file.getAbsolutePath
import com.anggrayudi.storage.file.moveFileTo
import com.anggrayudi.storage.media.FileDescription
import com.audiobookshelf.app.MainActivity
import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.device.FolderScanner
import com.audiobookshelf.app.models.DownloadItem
import com.audiobookshelf.app.models.DownloadItemPart
import com.fasterxml.jackson.core.json.JsonReadFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.getcapacitor.JSObject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.io.File
import java.io.FileOutputStream
import java.util.*
class DownloadItemManager(var downloadManager:DownloadManager, private var folderScanner: FolderScanner, var mainActivity: MainActivity, private var clientEventEmitter:DownloadEventEmitter) {
val tag = "DownloadItemManager"
private val maxSimultaneousDownloads = 3
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
enum class DownloadCheckStatus {
InProgress,
Successful,
Failed
}
var downloadItemQueue: MutableList<DownloadItem> = mutableListOf() // All pending and downloading items
var currentDownloadItemParts: MutableList<DownloadItemPart> = mutableListOf() // Item parts currently being downloaded
interface DownloadEventEmitter {
fun onDownloadItem(downloadItem:DownloadItem)
fun onDownloadItemPartUpdate(downloadItemPart:DownloadItemPart)
fun onDownloadItemComplete(jsobj:JSObject)
}
interface InternalProgressCallback {
fun onProgress(totalBytesWritten:Long, progress: Long)
fun onComplete(failed: Boolean)
}
companion object {
var isDownloading:Boolean = false
}
fun addDownloadItem(downloadItem:DownloadItem) {
DeviceManager.dbManager.saveDownloadItem(downloadItem)
Log.i(tag, "Add download item ${downloadItem.media.metadata.title}")
downloadItemQueue.add(downloadItem)
clientEventEmitter.onDownloadItem(downloadItem)
checkUpdateDownloadQueue()
}
private fun checkUpdateDownloadQueue() {
for (downloadItem in downloadItemQueue) {
val numPartsToGet = maxSimultaneousDownloads - currentDownloadItemParts.size
val nextDownloadItemParts = downloadItem.getNextDownloadItemParts(numPartsToGet)
Log.d(tag, "checkUpdateDownloadQueue: numPartsToGet=$numPartsToGet, nextDownloadItemParts=${nextDownloadItemParts.size}")
if (nextDownloadItemParts.size > 0) {
nextDownloadItemParts.forEach {
if (it.isInternalStorage) {
val file = File(it.finalDestinationPath)
file.parentFile?.mkdirs()
val fileOutputStream = FileOutputStream(it.finalDestinationPath)
val internalProgressCallback = (object : InternalProgressCallback {
override fun onProgress(totalBytesWritten:Long, progress: Long) {
it.bytesDownloaded = totalBytesWritten
it.progress = progress
}
override fun onComplete(failed:Boolean) {
it.failed = failed
it.completed = true
}
})
Log.d(tag, "Start internal download to destination path ${it.finalDestinationPath} from ${it.serverUrl}")
InternalDownloadManager(fileOutputStream, internalProgressCallback).download(it.serverUrl)
it.downloadId = 1
currentDownloadItemParts.add(it)
} else {
val dlRequest = it.getDownloadRequest()
val downloadId = downloadManager.enqueue(dlRequest)
it.downloadId = downloadId
Log.d(tag, "checkUpdateDownloadQueue: Starting download item part, downloadId=$downloadId")
currentDownloadItemParts.add(it)
}
}
}
if (currentDownloadItemParts.size >= maxSimultaneousDownloads) {
break
}
}
if (currentDownloadItemParts.size > 0) startWatchingDownloads()
}
private fun startWatchingDownloads() {
if (isDownloading) return // Already watching
GlobalScope.launch(Dispatchers.IO) {
Log.d(tag, "Starting watching downloads")
isDownloading = true
while (currentDownloadItemParts.size > 0) {
val itemParts = currentDownloadItemParts.filter { !it.isMoving }.map { it }
for (downloadItemPart in itemParts) {
if (downloadItemPart.isInternalStorage) {
clientEventEmitter.onDownloadItemPartUpdate(downloadItemPart)
if (downloadItemPart.completed) {
val downloadItem = downloadItemQueue.find { it.id == downloadItemPart.downloadItemId }
downloadItem?.let {
checkDownloadItemFinished(it)
}
currentDownloadItemParts.remove(downloadItemPart)
}
} else {
val downloadCheckStatus = checkDownloadItemPart(downloadItemPart)
clientEventEmitter.onDownloadItemPartUpdate(downloadItemPart)
// Will move to final destination, remove current item parts, and check if download item is finished
handleDownloadItemPartCheck(downloadCheckStatus, downloadItemPart)
}
}
delay(500)
if (currentDownloadItemParts.size < maxSimultaneousDownloads) {
checkUpdateDownloadQueue()
}
}
Log.d(tag, "Finished watching downloads")
isDownloading = false
}
}
private fun checkDownloadItemPart(downloadItemPart:DownloadItemPart):DownloadCheckStatus {
val downloadId = downloadItemPart.downloadId ?: return DownloadCheckStatus.Failed
val query = DownloadManager.Query().setFilterById(downloadId)
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.getLong(bytesDownloadedColumnIndex) else 0
Log.d(tag, "checkDownloads Download ${downloadItemPart.filename} bytes $totalBytes | bytes dled $bytesDownloadedSoFar | downloadStatus $downloadStatus")
return when (downloadStatus) {
DownloadManager.STATUS_SUCCESSFUL -> {
Log.d(tag, "checkDownloads Download ${downloadItemPart.filename} Successful")
downloadItemPart.completed = true
downloadItemPart.progress = 1
downloadItemPart.bytesDownloaded = bytesDownloadedSoFar
DownloadCheckStatus.Successful
}
DownloadManager.STATUS_FAILED -> {
Log.d(tag, "checkDownloads Download ${downloadItemPart.filename} Failed")
downloadItemPart.completed = true
downloadItemPart.failed = true
DownloadCheckStatus.Failed
}
else -> {
val percentProgress = if (totalBytes > 0) ((bytesDownloadedSoFar * 100L) / totalBytes) else 0
Log.d(tag, "checkDownloads Download ${downloadItemPart.filename} Progress = $percentProgress%")
downloadItemPart.progress = percentProgress
downloadItemPart.bytesDownloaded = bytesDownloadedSoFar
DownloadCheckStatus.InProgress
}
}
} else {
Log.d(tag, "Download ${downloadItemPart.filename} not found in dlmanager")
downloadItemPart.completed = true
downloadItemPart.failed = true
return DownloadCheckStatus.Failed
}
}
}
private fun handleDownloadItemPartCheck(downloadCheckStatus:DownloadCheckStatus, downloadItemPart:DownloadItemPart) {
val downloadItem = downloadItemQueue.find { it.id == downloadItemPart.downloadItemId }
if (downloadItem == null) {
Log.e(tag, "Download item part finished but download item not found ${downloadItemPart.filename}")
currentDownloadItemParts.remove(downloadItemPart)
} else if (downloadCheckStatus == DownloadCheckStatus.Successful) {
val file = DocumentFileCompat.fromUri(mainActivity, downloadItemPart.destinationUri)
Log.d(tag, "DOWNLOAD: DESTINATION URI ${downloadItemPart.destinationUri}")
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
downloadItemPart.isMoving = false
file?.delete()
checkDownloadItemFinished(downloadItem)
currentDownloadItemParts.remove(downloadItemPart)
}
override fun onCompleted(result:Any) {
Log.d(tag, "DOWNLOAD: FILE MOVE COMPLETED")
val resultDocFile = result as DocumentFile
Log.d(tag, "DOWNLOAD: COMPLETED FILE INFO (name=${resultDocFile.name}) ${resultDocFile.getAbsolutePath(mainActivity)}")
// Rename to fix appended .mp3 on m4b/m4a files
// REF: https://github.com/anggrayudi/SimpleStorage/issues/94
val docNameLowerCase = resultDocFile.name?.lowercase(Locale.getDefault()) ?: ""
if (docNameLowerCase.endsWith(".m4b.mp3")|| docNameLowerCase.endsWith(".m4a.mp3")) {
resultDocFile.renameTo(downloadItemPart.filename)
}
downloadItemPart.moved = true
downloadItemPart.isMoving = false
checkDownloadItemFinished(downloadItem)
currentDownloadItemParts.remove(downloadItemPart)
}
}
val localFolderFile = DocumentFileCompat.fromUri(mainActivity, Uri.parse(downloadItemPart.localFolderUrl))
if (localFolderFile == null) {
// fAILED
downloadItemPart.failed = true
Log.e(tag, "Local Folder File from uri is null")
checkDownloadItemFinished(downloadItem)
currentDownloadItemParts.remove(downloadItemPart)
} else {
downloadItemPart.isMoving = true
val mimetype = if (downloadItemPart.audioTrack != null) MimeType.AUDIO else MimeType.IMAGE
val fileDescription = FileDescription(downloadItemPart.filename, downloadItemPart.finalDestinationSubfolder, mimetype)
file?.moveFileTo(mainActivity, localFolderFile, fileDescription, fcb)
}
} else if (downloadCheckStatus != DownloadCheckStatus.InProgress) {
checkDownloadItemFinished(downloadItem)
currentDownloadItemParts.remove(downloadItemPart)
}
}
private fun checkDownloadItemFinished(downloadItem:DownloadItem) {
if (downloadItem.isDownloadFinished) {
Log.i(tag, "Download Item finished ${downloadItem.media.metadata.title}")
GlobalScope.launch(Dispatchers.IO) {
folderScanner.scanDownloadItem(downloadItem) { downloadItemScanResult ->
Log.d(tag, "Item download complete ${downloadItem.itemTitle} | local library item id: ${downloadItemScanResult?.localLibraryItem?.id}")
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)))
}
launch(Dispatchers.Main) {
clientEventEmitter.onDownloadItemComplete(jsobj)
downloadItemQueue.remove(downloadItem)
DeviceManager.dbManager.removeDownloadItem(downloadItem.id)
}
}
}
}
}
}
@@ -0,0 +1,74 @@
package com.audiobookshelf.app.managers
import android.util.Log
import com.google.common.net.HttpHeaders.CONTENT_LENGTH
import okhttp3.*
import java.io.*
import java.util.*
class InternalDownloadManager(outputStream:FileOutputStream, private val progressCallback: DownloadItemManager.InternalProgressCallback) : AutoCloseable {
private val tag = "InternalDownloadManager"
private val client: OkHttpClient = OkHttpClient()
private val writer = BinaryFileWriter(outputStream, progressCallback)
@Throws(IOException::class)
fun download(url:String) {
val request: Request = Request.Builder().url(url).build()
client.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
Log.e(tag, "download URL $url FAILED")
progressCallback.onComplete(true)
}
override fun onResponse(call: Call, response: Response) {
val responseBody: ResponseBody = response.body
?: throw IllegalStateException("Response doesn't contain a file")
val length: Long = (response.header(CONTENT_LENGTH, "1") ?: "0").toLong()
writer.write(responseBody.byteStream(), length)
}
})
}
@Throws(Exception::class)
override fun close() {
writer.close()
}
}
class BinaryFileWriter(outputStream: OutputStream, progressCallback: DownloadItemManager.InternalProgressCallback) :
AutoCloseable {
private val outputStream: OutputStream
private val progressCallback: DownloadItemManager.InternalProgressCallback
init {
this.outputStream = outputStream
this.progressCallback = progressCallback
}
@Throws(IOException::class)
fun write(inputStream: InputStream?, length: Long): Long {
BufferedInputStream(inputStream).use { input ->
val dataBuffer = ByteArray(CHUNK_SIZE)
var readBytes: Int
var totalBytes: Long = 0
while (input.read(dataBuffer).also { readBytes = it } != -1) {
totalBytes += readBytes.toLong()
outputStream.write(dataBuffer, 0, readBytes)
progressCallback.onProgress(totalBytes, (totalBytes * 100L) / length)
}
progressCallback.onComplete(false)
return totalBytes
}
}
@Throws(IOException::class)
override fun close() {
outputStream.close()
}
companion object {
private const val CHUNK_SIZE = 1024
}
}
@@ -0,0 +1,397 @@
package com.audiobookshelf.app.managers
import android.content.Context
import android.media.metrics.PlaybackSession
import android.os.*
import android.util.Log
import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.player.PlayerNotificationService
import com.audiobookshelf.app.player.SLEEP_TIMER_WAKE_UP_EXPIRATION
import java.text.SimpleDateFormat
import java.util.*
import kotlin.concurrent.schedule
import kotlin.math.roundToInt
class SleepTimerManager constructor(private val playerNotificationService: PlayerNotificationService) {
private val tag = "SleepTimerManager"
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 sleepTimerFinishedAt:Long = 0L
private var isAutoSleepTimer:Boolean = false // When timer was auto-set
private var isFirstAutoSleepTimer: Boolean = true
private var sleepTimerSessionId:String = ""
private fun getCurrentTime():Long {
return playerNotificationService.getCurrentTime()
}
private fun getDuration():Long {
return playerNotificationService.getDuration()
}
private fun getIsPlaying():Boolean {
return playerNotificationService.currentPlayer.isPlaying
}
private fun setVolume(volume:Float) {
playerNotificationService.currentPlayer.volume = volume
}
private fun pause() {
playerNotificationService.currentPlayer.pause()
}
private fun play() {
playerNotificationService.currentPlayer.play()
}
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
return (((sleepTimerEndTime - getCurrentTime()) / 1000).toDouble()).roundToInt()
}
private fun setSleepTimer(time: Long, isChapterTime: Boolean) : Boolean {
Log.d(tag, "Setting Sleep Timer for $time is chapter time $isChapterTime")
sleepTimerTask?.cancel()
sleepTimerRunning = true
sleepTimerFinishedAt = 0L
sleepTimerElapsed = 0L
setVolume(1f)
// Register shake sensor
playerNotificationService.registerSensor()
val currentTime = getCurrentTime()
if (isChapterTime) {
if (currentTime > time) {
Log.d(tag, "Invalid sleep timer - current time is already passed chapter time $time")
return false
}
sleepTimerEndTime = time
sleepTimerLength = 0
if (sleepTimerEndTime > getDuration()) {
sleepTimerEndTime = getDuration()
}
} else {
sleepTimerLength = time
sleepTimerEndTime = 0L
}
playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds(), isAutoSleepTimer)
sleepTimerTask = Timer("SleepTimer", false).schedule(0L, 1000L) {
Handler(Looper.getMainLooper()).post {
if (getIsPlaying()) {
sleepTimerElapsed += 1000L
val sleepTimeSecondsRemaining = getSleepTimerTimeRemainingSeconds()
Log.d(tag, "Timer Elapsed $sleepTimerElapsed | Sleep TIMER time remaining $sleepTimeSecondsRemaining s")
if (sleepTimeSecondsRemaining > 0) {
playerNotificationService.clientEventEmitter?.onSleepTimerSet(sleepTimeSecondsRemaining, isAutoSleepTimer)
}
if (sleepTimeSecondsRemaining <= 0) {
Log.d(tag, "Sleep Timer Pausing Player on Chapter")
pause()
playerNotificationService.clientEventEmitter?.onSleepTimerEnded(getCurrentTime())
clearSleepTimer()
sleepTimerFinishedAt = System.currentTimeMillis()
} else if (sleepTimeSecondsRemaining <= 60 && DeviceManager.deviceData.deviceSettings?.disableSleepTimerFadeOut != true) {
// Start fading out audio down to 10% volume
val percentToReduce = 1 - (sleepTimeSecondsRemaining / 60F)
val volume = 1f - (percentToReduce * 0.9f)
Log.d(tag, "SLEEP VOLUME FADE $volume | ${sleepTimeSecondsRemaining}s remaining")
setVolume(volume)
} else {
setVolume(1f)
}
}
}
}
return true
}
fun setManualSleepTimer(playbackSessionId:String, time: Long, isChapterTime:Boolean):Boolean {
sleepTimerSessionId = playbackSessionId
isAutoSleepTimer = false
return setSleepTimer(time, isChapterTime)
}
private fun clearSleepTimer() {
sleepTimerTask?.cancel()
sleepTimerTask = null
sleepTimerEndTime = 0
sleepTimerRunning = false
playerNotificationService.unregisterSensor()
setVolume(1f)
}
fun getSleepTimerTime():Long {
return sleepTimerEndTime
}
fun cancelSleepTimer() {
Log.d(tag, "Canceling Sleep Timer")
if (isAutoSleepTimer) {
Log.i(tag, "Disabling auto sleep timer")
DeviceManager.deviceData.deviceSettings?.autoSleepTimer = false
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
}
clearSleepTimer()
playerNotificationService.clientEventEmitter?.onSleepTimerSet(0, false)
}
// Vibrate when resetting sleep timer
private fun vibrateFeedback() {
if (DeviceManager.deviceData.deviceSettings?.disableSleepTimerResetFeedback == true) return
val context = playerNotificationService.getContext()
val vibrator:Vibrator
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val vibratorManager =
context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
vibrator = vibratorManager.defaultVibrator
} else {
@Suppress("DEPRECATION")
vibrator = context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
}
vibrator.let {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val vibrationEffect = VibrationEffect.createWaveform(longArrayOf(0, 150, 150, 150),-1)
it.vibrate(vibrationEffect)
} else {
@Suppress("DEPRECATION")
it.vibrate(10)
}
}
}
// Get the chapter end time for use in End of Chapter timers
// if less than 2s remain in chapter then use the next chapter
private fun getChapterEndTime():Long? {
val currentChapterEndTimeMs = playerNotificationService.getEndTimeOfChapterOrTrack()
if (currentChapterEndTimeMs == null) {
Log.e(tag, "Getting chapter sleep timer end of chapter/track but there is no current session")
return null
}
val timeLeftInChapter = currentChapterEndTimeMs - getCurrentTime()
return if (timeLeftInChapter < 2000L) {
Log.i(tag, "Getting chapter sleep timer time and current chapter has less than 2s remaining")
val nextChapterEndTimeMs = playerNotificationService.getEndTimeOfNextChapterOrTrack()
if (nextChapterEndTimeMs == null || currentChapterEndTimeMs == nextChapterEndTimeMs) {
Log.e(tag, "Invalid next chapter time. No current session or equal to current chapter. $nextChapterEndTimeMs")
null
} else {
nextChapterEndTimeMs
}
} else {
currentChapterEndTimeMs
}
}
private fun resetChapterTimer() {
this.getChapterEndTime()?.let { chapterEndTime ->
Log.d(tag, "Resetting stopped sleep timer to end of chapter $chapterEndTime")
vibrateFeedback()
setSleepTimer(chapterEndTime, true)
play()
}
}
private fun checkShouldResetSleepTimer() {
if (!sleepTimerRunning) {
if (sleepTimerFinishedAt <= 0L) return
val finishedAtDistance = System.currentTimeMillis() - sleepTimerFinishedAt
if (finishedAtDistance > SLEEP_TIMER_WAKE_UP_EXPIRATION) // 2 minutes
{
Log.d(tag, "Sleep timer finished over 2 mins ago, clearing it")
sleepTimerFinishedAt = 0L
return
}
// Automatically Rewind in the book if settings is enabled
if (isAutoSleepTimer) {
DeviceManager.deviceData.deviceSettings?.let { deviceSettings ->
if (deviceSettings.autoSleepTimerAutoRewind && !isFirstAutoSleepTimer) {
Log.i(tag, "Auto sleep timer auto rewind seeking back ${deviceSettings.autoSleepTimerAutoRewindTime}ms")
playerNotificationService.seekBackward(deviceSettings.autoSleepTimerAutoRewindTime)
}
isFirstAutoSleepTimer = false
}
}
// Set sleep timer
// When sleepTimerLength is 0 then use end of chapter/track time
if (sleepTimerLength == 0L) {
Log.d(tag, "Resetting stopped chapter sleep timer")
resetChapterTimer()
} else {
Log.d(tag, "Resetting stopped sleep timer to length $sleepTimerLength")
vibrateFeedback()
setSleepTimer(sleepTimerLength, false)
play()
}
return
}
// Does not apply to chapter sleep timers and timer must be running for at least 3 seconds
if (sleepTimerLength > 0L && sleepTimerElapsed > 3000L) {
Log.d(tag, "Resetting running sleep timer to length $sleepTimerLength")
vibrateFeedback()
setSleepTimer(sleepTimerLength, false)
} else if (sleepTimerLength == 0L) {
// When navigating to previous chapters make sure this is still the end of the current chapter
this.getChapterEndTime()?.let { chapterEndTime ->
if (chapterEndTime != sleepTimerEndTime) {
Log.d(tag, "Resetting chapter sleep timer to end of chapter $chapterEndTime from $sleepTimerEndTime")
vibrateFeedback()
setSleepTimer(chapterEndTime, true)
play()
}
}
}
}
fun handleShake() {
if (sleepTimerRunning || sleepTimerFinishedAt > 0L) {
if (DeviceManager.deviceData.deviceSettings?.disableShakeToResetSleepTimer == true) {
Log.d(tag, "Shake to reset sleep timer is disabled")
return
}
checkShouldResetSleepTimer()
}
}
fun increaseSleepTime(time: Long) {
Log.d(tag, "Increase Sleep time $time")
if (!sleepTimerRunning) return
if (sleepTimerEndTime == 0L) {
sleepTimerLength += time
if (sleepTimerLength + getCurrentTime() > getDuration()) sleepTimerLength = getDuration() - getCurrentTime()
} else {
val newSleepEndTime = sleepTimerEndTime + time
sleepTimerEndTime = if (newSleepEndTime >= getDuration()) {
getDuration()
} else {
newSleepEndTime
}
}
setVolume(1F)
playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds(), isAutoSleepTimer)
}
fun decreaseSleepTime(time: Long) {
Log.d(tag, "Decrease Sleep time $time")
if (!sleepTimerRunning) return
if (sleepTimerEndTime == 0L) {
sleepTimerLength -= time
if (sleepTimerLength <= 0) sleepTimerLength = 1000L
} else {
val newSleepEndTime = sleepTimerEndTime - time
sleepTimerEndTime = if (newSleepEndTime <= 1000) {
// End sleep timer in 1 second
getCurrentTime() + 1000
} else {
newSleepEndTime
}
}
setVolume(1F)
playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds(), isAutoSleepTimer)
}
fun checkAutoSleepTimer() {
if (sleepTimerRunning) { // Sleep timer already running
return
}
DeviceManager.deviceData.deviceSettings?.let { deviceSettings ->
if (!deviceSettings.autoSleepTimer) return // Check auto sleep timer is enabled
val startCalendar = Calendar.getInstance()
startCalendar.set(Calendar.HOUR_OF_DAY, deviceSettings.autoSleepTimerStartHour)
startCalendar.set(Calendar.MINUTE, deviceSettings.autoSleepTimerStartMinute)
val endCalendar = Calendar.getInstance()
endCalendar.set(Calendar.HOUR_OF_DAY, deviceSettings.autoSleepTimerEndHour)
endCalendar.set(Calendar.MINUTE, deviceSettings.autoSleepTimerEndMinute)
val currentCalendar = Calendar.getInstance()
// In cases where end time is before start time then we shift the time window forward or backward based on the current time.
// e.g. start time 22:00 and end time 06:00.
// If current time is less than start time (e.g. 00:30) then start time will be the previous day.
// If current time is greater than start time (e.g. 23:00) then end time will be the next day.
if (endCalendar.before(startCalendar)) {
if (currentCalendar.before(startCalendar)) { // Shift start back a day
startCalendar.add(Calendar.DAY_OF_MONTH, -1)
} else { // Shift end forward a day
endCalendar.add(Calendar.DAY_OF_MONTH, 1)
}
}
val currentHour = SimpleDateFormat("HH:mm", Locale.getDefault()).format(currentCalendar.time)
if (currentCalendar.after(startCalendar) && currentCalendar.before(endCalendar)) {
Log.i(tag, "Current hour $currentHour is between ${deviceSettings.autoSleepTimerStartTime} and ${deviceSettings.autoSleepTimerEndTime} - starting sleep timer")
// Automatically Rewind in the book if settings is enabled
if (deviceSettings.autoSleepTimerAutoRewind && !isFirstAutoSleepTimer) {
Log.i(tag, "Auto sleep timer auto rewind seeking back ${deviceSettings.autoSleepTimerAutoRewindTime}ms")
playerNotificationService.seekBackward(deviceSettings.autoSleepTimerAutoRewindTime)
}
isFirstAutoSleepTimer = false
// Set sleep timer
// When sleepTimerLength is 0 then use end of chapter/track time
if (deviceSettings.sleepTimerLength == 0L) {
val chapterEndTimeMs = this.getChapterEndTime()
if (chapterEndTimeMs == null) {
Log.e(tag, "Setting auto sleep timer to end of chapter/track but there is no current session")
} else {
isAutoSleepTimer = true
setSleepTimer(chapterEndTimeMs, true)
}
} else {
isAutoSleepTimer = true
setSleepTimer(deviceSettings.sleepTimerLength, false)
}
} else {
isFirstAutoSleepTimer = true
Log.d(tag, "Current hour $currentHour is NOT between ${deviceSettings.autoSleepTimerStartTime} and ${deviceSettings.autoSleepTimerEndTime}")
}
}
}
fun handleMediaPlayEvent(playbackSessionId:String) {
// Check if the playback session has changed
// If it hasn't changed OR the sleep timer is running then check reset the timer
// e.g. You set a manual sleep timer for 10 mins, then decide to change books, the sleep timer will stay on and reset to 10 mins
if (sleepTimerSessionId == playbackSessionId || sleepTimerRunning) {
checkShouldResetSleepTimer()
} else {
isFirstAutoSleepTimer = true
}
sleepTimerSessionId = playbackSessionId
checkAutoSleepTimer()
}
}
@@ -0,0 +1,111 @@
package com.audiobookshelf.app.media
import android.util.Log
import com.audiobookshelf.app.data.*
import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.player.PlayerNotificationService
object MediaEventManager {
const val tag = "MediaEventManager"
var clientEventEmitter: PlayerNotificationService.ClientEventEmitter? = null
fun playEvent(playbackSession: PlaybackSession) {
Log.i(tag, "Play Event for media \"${playbackSession.displayTitle}\"")
addPlaybackEvent("Play", playbackSession, null)
}
fun pauseEvent(playbackSession: PlaybackSession, syncResult: SyncResult?) {
Log.i(tag, "Pause Event for media \"${playbackSession.displayTitle}\"")
addPlaybackEvent("Pause", playbackSession, syncResult)
}
fun stopEvent(playbackSession: PlaybackSession, syncResult: SyncResult?) {
Log.i(tag, "Stop Event for media \"${playbackSession.displayTitle}\"")
addPlaybackEvent("Stop", playbackSession, syncResult)
}
fun saveEvent(playbackSession: PlaybackSession, syncResult: SyncResult?) {
Log.i(tag, "Save Event for media \"${playbackSession.displayTitle}\"")
addPlaybackEvent("Save", playbackSession, syncResult)
}
fun finishedEvent(playbackSession: PlaybackSession, syncResult: SyncResult?) {
Log.i(tag, "Finished Event for media \"${playbackSession.displayTitle}\"")
addPlaybackEvent("Finished", playbackSession, syncResult)
}
fun seekEvent(playbackSession: PlaybackSession, syncResult: SyncResult?) {
Log.i(tag, "Seek Event for media \"${playbackSession.displayTitle}\", currentTime=${playbackSession.currentTime}")
addPlaybackEvent("Seek", playbackSession, syncResult)
}
fun syncEvent(mediaProgress: MediaProgressWrapper, description: String) {
Log.i(tag, "Sync Event for media item id \"${mediaProgress.mediaItemId}\", currentTime=${mediaProgress.currentTime}")
addSyncEvent("Sync", mediaProgress, description)
}
private fun addSyncEvent(eventName:String, mediaProgress:MediaProgressWrapper, description: String) {
val mediaItemHistory = getMediaItemHistoryMediaItem(mediaProgress.mediaItemId)
if (mediaItemHistory == null) {
Log.w(tag, "addSyncEvent: Media Item History not created yet for media item id ${mediaProgress.mediaItemId}")
return
}
val mediaItemEvent = MediaItemEvent(
name = eventName,
type = "Sync",
description = description,
currentTime = mediaProgress.currentTime,
serverSyncAttempted = false,
serverSyncSuccess = null,
serverSyncMessage = null,
timestamp = System.currentTimeMillis()
)
mediaItemHistory.events.add(mediaItemEvent)
DeviceManager.dbManager.saveMediaItemHistory(mediaItemHistory)
clientEventEmitter?.onMediaItemHistoryUpdated(mediaItemHistory)
}
private fun addPlaybackEvent(eventName:String, playbackSession:PlaybackSession, syncResult: SyncResult?) {
val mediaItemHistory = getMediaItemHistoryMediaItem(playbackSession.mediaItemId) ?: createMediaItemHistoryForSession(playbackSession)
val mediaItemEvent = MediaItemEvent(
name = eventName,
type = "Playback",
description = "",
currentTime = playbackSession.currentTime,
serverSyncAttempted = syncResult?.serverSyncAttempted ?: false,
serverSyncSuccess = syncResult?.serverSyncSuccess,
serverSyncMessage = syncResult?.serverSyncMessage,
timestamp = System.currentTimeMillis()
)
mediaItemHistory.events.add(mediaItemEvent)
DeviceManager.dbManager.saveMediaItemHistory(mediaItemHistory)
clientEventEmitter?.onMediaItemHistoryUpdated(mediaItemHistory)
}
private fun getMediaItemHistoryMediaItem(mediaItemId: String) : MediaItemHistory? {
return DeviceManager.dbManager.getMediaItemHistory(mediaItemId)
}
private fun createMediaItemHistoryForSession(playbackSession: PlaybackSession):MediaItemHistory {
Log.i(tag, "Creating new media item history for media \"${playbackSession.displayTitle}\"")
val isLocalOnly = playbackSession.isLocalLibraryItemOnly
val libraryItemId = if (isLocalOnly) playbackSession.localLibraryItemId else playbackSession.libraryItemId ?: ""
val episodeId:String? = if (isLocalOnly && playbackSession.localEpisodeId != null) playbackSession.localEpisodeId else playbackSession.episodeId
return MediaItemHistory(
id = playbackSession.mediaItemId,
mediaDisplayTitle = playbackSession.displayTitle ?: "Unset",
libraryItemId,
episodeId,
isLocalOnly,
playbackSession.serverConnectionConfigId,
playbackSession.serverAddress,
playbackSession.userId,
createdAt = System.currentTimeMillis(),
events = mutableListOf())
}
}
@@ -12,25 +12,26 @@ import java.util.*
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.runBlocking
import org.json.JSONException
import org.json.JSONObject
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
val tag = "MediaManager"
var serverLibraryItems = mutableListOf<LibraryItem>() // Store all items here
var selectedLibraryItems = mutableListOf<LibraryItem>()
var selectedLibraryId = ""
private var serverLibraryItems = mutableListOf<LibraryItem>() // Store all items here
private var selectedLibraryItems = mutableListOf<LibraryItem>()
private var selectedLibraryId = ""
var selectedPodcast:Podcast? = null
var selectedLibraryItemId:String? = null
var podcastEpisodeLibraryItemMap = mutableMapOf<String, LibraryItemWithEpisode>()
var serverLibraryCategories = listOf<LibraryCategory>()
private var selectedPodcast:Podcast? = null
private var selectedLibraryItemId:String? = null
private var podcastEpisodeLibraryItemMap = mutableMapOf<String, LibraryItemWithEpisode>()
private var serverLibraryCategories = listOf<LibraryCategory>()
private var serverConfigIdUsed:String? = null
private var serverConfigLastPing:Long = 0L
var serverUserMediaProgress:MutableList<MediaProgress> = mutableListOf()
var serverItemsInProgress = listOf<ItemInProgress>()
var serverLibraries = listOf<Library>()
var serverConfigIdUsed:String? = null
var serverConfigLastPing:Long = 0L
var serverUserMediaProgress:MutableList<MediaProgress> = mutableListOf()
var userSettingsPlaybackRate:Float? = null
@@ -61,12 +62,41 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
return 1f
}
fun setSavedPlaybackRate(newRate: Float) {
val sharedPrefs = ctx.getSharedPreferences("CapacitorStorage", Activity.MODE_PRIVATE)
val sharedPrefEditor = sharedPrefs.edit()
if (sharedPrefs != null) {
val userSettingsPref = sharedPrefs.getString("userSettings", null)
if (userSettingsPref != null) {
try {
val userSettings = JSObject(userSettingsPref)
// toString().toDouble() to prevent float conversion issues (ex 1.2f becomes 1.2000000476837158d)
userSettings.put("playbackRate", newRate.toString().toDouble())
sharedPrefEditor.putString("userSettings", userSettings.toString())
sharedPrefEditor.apply()
userSettingsPlaybackRate = newRate
Log.d(tag, "Saved userSettings JSON from Android Auto with playbackRate=$newRate")
} catch(je:JSONException) {
Log.e(tag, "Failed to save userSettings JSON ${je.localizedMessage}")
}
} else {
// Not sure if this is the best place for this, but if a user has not changed any user settings in the app
// the object will not exist yet, could be moved to a centralized place or created on first app load
val userSettings = JSONObject()
userSettings.put("playbackRate", newRate.toString().toDouble())
sharedPrefEditor.putString("userSettings", userSettings.toString())
userSettingsPlaybackRate = newRate
Log.d(tag, "Created and saved userSettings JSON from Android Auto with playbackRate=$newRate")
}
}
}
fun checkResetServerItems() {
// When opening android auto need to check if still connected to server
// and reset any server data already set
val serverConnConfig = if (DeviceManager.isConnectedToServer) DeviceManager.serverConnectionConfig else DeviceManager.deviceData.getLastServerConnectionConfig()
if (!DeviceManager.isConnectedToServer || !apiHandler.isOnline() || serverConnConfig == null || serverConnConfig.id !== serverConfigIdUsed) {
if (!DeviceManager.isConnectedToServer || !DeviceManager.checkConnectivity(ctx) || serverConnConfig == null || serverConnConfig.id !== serverConfigIdUsed) {
podcastEpisodeLibraryItemMap = mutableMapOf()
serverLibraryCategories = listOf()
serverLibraries = listOf()
@@ -76,12 +106,15 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
}
}
fun loadItemsInProgressForAllLibraries(cb: (List<ItemInProgress>) -> Unit) {
private fun loadItemsInProgressForAllLibraries(cb: (List<ItemInProgress>) -> Unit) {
if (serverItemsInProgress.isNotEmpty()) {
cb(serverItemsInProgress)
} else {
apiHandler.getAllItemsInProgress { itemsInProgress ->
serverItemsInProgress = itemsInProgress
serverItemsInProgress = itemsInProgress.filter {
val libraryItem = it.libraryItemWrapper as LibraryItem
libraryItem.checkHasTracks()
}
cb(serverItemsInProgress)
}
}
@@ -109,7 +142,7 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
}
}
fun loadLibraryItem(libraryItemId:String, cb: (LibraryItemWrapper?) -> Unit) {
private fun loadLibraryItem(libraryItemId:String, cb: (LibraryItemWrapper?) -> Unit) {
if (libraryItemId.startsWith("local")) {
cb(DeviceManager.dbManager.getLocalLibraryItem(libraryItemId))
} else {
@@ -121,7 +154,7 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
}
}
fun loadPodcastEpisodeMediaBrowserItems(libraryItemId:String, cb: (MutableList<MediaBrowserCompat.MediaItem>) -> Unit) {
fun loadPodcastEpisodeMediaBrowserItems(libraryItemId:String, ctx:Context, cb: (MutableList<MediaBrowserCompat.MediaItem>) -> Unit) {
loadLibraryItem(libraryItemId) { libraryItemWrapper ->
Log.d(tag, "Loaded Podcast library item $libraryItemWrapper")
@@ -138,7 +171,8 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
Log.d(tag, "Local Podcast Episode ${podcastEpisode.title} | ${podcastEpisode.id}")
val progress = DeviceManager.dbManager.getLocalMediaProgress("${libraryItemWrapper.id}-${podcastEpisode.id}")
val description = podcastEpisode.getMediaDescription(libraryItemWrapper, progress)
val description = podcastEpisode.getMediaDescription(libraryItemWrapper, progress, ctx)
MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
}
children?.let { cb(children as MutableList) } ?: cb(mutableListOf())
@@ -157,7 +191,15 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
val children = podcast.episodes?.map { podcastEpisode ->
val progress = serverUserMediaProgress.find { it.libraryItemId == libraryItemWrapper.id && it.episodeId == podcastEpisode.id }
val description = podcastEpisode.getMediaDescription(libraryItemWrapper, progress)
// to show download icon
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLId(libraryItemWrapper.id)
localLibraryItem?.let { lli ->
val localEpisode = (lli.media as Podcast).episodes?.find { it.serverEpisodeId == podcastEpisode.id }
podcastEpisode.localEpisodeId = localEpisode?.id
}
val description = podcastEpisode.getMediaDescription(libraryItemWrapper, progress, ctx)
MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
}
children?.let { cb(children as MutableList) } ?: cb(mutableListOf())
@@ -167,7 +209,7 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
}
}
fun loadLibraries(cb: (List<Library>) -> Unit) {
private fun loadLibraries(cb: (List<Library>) -> Unit) {
if (serverLibraries.isNotEmpty()) {
cb(serverLibraries)
} else {
@@ -178,9 +220,9 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
}
}
suspend fun checkServerConnection(config:ServerConnectionConfig) : Boolean {
private suspend fun checkServerConnection(config:ServerConnectionConfig) : Boolean {
var successfulPing = false
suspendCoroutine<Boolean> { cont ->
suspendCoroutine { cont ->
apiHandler.pingServer(config) {
Log.d(tag, "checkServerConnection: Checked server conn for ${config.address} result = $it")
successfulPing = it
@@ -190,9 +232,9 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
return successfulPing
}
suspend fun authorize(config:ServerConnectionConfig) : MutableList<MediaProgress> {
private suspend fun authorize(config:ServerConnectionConfig) : MutableList<MediaProgress> {
var mediaProgress:MutableList<MediaProgress> = mutableListOf()
suspendCoroutine<MutableList<MediaProgress>> { cont ->
suspendCoroutine { cont ->
apiHandler.authorize(config) {
Log.d(tag, "authorize: Authorized server config ${config.address} result = $it")
if (!it.isNullOrEmpty()) {
@@ -204,15 +246,19 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
return mediaProgress
}
fun checkSetValidServerConnectionConfig(cb: (Boolean) -> Unit) = runBlocking {
Log.d(tag, "checkSetValidServerConnectionConfig | $serverConfigIdUsed")
private fun checkSetValidServerConnectionConfig(cb: (Boolean) -> Unit) = runBlocking {
Log.d(tag, "checkSetValidServerConnectionConfig | serverConfigIdUsed=$serverConfigIdUsed | lastServerConnectionConfigId=${DeviceManager.deviceData.lastServerConnectionConfigId}")
coroutineScope {
if (!apiHandler.isOnline()) {
if (!DeviceManager.checkConnectivity(ctx)) {
serverUserMediaProgress = mutableListOf()
Log.d(tag, "checkSetValidServerConnectionConfig: No connectivity")
cb(false)
} else if (DeviceManager.deviceData.lastServerConnectionConfigId.isNullOrBlank()) { // If in offline mode last server connection config is unset
serverUserMediaProgress = mutableListOf()
Log.d(tag, "checkSetValidServerConnectionConfig: No last server connection config")
cb(false)
} else {
var hasValidConn = false
var lookupMediaProgress = true
@@ -266,6 +312,23 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
}
fun loadServerUserMediaProgress(cb: () -> Unit) {
Log.d(tag, "Loading server media progress")
if (DeviceManager.serverConnectionConfig == null) {
return cb()
}
DeviceManager.serverConnectionConfig?.let { config ->
apiHandler.authorize(config) {
Log.d(tag, "loadServerUserMediaProgress: Authorized server config ${config.address} result = $it")
if (!it.isNullOrEmpty()) {
serverUserMediaProgress = it
}
cb()
}
}
}
fun loadAndroidAutoItems(cb: () -> Unit) {
Log.d(tag, "Load android auto items")
@@ -273,6 +336,7 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
checkSetValidServerConnectionConfig { isConnected ->
if (isConnected) {
serverConfigIdUsed = DeviceManager.serverConnectionConfigId
Log.d(tag, "loadAndroidAutoItems: Connected to server config id=$serverConfigIdUsed")
loadLibraries { libraries ->
if (libraries.isEmpty()) {
@@ -299,6 +363,7 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
}
}
} else { // Not connected to server
Log.d(tag, "loadAndroidAutoItems: Not connected to server")
cb()
}
}
@@ -314,18 +379,18 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
}
fun getPodcastWithEpisodeByEpisodeId(id:String) : LibraryItemWithEpisode? {
if (id.startsWith("local")) {
return DeviceManager.dbManager.getLocalLibraryItemWithEpisode(id)
return if (id.startsWith("local")) {
DeviceManager.dbManager.getLocalLibraryItemWithEpisode(id)
} else {
return podcastEpisodeLibraryItemMap[id]
podcastEpisodeLibraryItemMap[id]
}
}
fun getById(id:String) : LibraryItemWrapper? {
if (id.startsWith("local")) {
return DeviceManager.dbManager.getLocalLibraryItem(id)
return if (id.startsWith("local")) {
DeviceManager.dbManager.getLocalLibraryItem(id)
} else {
return serverLibraryItems.find { it.id == id }
serverLibraryItems.find { it.id == id }
}
}
@@ -338,7 +403,7 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
fun play(libraryItemWrapper:LibraryItemWrapper, episode:PodcastEpisode?, playItemRequestPayload:PlayItemRequestPayload, cb: (PlaybackSession?) -> Unit) {
if (libraryItemWrapper is LocalLibraryItem) {
cb(libraryItemWrapper.getPlaybackSession(episode))
cb(libraryItemWrapper.getPlaybackSession(episode, playItemRequestPayload.deviceInfo))
} else {
val libraryItem = libraryItemWrapper as LibraryItem
apiHandler.playLibraryItem(libraryItem.id,episode?.id ?: "", playItemRequestPayload) {
@@ -1,4 +1,4 @@
package com.audiobookshelf.app.player
package com.audiobookshelf.app.media
import android.os.Handler
import android.os.Looper
@@ -7,6 +7,7 @@ import com.audiobookshelf.app.data.LocalMediaProgress
import com.audiobookshelf.app.data.MediaProgress
import com.audiobookshelf.app.data.PlaybackSession
import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.player.PlayerNotificationService
import com.audiobookshelf.app.server.ApiHandler
import java.util.*
import kotlin.concurrent.schedule
@@ -17,7 +18,13 @@ data class MediaProgressSyncData(
var currentTime:Double // seconds
)
class MediaProgressSyncer(val playerNotificationService:PlayerNotificationService, private val apiHandler: ApiHandler) {
data class SyncResult(
var serverSyncAttempted:Boolean,
var serverSyncSuccess:Boolean?,
var serverSyncMessage:String?
)
class MediaProgressSyncer(val playerNotificationService: PlayerNotificationService, private val apiHandler: ApiHandler) {
private val tag = "MediaProgressSync"
private val METERED_CONNECTION_SYNC_INTERVAL = 60000
@@ -30,15 +37,15 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
var currentPlaybackSession: PlaybackSession? = null // copy of pb session currently syncing
var currentLocalMediaProgress: LocalMediaProgress? = null
val currentDisplayTitle get() = currentPlaybackSession?.displayTitle ?: "Unset"
private val currentDisplayTitle get() = currentPlaybackSession?.displayTitle ?: "Unset"
val currentIsLocal get() = currentPlaybackSession?.isLocal == true
val currentSessionId get() = currentPlaybackSession?.id ?: ""
val currentPlaybackDuration get() = currentPlaybackSession?.duration ?: 0.0
private val currentPlaybackDuration get() = currentPlaybackSession?.duration ?: 0.0
fun start() {
fun start(playbackSession:PlaybackSession) {
if (listeningTimerRunning) {
Log.d(tag, "start: Timer already running for $currentDisplayTitle")
if (playerNotificationService.getCurrentPlaybackSessionId() != currentSessionId) {
if (playbackSession.id != currentSessionId) {
Log.d(tag, "Playback session changed, reset timer")
currentLocalMediaProgress = null
listeningTimerTask?.cancel()
@@ -48,25 +55,32 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
} else {
return
}
} else if (playerNotificationService.getCurrentPlaybackSessionId() != currentSessionId) {
} else if (playbackSession.id != currentSessionId) {
currentLocalMediaProgress = null
}
listeningTimerRunning = true
lastSyncTime = System.currentTimeMillis()
Log.d(tag, "start: init last sync time $lastSyncTime")
currentPlaybackSession = playerNotificationService.getCurrentPlaybackSessionCopy()
currentPlaybackSession = playbackSession.clone()
Log.d(tag, "start: init last sync time $lastSyncTime with playback session id=${currentPlaybackSession?.id}")
listeningTimerTask = Timer("ListeningTimer", false).schedule(0L, 5000L) {
listeningTimerTask = Timer("ListeningTimer", false).schedule(15000L, 15000L) {
Handler(Looper.getMainLooper()).post() {
if (playerNotificationService.currentPlayer.isPlaying) {
// Only sync with server on unmetered connection every 5s OR sync with server if last sync time is >= 60s
// Set auto sleep timer if enabled and within start/end time
playerNotificationService.sleepTimerManager.checkAutoSleepTimer()
// Only sync with server on unmetered connection every 15s OR sync with server if last sync time is >= 60s
val shouldSyncServer = PlayerNotificationService.isUnmeteredNetwork || System.currentTimeMillis() - lastSyncTime >= METERED_CONNECTION_SYNC_INTERVAL
val currentTime = playerNotificationService.getCurrentTimeSeconds()
if (currentTime > 0) {
sync(shouldSyncServer, currentTime) {
sync(shouldSyncServer, currentTime) { syncResult ->
Log.d(tag, "Sync complete")
currentPlaybackSession?.let { playbackSession ->
MediaEventManager.saveEvent(playbackSession, syncResult)
}
}
}
}
@@ -74,8 +88,19 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
}
}
fun play(playbackSession:PlaybackSession) {
Log.d(tag, "play ${playbackSession.displayTitle}")
MediaEventManager.playEvent(playbackSession)
start(playbackSession)
}
fun stop(shouldSync:Boolean? = true, cb: () -> Unit) {
if (!listeningTimerRunning) return
if (!listeningTimerRunning) {
reset()
return cb()
}
listeningTimerTask?.cancel()
listeningTimerTask = null
listeningTimerRunning = false
@@ -83,11 +108,19 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
val currentTime = if (shouldSync == true) playerNotificationService.getCurrentTimeSeconds() else 0.0
if (currentTime > 0) { // Current time should always be > 0 on stop
sync(true, currentTime) {
sync(true, currentTime) { syncResult ->
currentPlaybackSession?.let { playbackSession ->
MediaEventManager.stopEvent(playbackSession, syncResult)
}
reset()
cb()
}
} else {
currentPlaybackSession?.let { playbackSession ->
MediaEventManager.stopEvent(playbackSession, null)
}
reset()
cb()
}
@@ -95,6 +128,7 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
fun pause(cb: () -> Unit) {
if (!listeningTimerRunning) return
listeningTimerTask?.cancel()
listeningTimerTask = null
listeningTimerRunning = false
@@ -103,103 +137,153 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
val currentTime = playerNotificationService.getCurrentTimeSeconds()
if (currentTime > 0) { // Current time should always be > 0 on pause
sync(true, currentTime) {
sync(true, currentTime) { syncResult ->
lastSyncTime = 0L
Log.d(tag, "pause: Set last sync time 0 $lastSyncTime")
failedSyncs = 0
currentPlaybackSession?.let { playbackSession ->
MediaEventManager.pauseEvent(playbackSession, syncResult)
}
cb()
}
} else {
lastSyncTime = 0L
Log.d(tag, "pause: Set last sync time 0 $lastSyncTime (current time < 0)")
failedSyncs = 0
currentPlaybackSession?.let { playbackSession ->
MediaEventManager.pauseEvent(playbackSession, null)
}
cb()
}
}
fun finished(cb: () -> Unit) {
if (!listeningTimerRunning) return
listeningTimerTask?.cancel()
listeningTimerTask = null
listeningTimerRunning = false
Log.d(tag, "finished: Stopping listening for $currentDisplayTitle")
sync(true, currentPlaybackSession?.duration ?: 0.0) { syncResult ->
reset()
currentPlaybackSession?.let { playbackSession ->
MediaEventManager.finishedEvent(playbackSession, syncResult)
}
cb()
}
}
fun seek() {
currentPlaybackSession?.currentTime = playerNotificationService.getCurrentTimeSeconds()
Log.d(tag, "seek: $currentDisplayTitle, currentTime=${currentPlaybackSession?.currentTime}")
if (currentPlaybackSession == null) {
Log.e(tag, "seek: Playback session not set")
return
}
MediaEventManager.seekEvent(currentPlaybackSession!!, null)
}
fun syncFromServerProgress(mediaProgress: MediaProgress) {
currentPlaybackSession?.let {
it.updatedAt = mediaProgress.lastUpdate
it.currentTime = mediaProgress.currentTime
DeviceManager.dbManager.saveLocalPlaybackSession(it)
MediaEventManager.syncEvent(mediaProgress, "Received from server get media progress request while playback session open")
saveLocalProgress(it)
}
}
fun sync(shouldSyncServer:Boolean, currentTime:Double, cb: () -> Unit) {
fun sync(shouldSyncServer:Boolean, currentTime:Double, cb: (SyncResult?) -> Unit) {
if (lastSyncTime <= 0) {
Log.e(tag, "Last sync time is not set $lastSyncTime")
return
return cb(null)
}
val diffSinceLastSync = System.currentTimeMillis() - lastSyncTime
if (diffSinceLastSync < 1000L) {
return cb()
return cb(null)
}
val listeningTimeToAdd = diffSinceLastSync / 1000L
val syncData = MediaProgressSyncData(listeningTimeToAdd,currentPlaybackDuration,currentTime)
currentPlaybackSession?.syncData(syncData)
if (currentPlaybackSession?.progress?.isNaN() == true) {
Log.e(tag, "Current Playback Session invalid progress ${currentPlaybackSession?.progress} | Current Time: ${currentPlaybackSession?.currentTime} | Duration: ${currentPlaybackSession?.getTotalDuration()}")
return cb()
return cb(null)
}
val hasNetworkConnection = DeviceManager.checkConnectivity(playerNotificationService)
// Save playback session to db (server linked sessions only)
// Sessions are removed once successfully synced with the server
currentPlaybackSession?.let {
if (!it.isLocalLibraryItemOnly) {
DeviceManager.dbManager.savePlaybackSession(it)
}
}
if (currentIsLocal) {
// Save local progress sync
currentPlaybackSession?.let {
DeviceManager.dbManager.saveLocalPlaybackSession(it)
saveLocalProgress(it)
lastSyncTime = System.currentTimeMillis()
Log.d(tag, "Sync local device current serverConnectionConfigId=${DeviceManager.serverConnectionConfig?.id}")
// Local library item is linked to a server library item
// Send sync to server also if connected to this server and local item belongs to this server
if (shouldSyncServer && !it.libraryItemId.isNullOrEmpty() && it.serverConnectionConfigId != null && DeviceManager.serverConnectionConfig?.id == it.serverConnectionConfigId) {
apiHandler.sendLocalProgressSync(it) { syncSuccess ->
Log.d(
tag,
"Local progress sync data sent to server $currentDisplayTitle for time $currentTime"
)
if (hasNetworkConnection && shouldSyncServer && !it.libraryItemId.isNullOrEmpty() && it.serverConnectionConfigId != null && DeviceManager.serverConnectionConfig?.id == it.serverConnectionConfigId) {
apiHandler.sendLocalProgressSync(it) { syncSuccess, errorMsg ->
if (syncSuccess) {
failedSyncs = 0
playerNotificationService.alertSyncSuccess()
DeviceManager.dbManager.removePlaybackSession(it.id) // Remove session from db
} else {
failedSyncs++
if (failedSyncs == 2) {
playerNotificationService.alertSyncFailing() // Show alert in client
failedSyncs = 0
}
Log.e(tag, "Local Progress sync failed ($failedSyncs) to send to server $currentDisplayTitle for time $currentTime")
Log.e(tag, "Local Progress sync failed ($failedSyncs) to send to server $currentDisplayTitle for time $currentTime with session id=${it.id}")
}
cb()
cb(SyncResult(true, syncSuccess, errorMsg))
}
} else {
cb()
cb(SyncResult(false, null, null))
}
}
} else if (shouldSyncServer) {
apiHandler.sendProgressSync(currentSessionId, syncData) {
if (it) {
} else if (hasNetworkConnection && shouldSyncServer) {
Log.d(tag, "sync: currentSessionId=$currentSessionId")
apiHandler.sendProgressSync(currentSessionId, syncData) { syncSuccess, errorMsg ->
if (syncSuccess) {
Log.d(tag, "Progress sync data sent to server $currentDisplayTitle for time $currentTime")
failedSyncs = 0
playerNotificationService.alertSyncSuccess()
lastSyncTime = System.currentTimeMillis()
DeviceManager.dbManager.removePlaybackSession(currentSessionId) // Remove session from db
} else {
failedSyncs++
if (failedSyncs == 2) {
playerNotificationService.alertSyncFailing() // Show alert in client
failedSyncs = 0
}
Log.e(tag, "Progress sync failed ($failedSyncs) to send to server $currentDisplayTitle for time $currentTime")
Log.e(tag, "Progress sync failed ($failedSyncs) to send to server $currentDisplayTitle for time $currentTime with session id=${currentSessionId}")
}
cb()
cb(SyncResult(true, syncSuccess, errorMsg))
}
} else {
cb()
cb(SyncResult(false, null, null))
}
}
@@ -210,22 +294,24 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
currentLocalMediaProgress = playbackSession.getNewLocalMediaProgress()
} else {
currentLocalMediaProgress = mediaProgress
currentLocalMediaProgress?.updateFromPlaybackSession(playbackSession)
}
} else {
currentLocalMediaProgress?.updateFromPlaybackSession(playbackSession)
}
currentLocalMediaProgress?.let {
if (it.progress.isNaN()) {
Log.e(tag, "Invalid progress on local media progress")
} else {
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.progressPercent}%")
}
}
}
fun reset() {
currentPlaybackSession = null
currentLocalMediaProgress = null
@@ -0,0 +1,44 @@
package com.audiobookshelf.app.models
import com.audiobookshelf.app.data.LocalFolder
import com.audiobookshelf.app.data.MediaProgress
import com.audiobookshelf.app.data.MediaType
import com.fasterxml.jackson.annotation.JsonIgnore
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 itemSubfolder: String,
val media: MediaType,
val downloadItemParts: MutableList<DownloadItemPart>
) {
@get:JsonIgnore
val isInternalStorage get() = localFolder.id.startsWith("internal-")
@get:JsonIgnore
val isDownloadFinished get() = !downloadItemParts.any { !it.completed || it.isMoving }
@JsonIgnore
fun getNextDownloadItemParts(limit:Int): MutableList<DownloadItemPart> {
val itemParts = mutableListOf<DownloadItemPart>()
if (limit == 0) return itemParts
for (it in downloadItemParts) {
if (!it.completed && it.downloadId == null) {
itemParts.add(it)
if (itemParts.size >= limit) break
}
}
return itemParts
}
}
@@ -0,0 +1,91 @@
package com.audiobookshelf.app.models
import android.app.DownloadManager
import android.net.Uri
import android.util.Log
import com.audiobookshelf.app.data.AudioTrack
import com.audiobookshelf.app.data.EBookFile
import com.audiobookshelf.app.data.LocalFolder
import com.audiobookshelf.app.data.PodcastEpisode
import com.audiobookshelf.app.device.DeviceManager
import com.fasterxml.jackson.annotation.JsonIgnore
import java.io.File
data class DownloadItemPart(
val id: String,
val downloadItemId: String,
val filename: String,
val fileSize: Long,
val finalDestinationPath:String,
val serverPath: String,
val localFolderName: String,
val localFolderUrl: String,
val localFolderId: String,
val ebookFile: EBookFile?,
val audioTrack: AudioTrack?,
val episode: PodcastEpisode?,
var completed:Boolean,
var moved:Boolean,
var isMoving:Boolean,
var failed:Boolean,
@JsonIgnore val uri: Uri,
@JsonIgnore val destinationUri: Uri,
@JsonIgnore val finalDestinationUri: Uri,
val finalDestinationSubfolder: String,
var downloadId: Long?,
var progress: Long,
var bytesDownloaded: Long
) {
companion object {
fun make(downloadItemId:String, filename:String, fileSize: Long, destinationFile: File, finalDestinationFile: File, subfolder:String, serverPath:String, localFolder: LocalFolder, ebookFile: EBookFile?, 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&raw=1" // 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),
downloadItemId,
filename = filename,
fileSize = fileSize,
finalDestinationPath = finalDestinationFile.absolutePath,
serverPath = serverPath,
localFolderName = localFolder.name,
localFolderUrl = localFolder.contentUrl,
localFolderId = localFolder.id,
ebookFile = ebookFile,
audioTrack = audioTrack,
episode = episode,
completed = false,
moved = false,
isMoving = false,
failed = false,
uri = downloadUri,
destinationUri = destinationUri,
finalDestinationUri = finalDestinationUri,
finalDestinationSubfolder = subfolder,
downloadId = null,
progress = 0,
bytesDownloaded = 0
)
}
}
@get:JsonIgnore
val isInternalStorage get() = localFolderId.startsWith("internal-")
@get:JsonIgnore
val serverUrl get() = "${DeviceManager.serverAddress}${serverPath}?token=${DeviceManager.token}"
@JsonIgnore
fun getDownloadRequest(): DownloadManager.Request {
val dlRequest = DownloadManager.Request(uri)
dlRequest.setTitle(filename)
dlRequest.setDescription("Downloading to $localFolderName with filename $filename")
dlRequest.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE)
dlRequest.setDestinationUri(destinationUri)
return dlRequest
}
}
@@ -0,0 +1,11 @@
package com.audiobookshelf.app.models
import com.audiobookshelf.app.data.MediaProgress
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
@JsonIgnoreProperties(ignoreUnknown = true)
data class User(
val id:String,
val username: String,
val mediaProgress:List<MediaProgress>
)
@@ -2,24 +2,24 @@ package com.audiobookshelf.app.player
import android.app.PendingIntent
import android.graphics.Bitmap
import android.graphics.ImageDecoder
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import android.support.v4.media.session.MediaControllerCompat
import android.util.Log
import com.audiobookshelf.app.BuildConfig
import com.audiobookshelf.app.R
import com.bumptech.glide.Glide
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.request.RequestOptions
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.ui.PlayerNotificationManager
import kotlinx.coroutines.*
const val NOTIFICATION_LARGE_ICON_SIZE = 144 // px
class AbMediaDescriptionAdapter constructor(private val controller: MediaControllerCompat, val playerNotificationService: PlayerNotificationService) : PlayerNotificationManager.MediaDescriptionAdapter {
class AbMediaDescriptionAdapter constructor(private val controller: MediaControllerCompat, private val playerNotificationService: PlayerNotificationService) : PlayerNotificationManager.MediaDescriptionAdapter {
private val tag = "MediaDescriptionAdapter"
var currentIconUri: Uri? = null
var currentBitmap: Bitmap? = null
private var currentIconUri: Uri? = null
private var currentBitmap: Bitmap? = null
private val serviceJob = SupervisorJob()
private val serviceScope = CoroutineScope(Dispatchers.Main + serviceJob)
@@ -42,13 +42,26 @@ class AbMediaDescriptionAdapter constructor(private val controller: MediaControl
// `getCurrentLargeIcon` don't cause the bitmap to be recreated.
currentIconUri = albumArtUri
Log.d(tag, "ART $currentIconUri")
serviceScope.launch {
currentBitmap = albumArtUri?.let {
resolveUriAsBitmap(it)
if (currentIconUri.toString().startsWith("content://")) {
currentBitmap = if (Build.VERSION.SDK_INT < 28) {
@Suppress("DEPRECATION")
MediaStore.Images.Media.getBitmap(playerNotificationService.contentResolver, currentIconUri)
} else {
val source: ImageDecoder.Source = ImageDecoder.createSource(playerNotificationService.contentResolver, currentIconUri!!)
ImageDecoder.decodeBitmap(source)
}
currentBitmap?.let { callback.onBitmap(it) }
currentBitmap
} else {
serviceScope.launch {
currentBitmap = albumArtUri?.let {
resolveUriAsBitmap(it)
}
currentBitmap?.let { callback.onBitmap(it) }
}
null
}
null
} else {
currentBitmap
}
@@ -62,15 +75,15 @@ class AbMediaDescriptionAdapter constructor(private val controller: MediaControl
.load(uri)
.placeholder(R.drawable.icon)
.error(R.drawable.icon)
.submit(NOTIFICATION_LARGE_ICON_SIZE, NOTIFICATION_LARGE_ICON_SIZE)
.submit()
.get()
} catch (e: Exception) {
e.printStackTrace()
Glide.with(playerNotificationService)
.asBitmap()
.load(Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon))
.submit(NOTIFICATION_LARGE_ICON_SIZE, NOTIFICATION_LARGE_ICON_SIZE)
.load(Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon))
.submit()
.get()
}
}
@@ -17,11 +17,10 @@ class BrowseTree(
/**
* get uri to drawable or any other resource type if u wish
* @param context - context
* @param drawableId - drawable res id
* @return - uri
*/
fun getUriToDrawable(@AnyRes drawableId: Int): Uri {
private fun getUriToDrawable(@AnyRes drawableId: Int): Uri {
return Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE
+ "://" + context.resources.getResourcePackageName(drawableId)
+ '/' + context.resources.getResourceTypeName(drawableId)
@@ -49,7 +48,7 @@ class BrowseTree(
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(R.drawable.icon_library_folder).toString())
}.build()
if (!itemsInProgress.isEmpty()) {
if (itemsInProgress.isNotEmpty()) {
rootList += continueListeningMetadata
}
@@ -123,7 +123,7 @@ class CastManager constructor(val mainActivity:Activity) {
}
fun startRouteScan(connListener:ChromecastListener) {
var callback = object : ScanCallback() {
val 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
@@ -136,7 +136,7 @@ class CastManager constructor(val mainActivity:Activity) {
// Let the client know a receiver is available
connListener.onReceiverAvailableUpdate(true)
// Since we have a receiver we may also have an active session
var session = getSessionManager()?.currentCastSession
val session = getSessionManager()?.currentCastSession
// If we do have a session
if (session != null) {
// Let the client know
@@ -246,15 +246,15 @@ class CastManager constructor(val mainActivity:Activity) {
onRouteUpdate(outRoutes)
}
override fun onRouteAdded(router: MediaRouter?, route: MediaRouter.RouteInfo?) {
override fun onRouteAdded(router: MediaRouter, route: MediaRouter.RouteInfo) {
onFilteredRouteUpdate()
}
override fun onRouteChanged(router: MediaRouter?, route: MediaRouter.RouteInfo?) {
override fun onRouteChanged(router: MediaRouter, route: MediaRouter.RouteInfo) {
onFilteredRouteUpdate()
}
override fun onRouteRemoved(router: MediaRouter?, route: MediaRouter.RouteInfo?) {
override fun onRouteRemoved(router: MediaRouter, route: MediaRouter.RouteInfo) {
onFilteredRouteUpdate()
}
}
@@ -11,12 +11,12 @@ 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.Tracks.Group
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.text.CueGroup
import com.google.android.exoplayer2.trackselection.TrackSelection
import com.google.android.exoplayer2.trackselection.TrackSelectionArray
import com.google.android.exoplayer2.trackselection.TrackSelectionParameters
@@ -49,7 +49,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
private var myPlayWhenReady:Boolean = false
private var playbackParameters:PlaybackParameters = PlaybackParameters.DEFAULT
private var myAvailableCommands: Commands
private var myCurrentTracksInfo: TracksInfo
private var myCurrentTracksInfo: Tracks
private var myCurrentTrackGroups: TrackGroupArray
private var myCurrentTrackSelections: TrackSelectionArray
@@ -76,7 +76,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
COMMAND_GET_MEDIA_ITEMS_METADATA,
COMMAND_SET_MEDIA_ITEMS_METADATA,
COMMAND_CHANGE_MEDIA_ITEMS,
COMMAND_GET_TRACK_INFOS)
COMMAND_GET_TRACKS)
.build()
var currentPlaybackState = Player.STATE_IDLE
@@ -95,7 +95,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
Log.d(tag, "Init CastPlayer")
myCurrentTrackGroups = TrackGroupArray.EMPTY
myCurrentTrackSelections = EMPTY_TRACK_SELECTION_ARRAY
myCurrentTracksInfo = TracksInfo.EMPTY
myCurrentTracksInfo = Tracks.EMPTY
statusListener = StatusListener()
timelineTracker = CastTimelineTracker()
myCurrentTimeline = CastTimeline.EMPTY_CAST_TIMELINE
@@ -385,7 +385,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
}
if (updateTracksAndSelectionsAndNotifyIfChanged()) {
listeners.queueEvent(
EVENT_TRACKS_CHANGED) { listener: Listener -> listener.onTracksInfoChanged(currentTracksInfo) }
EVENT_TRACKS_CHANGED) { listener: Listener -> listener.onTracksChanged(currentTracks) }
}
updateAvailableCommandsAndNotifyIfChanged()
listeners.flushEvents()
@@ -405,7 +405,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
val hasChanged = !myCurrentTrackGroups.isEmpty
myCurrentTrackGroups = TrackGroupArray.EMPTY
myCurrentTrackSelections = EMPTY_TRACK_SELECTION_ARRAY
myCurrentTracksInfo = TracksInfo.EMPTY
myCurrentTracksInfo = Tracks.EMPTY
return hasChanged
}
var activeTrackIds = mediaStatus.activeTrackIds
@@ -414,7 +414,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
}
val trackGroups = arrayOfNulls<TrackGroup>(castMediaTracks.size)
val trackSelections = arrayOfNulls<TrackSelection>(RENDERER_COUNT)
val trackGroupInfos = arrayOfNulls<TrackGroupInfo>(castMediaTracks.size)
val trackGroupInfos = arrayOfNulls<Group>(castMediaTracks.size)
for (i in castMediaTracks.indices) {
val mediaTrack = castMediaTracks[i]
trackGroups[i] = TrackGroup( /* id= */i.toString(), mediaTrackToFormat(mediaTrack))
@@ -428,18 +428,16 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
}
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)
trackGroupInfos[i] = Group(trackGroups[i]!!, false, trackSupport, trackSelected)
}
val tg = trackGroups.filterNotNull().toTypedArray()
var ts = trackSelections.filterNotNull().toTypedArray()
var tgi = trackGroupInfos.filterNotNull().toMutableList()
val ts = trackSelections.filterNotNull().toTypedArray()
val tgi = trackGroupInfos.filterNotNull().toMutableList()
val newTrackGroups = TrackGroupArray(*tg)
val newTrackSelections = TrackSelectionArray(*ts)
val newTracksInfo = TracksInfo(tgi)
if (newTrackGroups != currentTrackGroups
|| newTrackSelections != currentTrackSelections
|| newTracksInfo != currentTracksInfo) {
val newTracksInfo = Tracks(tgi)
if (newTracksInfo != currentTracks) {
myCurrentTrackSelections = newTrackSelections
myCurrentTrackGroups = newTrackGroups
myCurrentTracksInfo = newTracksInfo
@@ -593,7 +591,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
listeners.flushEvents()
val pendingResult: PendingResult<RemoteMediaClient.MediaChannelResult> = if (playWhenReady) remoteMediaClient!!.play() else remoteMediaClient!!.pause()
var resultCb = ResultCallback<RemoteMediaClient.MediaChannelResult?> {
val resultCb = ResultCallback<RemoteMediaClient.MediaChannelResult?> {
updatePlayerStateAndNotifyIfChanged()
}
@@ -622,10 +620,10 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
return false
}
override fun seekTo(mediaItemIndex: Int, positionMs: Long) {
override fun seekTo(mediaItemIndex: Int, positionMs: Long, seekCommand: Int, isRepeatingCurrentItem: Boolean) {
Log.d(tag, "seekTo $mediaItemIndex position $positionMs")
var resultCb = ResultCallback<RemoteMediaClient.MediaChannelResult?> {
val 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")
@@ -645,14 +643,14 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
// 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
val 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()
val mediaSeekOptions = MediaSeekOptions.Builder().setPosition(positionMsFinal).build()
remoteMediaClient?.seek(mediaSeekOptions)?.setResultCallback(resultCb)
}
val oldPosition = getCurrentPositionInfo()
@@ -709,7 +707,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
val pendingResult: PendingResult<RemoteMediaClient.MediaChannelResult>? = remoteMediaClient?.setPlaybackRate(actualPlaybackParameters.speed.toDouble(), /* customData= */null)
var resultCb = ResultCallback<RemoteMediaClient.MediaChannelResult?> {
val resultCb = ResultCallback<RemoteMediaClient.MediaChannelResult?> {
updatePlaybackRateAndNotifyIfChanged()
listeners.flushEvents()
}
@@ -736,15 +734,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
sessionManager.endCurrentSession(false)
}
override fun getCurrentTrackGroups(): TrackGroupArray {
return this.myCurrentTrackGroups
}
override fun getCurrentTrackSelections(): TrackSelectionArray {
return this.myCurrentTrackSelections
}
override fun getCurrentTracksInfo(): TracksInfo {
override fun getCurrentTracks(): Tracks {
return this.myCurrentTracksInfo
}
@@ -858,8 +848,12 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
return VideoSize.UNKNOWN
}
override fun getCurrentCues(): MutableList<Cue> {
return mutableListOf()
override fun getSurfaceSize(): Size {
return Size.UNKNOWN
}
override fun getCurrentCues(): CueGroup {
return CueGroup.EMPTY_TIME_ZERO
}
override fun getDeviceInfo(): DeviceInfo {
@@ -2,15 +2,13 @@ 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.os.*
import android.support.v4.media.session.MediaSessionCompat
import android.util.Log
import android.view.KeyEvent
import com.audiobookshelf.app.data.LibraryItemWrapper
import com.audiobookshelf.app.data.PodcastEpisode
import com.audiobookshelf.app.device.DeviceManager
import java.util.*
import kotlin.concurrent.schedule
@@ -18,7 +16,7 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
var tag = "MediaSessionCallback"
private var mediaButtonClickCount: Int = 0
var mediaButtonClickTimeout: Long = 1000 //ms
private var mediaButtonClickTimeout: Long = 1000 //ms
override fun onPrepare() {
Log.d(tag, "ON PREPARE MEDIA SESSION COMPAT")
@@ -28,7 +26,7 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
Log.e(tag, "Failed to play library item")
} else {
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
Handler(Looper.getMainLooper()).post() {
Handler(Looper.getMainLooper()).post {
playerNotificationService.preparePlayer(it,true, playbackRate)
}
}
@@ -54,7 +52,7 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
Log.e(tag, "Failed to play library item")
} else {
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
Handler(Looper.getMainLooper()).post() {
Handler(Looper.getMainLooper()).post {
playerNotificationService.preparePlayer(it, true, playbackRate)
}
}
@@ -88,25 +86,46 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
}
override fun onSeekTo(pos: Long) {
playerNotificationService.seekPlayer(pos)
val currentTrackStartOffset = playerNotificationService.getCurrentTrackStartOffsetMs()
playerNotificationService.seekPlayer(currentTrackStartOffset + pos)
}
private fun onChangeSpeed() {
// cycle to next speed, only contains preset android app options, as each increment needs it's own icon
// Rounding values in the event a non preset value (.5, 1, 1.2, 1.5, 2, 3) is selected in the phone app
val mediaManager = playerNotificationService.mediaManager
val newSpeed = when (mediaManager.getSavedPlaybackRate()) {
in 0.5f..0.7f -> 1.0f
in 0.8f..1.0f -> 1.2f
in 1.1f..1.2f -> 1.5f
in 1.3f..1.5f -> 2.0f
in 1.6f..2.0f -> 3.0f
in 2.1f..3.0f -> 0.5f
// anything set above 3 (can happen in the android app) will be reset to 1
else -> 1.0f
}
mediaManager.setSavedPlaybackRate(newSpeed)
playerNotificationService.setPlaybackSpeed(newSpeed)
playerNotificationService.clientEventEmitter?.onPlaybackSpeedChanged(newSpeed)
}
override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) {
Log.d(tag, "ON PLAY FROM MEDIA ID $mediaId")
var libraryItemWrapper: LibraryItemWrapper? = null
val libraryItemWrapper: LibraryItemWrapper?
var podcastEpisode: PodcastEpisode? = null
if (mediaId.isNullOrEmpty()) {
libraryItemWrapper = playerNotificationService.mediaManager.getFirstItem()
} else if (mediaId.startsWith("ep_") || mediaId.startsWith("local_ep_")) { // Playing podcast episode
val libraryItemWithEpisode = playerNotificationService.mediaManager.getPodcastWithEpisodeByEpisodeId(mediaId)
libraryItemWrapper = libraryItemWithEpisode?.libraryItemWrapper
podcastEpisode = libraryItemWithEpisode?.episode
} else {
libraryItemWrapper = playerNotificationService.mediaManager.getById(mediaId)
if (libraryItemWrapper == null) {
Log.e(tag, "onPlayFromMediaId: Media item not found $mediaId")
val libraryItemWithEpisode = playerNotificationService.mediaManager.getPodcastWithEpisodeByEpisodeId(mediaId)
if (libraryItemWithEpisode != null) {
libraryItemWrapper = libraryItemWithEpisode.libraryItemWrapper
podcastEpisode = libraryItemWithEpisode.episode
} else {
libraryItemWrapper = playerNotificationService.mediaManager.getById(mediaId)
if (libraryItemWrapper == null) {
Log.e(tag, "onPlayFromMediaId: Media item not found $mediaId")
}
}
}
@@ -116,7 +135,7 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
Log.e(tag, "Failed to play library item")
} else {
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
Handler(Looper.getMainLooper()).post() {
Handler(Looper.getMainLooper()).post {
playerNotificationService.preparePlayer(it, true, playbackRate)
}
}
@@ -128,24 +147,59 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
return handleCallMediaButton(mediaButtonEvent)
}
fun handleCallMediaButton(intent: Intent): Boolean {
private fun handleCallMediaButton(intent: Intent): Boolean {
Log.w(tag, "handleCallMediaButton $intent | ${intent.action}")
if(Intent.ACTION_MEDIA_BUTTON == intent.action) {
val keyEvent = intent.getParcelableExtra<KeyEvent>(Intent.EXTRA_KEY_EVENT)
val keyEvent = if (Build.VERSION.SDK_INT >= 33) {
intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT, KeyEvent::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT)
}
Log.d(tag, "handleCallMediaButton keyEvent = $keyEvent | action ${keyEvent?.action}")
// Widget button intent is only sending the action down event
if (keyEvent?.action == KeyEvent.ACTION_DOWN) {
Log.d(tag, "handleCallMediaButton: key action_down for ${keyEvent.keyCode}")
when (keyEvent.keyCode) {
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> {
Log.d(tag, "handleCallMediaButton: Media Play/Pause")
// TODO: Play/pause event sent from widget when app is closed. Currently the service gets destroyed before anything can happen
// if (playerNotificationService.currentPlaybackSession == null && DeviceManager.deviceData.lastPlaybackSession != null) {
// Log.i(tag, "No playback session but had one in the db")
//
// val connectionConfig = DeviceManager.deviceData.serverConnectionConfigs.find { it.id == DeviceManager.deviceData.lastPlaybackSession?.serverConnectionConfigId }
// connectionConfig?.let {
// Log.i(tag, "Setting playback session from db $it")
// DeviceManager.serverConnectionConfig = it
//
// playerNotificationService.currentPlaybackSession = DeviceManager.deviceData.lastPlaybackSession
// playerNotificationService.startNewPlaybackSession()
// return true
// }
// }
if (playerNotificationService.mPlayer.isPlaying) {
playerNotificationService.pause()
if (0 == mediaButtonClickCount) playerNotificationService.pause()
handleMediaButtonClickCount()
} else {
playerNotificationService.play()
if (0 == mediaButtonClickCount) {
playerNotificationService.play()
}
handleMediaButtonClickCount()
}
}
KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> {
Log.d(tag, "handleCallMediaButton: Media Fast Forward")
playerNotificationService.jumpForward()
}
KeyEvent.KEYCODE_MEDIA_REWIND -> {
Log.d(tag, "handleCallMediaButton: Media Rewind")
playerNotificationService.jumpBackward()
}
}
}
@@ -166,7 +220,6 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
Log.d(tag, "handleCallMediaButton: Media Play")
if (0 == mediaButtonClickCount) {
playerNotificationService.play()
playerNotificationService.sleepTimerManager.checkShouldExtendSleepTimer()
}
handleMediaButtonClickCount()
}
@@ -184,19 +237,6 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
KeyEvent.KEYCODE_MEDIA_STOP -> {
playerNotificationService.closePlayback()
}
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> {
Log.d(tag, "handleCallMediaButton: 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.keyCode}")
return false
@@ -239,6 +279,9 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
when (action) {
CUSTOM_ACTION_JUMP_FORWARD -> onFastForward()
CUSTOM_ACTION_JUMP_BACKWARD -> onRewind()
CUSTOM_ACTION_SKIP_FORWARD -> onSkipToNext()
CUSTOM_ACTION_SKIP_BACKWARD -> onSkipToPrevious()
CUSTOM_ACTION_CHANGE_SPEED -> onChangeSpeed()
}
}
}
@@ -35,7 +35,7 @@ class MediaSessionPlaybackPreparer(var playerNotificationService:PlayerNotificat
Log.e(tag, "Failed to play library item")
} else {
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
Handler(Looper.getMainLooper()).post() {
Handler(Looper.getMainLooper()).post {
playerNotificationService.preparePlayer(it, playWhenReady, playbackRate)
}
}
@@ -46,13 +46,13 @@ class MediaSessionPlaybackPreparer(var playerNotificationService:PlayerNotificat
override fun onPrepareFromMediaId(mediaId: String, playWhenReady: Boolean, extras: Bundle?) {
Log.d(tag, "ON PREPARE FROM MEDIA ID $mediaId $playWhenReady")
var libraryItemWrapper: LibraryItemWrapper? = null
val libraryItemWrapper: LibraryItemWrapper?
var podcastEpisode: PodcastEpisode? = null
if (mediaId.startsWith("ep_") || mediaId.startsWith("local_ep_")) { // Playing podcast episode
val libraryItemWithEpisode = playerNotificationService.mediaManager.getPodcastWithEpisodeByEpisodeId(mediaId)
libraryItemWrapper = libraryItemWithEpisode?.libraryItemWrapper
podcastEpisode = libraryItemWithEpisode?.episode
val libraryItemWithEpisode = playerNotificationService.mediaManager.getPodcastWithEpisodeByEpisodeId(mediaId)
if (libraryItemWithEpisode != null) {
libraryItemWrapper = libraryItemWithEpisode.libraryItemWrapper
podcastEpisode = libraryItemWithEpisode.episode
} else {
libraryItemWrapper = playerNotificationService.mediaManager.getById(mediaId)
}
@@ -63,7 +63,7 @@ class MediaSessionPlaybackPreparer(var playerNotificationService:PlayerNotificat
Log.e(tag, "Failed to play library item")
} else {
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
Handler(Looper.getMainLooper()).post() {
Handler(Looper.getMainLooper()).post {
playerNotificationService.preparePlayer(it, playWhenReady, playbackRate)
}
}
@@ -79,7 +79,7 @@ class MediaSessionPlaybackPreparer(var playerNotificationService:PlayerNotificat
Log.e(tag, "Failed to play library item")
} else {
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
Handler(Looper.getMainLooper()).post() {
Handler(Looper.getMainLooper()).post {
playerNotificationService.preparePlayer(it, playWhenReady, playbackRate)
}
}
@@ -2,3 +2,20 @@ package com.audiobookshelf.app.player
const val CUSTOM_ACTION_JUMP_FORWARD = "com.audiobookshelf.customAction.jump_forward";
const val CUSTOM_ACTION_JUMP_BACKWARD = "com.audiobookshelf.customAction.jump_backward";
const val CUSTOM_ACTION_SKIP_FORWARD = "com.audiobookshelf.customAction.skip_forward";
const val CUSTOM_ACTION_SKIP_BACKWARD = "com.audiobookshelf.customAction.skip_backward";
const val CUSTOM_ACTION_CHANGE_SPEED = "com.audiobookshelf.customAction.change_speed";
const val PLAYMETHOD_DIRECTPLAY = 0
const val PLAYMETHOD_DIRECTSTREAM = 1
const val PLAYMETHOD_TRANSCODE = 2
const val PLAYMETHOD_LOCAL = 3
//
// MEDIA BROWSER STUFF (ANDROID AUTO)
//
const val ANDROID_AUTO_PKG_NAME = "com.google.android.projection.gearhead"
const val ANDROID_AUTO_SIMULATOR_PKG_NAME = "com.google.android.autosimulator"
const val ANDROID_WEARABLE_PKG_NAME = "com.google.android.wearable.app"
const val ANDROID_GSEARCH_PKG_NAME = "com.google.android.googlequicksearchbox"
const val ANDROID_AUTOMOTIVE_PKG_NAME = "com.google.android.carassistant"
@@ -1,28 +1,119 @@
package com.audiobookshelf.app.player
import android.util.Log
import com.audiobookshelf.app.data.PlaybackSession
import com.audiobookshelf.app.data.PlayerState
import com.audiobookshelf.app.device.DeviceManager
import com.google.android.exoplayer2.PlaybackException
import com.google.android.exoplayer2.Player
const val PAUSE_LEN_BEFORE_RECHECK = 30000 // 30 seconds
//const val PAUSE_LEN_BEFORE_RECHECK = 30000 // 30 seconds
class PlayerListener(var playerNotificationService:PlayerNotificationService) : Player.Listener {
var tag = "PlayerListener"
companion object {
var lastPauseTime: Long = 0 //ms
var lazyIsPlaying: Boolean = false
}
private var onSeekBack: Boolean = false
override fun onPlayerError(error: PlaybackException) {
val errorMessage = error.message ?: "Unknown Error"
Log.e(tag, "onPlayerError $errorMessage")
playerNotificationService.handlePlayerPlaybackError(errorMessage) // If was direct playing session, fallback to transcode
}
override fun onPositionDiscontinuity(
oldPosition: Player.PositionInfo,
newPosition: Player.PositionInfo,
reason: Int
) {
if (reason == Player.DISCONTINUITY_REASON_SEEK) {
// If playing set seeking flag
Log.d(tag, "onPositionDiscontinuity: oldPosition=${oldPosition.positionMs}/${oldPosition.mediaItemIndex}, newPosition=${newPosition.positionMs}/${newPosition.mediaItemIndex}, isPlaying=${playerNotificationService.currentPlayer.isPlaying} reason=SEEK")
playerNotificationService.mediaProgressSyncer.seek()
lastPauseTime = 0 // When seeking while paused reset the auto-rewind
} else {
Log.d(tag, "onPositionDiscontinuity: oldPosition=${oldPosition.positionMs}/${oldPosition.mediaItemIndex}, newPosition=${newPosition.positionMs}/${newPosition.mediaItemIndex}, isPlaying=${playerNotificationService.currentPlayer.isPlaying}, reason=$reason")
}
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
Log.d(tag, "onIsPlayingChanged to $isPlaying | ${playerNotificationService.getMediaPlayer()} | playbackState=${playerNotificationService.currentPlayer.playbackState}")
val player = playerNotificationService.currentPlayer
// Goal of these 2 if statements and the lazyIsPlaying is to ignore this event when it is triggered by a seek
// When a seek occurs the player is paused and buffering, then plays again right afterwards.
if (!isPlaying && player.playbackState == Player.STATE_BUFFERING) {
Log.d(tag, "onIsPlayingChanged: Pause event when buffering is ignored")
return
}
if (lazyIsPlaying == isPlaying) {
Log.d(tag, "onIsPlayingChanged: Lazy is playing $lazyIsPlaying is already set to this so ignoring")
return
}
lazyIsPlaying = isPlaying
// Update widget
DeviceManager.widgetUpdater?.onPlayerChanged(playerNotificationService)
if (isPlaying) {
Log.d(tag, "SeekBackTime: Player is playing")
if (lastPauseTime > 0 && DeviceManager.deviceData.deviceSettings?.disableAutoRewind != true) {
Log.d(tag, "SeekBackTime: playing started now set seek back time $lastPauseTime")
var seekBackTime = calcPauseSeekBackTime()
if (seekBackTime > 0) {
// Current chapter is used so that seek back does not go back to the previous chapter
val currentChapter = playerNotificationService.getCurrentBookChapter()
val minSeekBackTime = currentChapter?.startMs ?: 0
val currentTime = playerNotificationService.getCurrentTime()
val newTime = currentTime - seekBackTime
if (newTime < minSeekBackTime) {
seekBackTime = currentTime - minSeekBackTime
}
Log.d(tag, "SeekBackTime $seekBackTime")
}
// TODO: this needs to be reworked so that the audio doesn't start playing before it checks for updated progress
// Check if playback session still exists or sync media progress if updated
// val pauseLength: Long = System.currentTimeMillis() - lastPauseTime
// if (pauseLength > PAUSE_LEN_BEFORE_RECHECK) {
// val shouldCarryOn = playerNotificationService.checkCurrentSessionProgress(seekBackTime)
// if (!shouldCarryOn) return
// }
if (seekBackTime > 0L) {
playerNotificationService.seekBackward(seekBackTime)
}
}
} else {
Log.d(tag, "SeekBackTime: Player not playing set last pause time | playbackState=${player.playbackState}")
lastPauseTime = System.currentTimeMillis()
}
// Start/stop progress sync interval
if (isPlaying) {
val playbackSession: PlaybackSession? = playerNotificationService.mediaProgressSyncer.currentPlaybackSession ?: playerNotificationService.currentPlaybackSession
playbackSession?.let {
// Handles auto-starting sleep timer and resetting sleep timer
playerNotificationService.sleepTimerManager.handleMediaPlayEvent(it.id)
player.volume = 1F // Volume on sleep timer might have decreased this
playerNotificationService.mediaProgressSyncer.play(it)
}
} else {
playerNotificationService.mediaProgressSyncer.pause {
Log.d(tag, "Media Progress Syncer paused and synced")
}
}
playerNotificationService.clientEventEmitter?.onPlayingUpdate(isPlaying)
}
override fun onEvents(player: Player, events: Player.Events) {
Log.d(tag, "onEvents ${playerNotificationService.getMediaPlayer()} | ${events.size()}")
@@ -41,7 +132,7 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
Log.d(tag, "STATE_READY : " + playerNotificationService.currentPlayer.duration)
if (lastPauseTime == 0L) {
lastPauseTime = -1;
lastPauseTime = -1
}
playerNotificationService.sendClientMetadata(PlayerState.READY)
}
@@ -52,6 +143,8 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
if (playerNotificationService.currentPlayer.playbackState == Player.STATE_ENDED) {
Log.d(tag, "STATE_ENDED")
playerNotificationService.sendClientMetadata(PlayerState.ENDED)
playerNotificationService.handlePlaybackEnded()
}
if (playerNotificationService.currentPlayer.playbackState == Player.STATE_IDLE) {
Log.d(tag, "STATE_IDLE")
@@ -65,62 +158,6 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
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 && DeviceManager.deviceData.deviceSettings?.disableAutoRewind != true) {
var seekBackTime = 0L
if (onSeekBack) onSeekBack = false
else {
Log.d(tag, "SeekBackTime: playing started now set seek back time $lastPauseTime")
seekBackTime = calcPauseSeekBackTime()
if (seekBackTime > 0) {
// Current chapter is used so that seek back does not go back to the previous chapter
val currentChapter = playerNotificationService.getCurrentBookChapter()
val minSeekBackTime = currentChapter?.startMs ?: 0
val currentTime = playerNotificationService.getCurrentTime()
val newTime = currentTime - seekBackTime
if (newTime < minSeekBackTime) {
seekBackTime = currentTime - minSeekBackTime
}
Log.d(tag, "SeekBackTime $seekBackTime")
onSeekBack = true
}
}
// Check if playback session still exists or sync media progress if updated
val pauseLength: Long = System.currentTimeMillis() - lastPauseTime
if (pauseLength > PAUSE_LEN_BEFORE_RECHECK) {
val shouldCarryOn = playerNotificationService.checkCurrentSessionProgress(seekBackTime)
if (!shouldCarryOn) return
}
if (seekBackTime > 0L) {
playerNotificationService.seekBackward(seekBackTime)
}
}
} else {
Log.d(tag, "SeekBackTime: Player not playing set last pause time")
lastPauseTime = System.currentTimeMillis()
}
// Start/stop progress sync interval
if (player.isPlaying) {
player.volume = 1F // Volume on sleep timer might have decreased this
playerNotificationService.mediaProgressSyncer.start()
} else {
playerNotificationService.mediaProgressSyncer.pause {
Log.d(tag, "Media Progress Syncer paused and synced")
}
}
playerNotificationService.clientEventEmitter?.onPlayingUpdate(player.isPlaying)
DeviceManager.widgetUpdater?.onPlayerChanged(playerNotificationService)
}
}
private fun calcPauseSeekBackTime() : Long {
@@ -1,21 +1,37 @@
package com.audiobookshelf.app.player
import android.app.Notification
import android.content.pm.ServiceInfo
import android.os.Build
import android.util.Log
import com.google.android.exoplayer2.ui.PlayerNotificationManager
class PlayerNotificationListener(var playerNotificationService:PlayerNotificationService) : PlayerNotificationManager.NotificationListener {
var tag = "PlayerNotificationListener"
companion object {
var isForegroundService = false
}
override fun onNotificationPosted(
notificationId: Int,
notification: Notification,
onGoing: Boolean) {
// Start foreground service
Log.d(tag, "Notification Posted $notificationId - Start Foreground | $notification")
PlayerNotificationService.isClosed = false
playerNotificationService.startForeground(notificationId, notification)
if (onGoing && !isForegroundService) {
// Start foreground service
Log.d(tag, "Notification Posted $notificationId - Start Foreground | $notification")
PlayerNotificationService.isClosed = false
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
playerNotificationService.startForeground(notificationId, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK)
} else {
playerNotificationService.startForeground(notificationId, notification)
}
isForegroundService = true
} else {
Log.d(tag, "Notification posted $notificationId, not starting foreground - onGoing=$onGoing | isForegroundService=$isForegroundService")
}
}
override fun onNotificationCancelled(
@@ -28,16 +44,13 @@ class PlayerNotificationListener(var playerNotificationService:PlayerNotificatio
} else {
Log.d(tag, "onNotificationCancelled not dismissed by user")
// When stop button is pressed on the notification I guess it isn't considered "dismissedByUser" so we need to close playback ourselves
if (!PlayerNotificationService.isClosed && !PlayerNotificationService.isSwitchingPlayer) {
Log.d(tag, "PNS is not closed - closing it now")
playerNotificationService.closePlayback()
} else if (PlayerNotificationService.isSwitchingPlayer) {
if (PlayerNotificationService.isSwitchingPlayer) {
// When switching from cast player to exo player and vice versa the notification is cancelled and posted again
// so we don't want to cancel the playback during this switch
Log.d(tag, "PNS is switching player")
PlayerNotificationService.isSwitchingPlayer = false
}
}
isForegroundService = false
}
}
@@ -1,18 +1,21 @@
package com.audiobookshelf.app.player
import android.annotation.SuppressLint
import android.app.*
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Color
import android.graphics.ImageDecoder
import android.hardware.Sensor
import android.hardware.SensorManager
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.net.*
import android.os.*
import android.provider.MediaStore
import android.provider.Settings
import android.support.v4.media.MediaBrowserCompat
import android.support.v4.media.MediaDescriptionCompat
import android.support.v4.media.MediaMetadataCompat
import android.support.v4.media.session.MediaControllerCompat
import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.session.PlaybackStateCompat
@@ -22,19 +25,23 @@ import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import androidx.media.MediaBrowserServiceCompat
import androidx.media.utils.MediaConstants
import com.audiobookshelf.app.BuildConfig
import com.audiobookshelf.app.R
import com.audiobookshelf.app.data.*
import com.audiobookshelf.app.data.DeviceInfo
import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.managers.DbManager
import com.audiobookshelf.app.managers.SleepTimerManager
import com.audiobookshelf.app.media.MediaManager
import com.audiobookshelf.app.media.MediaProgressSyncer
import com.audiobookshelf.app.server.ApiHandler
import com.fasterxml.jackson.annotation.JsonIgnore
import com.audiobookshelf.app.BuildConfig
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.MediaSessionConnector.CustomActionProvider
import com.google.android.exoplayer2.ext.mediasession.TimelineQueueNavigator
import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory
import com.google.android.exoplayer2.extractor.mp3.Mp3Extractor
import com.google.android.exoplayer2.source.MediaSource
import com.google.android.exoplayer2.source.ProgressiveMediaSource
import com.google.android.exoplayer2.source.hls.HlsMediaSource
@@ -45,6 +52,8 @@ import kotlin.concurrent.schedule
const val SLEEP_TIMER_WAKE_UP_EXPIRATION = 120000L // 2m
const val PLAYER_CAST = "cast-player"
const val PLAYER_EXO = "exo-player"
class PlayerNotificationService : MediaBrowserServiceCompat() {
@@ -52,25 +61,28 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
var isStarted = false
var isClosed = false
var isUnmeteredNetwork = false
var hasNetworkConnectivity = false // Not 100% reliable has internet
var isSwitchingPlayer = false // Used when switching between cast player and exoplayer
}
private val tag = "PlayerNotificationServ"
interface ClientEventEmitter {
fun onPlaybackSession(playbackSession:PlaybackSession)
fun onPlaybackClosed()
fun onPlayingUpdate(isPlaying: Boolean)
fun onMetadata(metadata: PlaybackMetadata)
fun onSleepTimerEnded(currentPosition: Long)
fun onSleepTimerSet(sleepTimeRemaining: Int)
fun onSleepTimerSet(sleepTimeRemaining: Int, isAutoSleepTimer:Boolean)
fun onLocalMediaProgressUpdate(localMediaProgress: LocalMediaProgress)
fun onPlaybackFailed(errorMessage:String)
fun onMediaPlayerChanged(mediaPlayer:String)
fun onProgressSyncFailing()
fun onProgressSyncSuccess()
fun onNetworkMeteredChanged(isUnmetered:Boolean)
fun onMediaItemHistoryUpdated(mediaItemHistory:MediaItemHistory)
fun onPlaybackSpeedChanged(playbackSpeed:Float)
}
private val tag = "PlayerService"
private val binder = LocalBinder()
var clientEventEmitter:ClientEventEmitter? = null
@@ -89,13 +101,13 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
var castPlayer:CastPlayer? = null
lateinit var sleepTimerManager:SleepTimerManager
lateinit var mediaProgressSyncer:MediaProgressSyncer
lateinit var mediaProgressSyncer: MediaProgressSyncer
private var notificationId = 10
private var channelId = "audiobookshelf_channel"
private var channelName = "Audiobookshelf Channel"
private var currentPlaybackSession:PlaybackSession? = null
var currentPlaybackSession:PlaybackSession? = null
private var initialPlaybackRate:Float? = null
private var isAndroidAuto = false
@@ -133,6 +145,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
return START_STICKY
}
@Deprecated("Deprecated in Java")
override fun onStart(intent: Intent?, startId: Int) {
Log.d(tag, "onStart $startId")
}
@@ -158,13 +171,16 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
}
Log.d(tag, "onDestroy")
isStarted = false
isClosed = true
DeviceManager.widgetUpdater?.onPlayerChanged(this)
playerNotificationManager.setPlayer(null)
mPlayer.release()
castPlayer?.release()
mediaSession.release()
mediaProgressSyncer.reset()
Log.d(tag, "onDestroy")
isStarted = false
super.onDestroy()
}
@@ -173,6 +189,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
override fun onTaskRemoved(rootIntent: Intent?) {
super.onTaskRemoved(rootIntent)
Log.d(tag, "onTaskRemoved")
stopSelf()
}
@@ -181,11 +198,17 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
super.onCreate()
ctx = this
// Initialize Paper
DbManager.initialize(ctx)
// Initialize widget
DeviceManager.initializeWidgetUpdater(ctx)
// To listen for network change from metered to unmetered
val networkRequest = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
.addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
.addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
.addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
.build()
val connectivityManager = getSystemService(ConnectivityManager::class.java) as ConnectivityManager
connectivityManager.registerNetworkCallback(networkRequest, networkCallback)
@@ -221,7 +244,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
.apply {
setSessionActivity(sessionActivityPendingIntent)
isActive = true
setFlags(MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS or MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS)
}
val mediaController = MediaControllerCompat(ctx, mediaSession.sessionToken)
@@ -243,11 +265,12 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
playerNotificationManager.setUseNextAction(false)
playerNotificationManager.setUsePreviousAction(false)
playerNotificationManager.setUseChronometer(false)
playerNotificationManager.setUseStopAction(true)
playerNotificationManager.setUseStopAction(false)
playerNotificationManager.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
playerNotificationManager.setPriority(NotificationCompat.PRIORITY_MAX)
playerNotificationManager.setUseFastForwardActionInCompactView(true)
playerNotificationManager.setUseRewindActionInCompactView(true)
playerNotificationManager.setSmallIcon(R.drawable.icon_monochrome)
// Unknown action
playerNotificationManager.setBadgeIconType(NotificationCompat.BADGE_ICON_LARGE)
@@ -256,13 +279,28 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
mediaSessionConnector = MediaSessionConnector(mediaSession)
val queueNavigator: TimelineQueueNavigator = object : TimelineQueueNavigator(mediaSession) {
override fun getSupportedQueueNavigatorActions(player: Player): Long {
return PlaybackStateCompat.ACTION_PLAY_PAUSE or PlaybackStateCompat.ACTION_PLAY or PlaybackStateCompat.ACTION_PAUSE
}
override fun getMediaDescription(player: Player, windowIndex: Int): MediaDescriptionCompat {
if (currentPlaybackSession == null) {
Log.e(tag,"Playback session is not set - returning blank MediaDescriptionCompat")
return MediaDescriptionCompat.Builder().build()
}
val coverUri = currentPlaybackSession!!.getCoverUri()
val coverUri = currentPlaybackSession!!.getCoverUri(ctx)
var bitmap: Bitmap? = null
// Local covers get bitmap
if (currentPlaybackSession!!.localLibraryItem?.coverContentUrl != null) {
bitmap = if (Build.VERSION.SDK_INT < 28) {
MediaStore.Images.Media.getBitmap(ctx.contentResolver, coverUri)
} else {
val source: ImageDecoder.Source = ImageDecoder.createSource(ctx.contentResolver, coverUri)
ImageDecoder.decodeBitmap(source)
}
}
// Fix for local images crashing on Android 11 for specific devices
// https://stackoverflow.com/questions/64186578/android-11-mediastyle-notification-crash/64232958#64232958
@@ -276,31 +314,26 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
Log.e(tag, "Grant uri permission error $error")
}
return MediaDescriptionCompat.Builder()
.setMediaId(currentPlaybackSession!!.id)
val extra = Bundle()
extra.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, currentPlaybackSession!!.displayAuthor)
val mediaDescriptionBuilder = MediaDescriptionCompat.Builder()
.setExtras(extra)
.setTitle(currentPlaybackSession!!.displayTitle)
.setSubtitle(currentPlaybackSession!!.displayAuthor)
.setIconUri(coverUri).build()
.setIconUri(coverUri)
bitmap?.let {
mediaDescriptionBuilder.setIconBitmap(it)
}
return mediaDescriptionBuilder.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
)
setMediaSessionConnectorPlaybackActions()
mediaSessionConnector.setQueueNavigator(queueNavigator)
mediaSessionConnector.setPlaybackPreparer(MediaSessionPlaybackPreparer(this))
mediaSessionConnector.setCustomActionProviders(
JumpForwardCustomActionProvider(),
JumpBackwardCustomActionProvider(),
)
mediaSession.setCallback(MediaSessionCallback(this))
initializeMPlayer()
@@ -322,7 +355,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
.build()
mPlayer.setHandleAudioBecomingNoisy(true)
mPlayer.addListener(PlayerListener(this))
val audioAttributes:AudioAttributes = AudioAttributes.Builder().setUsage(C.USAGE_MEDIA).setContentType(C.CONTENT_TYPE_SPEECH).build()
val audioAttributes:AudioAttributes = AudioAttributes.Builder().setUsage(C.USAGE_MEDIA).setContentType(C.AUDIO_CONTENT_TYPE_SPEECH).build()
mPlayer.setAudioAttributes(audioAttributes, true)
//attach player to playerNotificationManager
@@ -342,33 +375,49 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
}
}
// TODO: When an item isFinished the currentTime should be reset to 0
// will reset the time if currentTime is within 5s of duration (for android auto)
Log.d(tag, "Prepare Player Session Current Time=${playbackSession.currentTime}, Duration=${playbackSession.duration}")
if (playbackSession.duration - playbackSession.currentTime < 5) {
Log.d(tag, "Prepare Player Session is finished, so restart it")
playbackSession.currentTime = 0.0
}
isClosed = false
val metadata = playbackSession.getMediaMetadataCompat(ctx)
mediaSession.setMetadata(metadata)
val mediaItems = playbackSession.getMediaItems(ctx)
val playbackRateToUse = playbackRate ?: initialPlaybackRate ?: 1f
initialPlaybackRate = playbackRate
// Set actions on Android Auto like jump forward/backward
setMediaSessionConnectorCustomActions(playbackSession)
playbackSession.mediaPlayer = getMediaPlayer()
if (playbackSession.mediaPlayer == "cast-player" && playbackSession.isLocal) {
if (playbackSession.mediaPlayer == PLAYER_CAST && 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 (playbackSession.mediaPlayer == PLAYER_CAST) {
// 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}")
DeviceManager.setLastPlaybackSession(playbackSession) // Save playback session to use when app is closed
Log.d(tag, "Set CurrentPlaybackSession MediaPlayer ${currentPlaybackSession?.mediaPlayer}")
// Notify client
clientEventEmitter?.onPlaybackSession(playbackSession)
val metadata = playbackSession.getMediaMetadataCompat()
mediaSession.setMetadata(metadata)
val mediaItems = playbackSession.getMediaItems()
// Update widget
DeviceManager.widgetUpdater?.onPlayerChanged(this)
if (mediaItems.isEmpty()) {
Log.e(tag, "Invalid playback session no media items to play")
@@ -382,12 +431,26 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
if (playbackSession.isLocal) {
Log.d(tag, "Playing Local Item")
val dataSourceFactory = DefaultDataSource.Factory(ctx)
mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItems[0])
val extractorsFactory = DefaultExtractorsFactory()
if (DeviceManager.deviceData.deviceSettings?.enableMp3IndexSeeking == true) {
// @see https://exoplayer.dev/troubleshooting.html#why-is-seeking-inaccurate-in-some-mp3-files
extractorsFactory.setMp3ExtractorFlags(Mp3Extractor.FLAG_ENABLE_INDEX_SEEKING)
}
mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory, extractorsFactory).createMediaSource(mediaItems[0])
} else if (!playbackSession.isHLS) {
Log.d(tag, "Direct Playing Item")
val dataSourceFactory = DefaultHttpDataSource.Factory()
val extractorsFactory = DefaultExtractorsFactory()
if (DeviceManager.deviceData.deviceSettings?.enableMp3IndexSeeking == true) {
// @see https://exoplayer.dev/troubleshooting.html#why-is-seeking-inaccurate-in-some-mp3-files
extractorsFactory.setMp3ExtractorFlags(Mp3Extractor.FLAG_ENABLE_INDEX_SEEKING)
}
dataSourceFactory.setUserAgent(channelId)
mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItems[0])
mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory, extractorsFactory).createMediaSource(mediaItems[0])
} else {
Log.d(tag, "Playing HLS Item")
val dataSourceFactory = DefaultHttpDataSource.Factory()
@@ -426,6 +489,36 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
}
}
private fun setMediaSessionConnectorCustomActions(playbackSession:PlaybackSession) {
val mediaItems = playbackSession.getMediaItems(ctx)
val customActionProviders = mutableListOf(
JumpBackwardCustomActionProvider(),
JumpForwardCustomActionProvider(),
ChangePlaybackSpeedCustomActionProvider() // Will be pushed to far left
)
if (playbackSession.mediaPlayer != PLAYER_CAST && mediaItems.size > 1) {
customActionProviders.addAll(listOf(
SkipBackwardCustomActionProvider(),
SkipForwardCustomActionProvider(),
))
}
mediaSessionConnector.setCustomActionProviders(*customActionProviders.toTypedArray())
}
fun setMediaSessionConnectorPlaybackActions() {
var playbackActions = PlaybackStateCompat.ACTION_PLAY_PAUSE or
PlaybackStateCompat.ACTION_PLAY or
PlaybackStateCompat.ACTION_PAUSE or
PlaybackStateCompat.ACTION_FAST_FORWARD or
PlaybackStateCompat.ACTION_REWIND or
PlaybackStateCompat.ACTION_STOP
if (deviceSettings.allowSeekingOnMediaControls) {
playbackActions = playbackActions or PlaybackStateCompat.ACTION_SEEK_TO
}
mediaSessionConnector.setEnabledPlaybackActions(playbackActions)
}
fun handlePlayerPlaybackError(errorMessage:String) {
// On error and was attempting to direct play - fallback to transcode
currentPlaybackSession?.let { playbackSession ->
@@ -435,16 +528,19 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
val libraryItemId = playbackSession.libraryItemId ?: "" // Must be true since direct play
val episodeId = playbackSession.episodeId
apiHandler.playLibraryItem(libraryItemId, episodeId, playItemRequestPayload) {
if (it == null) { // Play request failed
clientEventEmitter?.onPlaybackFailed(errorMessage)
closePlayback(true)
} else {
Handler(Looper.getMainLooper()).post {
preparePlayer(it, true, null)
mediaProgressSyncer.stop(false) {
apiHandler.playLibraryItem(libraryItemId, episodeId, playItemRequestPayload) {
if (it == null) { // Play request failed
clientEventEmitter?.onPlaybackFailed(errorMessage)
closePlayback(true)
} else {
Handler(Looper.getMainLooper()).post {
preparePlayer(it, true, null)
}
}
}
}
} else {
clientEventEmitter?.onPlaybackFailed(errorMessage)
closePlayback(true)
@@ -452,19 +548,54 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
}
}
fun handlePlaybackEnded() {
Log.d(tag, "handlePlaybackEnded")
if (isAndroidAuto && currentPlaybackSession?.isPodcastEpisode == true) {
Log.d(tag, "Podcast playback ended on android auto")
val libraryItem = currentPlaybackSession?.libraryItem ?: return
// Need to sync with server to set as finished
mediaProgressSyncer.finished {
// Need to reload media progress
mediaManager.loadServerUserMediaProgress {
val podcast = libraryItem.media as Podcast
val nextEpisode = podcast.getNextUnfinishedEpisode(libraryItem.id, mediaManager)
Log.d(tag, "handlePlaybackEnded nextEpisode=$nextEpisode")
nextEpisode?.let { podcastEpisode ->
mediaManager.play(libraryItem, podcastEpisode, getPlayItemRequestPayload(false)) {
if (it == null) {
Log.e(tag, "Failed to play library item")
} else {
val playbackRate = mediaManager.getSavedPlaybackRate()
Handler(Looper.getMainLooper()).post {
preparePlayer(it,true, playbackRate)
}
}
}
}
}
}
}
}
fun startNewPlaybackSession() {
currentPlaybackSession?.let { playbackSession ->
Log.i(tag, "Starting new playback session for ${playbackSession.displayTitle}")
val forceTranscode = playbackSession.isHLS // If already HLS then force
val playItemRequestPayload = getPlayItemRequestPayload(forceTranscode)
val libraryItemId = playbackSession.libraryItemId ?: "" // Must be true since direct play
val episodeId = playbackSession.episodeId
apiHandler.playLibraryItem(libraryItemId, episodeId, playItemRequestPayload) {
if (it == null) {
Log.e(tag, "Failed to start new playback session")
} else {
Handler(Looper.getMainLooper()).post {
preparePlayer(it, true, null)
mediaProgressSyncer.stop(false) {
apiHandler.playLibraryItem(libraryItemId, episodeId, playItemRequestPayload) {
if (it == null) {
Log.e(tag, "Failed to start new playback session")
} else {
Log.d(tag, "New playback session response from server with session id ${it.id} for \"${it.displayTitle}\"")
Handler(Looper.getMainLooper()).post {
preparePlayer(it, true, null)
}
}
}
}
@@ -528,16 +659,20 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
}
}
fun getCurrentTime() : Long {
fun getCurrentTrackStartOffsetMs() : Long {
return if (currentPlayer.mediaItemCount > 1) {
val windowIndex = currentPlayer.currentMediaItemIndex
val currentTrackStartOffset = currentPlaybackSession?.getTrackStartOffsetMs(windowIndex) ?: 0L
currentPlayer.currentPosition + currentTrackStartOffset
currentTrackStartOffset
} else {
currentPlayer.currentPosition
0
}
}
fun getCurrentTime() : Long {
return currentPlayer.currentPosition + getCurrentTrackStartOffsetMs()
}
fun getCurrentTimeSeconds() : Double {
return getCurrentTime() / 1000.0
}
@@ -564,21 +699,29 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
return currentPlaybackSession?.clone()
}
fun getCurrentPlaybackSessionId() :String? {
return currentPlaybackSession?.id
}
fun getCurrentBookChapter():BookChapter? {
return currentPlaybackSession?.getChapterForTime(this.getCurrentTime())
}
fun getEndTimeOfChapterOrTrack():Long? {
return getCurrentBookChapter()?.endMs ?: currentPlaybackSession?.getCurrentTrackEndTime()
}
private fun getNextBookChapter():BookChapter? {
return currentPlaybackSession?.getNextChapterForTime(this.getCurrentTime())
}
fun getEndTimeOfNextChapterOrTrack():Long? {
return getNextBookChapter()?.endMs ?: currentPlaybackSession?.getNextTrackEndTime()
}
// Called from PlayerListener play event
// check with server if progress has updated since last play and sync progress update
fun checkCurrentSessionProgress(seekBackTime:Long):Boolean {
if (currentPlaybackSession == null) return true
mediaProgressSyncer.currentPlaybackSession?.let { playbackSession ->
if (!apiHandler.isOnline() || playbackSession.isLocalLibraryItemOnly) {
if (!DeviceManager.checkConnectivity(ctx) || playbackSession.isLocalLibraryItemOnly) {
return true // carry on
}
@@ -608,7 +751,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
seekPlayer(playbackSession.currentTimeMs)
// Should already be playing
currentPlayer.volume = 1F // Volume on sleep timer might have decreased this
mediaProgressSyncer.start()
currentPlaybackSession?.let { mediaProgressSyncer.play(it) }
clientEventEmitter?.onPlayingUpdate(true)
}
} else {
@@ -616,9 +759,12 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
if (seekBackTime > 0L) {
seekBackward(seekBackTime)
}
// Should already be playing
currentPlayer.volume = 1F // Volume on sleep timer might have decreased this
mediaProgressSyncer.start()
mediaProgressSyncer.currentPlaybackSession?.let { playbackSession ->
mediaProgressSyncer.play(playbackSession)
}
clientEventEmitter?.onPlayingUpdate(true)
}
}
@@ -627,7 +773,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
// Streaming from server so check if playback session still exists on server
Log.d(
tag,
"checkCurrentSessionProgress: Checking if playback session for server stream is still available"
"checkCurrentSessionProgress: Checking if playback session ${playbackSession.id} for server stream is still available"
)
apiHandler.getPlaybackSession(playbackSession.id) {
if (it == null) {
@@ -648,7 +794,10 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
}
currentPlayer.volume = 1F // Volume on sleep timer might have decreased this
mediaProgressSyncer.start()
mediaProgressSyncer.currentPlaybackSession?.let { playbackSession ->
mediaProgressSyncer.play(playbackSession)
}
clientEventEmitter?.onPlayingUpdate(true)
}
}
@@ -682,14 +831,24 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
}
fun seekPlayer(time: Long) {
Log.d(tag, "seekPlayer mediaCount = ${currentPlayer.mediaItemCount} | $time")
var timeToSeek = time
Log.d(tag, "seekPlayer mediaCount = ${currentPlayer.mediaItemCount} | $timeToSeek")
if (timeToSeek < 0) {
Log.w(tag, "seekPlayer invalid time $timeToSeek - setting to 0")
timeToSeek = 0L
} else if (timeToSeek > getDuration()) {
Log.w(tag, "seekPlayer invalid time $timeToSeek - setting to MAX - 2000")
timeToSeek = getDuration() - 2000L
}
if (currentPlayer.mediaItemCount > 1) {
currentPlaybackSession?.currentTime = time / 1000.0
currentPlaybackSession?.currentTime = timeToSeek / 1000.0
val newWindowIndex = currentPlaybackSession?.getCurrentTrackIndex() ?: 0
val newTimeOffset = currentPlaybackSession?.getCurrentTrackTimeMs() ?: 0
Log.d(tag, "seekPlayer seekTo $newWindowIndex | $newTimeOffset")
currentPlayer.seekTo(newWindowIndex, newTimeOffset)
} else {
currentPlayer.seekTo(time)
currentPlayer.seekTo(timeToSeek)
}
}
@@ -718,15 +877,37 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
}
fun setPlaybackSpeed(speed: Float) {
mediaManager.userSettingsPlaybackRate = speed
currentPlayer.setPlaybackSpeed(speed)
// Refresh Android Auto actions
mediaProgressSyncer.currentPlaybackSession?.let {
setMediaSessionConnectorCustomActions(it)
}
}
fun closePlayback(calledOnError:Boolean? = false) {
Log.d(tag, "closePlayback")
val isLocal = mediaProgressSyncer.currentIsLocal
val currentSessionId = mediaProgressSyncer.currentSessionId
if (mediaProgressSyncer.listeningTimerRunning) {
Log.i(tag, "About to close playback so stopping media progress syncer first")
mediaProgressSyncer.stop(calledOnError == false) { // If closing on error then do not sync progress (causes exception)
Log.d(tag, "Media Progress syncer stopped")
// If not local session then close on server
if (!isLocal && currentSessionId != "") {
apiHandler.closePlaybackSession(currentSessionId) {
Log.d(tag, "Closed playback session $currentSessionId")
}
}
}
} else {
// If not local session then close on server
if (!isLocal && currentSessionId != "") {
apiHandler.closePlaybackSession(currentSessionId) {
Log.d(tag, "Closed playback session $currentSessionId")
}
}
}
@@ -738,10 +919,13 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
}
currentPlaybackSession = null
mediaProgressSyncer.reset()
clientEventEmitter?.onPlaybackClosed()
PlayerListener.lastPauseTime = 0
isClosed = true
stopForeground(true)
DeviceManager.widgetUpdater?.onPlayerClosed()
stopForeground(Service.STOP_FOREGROUND_REMOVE)
stopSelf()
}
@@ -751,9 +935,10 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
}
fun getMediaPlayer():String {
return if(currentPlayer == castPlayer) "cast-player" else "exo-player"
return if(currentPlayer == castPlayer) PLAYER_CAST else PLAYER_EXO
}
@SuppressLint("HardwareIds")
fun getDeviceInfo(): DeviceInfo {
/* EXAMPLE
manufacturer: Google
@@ -762,11 +947,11 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
sdkVersion: 32
appVersion: 0.9.46-beta
*/
return DeviceInfo(Build.MANUFACTURER, Build.MODEL, Build.BRAND, Build.VERSION.SDK_INT, BuildConfig.VERSION_NAME)
val deviceId = Settings.Secure.getString(ctx.contentResolver, Settings.Secure.ANDROID_ID)
return DeviceInfo(deviceId, Build.MANUFACTURER, Build.MODEL, Build.VERSION.SDK_INT, BuildConfig.VERSION_NAME)
}
@get:JsonIgnore
val deviceSettings get() = DeviceManager.deviceData.deviceSettings ?: DeviceSettings.default()
private val deviceSettings get() = DeviceManager.deviceData.deviceSettings ?: DeviceSettings.default()
fun getPlayItemRequestPayload(forceTranscode:Boolean):PlayItemRequestPayload {
return PlayItemRequestPayload(getMediaPlayer(), !forceTranscode, forceTranscode, getDeviceInfo())
@@ -787,12 +972,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
//
// 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("com.audiobookshelf.app", ANDROID_AUTO_PKG_NAME, ANDROID_AUTO_SIMULATOR_PKG_NAME, ANDROID_WEARABLE_PKG_NAME, ANDROID_GSEARCH_PKG_NAME, ANDROID_AUTOMOTIVE_PKG_NAME)
private val VALID_MEDIA_BROWSERS = mutableListOf("com.audiobookshelf.app", "com.audiobookshelf.app.debug", "com.android.systemui", 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 LIBRARIES_ROOT = "__LIBRARIES__"
@@ -825,13 +1005,16 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
val extras = Bundle()
extras.putBoolean(
MediaConstants.BROWSER_SERVICE_EXTRAS_KEY_SEARCH_SUPPORTED, true)
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)
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)
MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM
)
BrowserRoot(AUTO_MEDIA_ROOT, extras)
}
@@ -842,41 +1025,23 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
result.detach()
if (parentMediaId.startsWith("li_") || parentMediaId.startsWith("local_")) { // Show podcast episodes
Log.d(tag, "Loading podcast episodes")
mediaManager.loadPodcastEpisodeMediaBrowserItems(parentMediaId) {
result.sendResult(it)
}
} else if (::browseTree.isInitialized && browseTree[parentMediaId] == null && mediaManager.getIsLibrary(parentMediaId)) { // Load library items for library
mediaManager.loadLibraryItemsWithAudio(parentMediaId) { libraryItems ->
val children = libraryItems.map { libraryItem ->
if (libraryItem.mediaType == "podcast") { // Podcasts are browseable
val mediaDescription = libraryItem.getMediaDescription(null)
MediaBrowserCompat.MediaItem(mediaDescription, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
} else {
val progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == libraryItem.id }
val description = libraryItem.getMediaDescription(progress)
MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
}
}
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
}
} else if (parentMediaId == DOWNLOADS_ROOT) { // Load downloads
if (parentMediaId == DOWNLOADS_ROOT) { // Load downloads
val localBooks = DeviceManager.dbManager.getLocalLibraryItems("book")
val localPodcasts = DeviceManager.dbManager.getLocalLibraryItems("podcast")
val localBrowseItems:MutableList<MediaBrowserCompat.MediaItem> = mutableListOf()
localBooks.forEach { localLibraryItem ->
val progress = DeviceManager.dbManager.getLocalMediaProgress(localLibraryItem.id)
val description = localLibraryItem.getMediaDescription(progress)
if (localLibraryItem.media.getAudioTracks().isNotEmpty()) {
val progress = DeviceManager.dbManager.getLocalMediaProgress(localLibraryItem.id)
val description = localLibraryItem.getMediaDescription(progress, ctx)
localBrowseItems += MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
localBrowseItems += MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
}
}
localPodcasts.forEach { localLibraryItem ->
val mediaDescription = localLibraryItem.getMediaDescription(null)
val mediaDescription = localLibraryItem.getMediaDescription(null, ctx)
localBrowseItems += MediaBrowserCompat.MediaItem(mediaDescription, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
}
@@ -888,33 +1053,66 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
val progress: MediaProgressWrapper?
val mediaDescription:MediaDescriptionCompat
if (itemInProgress.episode != null) {
if (itemInProgress.isLocal) {
progress = DeviceManager.dbManager.getLocalMediaProgress("${itemInProgress.libraryItemWrapper.id}-${itemInProgress.episode.id}")
} else {
progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == itemInProgress.libraryItemWrapper.id && it.episodeId == itemInProgress.episode.id }
}
mediaDescription = itemInProgress.episode.getMediaDescription(itemInProgress.libraryItemWrapper,progress)
if (itemInProgress.isLocal) {
progress = DeviceManager.dbManager.getLocalMediaProgress("${itemInProgress.libraryItemWrapper.id}-${itemInProgress.episode.id}")
} else {
progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == itemInProgress.libraryItemWrapper.id && it.episodeId == itemInProgress.episode.id }
// to show download icon
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLId(itemInProgress.libraryItemWrapper.id)
localLibraryItem?.let { lli ->
val localEpisode = (lli.media as Podcast).episodes?.find { it.serverEpisodeId == itemInProgress.episode.id }
itemInProgress.episode.localEpisodeId = localEpisode?.id
}
}
mediaDescription = itemInProgress.episode.getMediaDescription(itemInProgress.libraryItemWrapper, progress, ctx)
} else {
if (itemInProgress.isLocal) {
progress = DeviceManager.dbManager.getLocalMediaProgress(itemInProgress.libraryItemWrapper.id)
} else {
progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == itemInProgress.libraryItemWrapper.id }
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLId(itemInProgress.libraryItemWrapper.id)
(itemInProgress.libraryItemWrapper as LibraryItem).localLibraryItemId = localLibraryItem?.id // To show downloaded icon
}
mediaDescription = itemInProgress.libraryItemWrapper.getMediaDescription(progress)
mediaDescription = itemInProgress.libraryItemWrapper.getMediaDescription(progress, ctx)
}
localBrowseItems += MediaBrowserCompat.MediaItem(mediaDescription, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
}
result.sendResult(localBrowseItems)
} else {
} else if (parentMediaId == LIBRARIES_ROOT || parentMediaId == AUTO_MEDIA_ROOT) {
mediaManager.loadAndroidAutoItems {
browseTree = BrowseTree(this, mediaManager.serverItemsInProgress, mediaManager.serverLibraries)
val children = browseTree[parentMediaId]?.map { item ->
Log.d(tag, "Loading Browser Media Item ${item.description.title}")
MediaBrowserCompat.MediaItem(item.description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
MediaBrowserCompat.MediaItem(item.description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
}
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
}
} else if (mediaManager.getIsLibrary(parentMediaId)) { // Load library items for library
Log.d(tag, "Loading items for library $parentMediaId")
mediaManager.loadLibraryItemsWithAudio(parentMediaId) { libraryItems ->
val children = libraryItems.map { libraryItem ->
if (libraryItem.mediaType == "podcast") { // Podcasts are browseable
val mediaDescription = libraryItem.getMediaDescription(null, ctx)
MediaBrowserCompat.MediaItem(mediaDescription, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
} else {
val progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == libraryItem.id }
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLId(libraryItem.id)
libraryItem.localLibraryItemId = localLibraryItem?.id
val description = libraryItem.getMediaDescription(progress, ctx)
MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
}
}
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
}
} else {
Log.d(tag, "Loading podcast episodes for podcast $parentMediaId")
mediaManager.loadPodcastEpisodeMediaBrowserItems(parentMediaId, ctx) {
result.sendResult(it)
}
}
}
@@ -949,7 +1147,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
// Shake sensor used for sleep timer
fun registerSensor() {
if (isShakeSensorRegistered) {
Log.w(tag, "Shake sensor already registered")
Log.i(tag, "Shake sensor already registered")
return
}
shakeSensorUnregisterTask?.cancel()
@@ -969,7 +1167,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
// Unregister shake sensor after wake up expiration
shakeSensorUnregisterTask?.cancel()
shakeSensorUnregisterTask = Timer("ShakeUnregisterTimer", false).schedule(SLEEP_TIMER_WAKE_UP_EXPIRATION) {
Handler(Looper.getMainLooper()).post() {
Handler(Looper.getMainLooper()).post {
Log.d(tag, "wake time expired: Unregistering shake sensor")
mSensorManager!!.unregisterListener(mShakeDetector)
isShakeSensorRegistered = false
@@ -984,10 +1182,11 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
networkCapabilities: NetworkCapabilities
) {
super.onCapabilitiesChanged(network, networkCapabilities)
val unmetered = networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
Log.i(tag, "Network capabilities changed is unmetered = $unmetered")
isUnmeteredNetwork = unmetered
clientEventEmitter?.onNetworkMeteredChanged(unmetered)
isUnmeteredNetwork = networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
hasNetworkConnectivity = networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) && networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
Log.i(tag, "Network capabilities changed. hasNetworkConnectivity=$hasNetworkConnectivity | isUnmeteredNetwork=$isUnmeteredNetwork")
clientEventEmitter?.onNetworkMeteredChanged(isUnmeteredNetwork)
}
}
@@ -1024,5 +1223,73 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
).build()
}
}
inner class SkipForwardCustomActionProvider : CustomActionProvider {
override fun onCustomAction(player: Player, action: String, extras: Bundle?) {
/*
This does not appear to ever get called. Instead, MediaSessionCallback.onCustomAction() is
responsible to reacting to a custom action.
*/
}
override fun getCustomAction(player: Player): PlaybackStateCompat.CustomAction? {
return PlaybackStateCompat.CustomAction.Builder(
CUSTOM_ACTION_SKIP_FORWARD,
getContext().getString(R.string.action_skip_forward),
R.drawable.skip_next_24
).build()
}
}
inner class SkipBackwardCustomActionProvider : CustomActionProvider {
override fun onCustomAction(player: Player, action: String, extras: Bundle?) {
/*
This does not appear to ever get called. Instead, MediaSessionCallback.onCustomAction() is
responsible to reacting to a custom action.
*/
}
override fun getCustomAction(player: Player): PlaybackStateCompat.CustomAction? {
return PlaybackStateCompat.CustomAction.Builder(
CUSTOM_ACTION_SKIP_BACKWARD,
getContext().getString(R.string.action_skip_backward),
R.drawable.skip_previous_24
).build()
}
}
inner class ChangePlaybackSpeedCustomActionProvider : CustomActionProvider {
override fun onCustomAction(player: Player, action: String, extras: Bundle?) {
/*
This does not appear to ever get called. Instead, MediaSessionCallback.onCustomAction() is
responsible to reacting to a custom action.
*/
}
override fun getCustomAction(player: Player): PlaybackStateCompat.CustomAction? {
val playbackRate = mediaManager.getSavedPlaybackRate()
// Rounding values in the event a non preset value (.5, 1, 1.2, 1.5, 2, 3) is selected in the phone app
val drawable: Int = when (playbackRate) {
in 0.5f..0.7f -> R.drawable.ic_play_speed_0_5x
in 0.8f..1.0f -> R.drawable.ic_play_speed_1_0x
in 1.1f..1.3f -> R.drawable.ic_play_speed_1_2x
in 1.4f..1.7f -> R.drawable.ic_play_speed_1_5x
in 1.8f..2.4f -> R.drawable.ic_play_speed_2_0x
in 2.5f..3.0f -> R.drawable.ic_play_speed_3_0x
// anything set above 3 will be show the 3x to save from creating 100 icons
else -> R.drawable.ic_play_speed_3_0x
}
val customActionExtras = Bundle()
customActionExtras.putFloat("speed", playbackRate)
return PlaybackStateCompat.CustomAction.Builder(
CUSTOM_ACTION_CHANGE_SPEED,
getContext().getString(R.string.action_change_speed),
drawable
)
.setExtras(customActionExtras)
.build()
}
}
}
@@ -4,6 +4,7 @@ import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import com.audiobookshelf.app.device.DeviceManager
import kotlin.math.sqrt
class ShakeDetector : SensorEventListener {
@@ -34,7 +35,8 @@ class ShakeDetector : SensorEventListener {
val gZ = z / SensorManager.GRAVITY_EARTH
// gForce will be close to 1 when there is no movement.
val gForce: Float = sqrt(gX * gX + gY * gY + gZ * gZ)
if (gForce > SHAKE_THRESHOLD_GRAVITY) {
val shakeThreshold = DeviceManager.deviceData.deviceSettings?.getShakeThresholdGravity() ?: 1.6f
if (gForce > shakeThreshold) {
val now = System.currentTimeMillis()
// ignore shake events too close to each other (500ms)
if (mShakeTimestamp + SHAKE_SLOP_TIME_MS > now) {
@@ -59,7 +61,7 @@ class ShakeDetector : SensorEventListener {
* from the Google Play Store and run it to see how
* many G's it takes to register a shake
*/
private const val SHAKE_THRESHOLD_GRAVITY = 1.5f // orig 2.7f
// var SHAKE_THRESHOLD_GRAVITY = 1.5f // orig 2.7f
private const val SHAKE_SLOP_TIME_MS = 500
private const val SHAKE_COUNT_RESET_TIME_MS = 3000
}
@@ -1,257 +0,0 @@
package com.audiobookshelf.app.player
import android.content.Context
import android.os.*
import android.util.Log
import com.audiobookshelf.app.device.DeviceManager
import java.util.*
import kotlin.concurrent.schedule
import kotlin.math.roundToInt
const val SLEEP_EXTENSION_TIME = 900000L // 15m
class SleepTimerManager constructor(val playerNotificationService:PlayerNotificationService) {
private val tag = "SleepTimerManager"
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
private fun getCurrentTime():Long {
return playerNotificationService.getCurrentTime()
}
private fun getDuration():Long {
return playerNotificationService.getDuration()
}
private fun getIsPlaying():Boolean {
return playerNotificationService.currentPlayer.isPlaying
}
private fun setVolume(volume:Float) {
playerNotificationService.currentPlayer.volume = volume
}
private fun pause() {
playerNotificationService.currentPlayer.pause()
}
private fun play() {
playerNotificationService.currentPlayer.play()
}
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
return (((sleepTimerEndTime - getCurrentTime()) / 1000).toDouble()).roundToInt()
}
fun setSleepTimer(time: Long, isChapterTime: Boolean) : Boolean {
Log.d(tag, "Setting Sleep Timer for $time is chapter time $isChapterTime")
sleepTimerTask?.cancel()
sleepTimerRunning = false
sleepTimerFinishedAt = 0L
sleepTimerElapsed = 0L
// Register shake sensor
playerNotificationService.registerSensor()
val currentTime = getCurrentTime()
if (isChapterTime) {
if (currentTime > time) {
Log.d(tag, "Invalid sleep timer - current time is already passed chapter time $time")
return false
}
sleepTimerEndTime = time
sleepTimerLength = 0
sleepTimerExtensionTime = SLEEP_EXTENSION_TIME
if (sleepTimerEndTime > getDuration()) {
sleepTimerEndTime = getDuration()
}
} else {
sleepTimerLength = time
sleepTimerEndTime = 0L
sleepTimerExtensionTime = time
if (sleepTimerLength + getCurrentTime() > getDuration()) {
sleepTimerLength = getDuration() - getCurrentTime()
}
}
playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds())
sleepTimerRunning = true
sleepTimerTask = Timer("SleepTimer", false).schedule(0L, 1000L) {
Handler(Looper.getMainLooper()).post() {
if (getIsPlaying()) {
sleepTimerElapsed += 1000L
val sleepTimeSecondsRemaining = getSleepTimerTimeRemainingSeconds()
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.clientEventEmitter?.onSleepTimerEnded(getCurrentTime())
clearSleepTimer()
sleepTimerFinishedAt = System.currentTimeMillis()
} else if (sleepTimeSecondsRemaining <= 30) {
// Start fading out audio
val volume = sleepTimeSecondsRemaining / 30F
Log.d(tag, "SLEEP VOLUME FADE $volume | ${sleepTimeSecondsRemaining}s remaining")
setVolume(volume)
}
}
}
}
return true
}
fun clearSleepTimer() {
sleepTimerTask?.cancel()
sleepTimerTask = null
sleepTimerEndTime = 0
sleepTimerRunning = false
playerNotificationService.unregisterSensor()
}
fun getSleepTimerTime():Long {
return sleepTimerEndTime
}
fun cancelSleepTimer() {
Log.d(tag, "Canceling Sleep Timer")
clearSleepTimer()
playerNotificationService.clientEventEmitter?.onSleepTimerSet(0)
}
// Vibrate when extending sleep timer by shaking
private fun vibrate() {
val context = playerNotificationService.getContext()
val vibrator:Vibrator
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val vibratorManager =
context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
vibrator = vibratorManager.defaultVibrator
} else {
@Suppress("DEPRECATION")
vibrator = context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
}
vibrator.let {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val vibrationEffect = VibrationEffect.createWaveform(longArrayOf(0, 150, 150, 150),-1)
it.vibrate(vibrationEffect)
} else {
@Suppress("DEPRECATION")
it.vibrate(10)
}
}
}
private fun extendSleepTime() {
if (!sleepTimerRunning) return
setVolume(1F)
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() {
if (!sleepTimerRunning) {
if (sleepTimerFinishedAt <= 0L) return
val finishedAtDistance = System.currentTimeMillis() - sleepTimerFinishedAt
if (finishedAtDistance > SLEEP_TIMER_WAKE_UP_EXPIRATION) // 2 minutes
{
Log.d(tag, "Sleep timer finished over 2 mins ago, clearing it")
sleepTimerFinishedAt = 0L
return
}
val newSleepTime = if (sleepTimerExtensionTime >= 0) sleepTimerExtensionTime else SLEEP_EXTENSION_TIME
vibrate()
setSleepTimer(newSleepTime, false)
play()
return
}
// Only extend if within 30 seconds of finishing
val sleepTimeRemaining = getSleepTimerTimeRemainingSeconds()
if (sleepTimeRemaining <= 30) {
vibrate()
extendSleepTime()
}
}
fun handleShake() {
if (sleepTimerRunning || sleepTimerFinishedAt > 0L) {
if (DeviceManager.deviceData.deviceSettings?.disableShakeToResetSleepTimer == true) {
Log.d(tag, "Shake to reset sleep timer is disabled")
return
}
checkShouldExtendSleepTimer()
}
}
fun increaseSleepTime(time: Long) {
Log.d(tag, "Increase Sleep time $time")
if (!sleepTimerRunning) return
if (sleepTimerEndTime == 0L) {
sleepTimerLength += time
if (sleepTimerLength + getCurrentTime() > getDuration()) sleepTimerLength = getDuration() - getCurrentTime()
} else {
val newSleepEndTime = sleepTimerEndTime + time
sleepTimerEndTime = if (newSleepEndTime >= getDuration()) {
getDuration()
} else {
newSleepEndTime
}
}
setVolume(1F)
playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds())
}
fun decreaseSleepTime(time: Long) {
Log.d(tag, "Decrease Sleep time $time")
if (!sleepTimerRunning) return
if (sleepTimerEndTime == 0L) {
sleepTimerLength -= time
if (sleepTimerLength <= 0) sleepTimerLength = 1000L
} else {
val newSleepEndTime = sleepTimerEndTime - time
sleepTimerEndTime = if (newSleepEndTime <= 1000) {
// End sleep timer in 1 second
getCurrentTime() + 1000
} else {
newSleepEndTime
}
}
setVolume(1F)
playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds())
}
}
@@ -6,7 +6,9 @@ import android.util.Log
import com.audiobookshelf.app.MainActivity
import com.audiobookshelf.app.data.*
import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.media.MediaEventManager
import com.audiobookshelf.app.player.CastManager
import com.audiobookshelf.app.player.PlayerListener
import com.audiobookshelf.app.player.PlayerNotificationService
import com.audiobookshelf.app.server.ApiHandler
import com.fasterxml.jackson.core.json.JsonReadFeature
@@ -21,7 +23,7 @@ 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 var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
private lateinit var mainActivity: MainActivity
private lateinit var apiHandler:ApiHandler
@@ -35,7 +37,11 @@ class AbsAudioPlayer : Plugin() {
mainActivity = (activity as MainActivity)
apiHandler = ApiHandler(mainActivity)
initCastManager()
try {
initCastManager()
} catch(e:Exception) {
Log.e(tag, "initCastManager exception ${e.printStackTrace()}")
}
val foregroundServiceReady : () -> Unit = {
playerNotificationService = mainActivity.foregroundService
@@ -61,8 +67,11 @@ class AbsAudioPlayer : Plugin() {
emit("onSleepTimerEnded", currentPosition)
}
override fun onSleepTimerSet(sleepTimeRemaining: Int) {
emit("onSleepTimerSet", sleepTimeRemaining)
override fun onSleepTimerSet(sleepTimeRemaining: Int, isAutoSleepTimer:Boolean) {
val ret = JSObject()
ret.put("value", sleepTimeRemaining)
ret.put("isAuto", isAutoSleepTimer)
notifyListeners("onSleepTimerSet", ret)
}
override fun onLocalMediaProgressUpdate(localMediaProgress: LocalMediaProgress) {
@@ -88,7 +97,17 @@ class AbsAudioPlayer : Plugin() {
override fun onNetworkMeteredChanged(isUnmetered:Boolean) {
emit("onNetworkMeteredChanged", isUnmetered)
}
override fun onMediaItemHistoryUpdated(mediaItemHistory:MediaItemHistory) {
notifyListeners("onMediaItemHistoryUpdated", JSObject(jacksonMapper.writeValueAsString(mediaItemHistory)))
}
override fun onPlaybackSpeedChanged(playbackSpeed:Float) {
emit("onPlaybackSpeedChanged", playbackSpeed)
}
})
MediaEventManager.clientEventEmitter = playerNotificationService.clientEventEmitter
}
mainActivity.pluginCallback = foregroundServiceReady
}
@@ -160,6 +179,8 @@ class AbsAudioPlayer : Plugin() {
val episodeId = call.getString("episodeId", "").toString()
val playWhenReady = call.getBoolean("playWhenReady") == true
val playbackRate = call.getFloat("playbackRate",1f) ?: 1f
val startTimeOverride = call.getDouble("startTime")
Log.d(tag, "prepareLibraryItem lid=$libraryItemId, startTimeOverride=$startTimeOverride, playbackRate=$playbackRate")
if (libraryItemId.isEmpty()) {
Log.e(tag, "Invalid call to play library item no library item id")
@@ -180,11 +201,17 @@ class AbsAudioPlayer : Plugin() {
Handler(Looper.getMainLooper()).post {
Log.d(tag, "prepareLibraryItem: Preparing Local Media item ${jacksonMapper.writeValueAsString(it)}")
val playbackSession = it.getPlaybackSession(episode)
val playbackSession = it.getPlaybackSession(episode, playerNotificationService.getDeviceInfo())
if (startTimeOverride != null) {
Log.d(tag, "prepareLibraryItem: Using start time override $startTimeOverride")
playbackSession.currentTime = startTimeOverride
}
if (playerNotificationService.mediaProgressSyncer.listeningTimerRunning) { // If progress syncing then first stop before preparing next
playerNotificationService.mediaProgressSyncer.stop {
Log.d(tag, "Media progress syncer was already syncing - stopped")
PlayerListener.lazyIsPlaying = false
Handler(Looper.getMainLooper()).post { // TODO: This was needed again which is probably a design a flaw
playerNotificationService.preparePlayer(
playbackSession,
@@ -194,6 +221,7 @@ class AbsAudioPlayer : Plugin() {
}
}
} else {
playerNotificationService.mediaProgressSyncer.reset()
playerNotificationService.preparePlayer(playbackSession, playWhenReady, playbackRate)
}
}
@@ -201,28 +229,25 @@ class AbsAudioPlayer : Plugin() {
}
} else { // Play library item from server
val playItemRequestPayload = playerNotificationService.getPlayItemRequestPayload(false)
apiHandler.playLibraryItem(libraryItemId, episodeId, playItemRequestPayload) {
if (it == null) {
call.resolve(JSObject("{\"error\":\"Server play request failed\"}"))
} else {
Handler(Looper.getMainLooper()).post {
Log.d(tag, "Preparing Player playback session ${jacksonMapper.writeValueAsString(it)}")
if (playerNotificationService.mediaProgressSyncer.listeningTimerRunning) { // If progress syncing then first stop before preparing next
playerNotificationService.mediaProgressSyncer.stop {
Log.d(tag, "Media progress syncer was already syncing - stopped")
Handler(Looper.getMainLooper()).post { // TODO: This was needed again which is probably a design a flaw
playerNotificationService.preparePlayer(it, playWhenReady, playbackRate)
}
}
Handler(Looper.getMainLooper()).post {
playerNotificationService.mediaProgressSyncer.stop {
apiHandler.playLibraryItem(libraryItemId, episodeId, playItemRequestPayload) {
if (it == null) {
call.resolve(JSObject("{\"error\":\"Server play request failed\"}"))
} else {
playerNotificationService.preparePlayer(it, playWhenReady, playbackRate)
if (startTimeOverride != null) {
Log.d(tag, "prepareLibraryItem: Using start time override $startTimeOverride")
it.currentTime = startTimeOverride
}
Handler(Looper.getMainLooper()).post {
Log.d(tag, "Preparing Player playback session ${jacksonMapper.writeValueAsString(it)}")
PlayerListener.lazyIsPlaying = false
playerNotificationService.preparePlayer(it, playWhenReady, playbackRate)
}
call.resolve(JSObject(jacksonMapper.writeValueAsString(it)))
}
}
call.resolve(JSObject(jacksonMapper.writeValueAsString(it)))
}
}
}
@@ -267,6 +292,7 @@ class AbsAudioPlayer : Plugin() {
@PluginMethod
fun seek(call: PluginCall) {
val time:Int = call.getInt("value", 0) ?: 0 // Value in seconds
Log.d(tag, "seek action to $time")
Handler(Looper.getMainLooper()).post {
playerNotificationService.seekPlayer(time * 1000L) // convert to ms
call.resolve()
@@ -315,7 +341,8 @@ class AbsAudioPlayer : Plugin() {
val isChapterTime:Boolean = call.getBoolean("isChapterTime", false) == true
Handler(Looper.getMainLooper()).post {
val success:Boolean = playerNotificationService.sleepTimerManager.setSleepTimer(time, isChapterTime)
val playbackSession: PlaybackSession? = playerNotificationService.mediaProgressSyncer.currentPlaybackSession ?: playerNotificationService.currentPlaybackSession
val success:Boolean = playerNotificationService.sleepTimerManager.setManualSleepTimer(playbackSession?.id ?: "", time, isChapterTime)
val ret = JSObject()
ret.put("success", success)
call.resolve(ret)
@@ -356,7 +383,9 @@ class AbsAudioPlayer : Plugin() {
@PluginMethod
fun cancelSleepTimer(call: PluginCall) {
playerNotificationService.sleepTimerManager.cancelSleepTimer()
Handler(Looper.getMainLooper()).post {
playerNotificationService.sleepTimerManager.cancelSleepTimer()
}
call.resolve()
}
@@ -1,8 +1,12 @@
package com.audiobookshelf.app.data
package com.audiobookshelf.app.plugins
import android.os.Handler
import android.os.Looper
import android.util.Log
import com.audiobookshelf.app.MainActivity
import com.audiobookshelf.app.data.*
import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.media.MediaEventManager
import com.audiobookshelf.app.server.ApiHandler
import com.fasterxml.jackson.core.json.JsonReadFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
@@ -12,12 +16,11 @@ 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())
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
lateinit var mainActivity: MainActivity
lateinit var apiHandler: ApiHandler
@@ -189,6 +192,24 @@ class AbsDatabase : Plugin() {
}
}
@PluginMethod
fun getLocalMediaProgressForServerItem(call:PluginCall) {
val libraryItemId = call.getString("libraryItemId", "").toString()
var episodeId:String? = call.getString("episodeId", "").toString()
if (episodeId == "") episodeId = null
GlobalScope.launch(Dispatchers.IO) {
val allLocalMediaProgress = DeviceManager.dbManager.getAllLocalMediaProgress()
val localMediaProgress = allLocalMediaProgress.find { libraryItemId == it.libraryItemId && (episodeId == null || it.episodeId == episodeId) }
if (localMediaProgress == null) {
call.resolve()
} else {
call.resolve(JSObject(jacksonMapper.writeValueAsString(localMediaProgress)))
}
}
}
@PluginMethod
fun removeLocalMediaProgress(call:PluginCall) {
val localMediaProgressId = call.getString("localMediaProgressId", "").toString()
@@ -197,13 +218,31 @@ class AbsDatabase : Plugin() {
}
@PluginMethod
fun syncLocalMediaProgressWithServer(call:PluginCall) {
fun syncLocalSessionsWithServer(call:PluginCall) {
if (DeviceManager.serverConnectionConfig == null) {
Log.e(tag, "syncLocalMediaProgressWithServer not connected to server")
Log.e(tag, "syncLocalSessionsWithServer not connected to server")
return call.resolve()
}
apiHandler.syncMediaProgress {
call.resolve(JSObject(jacksonMapper.writeValueAsString(it)))
apiHandler.syncLocalMediaProgressForUser {
Log.d(tag, "Finished syncing local media progress for user")
val savedSessions = DeviceManager.dbManager.getPlaybackSessions().filter { it.serverConnectionConfigId == DeviceManager.serverConnectionConfigId }
if (savedSessions.isNotEmpty()) {
apiHandler.sendSyncLocalSessions(savedSessions) { success, errorMsg ->
if (!success) {
call.resolve(JSObject("{\"error\":\"$errorMsg\"}"))
} else {
// Remove all local sessions
savedSessions.forEach {
DeviceManager.dbManager.removePlaybackSession(it.id)
}
call.resolve()
}
}
} else {
call.resolve()
}
}
}
@@ -234,6 +273,8 @@ class AbsDatabase : Plugin() {
progress = mediaProgress.progress,
currentTime = mediaProgress.currentTime,
isFinished = mediaProgress.isFinished,
ebookLocation = mediaProgress.ebookLocation,
ebookProgress = mediaProgress.ebookProgress,
lastUpdate = mediaProgress.lastUpdate,
startedAt = mediaProgress.startedAt,
finishedAt = mediaProgress.finishedAt,
@@ -257,6 +298,8 @@ class AbsDatabase : Plugin() {
Log.w(tag, "syncServerMediaProgressWithLocalMediaProgress Local media progress not found $localMediaProgressId")
call.resolve()
} else {
MediaEventManager.syncEvent(mediaProgress, "Received from webhook event")
localMediaProgress.updateFromServerMediaProgress(mediaProgress)
DeviceManager.dbManager.saveLocalMediaProgress(localMediaProgress)
call.resolve(JSObject(jacksonMapper.writeValueAsString(localMediaProgress)))
@@ -287,8 +330,8 @@ class AbsDatabase : Plugin() {
return call.resolve(JSObject("{\"error\":\"Invalid library item not a podcast\"}"))
}
var duration = 0.0
var podcastEpisode:PodcastEpisode? = null
val duration: Double
var podcastEpisode: PodcastEpisode? = null
if (!localEpisodeId.isNullOrEmpty()) {
val podcast = localLibraryItem.media as Podcast
podcastEpisode = podcast.episodes?.find { episode ->
@@ -312,6 +355,8 @@ class AbsDatabase : Plugin() {
progress = if (isFinished) 1.0 else 0.0,
currentTime = 0.0,
isFinished = isFinished,
ebookLocation = null,
ebookProgress = null,
lastUpdate = currentTime,
startedAt = if (isFinished) currentTime else 0L,
finishedAt = if (isFinished) currentTime else null,
@@ -356,6 +401,55 @@ class AbsDatabase : Plugin() {
}
}
@PluginMethod
fun updateLocalEbookProgress(call:PluginCall) {
val localLibraryItemId = call.getString("localLibraryItemId", "").toString()
val ebookLocation = call.getString("ebookLocation", "").toString()
val ebookProgress = call.getDouble("ebookProgress") ?: 0.0
val localMediaProgressId = localLibraryItemId
var localMediaProgress = DeviceManager.dbManager.getLocalMediaProgress(localMediaProgressId)
if (localMediaProgress == null) {
Log.d(tag, "updateLocalEbookProgress Local Media Progress not found $localMediaProgressId - Creating new")
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
?: return call.resolve(JSObject("{\"error\":\"Library Item not found\"}"))
val book = localLibraryItem.media as Book
localMediaProgress = LocalMediaProgress(
id = localMediaProgressId,
localLibraryItemId = localLibraryItemId,
localEpisodeId = null,
duration = book.duration ?: 0.0,
progress = 0.0,
currentTime = 0.0,
isFinished = false,
ebookLocation = ebookLocation,
ebookProgress = ebookProgress,
lastUpdate = System.currentTimeMillis(),
startedAt = 0L,
finishedAt = null,
serverConnectionConfigId = localLibraryItem.serverConnectionConfigId,
serverAddress = localLibraryItem.serverAddress,
serverUserId = localLibraryItem.serverUserId,
libraryItemId = localLibraryItem.libraryItemId,
episodeId = null)
} else {
localMediaProgress.updateEbookProgress(ebookLocation, ebookProgress)
}
// Save local media progress locally
DeviceManager.dbManager.saveLocalMediaProgress(localMediaProgress)
val lmpstring = jacksonMapper.writeValueAsString(localMediaProgress)
Log.d(tag, "updateLocalEbookProgress: Local Media Progress String $lmpstring")
val jsobj = JSObject()
jsobj.put("localMediaProgress", JSObject(lmpstring))
call.resolve(jsobj)
}
@PluginMethod
fun updateLocalTrackOrder(call:PluginCall) {
val localLibraryItemId = call.getString("localLibraryItemId", "") ?: ""
@@ -401,10 +495,32 @@ class AbsDatabase : Plugin() {
fun updateDeviceSettings(call:PluginCall) { // Returns device data
Log.d(tag, "updateDeviceSettings ${call.data}")
val newDeviceSettings = jacksonMapper.readValue<DeviceSettings>(call.data.toString())
GlobalScope.launch(Dispatchers.IO) {
Handler(Looper.getMainLooper()).post {
DeviceManager.deviceData.deviceSettings = newDeviceSettings
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
// Updates playback actions for media notification (handles media control seek locking setting)
if (mainActivity.isPlayerNotificationServiceInitialized()) {
mainActivity.foregroundService.setMediaSessionConnectorPlaybackActions()
}
call.resolve(JSObject(jacksonMapper.writeValueAsString(DeviceManager.deviceData)))
}
}
@PluginMethod
fun getMediaItemHistory(call:PluginCall) { // Returns device data
Log.d(tag, "getMediaItemHistory ${call.data}")
val mediaId = call.getString("mediaId") ?: ""
GlobalScope.launch(Dispatchers.IO) {
val mediaItemHistory = DeviceManager.dbManager.getMediaItemHistory(mediaId)
if (mediaItemHistory == null) {
call.resolve()
} else {
call.resolve(JSObject(jacksonMapper.writeValueAsString(mediaItemHistory)))
}
}
}
}
@@ -2,20 +2,16 @@ 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.models.DownloadItem
import com.audiobookshelf.app.models.DownloadItemPart
import com.audiobookshelf.app.server.ApiHandler
import com.fasterxml.jackson.annotation.JsonIgnore
import com.audiobookshelf.app.managers.DownloadItemManager
import com.fasterxml.jackson.core.json.JsonReadFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.getcapacitor.JSObject
@@ -23,109 +19,37 @@ 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())
private 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
lateinit var downloadItemManager: DownloadItemManager
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
)
}
private val clientEventEmitter = (object : DownloadItemManager.DownloadEventEmitter {
override fun onDownloadItem(downloadItem:DownloadItem) {
notifyListeners("onDownloadItem", JSObject(jacksonMapper.writeValueAsString(downloadItem)))
}
@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
override fun onDownloadItemPartUpdate(downloadItemPart:DownloadItemPart) {
notifyListeners("onDownloadItemPartUpdate", JSObject(jacksonMapper.writeValueAsString(downloadItemPart)))
}
}
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 onDownloadItemComplete(jsobj:JSObject) {
notifyListeners("onItemDownloadComplete", jsobj)
}
})
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}")
downloadItemManager = DownloadItemManager(downloadManager, folderScanner, mainActivity, clientEventEmitter)
}
@PluginMethod
@@ -133,11 +57,11 @@ class AbsDownloader : Plugin() {
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()
var 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) {
if (downloadItemManager.downloadItemQueue.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\"}"))
}
@@ -148,9 +72,18 @@ class AbsDownloader : Plugin() {
} else {
Log.d(tag, "Got library item from server ${libraryItem.id}")
val localFolder = DeviceManager.dbManager.getLocalFolder(localFolderId)
if (localFolder != null) {
if (localFolderId == "") {
localFolderId = "internal-${libraryItem.mediaType}"
}
var localFolder = DeviceManager.dbManager.getLocalFolder(localFolderId)
if (localFolder == null && localFolderId.startsWith("internal-")) {
Log.d(tag, "Creating new App Storage internal LocalFolder $localFolderId")
localFolder = LocalFolder(localFolderId, "Internal App Storage", "", "", "", "", "internal", libraryItem.mediaType)
DeviceManager.dbManager.saveLocalFolder(localFolder)
}
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\"}"))
@@ -176,12 +109,6 @@ class AbsDownloader : Plugin() {
}
}
// 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 {
var cleanedRelPath = relPath.replace("\\", "_").replace("/", "_")
@@ -203,76 +130,116 @@ class AbsDownloader : Plugin() {
}
private fun startLibraryItemDownload(libraryItem: LibraryItem, localFolder: LocalFolder, episode:PodcastEpisode?) {
val tempFolderPath = mainActivity.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
val isInternal = localFolder.id.startsWith("internal-")
val tempFolderPath = if (isInternal) "${mainActivity.filesDir}/downloads/${libraryItem.id}" else mainActivity.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
Log.d(tag, "downloadCacheDirectory=$tempFolderPath")
if (libraryItem.mediaType == "book") {
val bookTitle = cleanStringForFileSystem(libraryItem.media.metadata.title)
val bookAuthor = cleanStringForFileSystem(libraryItem.media.metadata.getAuthorDisplayName())
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())
val itemSubfolder = "$bookAuthor/$bookTitle"
val itemFolderPath = if (isInternal) "$tempFolderPath" else "${localFolder.absolutePath}/$itemSubfolder"
val downloadItem = DownloadItem(libraryItem.id, libraryItem.id, null, libraryItem.userMediaProgress,DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, bookTitle, itemSubfolder, libraryItem.media, mutableListOf())
val book = libraryItem.media as Book
book.ebookFile?.let { ebookFile ->
val fileSize = ebookFile.metadata?.size ?: 0
val serverPath = "/api/items/${libraryItem.id}/file/${ebookFile.ino}/download"
val destinationFilename = getFilenameFromRelPath(ebookFile.metadata?.relPath ?: "")
val finalDestinationFile = File("$itemFolderPath/$destinationFilename")
val destinationFile = File("$tempFolderPath/$destinationFilename")
if (destinationFile.exists()) {
Log.d(tag, "TEMP ebook file already exists, removing it from ${destinationFile.absolutePath}")
destinationFile.delete()
}
if (finalDestinationFile.exists()) {
Log.d(tag, "ebook file already exists, removing it from ${finalDestinationFile.absolutePath}")
finalDestinationFile.delete()
}
val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, fileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,ebookFile,null,null)
downloadItem.downloadItemParts.add(downloadItemPart)
}
// Create download item part for each audio track
val audioFiles = (libraryItem.media as Book).audioFiles ?: mutableListOf()
tracks.forEach { audioTrack ->
val serverPath = "/s/item/${libraryItem.id}/${cleanRelPath(audioTrack.relPath)}"
val fileSize = audioTrack.metadata?.size ?: 0
// TODO: Currently file ino is only stored on AudioFile. This should be updated server side to be in FileMetadata or on the AudioTrack
val audioFileIno = audioFiles.find { it.metadata.path == audioTrack.metadata?.path }?.ino
val serverPath = "/api/items/${libraryItem.id}/file/${audioFileIno}/download"
val destinationFilename = getFilenameFromRelPath(audioTrack.relPath)
Log.d(tag, "Audio File Server Path $serverPath | AF RelPath ${audioTrack.relPath} | LocalFolder Path ${localFolder.absolutePath} | DestName ${destinationFilename}")
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 (destinationFile.exists()) {
Log.d(tag, "TEMP Audio file already exists, removing it from ${destinationFile.absolutePath}")
destinationFile.delete()
}
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)
val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, fileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,null,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 coverLibraryFile = libraryItem.libraryFiles?.find { it.metadata.path == libraryItem.media.coverPath }
val coverFileSize = coverLibraryFile?.metadata?.size ?: 0
val serverPath = "/api/items/${libraryItem.id}/cover"
val destinationFilename = "cover.jpg"
val destinationFilename = "cover-${libraryItem.id}.jpg"
val destinationFile = File("$tempFolderPath/$destinationFilename")
val finalDestinationFile = File("$itemFolderPath/$destinationFilename")
if (destinationFile.exists()) {
Log.d(tag, "TEMP Audio file already exists, removing it from ${destinationFile.absolutePath}")
destinationFile.delete()
}
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)
val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, coverFileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,null,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)
downloadItemManager.addDownloadItem(downloadItem)
}
} else {
// Podcast episode download
val podcastTitle = cleanStringForFileSystem(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())
val audioFileIno = episode?.audioFile?.ino
val fileSize = audioTrack?.metadata?.size ?: 0
var serverPath = "/s/item/${libraryItem.id}/${cleanRelPath(audioTrack?.relPath ?: "")}"
Log.d(tag, "Starting podcast episode download")
val itemFolderPath = if (isInternal) "$tempFolderPath" else "${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, podcastTitle, libraryItem.media, mutableListOf())
var serverPath = "/api/items/${libraryItem.id}/file/${audioFileIno}/download"
var destinationFilename = getFilenameFromRelPath(audioTrack?.relPath ?: "")
Log.d(tag, "Audio File Server Path $serverPath | AF RelPath ${audioTrack?.relPath} | LocalFolder Path ${localFolder.absolutePath} | DestName ${destinationFilename}")
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")
@@ -281,14 +248,13 @@ class AbsDownloader : Plugin() {
finalDestinationFile.delete()
}
var downloadItemPart = DownloadItemPart.make(destinationFilename,destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,audioTrack,episode)
var downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,fileSize, destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,null,audioTrack,episode)
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) {
val coverLibraryFile = libraryItem.libraryFiles?.find { it.metadata.path == libraryItem.media.coverPath }
val coverFileSize = coverLibraryFile?.metadata?.size ?: 0
serverPath = "/api/items/${libraryItem.id}/cover"
destinationFilename = "cover.jpg"
@@ -298,134 +264,12 @@ class AbsDownloader : Plugin() {
if (finalDestinationFile.exists()) {
Log.d(tag, "Podcast cover already exists - not downloading cover again")
} else {
downloadItemPart = DownloadItemPart.make(destinationFilename,destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,null,null)
downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,coverFileSize,destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,null,null,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)
}
// Remove download notifications
downloadItem.downloadItemParts.forEach { downloadItemPart ->
downloadItemPart.downloadId?.let {
downloadManager.remove(it)
}
}
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))
if (localFolderFile == null) {
Log.e(tag, "Local Folder File from uri is null")
} else {
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)
}
}
}
downloadItemManager.addDownloadItem(downloadItem)
}
}
}
@@ -1,7 +1,6 @@
package com.audiobookshelf.app.plugins
import android.app.AlertDialog
import android.database.Cursor
import android.net.Uri
import android.os.Build
import android.util.Log
@@ -13,22 +12,18 @@ 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
import java.io.File
@CapacitorPlugin(name = "AbsFileSystem")
class AbsFileSystem : Plugin() {
private val TAG = "AbsFileSystem"
private val tag = "AbsFileSystem"
var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
lateinit var mainActivity: MainActivity
@@ -85,21 +80,28 @@ class AbsFileSystem : Plugin() {
call.resolve(JSObject(jacksonMapper.writeValueAsString(localFolder)))
}
override fun onStorageAccessDenied(requestCode: Int, folder: DocumentFile?, storageType: StorageType) {
override fun onStorageAccessDenied(
requestCode: Int,
folder: DocumentFile?,
storageType: StorageType,
storageId: String
) {
Log.e(tag, "Storage Access Denied ${folder?.getAbsolutePath(mainActivity)}")
val jsobj = JSObject()
if (requestCode == REQUEST_CODE_SELECT_FOLDER) {
val builder: AlertDialog.Builder = AlertDialog.Builder(mainActivity)
builder.setMessage(
"You have no write access to this storage, thus selecting this folder is useless." +
"\nWould you like to grant access to this folder?")
"\nWould you like to grant access to this folder?")
builder.setNegativeButton("Dont Allow") { _, _ ->
run {
jsobj.put("error", "User Canceled, Access Denied")
call.resolve(jsobj)
}
}
builder.setPositiveButton("Allow.") { _, _ -> mainActivity.storageHelper.requestStorageAccess(REQUEST_CODE_SDCARD_ACCESS, storageType) }
builder.setPositiveButton("Allow.") { _, _ -> mainActivity.storageHelper.requestStorageAccess(REQUEST_CODE_SDCARD_ACCESS, initialPath = FileFullPath(mainActivity, storageId, "")) }
builder.show()
} else {
Log.d(TAG, "STORAGE ACCESS DENIED $requestCode")
@@ -131,7 +133,7 @@ class AbsFileSystem : Plugin() {
@PluginMethod
fun checkStoragePermission(call: PluginCall) {
var res: Boolean
val 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")
@@ -140,87 +142,91 @@ class AbsFileSystem : Plugin() {
res = true
}
var jsobj = JSObject()
val 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")
val folderUrl = call.data.getString("folderUrl", "").toString()
Log.d(TAG, "Check Folder Permissions for $folderUrl")
var hasAccess = SimpleStorage.hasStorageAccess(context,folderUrl,true)
val hasAccess = SimpleStorage.hasStorageAccess(context,folderUrl,true)
var jsobj = JSObject()
val 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())
fun getSDKVersion(call: PluginCall) {
val jsObject = JSObject()
jsObject.put("version", Build.VERSION.SDK_INT)
call.resolve(jsObject)
}
@PluginMethod
fun removeFolder(call: PluginCall) {
var folderId = call.data.getString("folderId", "").toString()
val folderId = call.data.getString("folderId", "").toString()
DeviceManager.dbManager.removeLocalFolder(folderId)
call.resolve()
}
@PluginMethod
fun removeLocalLibraryItem(call: PluginCall) {
var localLibraryItemId = call.data.getString("localLibraryItemId", "").toString()
val 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()
val localLibraryItemId = call.data.getString("id", "").toString()
val absolutePath = call.data.getString("absolutePath", "").toString()
val 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
// Check if should delete subfolder
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
val success: Boolean
// If internal library item use File to delete
if (localLibraryItem?.folderId?.startsWith("internal-") == true) {
Log.d(tag, "Deleting internal library item at absolutePath $absolutePath")
val file = File(absolutePath)
success = file.deleteRecursively()
} else {
var subfolderPathToDelete = ""
localLibraryItem?.folderId?.let { folderId ->
val folder = DeviceManager.dbManager.getLocalFolder(folderId)
folder?.absolutePath?.let { folderPath ->
val splitAbsolutePath = absolutePath.split("/")
val fullSubDir = splitAbsolutePath.subList(0, splitAbsolutePath.size - 1).joinToString("/")
if (fullSubDir != folderPath) {
val subdirHasAnItem = DeviceManager.dbManager.getLocalLibraryItems().any { _localLibraryItem ->
if (_localLibraryItem.id == localLibraryItemId) {
false
} else {
_localLibraryItem.absolutePath.startsWith(fullSubDir)
}
}
subfolderPathToDelete = if (subdirHasAnItem) "" else fullSubDir
}
}
}
val docfile = DocumentFileCompat.fromUri(mainActivity, Uri.parse(contentUrl))
success = docfile?.delete() == true
if (subfolderPathToDelete != "") {
Log.d(tag, "Deleting empty subfolder at $subfolderPathToDelete")
val docfilesub = DocumentFileCompat.fromFullPath(mainActivity, subfolderPathToDelete)
docfilesub?.delete()
}
}
if (success) {
DeviceManager.dbManager.removeLocalLibraryItem(localLibraryItemId)
}
@@ -229,19 +235,19 @@ class AbsFileSystem : Plugin() {
@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()
val localLibraryItemId = call.data.getString("id", "").toString()
val trackLocalFileId = call.data.getString("trackLocalFileId", "").toString()
val contentUrl = call.data.getString("trackContentUrl", "").toString()
Log.d(tag, "deleteTrackFromItem $contentUrl")
var localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
val 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
val docfile = DocumentFileCompat.fromUri(mainActivity, Uri.parse(contentUrl))
val success = docfile?.delete() == true
if (success) {
localLibraryItem.media.removeAudioTrack(trackLocalFileId)
localLibraryItem.removeLocalFile(trackLocalFileId)
@@ -1,12 +1,17 @@
package com.audiobookshelf.app.server
import android.annotation.SuppressLint
import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.os.Build
import android.provider.Settings
import android.util.Log
import com.audiobookshelf.app.data.*
import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.player.MediaProgressSyncData
import com.audiobookshelf.app.media.MediaEventManager
import com.audiobookshelf.app.media.MediaProgressSyncData
import com.audiobookshelf.app.media.SyncResult
import com.audiobookshelf.app.models.User
import com.audiobookshelf.app.BuildConfig
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.core.json.JsonReadFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
@@ -16,6 +21,7 @@ import com.getcapacitor.JSObject
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.internal.EMPTY_REQUEST
import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject
@@ -27,14 +33,14 @@ class ApiHandler(var ctx:Context) {
private var defaultClient = OkHttpClient()
private var pingClient = OkHttpClient.Builder().callTimeout(3, TimeUnit.SECONDS).build()
var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
data class LocalMediaProgressSyncPayload(val localMediaProgress:List<LocalMediaProgress>)
data class LocalSessionsSyncRequestPayload(val sessions:List<PlaybackSession>, val deviceInfo:DeviceInfo)
@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)
data class LocalSessionSyncResult(val id:String, val success:Boolean, val progressSynced:Boolean?, val error:String?)
data class LocalSessionsSyncResponsePayload(val results:List<LocalSessionSyncResult>)
fun getRequest(endpoint:String, httpClient:OkHttpClient?, config:ServerConnectionConfig?, cb: (JSObject) -> Unit) {
private fun getRequest(endpoint:String, httpClient:OkHttpClient?, config:ServerConnectionConfig?, cb: (JSObject) -> Unit) {
val address = config?.address ?: DeviceManager.serverAddress
val token = config?.token ?: DeviceManager.token
@@ -44,18 +50,20 @@ class ApiHandler(var ctx:Context) {
makeRequest(request, httpClient, cb)
}
fun postRequest(endpoint:String, payload: JSObject, config:ServerConnectionConfig?, cb: (JSObject) -> Unit) {
private fun postRequest(endpoint:String, payload: JSObject?, config:ServerConnectionConfig?, cb: (JSObject) -> Unit) {
val address = config?.address ?: DeviceManager.serverAddress
val token = config?.token ?: DeviceManager.token
val mediaType = "application/json; charset=utf-8".toMediaType()
val requestBody = payload.toString().toRequestBody(mediaType)
val requestBody = payload?.toString()?.toRequestBody(mediaType) ?: EMPTY_REQUEST
val requestUrl = "${address}$endpoint"
Log.d(tag, "postRequest to $requestUrl")
val request = Request.Builder().post(requestBody)
.url("${address}$endpoint").addHeader("Authorization", "Bearer ${token}")
.url(requestUrl).addHeader("Authorization", "Bearer ${token}")
.build()
makeRequest(request, null, cb)
}
fun patchRequest(endpoint:String, payload: JSObject, cb: (JSObject) -> Unit) {
private 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)
@@ -64,31 +72,7 @@ class ApiHandler(var ctx:Context) {
makeRequest(request, null, 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 isUsingCellularData(): Boolean {
val connectivityManager = ctx.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val capabilities = connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork)
return capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == true
}
fun makeRequest(request:Request, httpClient:OkHttpClient?, cb: (JSObject) -> Unit) {
private fun makeRequest(request:Request, httpClient:OkHttpClient?, cb: (JSObject) -> Unit) {
val client = httpClient ?: defaultClient
client.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
@@ -134,16 +118,32 @@ class ApiHandler(var ctx:Context) {
})
}
fun getCurrentUser(cb: (User?) -> Unit) {
getRequest("/api/me", null, null) {
if (it.has("error")) {
Log.e(tag, it.getString("error") ?: "getCurrentUser Failed")
cb(null)
} else {
val user = jacksonMapper.readValue<User>(it.toString())
cb(user)
}
}
}
fun getLibraries(cb: (List<Library>) -> Unit) {
val mapper = jacksonMapper
getRequest("/api/libraries", null,null) {
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)
}
var array = JSONArray()
if (it.has("libraries")) { // TODO: Server 2.2.9 changed to this
array = it.getJSONArray("libraries")
} else if (it.has("value")) {
array = it.getJSONArray("value")
}
for (i in 0 until array.length()) {
libraries.add(mapper.readValue(array.get(i).toString()))
}
cb(libraries)
}
@@ -222,76 +222,30 @@ class ApiHandler(var ctx:Context) {
}
}
fun sendProgressSync(sessionId:String, syncData: MediaProgressSyncData, cb: (Boolean) -> Unit) {
fun sendProgressSync(sessionId:String, syncData: MediaProgressSyncData, cb: (Boolean, String?) -> Unit) {
val payload = JSObject(jacksonMapper.writeValueAsString(syncData))
postRequest("/api/session/$sessionId/sync", payload, null) {
if (!it.getString("error").isNullOrEmpty()) {
cb(false)
cb(false, it.getString("error"))
} else {
cb(true)
cb(true, null)
}
}
}
fun sendLocalProgressSync(playbackSession:PlaybackSession, cb: (Boolean) -> Unit) {
fun sendLocalProgressSync(playbackSession:PlaybackSession, cb: (Boolean, String?) -> Unit) {
val payload = JSObject(jacksonMapper.writeValueAsString(playbackSession))
postRequest("/api/session/local", payload, null) {
if (!it.getString("error").isNullOrEmpty()) {
cb(false)
cb(false, it.getString("error"))
} else {
cb(true)
cb(true, null)
}
}
}
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, null) {
Log.d(tag, "Media Progress Sync payload $payload - response ${it}")
if (it.toString() == "{}") {
Log.e(tag, "Progress sync received empty object")
} else if (it.has("error")) {
Log.e(tag, it.getString("error") ?: "Progress sync error")
} 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"
@@ -317,6 +271,7 @@ class ApiHandler(var ctx:Context) {
}
fun getPlaybackSession(playbackSessionId:String, cb: (PlaybackSession?) -> Unit) {
Log.d(tag, "getPlaybackSession for $playbackSessionId for server ${DeviceManager.serverAddress}")
val endpoint = "/api/session/$playbackSessionId"
getRequest(endpoint, null, null) {
val err = it.getString("error")
@@ -342,6 +297,13 @@ class ApiHandler(var ctx:Context) {
}
}
fun closePlaybackSession(playbackSessionId:String, cb: (Boolean) -> Unit) {
Log.d(tag, "closePlaybackSession: playbackSessionId=$playbackSessionId")
postRequest("/api/session/$playbackSessionId/close", null, null) {
cb(true)
}
}
fun authorize(config:ServerConnectionConfig, cb: (MutableList<MediaProgress>?) -> Unit) {
Log.d(tag, "authorize: Authorizing ${config.address}")
postRequest("/api/authorize", JSObject(), config) {
@@ -362,4 +324,71 @@ class ApiHandler(var ctx:Context) {
}
}
}
fun sendSyncLocalSessions(playbackSessions:List<PlaybackSession>, cb: (Boolean, String?) -> Unit) {
@SuppressLint("HardwareIds")
val deviceId = Settings.Secure.getString(ctx.contentResolver, Settings.Secure.ANDROID_ID)
val deviceInfo = DeviceInfo(deviceId, Build.MANUFACTURER, Build.MODEL, Build.VERSION.SDK_INT, BuildConfig.VERSION_NAME)
val payload = JSObject(jacksonMapper.writeValueAsString(LocalSessionsSyncRequestPayload(playbackSessions, deviceInfo)))
Log.d(tag, "Sending ${playbackSessions.size} saved local playback sessions to server")
postRequest("/api/session/local-all", payload, null) {
if (!it.getString("error").isNullOrEmpty()) {
Log.e(tag, "Failed to sync local sessions")
cb(false, it.getString("error"))
} else {
val response = jacksonMapper.readValue<LocalSessionsSyncResponsePayload>(it.toString())
response.results.forEach { localSessionSyncResult ->
Log.d(tag, "Synced session result ${localSessionSyncResult.id}|${localSessionSyncResult.progressSynced}|${localSessionSyncResult.success}")
playbackSessions.find { ps -> ps.id == localSessionSyncResult.id }?.let { session ->
if (localSessionSyncResult.progressSynced == true) {
val syncResult = SyncResult(true, true, "Progress synced on server")
MediaEventManager.saveEvent(session, syncResult)
Log.i(tag, "Successfully synced session ${session.displayTitle} with server")
} else if (!localSessionSyncResult.success) {
Log.e(tag, "Failed to sync session ${session.displayTitle} with server. Error: ${localSessionSyncResult.error}")
}
}
}
cb(true, null)
}
}
}
fun syncLocalMediaProgressForUser(cb: () -> Unit) {
// Get all local media progress for this server
val allLocalMediaProgress = DeviceManager.dbManager.getAllLocalMediaProgress().filter { it.serverConnectionConfigId == DeviceManager.serverConnectionConfigId }
if (allLocalMediaProgress.isEmpty()) {
Log.d(tag, "No local media progress to sync")
return cb()
}
getCurrentUser { _user ->
_user?.let { user->
// Compare server user progress with local progress
user.mediaProgress.forEach { mediaProgress ->
// Get matching local media progress
allLocalMediaProgress.find { it.isMatch(mediaProgress) }?.let { localMediaProgress ->
if (mediaProgress.lastUpdate > localMediaProgress.lastUpdate) {
Log.d(tag, "Server progress for media item id=\"${mediaProgress.mediaItemId}\" is more recent then local. Updating local current time ${localMediaProgress.currentTime} to ${mediaProgress.currentTime}")
localMediaProgress.updateFromServerMediaProgress(mediaProgress)
MediaEventManager.syncEvent(mediaProgress, "Sync on server connection")
DeviceManager.dbManager.saveLocalMediaProgress(localMediaProgress)
} else if (localMediaProgress.lastUpdate > mediaProgress.lastUpdate && localMediaProgress.ebookLocation != null && localMediaProgress.ebookLocation != mediaProgress.ebookLocation) {
// Patch ebook progress to server
val endpoint = "/api/me/progress/${localMediaProgress.libraryItemId}"
val updatePayload = JSObject()
updatePayload.put("ebookLocation", localMediaProgress.ebookLocation)
updatePayload.put("ebookProgress", localMediaProgress.ebookProgress)
updatePayload.put("lastUpdate", localMediaProgress.lastUpdate)
patchRequest(endpoint,updatePayload) {
Log.d(tag, "syncLocalMediaProgressForUser patched ebook progress")
}
}
}
}
}
cb()
}
}
}
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="272"
android:viewportHeight="181.125"
android:tint="#FFFFFF">
<group android:scaleY="0.6659007"
android:translateY="30.256865">
<group android:translateY="144.77344">
<path android:pathData="M7,-50.96875Q7,-69.375,10.6875,-80.84375Q14.390625,-92.328125,21.671875,-98.15625Q28.953125,-104,40,-104Q48.140625,-104,54.53125,-100.625Q60.9375,-97.265625,64.6875,-90.921875Q68.4375,-84.59375,70.71875,-75.5Q73,-66.40625,73,-50.96875Q73,-32.703125,69.34375,-21.21875Q65.6875,-9.75,58.390625,-3.875Q51.109375,2,40,2Q25.359375,2,17.015625,-8.265625Q7,-21.71875,7,-50.96875ZM20,-51Q20,-25.828125,25.765625,-17.90625Q31.53125,-10,40,-10Q48.46875,-10,54.234375,-17.953125Q60,-25.90625,60,-51Q60,-75.734375,54.359375,-83.859375Q48.71875,-92,39.859375,-92Q31.40625,-92,26.09375,-84.828125Q20,-75.671875,20,-51Z"
android:fillColor="#000000"
android:fillAlpha="0.6"/>
<path android:pathData="M93,0L93,-13L106,-13L106,0L93,0Z"
android:fillColor="#000000"
android:fillAlpha="0.6"/>
<path android:pathData="M127,-27L140,-28Q141.42188,-19.03125,146.32812,-14.515625Q151.25,-10,159.04688,-10Q168.59375,-10,174.29688,-16.53125Q180,-23.0625,180,-33.859375Q180,-44.140625,174.70312,-50.0625Q169.42188,-56,159.5,-56Q153.29688,-56,148.95312,-53.5Q144.625,-51,142,-47L129,-49L140,-102L188,-102L188,-89L150.54688,-89L145.28125,-61.0625Q153.875,-68,163.3125,-68Q175.8125,-68,184.40625,-58.65625Q193,-49.328125,193,-34.65625Q193,-20.671875,185.125,-10.5Q175.54688,2,158.98438,2Q145.40625,2,136.8125,-5.9375Q128.21875,-13.875,127,-27Z"
android:fillColor="#000000"
android:fillAlpha="0.6"/>
<path android:pathData="M201,0L228.85938,-38L203,-75L218.57812,-75L230.82812,-57.484375Q234.23438,-52.609375,236.84375,-48.890625Q239.65625,-52.71875,242.28125,-56.296875L256,-75L271.875,-75L244.59375,-37.796875L271,0L255.42188,0L239.48438,-22.796875L236.60938,-26.90625L216.875,0L201,0Z"
android:fillColor="#000000"
android:fillAlpha="0.6"/>
</group>
</group>
</vector>
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="272"
android:viewportHeight="181.125"
android:tint="#FFFFFF">
<group android:scaleX="1.0434783"
android:scaleY="0.69485295"
android:translateX="-11.826087"
android:translateY="27.63488">
<group android:translateY="144.77344">
<path android:pathData="M54,-0L41,-0L41,-81.03125Q36.484375,-76.65625,29.15625,-72.265625Q21.828125,-67.890625,16,-65.703125L16,-78Q26.546875,-83.015625,34.4375,-90.140625Q42.34375,-97.28125,45.625,-104L54,-104L54,-0Z"
android:fillColor="#000000"
android:fillAlpha="0.8"/>
<path android:pathData="M93,0L93,-13L106,-13L106,0L93,0Z"
android:fillColor="#000000"
android:fillAlpha="0.8"/>
<path android:pathData="M127,-50.96875Q127,-69.375,130.6875,-80.84375Q134.39062,-92.328125,141.67188,-98.15625Q148.95312,-104,160,-104Q168.14062,-104,174.53125,-100.625Q180.9375,-97.265625,184.6875,-90.921875Q188.4375,-84.59375,190.71875,-75.5Q193,-66.40625,193,-50.96875Q193,-32.703125,189.34375,-21.21875Q185.6875,-9.75,178.39062,-3.875Q171.10938,2,160,2Q145.35938,2,137.01562,-8.265625Q127,-21.71875,127,-50.96875ZM140,-51Q140,-25.828125,145.76562,-17.90625Q151.53125,-10,160,-10Q168.46875,-10,174.23438,-17.953125Q180,-25.90625,180,-51Q180,-75.734375,174.35938,-83.859375Q168.71875,-92,159.85938,-92Q151.40625,-92,146.09375,-84.828125Q140,-75.671875,140,-51Z"
android:fillColor="#000000"
android:fillAlpha="0.8"/>
<path android:pathData="M201,0L228.85938,-38L203,-75L218.57812,-75L230.82812,-57.484375Q234.23438,-52.609375,236.84375,-48.890625Q239.65625,-52.71875,242.28125,-56.296875L256,-75L271.875,-75L244.59375,-37.796875L271,0L255.42188,0L239.48438,-22.796875L236.60938,-26.90625L216.875,0L201,0Z"
android:fillColor="#000000"
android:fillAlpha="0.8"/>
</group>
</group>
</vector>
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="272"
android:viewportHeight="181.125"
android:tint="#FFFFFF">
<group android:scaleX="1.0434783"
android:scaleY="0.69485295"
android:translateX="-11.826087"
android:translateY="27.63488">
<group android:translateY="144.77344">
<path android:pathData="M54,-0L41,-0L41,-81.03125Q36.484375,-76.65625,29.15625,-72.265625Q21.828125,-67.890625,16,-65.703125L16,-78Q26.546875,-83.015625,34.4375,-90.140625Q42.34375,-97.28125,45.625,-104L54,-104L54,-0Z"
android:fillColor="#000000"
android:fillAlpha="0.8"/>
<path android:pathData="M93,0L93,-13L106,-13L106,0L93,0Z"
android:fillColor="#000000"
android:fillAlpha="0.8"/>
<path android:pathData="M192,-13L192,0L124,0Q123.859375,-4.75,125.421875,-9.125Q127.921875,-16.359375,133.4375,-23.25Q138.96875,-30.140625,149.64062,-38.6875Q166.21875,-52.625,172.10938,-60.28125Q178,-67.9375,178,-75.015625Q178,-82.171875,172.70312,-87.078125Q167.40625,-92,158.875,-92Q149.875,-92,144.46875,-86.953125Q139.0625,-81.921875,139,-73L126,-75Q127.328125,-89.15625,135.95312,-96.578125Q144.59375,-104,159.15625,-104Q173.85938,-104,182.42188,-95.71875Q191,-87.4375,191,-75.203125Q191,-68.96875,188.59375,-62.953125Q186.20312,-56.9375,180.64062,-50.28125Q175.09375,-43.640625,161.73438,-32.375Q150.35938,-23.03125,146.96875,-19.6875Q143.59375,-16.359375,141.39062,-13L192,-13Z"
android:fillColor="#000000"
android:fillAlpha="0.8"/>
<path android:pathData="M201,0L228.85938,-38L203,-75L218.57812,-75L230.82812,-57.484375Q234.23438,-52.609375,236.84375,-48.890625Q239.65625,-52.71875,242.28125,-56.296875L256,-75L271.875,-75L244.59375,-37.796875L271,0L255.42188,0L239.48438,-22.796875L236.60938,-26.90625L216.875,0L201,0Z"
android:fillColor="#000000"
android:fillAlpha="0.8"/>
</group>
</group>
</vector>
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="272"
android:viewportHeight="181.125"
android:tint="#FFFFFF">
<group android:scaleX="1.0434783"
android:scaleY="0.69485295"
android:translateX="-11.826087"
android:translateY="27.63488">
<group android:translateY="144.77344">
<path android:pathData="M54,-0L41,-0L41,-81.03125Q36.484375,-76.65625,29.15625,-72.265625Q21.828125,-67.890625,16,-65.703125L16,-78Q26.546875,-83.015625,34.4375,-90.140625Q42.34375,-97.28125,45.625,-104L54,-104L54,-0Z"
android:fillColor="#000000"
android:fillAlpha="0.8"/>
<path android:pathData="M93,0L93,-13L106,-13L106,0L93,0Z"
android:fillColor="#000000"
android:fillAlpha="0.8"/>
<path android:pathData="M127,-27L140,-28Q141.42188,-19.03125,146.32812,-14.515625Q151.25,-10,159.04688,-10Q168.59375,-10,174.29688,-16.53125Q180,-23.0625,180,-33.859375Q180,-44.140625,174.70312,-50.0625Q169.42188,-56,159.5,-56Q153.29688,-56,148.95312,-53.5Q144.625,-51,142,-47L129,-49L140,-102L188,-102L188,-89L150.54688,-89L145.28125,-61.0625Q153.875,-68,163.3125,-68Q175.8125,-68,184.40625,-58.65625Q193,-49.328125,193,-34.65625Q193,-20.671875,185.125,-10.5Q175.54688,2,158.98438,2Q145.40625,2,136.8125,-5.9375Q128.21875,-13.875,127,-27Z"
android:fillColor="#000000"
android:fillAlpha="0.8"/>
<path android:pathData="M201,0L228.85938,-38L203,-75L218.57812,-75L230.82812,-57.484375Q234.23438,-52.609375,236.84375,-48.890625Q239.65625,-52.71875,242.28125,-56.296875L256,-75L271.875,-75L244.59375,-37.796875L271,0L255.42188,0L239.48438,-22.796875L236.60938,-26.90625L216.875,0L201,0Z"
android:fillColor="#000000"
android:fillAlpha="0.8"/>
</group>
</group>
</vector>
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="272"
android:viewportHeight="181.125"
android:tint="#FFFFFF">
<group android:scaleY="0.6659007"
android:translateY="30.256865">
<group android:translateY="144.77344">
<path android:pathData="M72,-13L72,-0L4,-0Q3.859375,-4.75,5.421875,-9.125Q7.921875,-16.359375,13.4375,-23.25Q18.96875,-30.140625,29.640625,-38.6875Q46.21875,-52.625,52.109375,-60.28125Q58,-67.9375,58,-75.015625Q58,-82.171875,52.703125,-87.078125Q47.40625,-92,38.875,-92Q29.875,-92,24.46875,-86.953125Q19.0625,-81.921875,19,-73L6,-75Q7.328125,-89.15625,15.953125,-96.578125Q24.59375,-104,39.15625,-104Q53.859375,-104,62.421875,-95.71875Q71,-87.4375,71,-75.203125Q71,-68.96875,68.59375,-62.953125Q66.203125,-56.9375,60.640625,-50.28125Q55.09375,-43.640625,41.734375,-32.375Q30.359375,-23.03125,26.96875,-19.6875Q23.59375,-16.359375,21.390625,-13L72,-13Z"
android:fillColor="#000000"
android:fillAlpha="0.8"/>
<path android:pathData="M93,0L93,-13L106,-13L106,0L93,0Z"
android:fillColor="#000000"
android:fillAlpha="0.8"/>
<path android:pathData="M127,-50.96875Q127,-69.375,130.6875,-80.84375Q134.39062,-92.328125,141.67188,-98.15625Q148.95312,-104,160,-104Q168.14062,-104,174.53125,-100.625Q180.9375,-97.265625,184.6875,-90.921875Q188.4375,-84.59375,190.71875,-75.5Q193,-66.40625,193,-50.96875Q193,-32.703125,189.34375,-21.21875Q185.6875,-9.75,178.39062,-3.875Q171.10938,2,160,2Q145.35938,2,137.01562,-8.265625Q127,-21.71875,127,-50.96875ZM140,-51Q140,-25.828125,145.76562,-17.90625Q151.53125,-10,160,-10Q168.46875,-10,174.23438,-17.953125Q180,-25.90625,180,-51Q180,-75.734375,174.35938,-83.859375Q168.71875,-92,159.85938,-92Q151.40625,-92,146.09375,-84.828125Q140,-75.671875,140,-51Z"
android:fillColor="#000000"
android:fillAlpha="0.8"/>
<path android:pathData="M201,0L228.85938,-38L203,-75L218.57812,-75L230.82812,-57.484375Q234.23438,-52.609375,236.84375,-48.890625Q239.65625,-52.71875,242.28125,-56.296875L256,-75L271.875,-75L244.59375,-37.796875L271,0L255.42188,0L239.48438,-22.796875L236.60938,-26.90625L216.875,0L201,0Z"
android:fillColor="#000000"
android:fillAlpha="0.8"/>
</group>
</group>
</vector>
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="272"
android:viewportHeight="181.125"
android:tint="#FFFFFF">
<group android:scaleY="0.6659007"
android:translateY="30.256865">
<group android:translateY="144.77344">
<path android:pathData="M7,-27L20,-29Q22.109375,-18.90625,26.90625,-14.453125Q31.71875,-10,39.484375,-10Q48.609375,-10,54.296875,-16Q60,-22.015625,60,-30.90625Q60,-39.375,54.5,-44.890625Q49,-50.40625,40.515625,-50.40625Q37.046875,-50.40625,31.890625,-49L33.25,-61Q34.46875,-60.859375,35.203125,-60.859375Q42.8125,-60.859375,48.90625,-64.796875Q55,-68.75,55,-76.953125Q55,-83.46875,50.484375,-87.734375Q45.984375,-92,38.84375,-92Q31.765625,-92,27.046875,-87.75Q22.34375,-83.5,21,-75L8,-77Q10.28125,-89.859375,18.34375,-96.921875Q26.40625,-104,38.40625,-104Q46.6875,-104,53.65625,-100.390625Q60.625,-96.796875,64.3125,-90.578125Q68,-84.359375,68,-77.359375Q68,-70.71875,64.40625,-65.265625Q60.828125,-59.8125,53.796875,-56.59375Q62.9375,-54.453125,67.96875,-47.6875Q73,-40.9375,73,-30.796875Q73,-17.078125,63.375,-7.53125Q53.75,2,39.03125,2Q25.765625,2,17,-6.078125Q8.234375,-14.15625,7,-27Z"
android:fillColor="#000000"
android:fillAlpha="0.8"/>
<path android:pathData="M93,0L93,-13L106,-13L106,0L93,0Z"
android:fillColor="#000000"
android:fillAlpha="0.8"/>
<path android:pathData="M127,-50.96875Q127,-69.375,130.6875,-80.84375Q134.39062,-92.328125,141.67188,-98.15625Q148.95312,-104,160,-104Q168.14062,-104,174.53125,-100.625Q180.9375,-97.265625,184.6875,-90.921875Q188.4375,-84.59375,190.71875,-75.5Q193,-66.40625,193,-50.96875Q193,-32.703125,189.34375,-21.21875Q185.6875,-9.75,178.39062,-3.875Q171.10938,2,160,2Q145.35938,2,137.01562,-8.265625Q127,-21.71875,127,-50.96875ZM140,-51Q140,-25.828125,145.76562,-17.90625Q151.53125,-10,160,-10Q168.46875,-10,174.23438,-17.953125Q180,-25.90625,180,-51Q180,-75.734375,174.35938,-83.859375Q168.71875,-92,159.85938,-92Q151.40625,-92,146.09375,-84.828125Q140,-75.671875,140,-51Z"
android:fillColor="#000000"
android:fillAlpha="0.8"/>
<path android:pathData="M201,0L228.85938,-38L203,-75L218.57812,-75L230.82812,-57.484375Q234.23438,-52.609375,236.84375,-48.890625Q239.65625,-52.71875,242.28125,-56.296875L256,-75L271.875,-75L244.59375,-37.796875L271,0L255.42188,0L239.48438,-22.796875L236.60938,-26.90625L216.875,0L201,0Z"
android:fillColor="#000000"
android:fillAlpha="0.8"/>
</group>
</group>
</vector>
@@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<group android:scaleX="1.127451"
android:scaleY="1.127451"
android:translateX="-1.5294118"
android:translateY="-1.5294118">
<path
android:fillColor="@android:color/white"
android:pathData="M6,18l8.5,-6L6,6v12zM16,6v12h2V6h-2z"/>
</group>
</vector>
@@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<group android:scaleX="1.127451"
android:scaleY="1.127451"
android:translateX="-1.5294118"
android:translateY="-1.5294118">
<path
android:fillColor="@android:color/white"
android:pathData="M6,6h2v12L6,18zM9.5,12l8.5,6L18,6z"/>
</group>
</vector>
Binary file not shown.

After

Width:  |  Height:  |  Size: 500 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 447 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 465 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 512 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 323 B

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