Compare commits

..
Author SHA1 Message Date
advplyrandGitHub 185cba16eb Merge pull request #1868 from pavel-miniutka/master
Belarusian language option
2026-05-13 16:32:34 -05:00
pavel-miniutka 547d086228 Belarusian language option 2026-05-13 09:11:27 +03:00
advplyr 3a2d5647d4 Merge branch 'master' of https://github.com/advplyr/audiobookshelf-app 2026-05-12 08:22:20 -05:00
advplyr 0a67c9b802 Update gh issue templates for 0.13.0 2026-05-12 08:22:15 -05:00
advplyr 0254afd5d8 iOS version bump 0.13.0 2026-05-10 15:18:30 -05:00
advplyr 853546f3d2 Fix cover image sampling not working when webview strips origin header, instead fetch image from server with CapacitorHttp 2026-05-10 15:15:13 -05:00
advplyr b8ac073247 Version bump 0.13.0-beta 2026-05-10 14:29:52 -05:00
advplyr 56cb4df31a Cleanup unnecessary logs 2026-05-10 14:23:17 -05:00
advplyr 7285b0bf26 iOS update RealmSwift to 10.54.6 2026-05-10 14:12:58 -05:00
advplyrandGitHub 3fdc2e6dcf Merge pull request #1864 from advplyr/fix_open_session_progress_sync
Fix external progress sync for open sessions
2026-05-10 11:24:38 -05:00
advplyr ed2f9d2e45 Prevent focus and socket reconnect from both fetching server media progress 2026-05-10 11:12:17 -05:00
advplyr 68dafbba75 Sync local media open in player when socket reconnects after 30s+ 2026-05-10 11:05:17 -05:00
advplyr 8be08702c1 Fix regain focus check server progress for local media items open in player & sync more recent 2026-05-09 16:48:55 -05:00
advplyrandGitHub 2c2e77f6cd Merge pull request #1824 from weblate/weblate-audiobookshelf-abs-mobile-app
Translations update from Hosted Weblate
2026-05-05 15:55:32 -05:00
LvanAlphenandHosted Weblate 12d02645fd Translated using Weblate (Dutch)
Currently translated at 100.0% (374 of 374 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/nl/
2026-05-04 22:12:49 +00:00
PimandHosted Weblate 4578339594 Translated using Weblate (Dutch)
Currently translated at 100.0% (374 of 374 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/nl/
2026-05-04 22:12:48 +00:00
LvanAlphenandHosted Weblate b81f78755d Translated using Weblate (Dutch)
Currently translated at 100.0% (374 of 374 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/nl/
2026-05-04 22:12:47 +00:00
Naoto IshikawaandHosted Weblate 82664d6c81 Translated using Weblate (Japanese)
Currently translated at 76.4% (286 of 374 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/ja/
2026-05-04 22:12:47 +00:00
Pavel MiniutkaandHosted Weblate 9e06db58d2 Translated using Weblate (Belarusian)
Currently translated at 100.0% (374 of 374 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/be/
2026-05-04 22:12:46 +00:00
ugyesandHosted Weblate de3461af52 Translated using Weblate (Hungarian)
Currently translated at 100.0% (374 of 374 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/hu/
2026-05-04 22:12:46 +00:00
Serhiy BobrovandHosted Weblate 61f676d106 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (374 of 374 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/uk/
2026-05-04 22:12:45 +00:00
Владимир МакеевandHosted Weblate 82627b85a5 Translated using Weblate (Russian)
Currently translated at 100.0% (374 of 374 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/ru/
2026-05-04 22:12:45 +00:00
Pavel MiniutkaandHosted Weblate 21bc6cc8b6 Translated using Weblate (Belarusian)
Currently translated at 100.0% (374 of 374 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/be/
2026-05-04 22:12:44 +00:00
PlazecandHosted Weblate 2295b1071e Translated using Weblate (Czech)
Currently translated at 98.6% (369 of 374 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/cs/
2026-05-04 22:12:43 +00:00
Vadzim KurdzesauandHosted Weblate c75e376533 Translated using Weblate (Russian)
Currently translated at 97.5% (365 of 374 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/ru/
2026-05-04 22:12:43 +00:00
A LandHosted Weblate 3a5e2af9e4 Translated using Weblate (Bulgarian)
Currently translated at 100.0% (374 of 374 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/bg/
2026-05-04 22:12:42 +00:00
AlexandHosted Weblate 30f1414ea5 Translated using Weblate (Slovak)
Currently translated at 100.0% (374 of 374 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/sk/
2026-05-04 22:12:42 +00:00
tfr tintandHosted Weblate 737364aa3a Translated using Weblate (Italian)
Currently translated at 100.0% (374 of 374 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/it/
2026-05-04 22:12:41 +00:00
xxzp3andHosted Weblate fd988afad1 Translated using Weblate (Danish)
Currently translated at 98.9% (370 of 374 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/da/
2026-05-04 22:12:41 +00:00
kuci-JKandHosted Weblate 56dc7f856c Translated using Weblate (Czech)
Currently translated at 98.1% (367 of 374 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/cs/
2026-05-04 22:12:40 +00:00
Paolo RicciandHosted Weblate 5e4a41b0b5 Translated using Weblate (Italian)
Currently translated at 97.5% (365 of 374 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/it/
2026-05-04 22:12:40 +00:00
Pavel MiniutkaandHosted Weblate 66f426f6bb Translated using Weblate (Belarusian)
Currently translated at 100.0% (374 of 374 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/be/
2026-05-04 22:12:39 +00:00
Francisco SerradorandHosted Weblate 03efa352f5 Translated using Weblate (Spanish)
Currently translated at 100.0% (374 of 374 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/es/
2026-05-04 22:12:39 +00:00
Francisco SerradorandHosted Weblate 5c147955b7 Translated using Weblate (Spanish)
Currently translated at 100.0% (374 of 374 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/es/
2026-05-04 22:12:38 +00:00
Pavel MiniutkaandHosted Weblate 0a55573bd0 Translated using Weblate (Belarusian)
Currently translated at 100.0% (374 of 374 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/be/
2026-05-04 22:12:37 +00:00
Pavel MiniutkaandHosted Weblate de3812ac33 Translated using Weblate (Belarusian)
Currently translated at 100.0% (374 of 374 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/be/
2026-05-04 22:12:37 +00:00
Abdellatif AhammadandHosted Weblate b6de52eb91 Translated using Weblate (Arabic)
Currently translated at 96.5% (361 of 374 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/ar/
2026-05-04 22:12:36 +00:00
advplyr 65ec1ec5d4 Update search to include podcast episodes #1793 2026-05-04 17:12:29 -05:00
advplyrandGitHub 614118d1d3 Merge pull request #1856 from EndToEndDisciple/patch-1
Update i18n.js to add Bulgarian language
2026-05-04 16:23:39 -05:00
advplyr e3999360f0 Update github template app versions 2026-04-24 05:18:19 -05:00
Radoslav MandevandGitHub 50d1b62b95 Update i18n.js to add Bulgarian language 2026-04-24 12:03:37 +02:00
advplyrandGitHub 1571bcd9f3 Merge pull request #1806 from weblate/weblate-audiobookshelf-abs-mobile-app
Translations update from Hosted Weblate
2026-03-08 17:31:40 -05:00
Felix GrollandHosted Weblate 5531ced129 Translated using Weblate (German)
Currently translated at 100.0% (374 of 374 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/de/
2026-03-04 05:09:49 +00:00
Grzegorz OrlowskiandHosted Weblate 2b258de5b8 Translated using Weblate (Polish)
Currently translated at 100.0% (374 of 374 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/pl/
2026-03-04 05:09:48 +00:00
ArturandHosted Weblate 4e3d3f7752 Translated using Weblate (Polish)
Currently translated at 100.0% (374 of 374 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/pl/
2026-03-04 05:09:47 +00:00
Torstein EideandHosted Weblate 63863a8800 Translated using Weblate (Norwegian Bokmål)
Currently translated at 100.0% (374 of 374 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/nb_NO/
2026-03-02 09:09:49 +01:00
thehijackerandHosted Weblate d594fcb08b Translated using Weblate (Slovenian)
Currently translated at 100.0% (374 of 374 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/sl/
2026-03-02 09:09:47 +01:00
Pavel MiniutkaandHosted Weblate 85aab8df06 Translated using Weblate (Belarusian)
Currently translated at 100.0% (374 of 374 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/be/
2026-02-26 10:09:46 +01:00
Pavel MiniutkaandHosted Weblate ec1585f7c1 Translated using Weblate (Belarusian)
Currently translated at 99.1% (371 of 374 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/be/
2026-02-24 19:09:50 +00:00
Pavel MiniutkaandHosted Weblate 3b3bda117c Translated using Weblate (Belarusian)
Currently translated at 96.5% (361 of 374 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/be/
2026-02-20 19:09:51 +01:00
lambolightingandHosted Weblate c37f2b71d4 Translated using Weblate (Greek)
Currently translated at 61.7% (231 of 374 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/el/
2026-02-20 19:09:50 +01:00
PlazecandHosted Weblate 04de5960f1 Translated using Weblate (Czech)
Currently translated at 97.0% (363 of 374 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/cs/
2026-02-19 03:09:52 +01:00
GnebandHosted Weblate dad7ac4efd Translated using Weblate (Czech)
Currently translated at 97.0% (363 of 374 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/cs/
2026-02-19 03:09:51 +01:00
Silviu BajenaruandHosted Weblate d2830c1603 Translated using Weblate (Romanian)
Currently translated at 100.0% (374 of 374 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/ro/
2026-02-19 03:09:50 +01:00
peter cernyandHosted Weblate 6091fd6f79 Translated using Weblate (Slovak)
Currently translated at 100.0% (374 of 374 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/sk/
2026-02-19 03:09:48 +01:00
herny ucetandHosted Weblate c9935eca2b Translated using Weblate (Slovak)
Currently translated at 100.0% (374 of 374 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/sk/
2026-02-19 03:09:47 +01:00
GnebandHosted Weblate 38eacdd7b4 Translated using Weblate (Slovak)
Currently translated at 100.0% (374 of 374 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/sk/
2026-02-19 03:09:46 +01:00
Igor DobračaandHosted Weblate 1bde917b09 Translated using Weblate (Croatian)
Currently translated at 100.0% (374 of 374 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/hr/
2026-02-16 06:11:22 +01:00
ugyesandHosted Weblate 0859e219fa Translated using Weblate (Hungarian)
Currently translated at 100.0% (374 of 374 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/hu/
2026-02-16 06:11:22 +01:00
Karl BernstålandHosted Weblate dac9279320 Translated using Weblate (Swedish)
Currently translated at 100.0% (374 of 374 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/sv/
2026-02-16 06:11:21 +01:00
advplyr 4fb9f6b92d Version bump 0.12.0-beta 2026-02-09 17:28:57 -06:00
advplyrandGitHub fabd579247 Merge pull request #1797 from weblate/weblate-audiobookshelf-abs-mobile-app
Translations update from Hosted Weblate
2026-02-09 17:14:58 -06:00
Yusuke SakaiandHosted Weblate b4ee3b80a8 Translated using Weblate (Japanese)
Currently translated at 54.8% (205 of 374 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/ja/
2026-02-10 00:14:41 +01:00
Bjorn JorgensenandHosted Weblate a75bdce9b4 Translated using Weblate (Danish)
Currently translated at 97.0% (363 of 374 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/da/
2026-02-10 00:14:41 +01:00
Luis LandeiroandHosted Weblate d48ac952a5 Translated using Weblate (Portuguese (Brazil))
Currently translated at 98.1% (367 of 374 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/pt_BR/
2026-02-10 00:14:40 +01:00
advplyrandGitHub d83b2e032b Merge pull request #1749 from danyhoron/master
feat: implement download queue on iOS
2026-02-09 17:14:30 -06:00
advplyr 46c9c3f006 Fix downloading page filename overflow 2026-02-08 17:06:23 -06:00
advplyrandGitHub 4199add608 Merge pull request #1738 from eagleeyetom/chromecast-volume-fix-updated
Android cast: stabilize MediaSession volume handling; simplify Google…
2026-02-08 16:37:15 -06:00
advplyrandGitHub 8311643e73 Merge pull request #1774 from FreedomBen/dont-queue-background-display-events
Significantly improve Android UI performance when resuming from background
2026-02-08 16:08:49 -06:00
advplyrandGitHub 7775d5216f Merge pull request #1758 from weblate/weblate-audiobookshelf-abs-mobile-app
Translations update from Hosted Weblate
2026-02-03 17:27:10 -06:00
Lluís FornsandHosted Weblate 6f2fc6b5c1 Translated using Weblate (Catalan)
Currently translated at 79.6% (298 of 374 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/ca/
2026-02-03 20:10:04 +01:00
NapitaukiandHosted Weblate 235cac7ab4 Translated using Weblate (Finnish)
Currently translated at 100.0% (374 of 374 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/fi/
2026-02-03 20:10:04 +01:00
dapitch666andHosted Weblate 93b4acfe13 Translated using Weblate (French)
Currently translated at 100.0% (374 of 374 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/fr/
2026-02-02 20:02:04 +00:00
Jan-Eric MyhrgrenandHosted Weblate bd7c73c48d Translated using Weblate (Swedish)
Currently translated at 100.0% (374 of 374 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/sv/
2026-02-02 20:02:02 +00:00
Dawid KuźnickiandHosted Weblate 45ecca7ce4 Translated using Weblate (Polish)
Currently translated at 96.7% (362 of 374 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/pl/
2026-02-02 20:02:00 +00:00
FiendFEARingandHosted Weblate 0a8c67cf13 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (374 of 374 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/zh_Hans/
2026-02-02 20:01:59 +00:00
ugyesandHosted Weblate 316d3d8324 Translated using Weblate (Hungarian)
Currently translated at 100.0% (374 of 374 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/hu/
2026-02-02 20:01:57 +00:00
enoshandHosted Weblate 182d45b07c Translated using Weblate (Hebrew)
Currently translated at 94.9% (355 of 374 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/he/
2026-02-02 20:01:55 +00:00
Delta UmhöferandHosted Weblate 58d8afca38 Translated using Weblate (German)
Currently translated at 100.0% (374 of 374 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/de/
2026-02-01 00:26:04 +01:00
dv4yGY2UandHosted Weblate 136709d7ae Translated using Weblate (Turkish)
Currently translated at 98.8% (359 of 363 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/tr/
2026-02-01 00:26:04 +01:00
bittin1ddc447d824349b2andHosted Weblate b0d02aa769 Translated using Weblate (Swedish)
Currently translated at 100.0% (363 of 363 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/sv/
2026-02-01 00:26:03 +01:00
Samuel GuerreroandHosted Weblate ca66a45ba4 Translated using Weblate (Spanish)
Currently translated at 99.1% (360 of 363 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/es/
2026-02-01 00:26:02 +01:00
FiendFEARingandHosted Weblate d7d4114ee6 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (363 of 363 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/zh_Hans/
2026-02-01 00:26:02 +01:00
MantasandHosted Weblate e589c0a7ea Translated using Weblate (Lithuanian)
Currently translated at 52.3% (189 of 361 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/lt/
2026-02-01 00:26:01 +01:00
Kabika82andHosted Weblate eb2625a2df Translated using Weblate (Hungarian)
Currently translated at 100.0% (361 of 361 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/hu/
2026-02-01 00:26:01 +01:00
N VisiandHosted Weblate aacbacf557 Translated using Weblate (Japanese)
Currently translated at 55.9% (202 of 361 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/ja/
2026-02-01 00:26:00 +01:00
Henrik LyngeandHosted Weblate 0a7b129db2 Translated using Weblate (Danish)
Currently translated at 100.0% (361 of 361 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/da/
2026-02-01 00:25:59 +01:00
herny ucetandHosted Weblate c927264b62 Translated using Weblate (Slovak)
Currently translated at 100.0% (361 of 361 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/sk/
2026-02-01 00:25:59 +01:00
xxzp3andHosted Weblate 808225351e Translated using Weblate (Danish)
Currently translated at 99.1% (358 of 361 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/da/
2026-02-01 00:25:58 +01:00
J. LavoieandHosted Weblate 40ee8d5221 Translated using Weblate (Italian)
Currently translated at 100.0% (361 of 361 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/it/
2026-02-01 00:25:58 +01:00
J. LavoieandHosted Weblate 8cc80e8378 Translated using Weblate (French)
Currently translated at 100.0% (361 of 361 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/fr/
2026-02-01 00:25:57 +01:00
MarcinandHosted Weblate db5f249c14 Translated using Weblate (Polish)
Currently translated at 99.7% (360 of 361 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/pl/
2026-02-01 00:25:56 +01:00
MarioandHosted Weblate e86028e577 Translated using Weblate (German)
Currently translated at 100.0% (361 of 361 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/de/
2026-02-01 00:25:56 +01:00
Jan-Eric MyhrgrenandHosted Weblate 54bed2bee1 Translated using Weblate (Swedish)
Currently translated at 100.0% (361 of 361 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/sv/
2026-02-01 00:25:55 +01:00
Petri HämäläinenandHosted Weblate 1de78165d0 Translated using Weblate (Finnish)
Currently translated at 100.0% (361 of 361 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/fi/
2026-02-01 00:25:55 +01:00
Ivan SmoliakovandHosted Weblate 4c9ed5e2bf Translated using Weblate (Russian)
Currently translated at 100.0% (361 of 361 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/ru/
2026-02-01 00:25:54 +01:00
lambolightingandHosted Weblate a036a8bda6 Translated using Weblate (Greek)
Currently translated at 60.6% (219 of 361 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/el/
2026-02-01 00:25:53 +01:00
biuklijaandHosted Weblate 5a482e603e Translated using Weblate (Croatian)
Currently translated at 100.0% (361 of 361 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/hr/
2026-02-01 00:25:53 +01:00
FiendFEARingandHosted Weblate d5eb8a341e Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (361 of 361 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/zh_Hans/
2026-02-01 00:25:52 +01:00
Максим ГорпинічandHosted Weblate 9c74c47448 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (361 of 361 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/uk/
2026-02-01 00:25:51 +01:00
AhetekandHosted Weblate 8584bebe6c Translated using Weblate (Polish)
Currently translated at 100.0% (360 of 360 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/pl/
2026-02-01 00:25:50 +01:00
advplyrandGitHub 39cd6c65f2 Merge pull request #1763 from randomdude583/fix/android-aac-seek-issue
Enable constant bitrate seeking to fix AAC seek reset in ExoPlayer
2026-01-31 17:25:39 -06:00
advplyrandGitHub 1056541dd2 Merge pull request #1772 from KiwiHour/sort-by-progress
added 'sort by progress' entries
2026-01-30 17:31:26 -06:00
advplyrandGitHub f9a3a4a218 Merge pull request #1775 from anstosa/ansel/playnextepisodesort
Play Next Episode correctly for serial podcasts
2026-01-30 17:11:54 -06:00
advplyrandGitHub 80bafd9281 Merge pull request #1755 from sir-wilhelm/localize
Localized various strings.
2026-01-30 16:59:16 -06:00
advplyr c06c17d3ae Update HeaderWelcome to include username 2026-01-30 16:50:40 -06:00
advplyr 2a7969fe68 Fix backButton dialog i18n strings 2026-01-30 16:47:51 -06:00
advplyr decda748ba Fix unnecessary $ in getString call 2026-01-30 16:22:57 -06:00
advplyrandGitHub 427f858be5 Merge pull request #1767 from FreedomBen/add-more-backwards-forward-options
Expand time interval options for jump controls
2026-01-29 17:38:42 -06:00
advplyr 1043fb3f3e fix i18n string order 2026-01-29 17:33:58 -06:00
advplyr 7c5e8cb046 Revert audio player playerControls padding 2026-01-29 17:32:34 -06:00
advplyr 5fb8baa186 Remove icons from jump options, remove unused computed properties 2026-01-29 17:17:29 -06:00
advplyr f63b04451b Update jump label to only show on fullscreen audio player 2026-01-29 17:08:12 -06:00
advplyr a80722ff79 Fix auth re-login error updating vuex state 2026-01-29 16:41:16 -06:00
Daniel HoronandGitHub 9d5cbd37c8 Merge branch 'advplyr:master' into master 2026-01-29 17:35:37 +02:00
KiwiHour fada8fccb1 made order-by menu scrollable 2026-01-12 00:07:34 +00:00
Ansel Santosa 9d650019cd Play Next Episode correctly for serial podcasts
The default episode sort correctly takes podcast type into account (oldest to newest for serial, newest to oldest for episodic). However the Play Next Episode button uses episodic sorting for all podcast types. This minimal change fixes that.
2026-01-07 22:19:27 -08:00
KiwiHourandGitHub 915f3c88bc Removed whitespaces around LabelLibrarySortByProgress 2026-01-06 01:48:36 +00:00
Benjamin Porter 140bc7fbc5 Skip progress updates when backgrounded
When we are backgrounded, these queue events build up and can cause
performance issues as the queue is processed.  We don't need to process
these when backgrounded, so skip them.

Also add small delay when resuming for webview to load before sending it
update events
2026-01-05 16:58:10 -07:00
KiwiHour 2a12c1e811 added 'sort by progress' entries, strings added for en-us 2026-01-05 23:50:15 +00:00
Benjamin Porter 5250adb36e Don't queue display events while backgrounded
When we are backgrounded, don't queue display events.  This prevents
a hang and sluggish replay of display events once we are re-foregrounded
later
2026-01-04 10:03:50 -07:00
Benjamin Porter f548163689 Make jump labels compatible with i18n/translation 2025-12-29 17:08:37 -07:00
Benjamin Porter 589ffcecca Move jump back/forward labels below the icon
Move jump back/forward labels below the icon.  Previously they
were on the side which looked a little awkward
2025-12-29 14:09:01 -07:00
Benjamin Porter d9c591b0ea Expand time interval options for Make jump controls
Fixes #1445

- Added additional options for customizing jump forward/backward.  Instead of just 5,
  10, and 30 seconds, we now have 5s, 10s, 15s, 30s, 60s, 2m, and 5m.
- Switched the jump interval to a list in the settings menu for better UX
- Reordered "Disable auto rewind time" for aesthetics in settings menu (it looked
  out of place after backwards time and forwards time were turned to dropdowns

The only downside of this (afaict), and what created the biggest challenge,
is the lack of numeric glyphs in the Material Symbols font.  They only have 5, 10,
and 30, which we were previously using.  Given that they don't have others, that means
we had to change strategies for displaying.  I switched all to generic backward and
forward icons and added the time interval as a label next to it so the user can still
tell at a glance what the setting is.  For consistency we don't use the numbered
glyphs anymore.  For more info on the Material Symbols font, see:
https://fonts.google.com/icons?icon.query=forward

However this does feel like two steps forward, one step back, though I can't think
of good options.  Other possibility would be to create SVGs, though I'm not skilled
enough to do that.  Personally this feels like a good compromise, but others will
probably differ. I considered using the numbered glyphs when the selected value was
5, 10, or 30, but when testing I found the inconsistency jarring and kind of janky.
2025-12-29 12:55:18 -07:00
sir-wilhelm 462021225f Localized various strings.
Confirm title, corresponding messages, welcome, and media type dropdown.
2025-12-28 10:36:17 -06:00
Alex 3f0bc74c21 Enable constant bitrate seeking to fix AAC seek reset in ExoPlayer 2025-12-24 13:03:27 -06:00
advplyr b2c2b625e3 Cleanup ItemSearchCard remove unused match properties, add LabelByAuthor string 2025-12-16 17:44:34 -06:00
advplyrandGitHub d3bd3427e3 Merge pull request #1733 from weblate/weblate-audiobookshelf-abs-mobile-app
Translations update from Hosted Weblate
2025-12-14 16:35:05 -06:00
lambolightingandHosted Weblate a040a73174 Translated using Weblate (Greek)
Currently translated at 24.4% (88 of 360 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/el/
2025-12-14 22:00:20 +00:00
lambolightingandHosted Weblate 332d9f203e Translated using Weblate (Greek)
Currently translated at 8.3% (30 of 360 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/el/
2025-12-12 23:13:10 +00:00
Lucas JaksysandHosted Weblate 2d97329836 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (360 of 360 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/pt_BR/
2025-12-12 23:13:10 +00:00
kfctatertotandHosted Weblate 730d030623 Translated using Weblate (Arabic)
Currently translated at 99.7% (359 of 360 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/ar/
2025-12-12 23:13:09 +00:00
kfctatertotandHosted Weblate 2e29daab1d Translated using Weblate (Spanish)
Currently translated at 99.7% (359 of 360 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/es/
2025-12-12 23:13:08 +00:00
zard KimandHosted Weblate d7b48b4f61 Translated using Weblate (Korean)
Currently translated at 100.0% (360 of 360 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/ko/
2025-12-12 23:13:08 +00:00
advplyrandHosted Weblate 24b98f10e3 Added translation using Weblate (Greek) 2025-12-12 23:13:07 +00:00
Igor DobračaandHosted Weblate 5fd6f5e4cc Translated using Weblate (Croatian)
Currently translated at 100.0% (360 of 360 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/hr/
2025-12-12 23:13:06 +00:00
thehijackerandHosted Weblate c21af649c3 Translated using Weblate (Slovenian)
Currently translated at 100.0% (360 of 360 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/sl/
2025-12-12 23:13:06 +00:00
Alessandro BurzioandHosted Weblate 1d8a23922e Translated using Weblate (Italian)
Currently translated at 100.0% (360 of 360 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/it/
2025-12-12 23:13:05 +00:00
J. LavoieandHosted Weblate 68893ab1f4 Translated using Weblate (German)
Currently translated at 100.0% (360 of 360 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/de/
2025-12-12 23:13:04 +00:00
Petri HämäläinenandHosted Weblate dcf172bd98 Translated using Weblate (Finnish)
Currently translated at 100.0% (360 of 360 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/fi/
2025-12-12 23:13:04 +00:00
J. LavoieandHosted Weblate 6dd466a9ce Translated using Weblate (French)
Currently translated at 100.0% (360 of 360 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/fr/
2025-12-12 23:13:03 +00:00
advplyrandHosted Weblate ca222d4110 Added translation using Weblate (Icelandic) 2025-12-12 23:13:02 +00:00
Ivan SmoliakovandHosted Weblate c9af9582e3 Translated using Weblate (Russian)
Currently translated at 100.0% (360 of 360 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/ru/
2025-12-12 23:13:01 +00:00
zard KimandHosted Weblate a3028127ce Translated using Weblate (Korean)
Currently translated at 100.0% (360 of 360 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/ko/
2025-12-12 23:13:01 +00:00
PlazecandHosted Weblate 23133194b2 Translated using Weblate (Czech)
Currently translated at 100.0% (360 of 360 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/cs/
2025-12-12 23:13:00 +00:00
Renaldas RepečkaandHosted Weblate b84555db6c Translated using Weblate (Lithuanian)
Currently translated at 51.9% (187 of 360 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/lt/
2025-12-12 23:12:59 +00:00
ArturandHosted Weblate 229298df1b Translated using Weblate (Polish)
Currently translated at 100.0% (360 of 360 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/pl/
2025-12-12 23:12:59 +00:00
Cèlia GarrigaandHosted Weblate fb270cb50b Translated using Weblate (Catalan)
Currently translated at 78.0% (281 of 360 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/ca/
2025-12-12 23:12:58 +00:00
B0raxandHosted Weblate a861c880ba Translated using Weblate (German)
Currently translated at 100.0% (360 of 360 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/de/
2025-12-12 23:12:57 +00:00
Jan-Eric MyhrgrenandHosted Weblate 9712d3f527 Translated using Weblate (Swedish)
Currently translated at 100.0% (360 of 360 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/sv/
2025-12-12 23:12:57 +00:00
ugyesandHosted Weblate 51a7ce41de Translated using Weblate (Hungarian)
Currently translated at 100.0% (360 of 360 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/hu/
2025-12-12 23:12:56 +00:00
Максим ГорпинічandHosted Weblate 4c657c66d0 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (360 of 360 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/uk/
2025-12-12 23:12:55 +00:00
FiendFEARingandHosted Weblate 61c3fafd94 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (360 of 360 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/zh_Hans/
2025-12-12 23:12:54 +00:00
advplyr bab95530c8 Update WrappingMarquee to use textContent 2025-12-12 17:12:39 -06:00
Dani Horon 68afba45bc feat: implement download queue on iOS 2025-12-10 17:55:43 +02:00
eagleeyetom 1a52f19b32 Android cast: stabilize MediaSession volume handling; simplify Google Cast volume 2025-11-27 00:32:33 +01:00
advplyr aaef0a7e8d Update github issue templates with 0.11.0 2025-11-25 17:28:10 -06:00
advplyr 02d9023f9d Merge branch 'master' of https://github.com/advplyr/audiobookshelf-app 2025-11-24 14:52:04 -06:00
advplyr 59a27cdd4d Add Turkish language option #1734 2025-11-24 14:51:59 -06:00
advplyr 9440782027 iOS version bump 0.11.0 2025-11-23 17:20:32 -06:00
advplyr f19ec05780 Version bump v0.11.0-beta 2025-11-23 17:00:45 -06:00
advplyr d95b75e317 Increase padding on reader toolbar for ios 2025-11-23 16:54:36 -06:00
advplyr a6dec31461 Revert #1727 2025-11-23 16:23:53 -06:00
advplyrandGitHub b12427daa6 Merge pull request #1727 from indiefan/lost-progress-fix
Pause and end playback if the Vue layer receives a progress update wh…
2025-11-23 15:50:41 -06:00
advplyrandGitHub 892686b213 Update components/app/AudioPlayer.vue 2025-11-23 15:44:41 -06:00
advplyr 81e71cd7a2 Fix disconnect in side drawer causing audio playing with player closed #1385 2025-11-23 15:16:44 -06:00
advplyrandGitHub 38a5e3637d Merge pull request #1730 from eagleeyetom/feature/cast-volume-fix
Android: fix Cast volume to control receiver
2025-11-23 14:31:15 -06:00
advplyr eb16e769fe Remove logo shown on server connect loading 2025-11-22 17:31:54 -06:00
advplyrandGitHub 9417f0dd3d Merge pull request #1732 from jamerst/days-in-a-row-today-optional
Don't require listening today for 'Days in a row' stat
2025-11-22 17:27:57 -06:00
advplyr 68d82f7a3a Fix unable to delete servers that are unreachable, include delete icon btn on server item #1703 2025-11-21 17:24:16 -06:00
James Tattersall b8fa041dcb Don't require listening today for 'Days in a row' stat 2025-11-21 16:54:13 +00:00
advplyrandGitHub 1df07dc58d Merge pull request #1688 from weilBaum9/master
Add font selection setting in epub e-reader to match web-client (#1373)
2025-11-19 17:01:39 -06:00
advplyr e63358f776 Fix e-reader settings modal on landscape 2025-11-19 17:01:06 -06:00
advplyr 9518189898 Add font family Sans and Serif translations 2025-11-19 16:46:39 -06:00
advplyrandGitHub 70a9cab6f2 Merge pull request #1706 from weblate/weblate-audiobookshelf-abs-mobile-app
Translations update from Hosted Weblate
2025-11-19 08:06:56 -06:00
advplyrandHosted Weblate 495ec80ebd Added translation using Weblate (Korean) 2025-11-19 15:03:24 +01:00
Mikael EngströmandHosted Weblate 34454fee10 Translated using Weblate (Swedish)
Currently translated at 100.0% (357 of 357 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/sv/
2025-11-18 23:28:18 +00:00
Milo IvirandHosted Weblate fbac4be4a5 Translated using Weblate (Croatian)
Currently translated at 100.0% (357 of 357 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/hr/
2025-11-18 23:28:17 +00:00
phewiandHosted Weblate a150e6109e Translated using Weblate (Finnish)
Currently translated at 100.0% (357 of 357 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/fi/
2025-11-18 23:28:16 +00:00
Lucas Gonçalves JaksysandHosted Weblate f392075836 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (357 of 357 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/pt_BR/
2025-11-18 23:28:15 +00:00
Petri HämäläinenandHosted Weblate 5d9dd394ea Translated using Weblate (Finnish)
Currently translated at 100.0% (357 of 357 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/fi/
2025-11-18 23:28:14 +00:00
Aleksandr ZakirovandHosted Weblate 162c189d4f Translated using Weblate (Estonian)
Currently translated at 49.5% (177 of 357 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/et/
2025-11-18 23:28:13 +00:00
advplyrandGitHub 01902f8a79 Merge pull request #1679 from clayreimann/feat-ios-logs
feat: add logs on iOS
2025-11-18 17:28:00 -06:00
advplyr 399a01a937 iOS fix onLog listener event for realtime logs 2025-11-18 17:24:49 -06:00
eagleeyetom dcaa19071f Android: fix Cast volume to control receiver
- Route hardware volume keys to Cast via MediaSession VolumeProvider
- Use CastSession.volume (0–1) mapped to 0–100; clamp and step by 1
- Update provider with actual device volume after each change (prevents stalls)
- Drop stream-volume/polling attempts and other leftovers
- Expose get/set/adjust device volume commands to the player
2025-11-18 20:57:27 +01:00
advplyrandGitHub a6280805e6 Merge pull request #1685 from sir-wilhelm/chapter_duration_player_modal
Adds a player preference to use chapter duration in the chapters modal view
2025-11-16 17:51:11 -06:00
advplyr 3d8843109a Update chapters modal to always show both start and duration, add sticky header 2025-11-16 17:44:11 -06:00
advplyrandGitHub 16eaccc8e3 Merge pull request #1683 from sir-wilhelm/chapter_duration_chapters_table
Add chapter duration to chapters table on book page
2025-11-16 17:00:14 -06:00
advplyrandGitHub 9df996a507 Merge pull request #1666 from mfulgo/master
Allows widget on Samsung cover screens
2025-11-16 16:22:06 -06:00
Marke Hallowell fa0b9ed6ab Pause and end playback if the Vue layer receives a progress update while having no played track ref. 2025-11-15 16:49:00 -08:00
sir-wilhelm 9b8536a581 Adds a player setting to use chapter duration in the player Chapter modal.
Allows users to view chapter duration instead of start time in the player chapter list.
2025-11-04 19:37:31 -06:00
sir-wilhelm 9158f4f8f1 Add chapter duration next to start time.
This adds the duration to the chapter table on the book page.
2025-11-03 17:39:44 -06:00
advplyrandGitHub 072f19e157 Merge pull request #1647 from IzzySoft/fastlane
initial fastlane structures from IzzyOnDroid
2025-10-28 08:31:26 -05:00
advplyrandGitHub 44198fa17a Merge pull request #1701 from weblate/weblate-audiobookshelf-abs-mobile-app
Translations update from Hosted Weblate
2025-10-21 17:27:00 -05:00
Petri HämäläinenandHosted Weblate 83ccb398c5 Translated using Weblate (Finnish)
Currently translated at 97.7% (349 of 357 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/fi/
2025-10-17 00:06:25 +02:00
burghy86andHosted Weblate 824e335dec Translated using Weblate (Italian)
Currently translated at 100.0% (357 of 357 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/it/
2025-10-17 00:06:24 +02:00
nlqogandHosted Weblate e28e9d71ae Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (357 of 357 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/pt_BR/
2025-10-14 05:07:24 +00:00
pmangroandHosted Weblate 46acc76f07 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (357 of 357 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/pt_BR/
2025-10-11 13:58:53 +02:00
CoxeandHosted Weblate 356fa4eb7c Translated using Weblate (Danish)
Currently translated at 99.7% (356 of 357 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/da/
2025-10-11 13:58:53 +02:00
pryszczoskorandHosted Weblate 09a6c631cb Translated using Weblate (Polish)
Currently translated at 99.4% (355 of 357 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/pl/
2025-10-10 09:07:24 +00:00
HezhaandHosted Weblate cf19816e06 Translated using Weblate (Arabic)
Currently translated at 98.5% (352 of 357 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/ar/
2025-10-08 22:55:30 +02:00
advplyrandGitHub 2c4b9f5a50 Merge pull request #1691 from weblate/weblate-audiobookshelf-abs-mobile-app
Translations update from Hosted Weblate
2025-10-07 16:25:36 -05:00
AhetekandHosted Weblate 9e29d45fc3 Translated using Weblate (Polish)
Currently translated at 99.4% (355 of 357 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/pl/
2025-10-07 03:02:02 +00:00
Kabika82andHosted Weblate b4e350a308 Translated using Weblate (Hungarian)
Currently translated at 100.0% (357 of 357 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/hu/
2025-10-07 03:02:01 +00:00
SmileFateandHosted Weblate 8274072b6a Translated using Weblate (Turkish)
Currently translated at 100.0% (357 of 357 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/tr/
2025-10-01 09:02:06 +00:00
DRandHosted Weblate 1ddd146558 Translated using Weblate (Hebrew)
Currently translated at 98.5% (352 of 357 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/he/
2025-10-01 09:02:05 +00:00
JanandHosted Weblate 2d76fc6e8c Translated using Weblate (German)
Currently translated at 100.0% (357 of 357 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/de/
2025-10-01 09:02:03 +00:00
B0raxandHosted Weblate e19e541a70 Translated using Weblate (German)
Currently translated at 100.0% (357 of 357 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/de/
2025-10-01 09:02:02 +00:00
kuci-JKandHosted Weblate 3a56c82501 Translated using Weblate (Czech)
Currently translated at 100.0% (357 of 357 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/cs/
2025-10-01 09:02:00 +00:00
Petri HämäläinenandHosted Weblate 2f0e385ca2 Translated using Weblate (Finnish)
Currently translated at 97.4% (348 of 357 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/fi/
2025-09-29 05:02:00 +00:00
Amirhossein GhorbanmehrandHosted Weblate 9241a4729c Translated using Weblate (Persian)
Currently translated at 15.4% (55 of 357 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/fa/
2025-09-25 05:02:01 +02:00
SmileFateandHosted Weblate ea331bd4ef Translated using Weblate (Turkish)
Currently translated at 100.0% (357 of 357 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/tr/
2025-09-25 05:02:00 +02:00
advplyrandHosted Weblate c2b598d98e Added translation using Weblate (Persian) 2025-09-23 15:38:29 +02:00
advplyrandGitHub 2fde05412c Merge pull request #1681 from weblate/weblate-audiobookshelf-abs-mobile-app
Translations update from Hosted Weblate
2025-09-22 09:41:31 -05:00
GernomalyandHosted Weblate d8436d3273 Translated using Weblate (German)
Currently translated at 100.0% (357 of 357 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/de/
2025-09-21 02:02:00 +02:00
weilBaum9 e662dae396 Add font selection setting in epub e-reader to match web-client (#1373) 2025-09-16 17:32:00 +02:00
Milo IvirandHosted Weblate e74c5d8609 Translated using Weblate (Croatian)
Currently translated at 100.0% (357 of 357 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/hr/
2025-09-11 10:01:59 +00:00
SatanowskiandHosted Weblate 3f05a8408d Translated using Weblate (Polish)
Currently translated at 99.4% (355 of 357 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/pl/
2025-09-09 21:02:05 +00:00
Milo IvirandHosted Weblate 61f0ba4643 Translated using Weblate (Croatian)
Currently translated at 99.7% (356 of 357 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/hr/
2025-09-09 21:02:04 +00:00
Clay Jensen-Reimann b773ec7690 feat: usable logs on iOS
- enable logs page on iOS
- centralize all logging
- wrap long identifiers in logs UI
- update editorconfig for swift files
- update podfile
2025-09-07 16:31:59 -05:00
peter cernyandHosted Weblate df294730a0 Translated using Weblate (Slovak)
Currently translated at 100.0% (357 of 357 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/sk/
2025-09-06 21:01:58 +00:00
Troj@andHosted Weblate ee1cd38fa2 Translated using Weblate (Belarusian)
Currently translated at 100.0% (357 of 357 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/be/
2025-09-06 21:01:57 +00:00
jhonthanandHosted Weblate a012328f4c Translated using Weblate (Portuguese (Brazil))
Currently translated at 84.8% (303 of 357 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/pt_BR/
2025-09-06 21:01:56 +00:00
kuci-JKandHosted Weblate 1df9656191 Translated using Weblate (Czech)
Currently translated at 100.0% (357 of 357 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/cs/
2025-09-06 21:01:55 +00:00
petr-prikrylandHosted Weblate fde2d0887e Translated using Weblate (Czech)
Currently translated at 100.0% (357 of 357 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/cs/
2025-09-06 21:01:54 +00:00
advplyr 356e439715 Update app versions in github issue templates 0.10.1 2025-09-04 10:47:04 -05:00
advplyr 00ee7b662e iOS version bump 0.10.1 2025-09-03 17:27:41 -05:00
advplyr b222b2a5e5 Version bump v0.10.1-beta 2025-09-03 16:19:04 -05:00
advplyrandGitHub 15b212734e Merge pull request #1669 from weblate/weblate-audiobookshelf-abs-mobile-app
Translations update from Hosted Weblate
2025-09-03 17:00:11 -04:00
icutehunterandHosted Weblate 631ba52ffb Translated using Weblate (Turkish)
Currently translated at 76.7% (274 of 357 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/tr/
2025-09-03 01:01:59 +02:00
kuci-JKandHosted Weblate a4b58a893d Translated using Weblate (Czech)
Currently translated at 98.8% (353 of 357 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/cs/
2025-09-03 01:01:57 +02:00
HnatiucbandHosted Weblate 47a3fd9462 Translated using Weblate (Romanian)
Currently translated at 100.0% (357 of 357 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/ro/
2025-08-31 23:41:26 +02:00
HnatiucbandHosted Weblate abcd56f0c5 Translated using Weblate (Romanian)
Currently translated at 100.0% (357 of 357 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/ro/
2025-08-31 23:41:25 +02:00
HnatiucbandHosted Weblate b498b5eafa Translated using Weblate (Romanian)
Currently translated at 23.2% (83 of 357 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/ro/
2025-08-31 23:41:25 +02:00
tngchandHosted Weblate be114e7789 Translated using Weblate (Japanese)
Currently translated at 45.3% (162 of 357 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/ja/
2025-08-31 23:41:24 +02:00
Jan-Eric MyhrgrenandHosted Weblate 8c44142e70 Translated using Weblate (Swedish)
Currently translated at 99.7% (356 of 357 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/sv/
2025-08-31 23:41:23 +02:00
HnatiucbandHosted Weblate 5a655f77d2 Translated using Weblate (Romanian)
Currently translated at 19.3% (69 of 357 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/ro/
2025-08-31 23:41:23 +02:00
Jan-Eric MyhrgrenandHosted Weblate c825f791d0 Translated using Weblate (Swedish)
Currently translated at 99.7% (356 of 357 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/sv/
2025-08-31 23:41:22 +02:00
Jan-Eric MyhrgrenandHosted Weblate 83a9d8a162 Translated using Weblate (Swedish)
Currently translated at 99.7% (356 of 357 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/sv/
2025-08-31 23:41:22 +02:00
Mathias FrancoandHosted Weblate acfa2e2183 Translated using Weblate (Dutch)
Currently translated at 100.0% (357 of 357 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/nl/
2025-08-31 23:41:21 +02:00
Jan-Eric MyhrgrenandHosted Weblate 591933f3d8 Translated using Weblate (Swedish)
Currently translated at 99.7% (356 of 357 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/sv/
2025-08-31 23:41:21 +02:00
idojius86andHosted Weblate 982f94d955 Translated using Weblate (Spanish)
Currently translated at 98.5% (352 of 357 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/es/
2025-08-31 23:41:20 +02:00
advplyr 95bf28d7c6 Fix tab focus for appbar and sidenav, add tabindex to book card 2025-08-31 16:41:13 -05:00
advplyrandGitHub 48a299de45 Merge pull request #1675 from laurisvr/remove-busy-loops
Remove busy loops
2025-08-31 16:12:07 -04:00
Lauris van RijnandLauris van Rijn 719e517dda fix(androidauto): async handling of browseTree init instead of busy‑loop
Removed blocking `while (!browseTree.isInitialized){}` in
`onLoadChildren`. Added `waitForBrowseTree` and `onBrowseTreeInitialized`
helpers to queue pending results until browseTree is ready. All
browseTree assignments now call `onBrowseTreeInitialized()`. This avoids
ANRs and high CPU when Android Auto requests children before init.
2025-08-29 00:43:02 +02:00
Lauris van RijnandLauris van Rijn 361c55c5ac fix(media): remove busy‑wait loop in library personalization loading
Replaced the infinite `while(libraryPersonalizationsDone > 0){}` spin‑loop
with an async counter callback. This prevents pegging the CPU if one
personalization never completes, and allows completion to trigger via
AtomicInteger decrement. Now the final callback fires only when all
libraries have finished loading.
2025-08-29 00:42:48 +02:00
advplyrandGitHub b157fff229 Merge pull request #1653 from weblate/weblate-audiobookshelf-abs-mobile-app
Translations update from Hosted Weblate
2025-08-23 17:46:51 -04:00
thehijackerandHosted Weblate e23e6417ee Translated using Weblate (Slovenian)
Currently translated at 100.0% (357 of 357 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/sl/
2025-08-23 05:01:54 +00:00
Yurt PageandHosted Weblate ad77209832 Translated using Weblate (Russian)
Currently translated at 100.0% (357 of 357 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/ru/
2025-08-23 05:01:53 +00:00
Zhelyan RadoevandHosted Weblate 4ceb427d9c Translated using Weblate (Bulgarian)
Currently translated at 100.0% (357 of 357 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/bg/
2025-08-20 05:02:15 +00:00
Ivan SmoliakovandHosted Weblate 16a26130f2 Translated using Weblate (Russian)
Currently translated at 100.0% (357 of 357 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/ru/
2025-08-20 05:02:14 +00:00
numerfoltandHosted Weblate e7773c9e88 Translated using Weblate (German)
Currently translated at 99.7% (356 of 357 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/de/
2025-08-20 05:02:13 +00:00
B0raxandHosted Weblate 1b38b92177 Translated using Weblate (German)
Currently translated at 99.7% (356 of 357 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/de/
2025-08-20 05:02:12 +00:00
Laurin SorgendandHosted Weblate ef180f08b0 Translated using Weblate (German)
Currently translated at 99.7% (356 of 357 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/de/
2025-08-20 05:02:11 +00:00
Matt Fulgo 941e823374 Allows widget on Samsung cover screens
Adds meta-data to the widget, allowing it to be used on the cover screen
of Samsung devices such as the Flip.

The widget renders as a 4x4 grid (not an optimal layout) but functions
for launching the most recent audiobook or controlling playback.
2025-08-19 22:24:58 -04:00
Zhelyan RadoevandHosted Weblate 6de45a3b88 Translated using Weblate (Bulgarian)
Currently translated at 99.7% (356 of 357 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/bg/
2025-08-18 17:02:14 +02:00
FiendFEARingandHosted Weblate ceb9537b73 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (357 of 357 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/zh_Hans/
2025-08-18 17:02:12 +02:00
Максим ГорпинічandHosted Weblate 976bf73388 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (357 of 357 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/uk/
2025-08-18 17:02:10 +02:00
ugyesandHosted Weblate 6c7f2d96f3 Translated using Weblate (Hungarian)
Currently translated at 100.0% (357 of 357 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/hu/
2025-08-18 17:02:09 +02:00
CharlieandHosted Weblate ae2fc1fcfb Translated using Weblate (French)
Currently translated at 99.7% (356 of 357 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/fr/
2025-08-18 17:02:07 +02:00
owlcollectorandHosted Weblate 3aad7e9e07 Translated using Weblate (Japanese)
Currently translated at 16.5% (59 of 356 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/ja/
2025-08-17 00:55:15 +02:00
Zhelyan RadoevandHosted Weblate e43f1e75c8 Translated using Weblate (Bulgarian)
Currently translated at 100.0% (356 of 356 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/bg/
2025-08-17 00:55:14 +02:00
Максим ГорпинічandHosted Weblate bc5505e12d Translated using Weblate (Ukrainian)
Currently translated at 100.0% (356 of 356 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/uk/
2025-08-17 00:55:13 +02:00
Hang PhamandHosted Weblate 19a2703ea4 Translated using Weblate (Vietnamese)
Currently translated at 80.0% (285 of 356 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/vi/
2025-08-17 00:55:13 +02:00
SneakyandHosted Weblate 8a8f7d4f27 Translated using Weblate (Swedish)
Currently translated at 100.0% (356 of 356 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/sv/
2025-08-17 00:55:12 +02:00
Paolo RicciandHosted Weblate e350b4970f Translated using Weblate (Italian)
Currently translated at 100.0% (356 of 356 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/it/
2025-08-17 00:55:11 +02:00
owlcollectorandHosted Weblate 4be28bc579 Translated using Weblate (Japanese)
Currently translated at 7.8% (28 of 356 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/ja/
2025-08-17 00:55:11 +02:00
J. LavoieandHosted Weblate 13855a8682 Translated using Weblate (German)
Currently translated at 99.7% (355 of 356 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/de/
2025-08-17 00:55:10 +02:00
advplyr 4be1598eca Fix oidc button not showing on re-login, fix oidc re-login showing config already exists #1638 #1634 2025-08-16 17:55:00 -05:00
advplyr 7aebcd92c3 Fix refresh token not persisted on new server connections #1634 2025-08-15 17:41:23 -05:00
advplyr bd8668f0bf Fix rss feed modal not showing full URL once open #1652 2025-08-08 17:39:27 -05:00
advplyrandGitHub c6a7c6fec2 Merge pull request #1637 from weblate/weblate-audiobookshelf-abs-mobile-app
Translations update from Hosted Weblate
2025-08-07 18:45:51 -04:00
Aleksandr ZakirovandHosted Weblate e367fa8a86 Translated using Weblate (Estonian)
Currently translated at 49.1% (175 of 356 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/et/
2025-08-07 03:01:53 +02:00
weblate.user.1274andHosted Weblate ecdbabfa17 Translated using Weblate (Norwegian Bokmål)
Currently translated at 100.0% (356 of 356 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/nb_NO/
2025-08-07 03:01:52 +02:00
Kent HenriksenandHosted Weblate 796189ad48 Translated using Weblate (Norwegian Bokmål)
Currently translated at 100.0% (356 of 356 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/nb_NO/
2025-08-07 03:01:51 +02:00
Ashish WadekarandHosted Weblate 76d1f2b29b Translated using Weblate (Hindi)
Currently translated at 13.2% (47 of 356 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/hi/
2025-08-07 03:01:50 +02:00
Camille de LuneandHosted Weblate 73599815ba Translated using Weblate (French)
Currently translated at 99.7% (355 of 356 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/fr/
2025-08-07 03:01:49 +02:00
Grzegorz OrlowskiandHosted Weblate 66372e9743 Translated using Weblate (Polish)
Currently translated at 99.4% (354 of 356 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/pl/
2025-08-05 04:02:09 +02:00
Grzegorz OrlowskiandHosted Weblate 0d7f92129b Translated using Weblate (Polish)
Currently translated at 98.0% (349 of 356 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/pl/
2025-08-03 12:02:20 +02:00
Izzy f74a1ceb47 initial fastlane structures from IzzyOnDroid 2025-08-03 01:11:32 +02:00
Remco SchrijverandHosted Weblate 9da49d6116 Translated using Weblate (Dutch)
Currently translated at 100.0% (356 of 356 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/nl/
2025-07-31 20:02:32 +02:00
Troj@andHosted Weblate 7eeb9095e8 Translated using Weblate (Belarusian)
Currently translated at 100.0% (356 of 356 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/be/
2025-07-30 02:08:19 +00:00
thehijackerandHosted Weblate 58281bb5ce Translated using Weblate (Slovenian)
Currently translated at 100.0% (356 of 356 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/sl/
2025-07-30 02:08:18 +00:00
ugyesandHosted Weblate f24dd3b289 Translated using Weblate (Hungarian)
Currently translated at 100.0% (356 of 356 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/hu/
2025-07-30 02:08:17 +00:00
B0raxandHosted Weblate 51f2e5e7ec Translated using Weblate (German)
Currently translated at 99.7% (355 of 356 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/de/
2025-07-30 02:08:16 +00:00
numerfoltandHosted Weblate a937800ef0 Translated using Weblate (German)
Currently translated at 99.7% (355 of 356 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/de/
2025-07-30 02:08:16 +00:00
Mikkel Dupont OlesenandHosted Weblate c34d14edb5 Translated using Weblate (Danish)
Currently translated at 100.0% (356 of 356 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/da/
2025-07-27 09:04:25 +00:00
DmitryandHosted Weblate 8722e91c0f Translated using Weblate (Russian)
Currently translated at 100.0% (356 of 356 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/ru/
2025-07-23 08:03:14 +02:00
kuci-JKandHosted Weblate 8038a8ac66 Translated using Weblate (Czech)
Currently translated at 99.1% (353 of 356 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/cs/
2025-07-23 08:03:14 +02:00
Pavel VachekandHosted Weblate 69a54c5fa9 Translated using Weblate (Czech)
Currently translated at 99.1% (353 of 356 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/cs/
2025-07-23 08:03:14 +02:00
advplyrandGitHub aa508887f3 Merge pull request #1636 from advplyr/fix_android_transcode_track_url
Fix track URL used for transcodes on Android #1635
2025-07-22 15:15:47 -05:00
133 changed files with 4219 additions and 1124 deletions
+4
View File
@@ -6,3 +6,7 @@ indent_size = 2
charset = utf-8 charset = utf-8
insert_final_newline = true insert_final_newline = true
trim_trailing_whitespace = true trim_trailing_whitespace = true
[*.swift]
indent_style = space
indent_size = 4
+4 -4
View File
@@ -72,10 +72,10 @@ body:
description: Please ensure your app is up to date. *If you are using a 3rd-party app, please reach out to them directly.* 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 multiple: true
options: options:
- 'Android App - 0.10.0' - 'Android App - 0.13.0'
- 'iOS App - 0.10.0' - 'iOS App - 0.13.0'
- 'Android App - 0.9.81' - 'Android App - 0.12.0'
- 'iOS App - 0.9.81' - 'iOS App - 0.12.0'
validations: validations:
required: true required: true
- type: dropdown - type: dropdown
+4 -4
View File
@@ -43,10 +43,10 @@ body:
description: Please ensure your app is up to date. *If you are using a 3rd-party app, please reach out to them directly.* 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 multiple: true
options: options:
- 'Android App - 0.10.0' - 'Android App - 0.13.0'
- 'iOS App - 0.10.0' - 'iOS App - 0.13.0'
- 'Android App - 0.9.81' - 'Android App - 0.12.0'
- 'iOS App - 0.9.81' - 'iOS App - 0.12.0'
validations: validations:
required: true required: true
- type: textarea - type: textarea
+2 -2
View File
@@ -36,8 +36,8 @@ android {
applicationId "com.audiobookshelf.app" applicationId "com.audiobookshelf.app"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 113 versionCode 117
versionName "0.10.0-beta" versionName "0.13.0-beta"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
manifestPlaceholders = [ manifestPlaceholders = [
"appAuthRedirectScheme": "com.audiobookshelf.app" "appAuthRedirectScheme": "com.audiobookshelf.app"
+4
View File
@@ -36,6 +36,10 @@
<meta-data <meta-data
android:name="android.appwidget.provider" android:name="android.appwidget.provider"
android:resource="@xml/media_player_widget_info" /> android:resource="@xml/media_player_widget_info" />
<meta-data
android:name="com.samsung.android.appwidget.provider"
android:resource="@xml/samsung_cover_widget_info" />
</receiver> </receiver>
<!-- Used by Android Auto --> <!-- Used by Android Auto -->
@@ -527,4 +527,18 @@ constructor(private val playerNotificationService: PlayerNotificationService) {
checkAutoSleepTimer() checkAutoSleepTimer()
} }
/**
* Sends the current sleep timer state to the client.
* Called when app resumes from background to sync UI state.
*/
fun sendCurrentSleepTimerState() {
if (sleepTimerRunning) {
val timeRemaining = getSleepTimerTimeRemainingSeconds(getPlaybackSpeed())
playerNotificationService.clientEventEmitter?.onSleepTimerSet(timeRemaining, isAutoSleepTimer)
} else {
// No timer running - send 0 to clear any stale UI state
playerNotificationService.clientEventEmitter?.onSleepTimerSet(0, false)
}
}
} }
@@ -15,6 +15,7 @@ import org.json.JSONException
import org.json.JSONObject import org.json.JSONObject
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine import kotlin.coroutines.suspendCoroutine
import java.util.concurrent.atomic.AtomicInteger
class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) { class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
val tag = "MediaManager" val tag = "MediaManager"
@@ -176,21 +177,20 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
* [cb] resolves when all libraries are processed * [cb] resolves when all libraries are processed
*/ */
fun populatePersonalizedDataForAllLibraries(cb: () -> Unit) { fun populatePersonalizedDataForAllLibraries(cb: () -> Unit) {
serverLibraries.forEach { val remaining = AtomicInteger(serverLibraries.size)
libraryPersonalizationsDone++
Log.d(tag, "Loading personalization for library ${it.name} - ${it.id} - ${it.mediaType}")
populatePersonalizedDataForLibrary(it.id) {
Log.d(tag, "Loaded personalization for library ${it.name} - ${it.id} - ${it.mediaType}")
libraryPersonalizationsDone--
}
}
while (libraryPersonalizationsDone > 0) { }
serverLibraries.forEach { lib ->
Log.d(tag, "Loading personalization for library ${lib.name}")
populatePersonalizedDataForLibrary(lib.id) {
Log.d(tag, "Loaded personalization for library ${lib.name}")
if (remaining.decrementAndGet() == 0) {
Log.d(tag, "Finished loading all library personalization data") Log.d(tag, "Finished loading all library personalization data")
allLibraryPersonalizationsDone = true allLibraryPersonalizationsDone = true
cb() cb()
} }
}
}
}
/** /**
* Get personalized shelves from server for selected [libraryId]. * Get personalized shelves from server for selected [libraryId].
@@ -47,7 +47,7 @@ data class DownloadItemPart(
} }
val downloadUri = Uri.parse(downloadUrl) val downloadUri = Uri.parse(downloadUrl)
Log.d("DownloadItemPart", "Audio File Destination Uri: $destinationUri | Final Destination Uri: $finalDestinationUri | Download URI $downloadUri") Log.d("DownloadItemPart", "Audio File Destination Uri: $destinationUri | Final Destination Uri: $finalDestinationUri | Server Path $serverPath")
return DownloadItemPart( return DownloadItemPart(
id = DeviceManager.getBase64Id(finalDestinationFile.absolutePath), id = DeviceManager.getBase64Id(finalDestinationFile.absolutePath),
downloadItemId, downloadItemId,
@@ -3,6 +3,7 @@ package com.audiobookshelf.app.player
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.os.Looper import android.os.Looper
import android.util.Log import android.util.Log
import kotlin.math.roundToInt
import android.view.Surface import android.view.Surface
import android.view.SurfaceHolder import android.view.SurfaceHolder
import android.view.SurfaceView import android.view.SurfaceView
@@ -53,6 +54,10 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
private var myCurrentTrackGroups: TrackGroupArray private var myCurrentTrackGroups: TrackGroupArray
private var myCurrentTrackSelections: TrackSelectionArray private var myCurrentTrackSelections: TrackSelectionArray
// Cache for pending volume to handle async setVolume
private var pendingVolume: Int? = null
private var pendingVolumeSetAt: Long = 0L
var currentMediaItems:List<MediaItem> = mutableListOf() var currentMediaItems:List<MediaItem> = mutableListOf()
var remoteMediaClient:RemoteMediaClient? = null var remoteMediaClient:RemoteMediaClient? = null
var sessionAvailabilityListener:SessionAvailabilityListener? = null var sessionAvailabilityListener:SessionAvailabilityListener? = null
@@ -76,7 +81,10 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
COMMAND_GET_MEDIA_ITEMS_METADATA, COMMAND_GET_MEDIA_ITEMS_METADATA,
COMMAND_SET_MEDIA_ITEMS_METADATA, COMMAND_SET_MEDIA_ITEMS_METADATA,
COMMAND_CHANGE_MEDIA_ITEMS, COMMAND_CHANGE_MEDIA_ITEMS,
COMMAND_GET_TRACKS) COMMAND_GET_TRACKS,
COMMAND_GET_DEVICE_VOLUME,
COMMAND_SET_DEVICE_VOLUME,
COMMAND_ADJUST_DEVICE_VOLUME)
.build() .build()
var currentPlaybackState = Player.STATE_IDLE var currentPlaybackState = Player.STATE_IDLE
@@ -861,27 +869,66 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
} }
override fun getDeviceVolume(): Int { override fun getDeviceVolume(): Int {
return 0 // Short-lived cache without even-value quantization
val now = System.currentTimeMillis()
if (pendingVolume != null && (now - pendingVolumeSetAt) < 100) {
return pendingVolume!!
}
val session = castContext.sessionManager.currentCastSession
val vol = if (session != null && !session.isMute) {
(session.volume * 100).roundToInt().coerceIn(0, 100)
} else {
0
}
if (pendingVolume != null && vol == pendingVolume) {
pendingVolume = null
}
return vol
} }
override fun isDeviceMuted(): Boolean { override fun isDeviceMuted(): Boolean {
return false val session = castContext.sessionManager.currentCastSession
return session?.isMute ?: false
} }
override fun setDeviceVolume(volume: Int) { override fun setDeviceVolume(volume: Int) {
val session = castContext.sessionManager.currentCastSession
try {
val clamped = volume.coerceIn(0, 100)
val target = (clamped / 100.0).let { kotlin.math.round(it * 100.0) / 100.0 }
// Cache for 100ms to return in getDeviceVolume during async period
pendingVolume = clamped
pendingVolumeSetAt = System.currentTimeMillis()
session?.setVolume(target)
} catch (e: Exception) {
Log.e(tag, "Failed to set cast device volume", e)
}
} }
override fun increaseDeviceVolume() { override fun increaseDeviceVolume() {
val current = getDeviceVolume()
val next = current + 1
setDeviceVolume(next.coerceAtMost(100))
} }
override fun decreaseDeviceVolume() { override fun decreaseDeviceVolume() {
val current = getDeviceVolume()
val next = current - 1
setDeviceVolume(next.coerceAtLeast(0))
} }
override fun setDeviceMuted(muted: Boolean) { override fun setDeviceMuted(muted: Boolean) {
val session = castContext.sessionManager.currentCastSession
try {
session?.isMute = muted
} catch (e: Exception) {
Log.e(tag, "Failed to set cast device mute state", e)
}
} }
inner class StatusListener() : RemoteMediaClient.Callback(), SessionManagerListener<CastSession>, RemoteMediaClient.ProgressListener { inner class StatusListener() : RemoteMediaClient.Callback(), SessionManagerListener<CastSession>, RemoteMediaClient.ProgressListener {
@@ -18,6 +18,8 @@ import android.support.v4.media.MediaDescriptionCompat
import android.support.v4.media.MediaMetadataCompat import android.support.v4.media.MediaMetadataCompat
import android.support.v4.media.session.MediaControllerCompat import android.support.v4.media.session.MediaControllerCompat
import android.support.v4.media.session.MediaSessionCompat import android.support.v4.media.session.MediaSessionCompat
import androidx.media.VolumeProviderCompat
import android.media.AudioManager
import android.support.v4.media.session.PlaybackStateCompat import android.support.v4.media.session.PlaybackStateCompat
import android.util.Log import android.util.Log
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
@@ -94,6 +96,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
private lateinit var mediaSessionConnector: MediaSessionConnector private lateinit var mediaSessionConnector: MediaSessionConnector
private lateinit var playerNotificationManager: PlayerNotificationManager private lateinit var playerNotificationManager: PlayerNotificationManager
lateinit var mediaSession: MediaSessionCompat lateinit var mediaSession: MediaSessionCompat
private var remoteVolumeProvider: VolumeProviderCompat? = null
private lateinit var transportControls: MediaControllerCompat.TransportControls private lateinit var transportControls: MediaControllerCompat.TransportControls
lateinit var mediaManager: MediaManager lateinit var mediaManager: MediaManager
@@ -477,6 +480,8 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
val dataSourceFactory = DefaultDataSource.Factory(ctx) val dataSourceFactory = DefaultDataSource.Factory(ctx)
val extractorsFactory = DefaultExtractorsFactory() val extractorsFactory = DefaultExtractorsFactory()
extractorsFactory.setConstantBitrateSeekingEnabled(true)
if (DeviceManager.deviceData.deviceSettings?.enableMp3IndexSeeking == true) { if (DeviceManager.deviceData.deviceSettings?.enableMp3IndexSeeking == true) {
// @see // @see
// https://exoplayer.dev/troubleshooting.html#why-is-seeking-inaccurate-in-some-mp3-files // https://exoplayer.dev/troubleshooting.html#why-is-seeking-inaccurate-in-some-mp3-files
@@ -491,6 +496,8 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
val dataSourceFactory = DefaultHttpDataSource.Factory() val dataSourceFactory = DefaultHttpDataSource.Factory()
val extractorsFactory = DefaultExtractorsFactory() val extractorsFactory = DefaultExtractorsFactory()
extractorsFactory.setConstantBitrateSeekingEnabled(true)
if (DeviceManager.deviceData.deviceSettings?.enableMp3IndexSeeking == true) { if (DeviceManager.deviceData.deviceSettings?.enableMp3IndexSeeking == true) {
// @see // @see
// https://exoplayer.dev/troubleshooting.html#why-is-seeking-inaccurate-in-some-mp3-files // https://exoplayer.dev/troubleshooting.html#why-is-seeking-inaccurate-in-some-mp3-files
@@ -705,11 +712,13 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
Log.d(tag, "switchToPlayer: Using Cast Player " + castPlayer?.deviceInfo) Log.d(tag, "switchToPlayer: Using Cast Player " + castPlayer?.deviceInfo)
mediaSessionConnector.setPlayer(castPlayer) mediaSessionConnector.setPlayer(castPlayer)
playerNotificationManager.setPlayer(castPlayer) playerNotificationManager.setPlayer(castPlayer)
setMediaSessionToCastVolume()
castPlayer as CastPlayer castPlayer as CastPlayer
} else { } else {
Log.d(tag, "switchToPlayer: Using ExoPlayer") Log.d(tag, "switchToPlayer: Using ExoPlayer")
mediaSessionConnector.setPlayer(mPlayer) mediaSessionConnector.setPlayer(mPlayer)
playerNotificationManager.setPlayer(mPlayer) playerNotificationManager.setPlayer(mPlayer)
setMediaSessionToLocalVolume()
mPlayer mPlayer
} }
@@ -726,6 +735,33 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
} }
} }
private fun setMediaSessionToCastVolume() {
val currentVol = try { castPlayer?.getDeviceVolume() ?: 0 } catch (_: Exception) { 0 }
val provider = object : VolumeProviderCompat(VolumeProviderCompat.VOLUME_CONTROL_ABSOLUTE, 100, currentVol) {
override fun onSetVolumeTo(volume: Int) {
// Clamp, update UI immediately, then send to device
val clamped = volume.coerceIn(0, 100)
setCurrentVolume(clamped)
try { castPlayer?.setDeviceVolume(clamped) } catch (_: Exception) {}
}
override fun onAdjustVolume(direction: Int) {
// Use Android-provided step (1, 0, +1). Clamp, update UI immediately, then send.
val current = currentVolume
val target = (current + direction).coerceIn(0, 100)
setCurrentVolume(target)
try { castPlayer?.setDeviceVolume(target) } catch (_: Exception) {}
}
}
remoteVolumeProvider = provider
mediaSession.setPlaybackToRemote(provider)
}
private fun setMediaSessionToLocalVolume() {
mediaSession.setPlaybackToLocal(AudioManager.STREAM_MUSIC)
remoteVolumeProvider = null
}
fun getCurrentTrackStartOffsetMs(): Long { fun getCurrentTrackStartOffsetMs(): Long {
return if (currentPlayer.mediaItemCount > 1) { return if (currentPlayer.mediaItemCount > 1) {
val windowIndex = currentPlayer.currentMediaItemIndex val windowIndex = currentPlayer.currentMediaItemIndex
@@ -1087,6 +1123,26 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
private val DOWNLOADS_ROOT = "__DOWNLOADS__" private val DOWNLOADS_ROOT = "__DOWNLOADS__"
private val CONTINUE_ROOT = "__CONTINUE__" private val CONTINUE_ROOT = "__CONTINUE__"
private lateinit var browseTree: BrowseTree private lateinit var browseTree: BrowseTree
private val browseTreeInitListeners = mutableListOf<() -> Unit>()
private fun waitForBrowseTree(cb: () -> Unit)
{
if (this::browseTree.isInitialized)
{
cb()
}
else
{
browseTreeInitListeners += cb
}
}
private fun onBrowseTreeInitialized()
{
// Called after browseTree is assigned for the first time
browseTreeInitListeners.forEach { it.invoke() }
browseTreeInitListeners.clear()
}
// Only allowing android auto or similar to access media browser service // Only allowing android auto or similar to access media browser service
// normal loading of audiobooks is handled in webview (not natively) // normal loading of audiobooks is handled in webview (not natively)
@@ -1257,6 +1313,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
mediaManager.serverLibraries, mediaManager.serverLibraries,
mediaManager.allLibraryPersonalizationsDone mediaManager.allLibraryPersonalizationsDone
) )
onBrowseTreeInitialized()
val children = val children =
browseTree[parentMediaId]?.map { item -> browseTree[parentMediaId]?.map { item ->
Log.d(tag, "Found top menu item: ${item.description.title}") Log.d(tag, "Found top menu item: ${item.description.title}")
@@ -1291,6 +1348,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
mediaManager.serverLibraries, mediaManager.serverLibraries,
mediaManager.allLibraryPersonalizationsDone mediaManager.allLibraryPersonalizationsDone
) )
onBrowseTreeInitialized()
val children = val children =
browseTree[parentMediaId]?.map { item -> browseTree[parentMediaId]?.map { item ->
Log.d(tag, "Found top menu item: ${item.description.title}") Log.d(tag, "Found top menu item: ${item.description.title}")
@@ -1303,16 +1361,34 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
AbsLogger.info(tag, "onLoadChildren: Android auto data loaded") AbsLogger.info(tag, "onLoadChildren: Android auto data loaded")
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?) result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
} }
} else if (parentMediaId == LIBRARIES_ROOT || parentMediaId == RECENTLY_ROOT) { } else if (parentMediaId == LIBRARIES_ROOT || parentMediaId == RECENTLY_ROOT)
{
Log.d(tag, "First load done: $firstLoadDone") Log.d(tag, "First load done: $firstLoadDone")
if (!firstLoadDone) { if (!firstLoadDone)
{
result.sendResult(null) result.sendResult(null)
return return
} }
// Wait until top-menu is initialized
while (!this::browseTree.isInitialized) {} if (!this::browseTree.isInitialized)
val children = {
browseTree[parentMediaId]?.map { item -> // ✅ good: detach and wait for init
result.detach()
waitForBrowseTree {
val children = browseTree[parentMediaId]?.map { item ->
Log.d(tag, "[MENU: $parentMediaId] Showing list item ${item.description.title}")
MediaBrowserCompat.MediaItem(
item.description,
MediaBrowserCompat.MediaItem.FLAG_BROWSABLE
)
}
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
}
return
}
// Already initialized: just return
val children = browseTree[parentMediaId]?.map { item ->
Log.d(tag, "[MENU: $parentMediaId] Showing list item ${item.description.title}") Log.d(tag, "[MENU: $parentMediaId] Showing list item ${item.description.title}")
MediaBrowserCompat.MediaItem( MediaBrowserCompat.MediaItem(
item.description, item.description,
@@ -33,6 +33,9 @@ class AbsAudioPlayer : Plugin() {
private var isCastAvailable:Boolean = false private var isCastAvailable:Boolean = false
// Track foreground state to avoid flooding WebView with events while backgrounded
private var isInForeground: Boolean = true
override fun load() { override fun load() {
mainActivity = (activity as MainActivity) mainActivity = (activity as MainActivity)
apiHandler = ApiHandler(mainActivity) apiHandler = ApiHandler(mainActivity)
@@ -60,6 +63,8 @@ class AbsAudioPlayer : Plugin() {
} }
override fun onMetadata(metadata: PlaybackMetadata) { override fun onMetadata(metadata: PlaybackMetadata) {
// Skip frequent metadata updates when app is backgrounded to prevent event queue buildup
if (!isInForeground) return
notifyListeners("onMetadata", JSObject(jacksonMapper.writeValueAsString(metadata))) notifyListeners("onMetadata", JSObject(jacksonMapper.writeValueAsString(metadata)))
} }
@@ -68,6 +73,8 @@ class AbsAudioPlayer : Plugin() {
} }
override fun onSleepTimerSet(sleepTimeRemaining: Int, isAutoSleepTimer:Boolean) { override fun onSleepTimerSet(sleepTimeRemaining: Int, isAutoSleepTimer:Boolean) {
// Skip sleep timer updates when app is backgrounded to prevent event queue buildup
if (!isInForeground) return
val ret = JSObject() val ret = JSObject()
ret.put("value", sleepTimeRemaining) ret.put("value", sleepTimeRemaining)
ret.put("isAuto", isAutoSleepTimer) ret.put("isAuto", isAutoSleepTimer)
@@ -75,6 +82,8 @@ class AbsAudioPlayer : Plugin() {
} }
override fun onLocalMediaProgressUpdate(localMediaProgress: LocalMediaProgress) { override fun onLocalMediaProgressUpdate(localMediaProgress: LocalMediaProgress) {
// Skip progress updates when app is backgrounded to prevent event queue buildup
if (!isInForeground) return
notifyListeners("onLocalMediaProgressUpdate", JSObject(jacksonMapper.writeValueAsString(localMediaProgress))) notifyListeners("onLocalMediaProgressUpdate", JSObject(jacksonMapper.writeValueAsString(localMediaProgress)))
} }
@@ -118,6 +127,27 @@ class AbsAudioPlayer : Plugin() {
notifyListeners(evtName, ret) notifyListeners(evtName, ret)
} }
override fun handleOnPause() {
super.handleOnPause()
isInForeground = false
}
override fun handleOnResume() {
super.handleOnResume()
isInForeground = true
// Send current state to UI after resume to sync up (with small delay to let WebView fully resume)
if (::playerNotificationService.isInitialized && playerNotificationService.currentPlaybackSession != null) {
Handler(Looper.getMainLooper()).postDelayed({
playerNotificationService.sendClientMetadata(PlayerState.READY)
playerNotificationService.sleepTimerManager.sendCurrentSleepTimerState()
playerNotificationService.mediaProgressSyncer.currentLocalMediaProgress?.let {
playerNotificationService.clientEventEmitter?.onLocalMediaProgressUpdate(it)
}
}, 100)
}
}
private fun initCastManager() { private fun initCastManager() {
val googleApi = GoogleApiAvailability.getInstance() val googleApi = GoogleApiAvailability.getInstance()
val statusCode = googleApi.isGooglePlayServicesAvailable(mainActivity) val statusCode = googleApi.isGooglePlayServicesAvailable(mainActivity)
@@ -119,7 +119,7 @@ class AbsDatabase : Plugin() {
@PluginMethod @PluginMethod
fun setCurrentServerConnectionConfig(call:PluginCall) { fun setCurrentServerConnectionConfig(call:PluginCall) {
Log.d(tag, "setCurrentServerConnectionConfig ${call.data}") Log.d(tag, "setCurrentServerConnectionConfig called")
val serverConfigPayload = jacksonMapper.readValue<ServerConnConfigPayload>(call.data.toString()) val serverConfigPayload = jacksonMapper.readValue<ServerConnConfigPayload>(call.data.toString())
var serverConnectionConfig = DeviceManager.deviceData.serverConnectionConfigs.find { it.id == serverConfigPayload.id } var serverConnectionConfig = DeviceManager.deviceData.serverConnectionConfigs.find { it.id == serverConfigPayload.id }
@@ -558,7 +558,7 @@ class AbsDatabase : Plugin() {
@PluginMethod @PluginMethod
fun updateDeviceSettings(call:PluginCall) { // Returns device data fun updateDeviceSettings(call:PluginCall) { // Returns device data
Log.d(tag, "updateDeviceSettings ${call.data}") Log.d(tag, "updateDeviceSettings called")
val newDeviceSettings = jacksonMapper.readValue<DeviceSettings>(call.data.toString()) val newDeviceSettings = jacksonMapper.readValue<DeviceSettings>(call.data.toString())
Handler(Looper.getMainLooper()).post { Handler(Looper.getMainLooper()).post {
@@ -576,7 +576,7 @@ class AbsDatabase : Plugin() {
@PluginMethod @PluginMethod
fun getMediaItemHistory(call:PluginCall) { // Returns device data fun getMediaItemHistory(call:PluginCall) { // Returns device data
Log.d(tag, "getMediaItemHistory ${call.data}") Log.d(tag, "getMediaItemHistory called")
val mediaId = call.getString("mediaId") ?: "" val mediaId = call.getString("mediaId") ?: ""
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {
@@ -407,6 +407,9 @@ class ApiHandler(var ctx:Context) {
if (checkAbsDatabaseNotifyListenersInitted()) { if (checkAbsDatabaseNotifyListenersInitted()) {
val tokenJsObject = JSObject() val tokenJsObject = JSObject()
tokenJsObject.put("error", "Token refresh failed") tokenJsObject.put("error", "Token refresh failed")
if (serverConnectionConfigId.isNotEmpty()) {
tokenJsObject.put("serverConnectionConfigId", serverConnectionConfigId)
}
absDatabaseNotifyListeners("onTokenRefreshFailure", tokenJsObject) absDatabaseNotifyListeners("onTokenRefreshFailure", tokenJsObject)
} else { } else {
// Can happen if Webview is never run // Can happen if Webview is never run
@@ -11,4 +11,4 @@
android:targetCellWidth="4" android:targetCellWidth="4"
android:targetCellHeight="1" android:targetCellHeight="1"
android:updatePeriodMillis="86400000" android:updatePeriodMillis="86400000"
android:widgetCategory="home_screen" /> android:widgetCategory="home_screen|keyguard" />
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<samsung-appwidget-provider
display="sub_screen"
privacyWidget="false" />
+7 -5
View File
@@ -39,11 +39,11 @@ class WrappingMarquee {
this.setMask(true) this.setMask(true)
let textScrollAmount = this.el.scrollWidth let textScrollAmount = this.el.scrollWidth
this.pEl.innerHTML = this.innerText + '&nbsp;'.repeat(15) this.pEl.textContent = this.innerText + '\u00A0'.repeat(15) // \u00A0 = non-breaking space
let totalScrollAmount = this.el.scrollWidth let totalScrollAmount = this.el.scrollWidth
let scrollDuration = totalScrollAmount * this.#scrollSpeed let scrollDuration = totalScrollAmount * this.#scrollSpeed
this.pEl.innerHTML = this.pEl.innerHTML + this.innerText this.pEl.textContent = this.pEl.textContent + this.innerText
let done = false let done = false
let start, previousTimeStamp let start, previousTimeStamp
@@ -55,13 +55,14 @@ class WrappingMarquee {
const elapsed = timeStamp - start const elapsed = timeStamp - start
if (this.isScrolling && previousTimeStamp !== timeStamp) { if (this.isScrolling && previousTimeStamp !== timeStamp) {
const amountToMove = Math.min(elapsed / scrollDuration * totalScrollAmount, totalScrollAmount) const amountToMove = Math.min((elapsed / scrollDuration) * totalScrollAmount, totalScrollAmount)
this.pEl.style.transform = `translateX(-${amountToMove}px)` this.pEl.style.transform = `translateX(-${amountToMove}px)`
if (amountToMove === totalScrollAmount) done = true if (amountToMove === totalScrollAmount) done = true
if (amountToMove > textScrollAmount) this.setMask(false) if (amountToMove > textScrollAmount) this.setMask(false)
} }
if (!this.isScrolling || done) { // canceled or done if (!this.isScrolling || done) {
// canceled or done
this.isScrolling = false this.isScrolling = false
this.pEl.style.transform = 'translateX(0px)' this.pEl.style.transform = 'translateX(0px)'
this.pEl.innerText = this.innerText this.pEl.innerText = this.innerText
@@ -69,7 +70,8 @@ class WrappingMarquee {
if (done) { if (done) {
this.startTimer() this.startTimer()
} }
} else if (elapsed < scrollDuration) { // step } else if (elapsed < scrollDuration) {
// step
previousTimeStamp = timeStamp previousTimeStamp = timeStamp
this.animationId = window.requestAnimationFrame(step) this.animationId = window.requestAnimationFrame(step)
} }
+9 -9
View File
@@ -4,14 +4,14 @@
<nuxt-link v-show="!showBack" to="/" class="mr-3"> <nuxt-link v-show="!showBack" to="/" class="mr-3">
<img src="/Logo.png" class="h-10 w-10" /> <img src="/Logo.png" class="h-10 w-10" />
</nuxt-link> </nuxt-link>
<a v-if="showBack" @click="back" class="rounded-full h-10 w-10 flex items-center justify-center mr-2 cursor-pointer"> <a v-if="showBack" @click="back" aria-label="Back" class="rounded-full h-10 w-10 flex items-center justify-center mr-2 cursor-pointer">
<span class="material-symbols text-3xl text-fg">arrow_back</span> <span class="material-symbols text-3xl text-fg">arrow_back</span>
</a> </a>
<div v-if="user && currentLibrary"> <div v-if="user && currentLibrary">
<div class="pl-1.5 pr-2.5 py-2 bg-bg bg-opacity-30 rounded-md flex items-center" @click="clickShowLibraryModal"> <button type="button" aria-label="Show library modal" class="pl-1.5 pr-2.5 py-2 bg-bg bg-opacity-30 rounded-md flex items-center" @click="clickShowLibraryModal">
<ui-library-icon :icon="currentLibraryIcon" :size="4" font-size="base" /> <ui-library-icon :icon="currentLibraryIcon" :size="4" font-size="base" />
<p class="text-sm leading-4 ml-2 mt-0.5 max-w-24 truncate">{{ currentLibraryName }}</p> <p class="text-sm leading-4 ml-2 mt-0.5 max-w-24 truncate">{{ currentLibraryName }}</p>
</div> </button>
</div> </div>
<widgets-connection-indicator /> <widgets-connection-indicator />
@@ -21,19 +21,19 @@
<widgets-download-progress-indicator /> <widgets-download-progress-indicator />
<!-- Must be connected to a server to cast, only supports media items on server --> <!-- Must be connected to a server to cast, only supports media items on server -->
<div v-show="isCastAvailable && user" class="mx-2 cursor-pointer flex items-center" @click="castClick"> <button type="button" aria-label="Cast" v-show="isCastAvailable && user" class="mx-2 cursor-pointer flex items-center" @click="castClick">
<span class="material-symbols text-2xl leading-none"> <span class="material-symbols text-2xl leading-none">
{{ isCasting ? 'cast_connected' : 'cast' }} {{ isCasting ? 'cast_connected' : 'cast' }}
</span> </span>
</div> </button>
<nuxt-link v-if="user" class="mx-1.5 flex items-center h-10" to="/search"> <nuxt-link v-if="user" class="mx-1.5 flex items-center h-10" to="/search" aria-label="Search">
<span class="material-symbols text-2xl leading-none">search</span> <span class="material-symbols text-2xl leading-none">search</span>
</nuxt-link> </nuxt-link>
<div class="h-7 mx-1.5"> <button type="button" aria-label="Toggle side drawer" class="h-7 mx-1.5" @click="clickShowSideDrawer">
<span class="material-symbols" style="font-size: 1.75rem" @click="clickShowSideDrawer">menu</span> <span class="material-symbols" style="font-size: 1.75rem">menu</span>
</div> </button>
</div> </div>
</div> </div>
</template> </template>
+47 -28
View File
@@ -69,16 +69,22 @@
<div id="playerControls" class="absolute right-0 bottom-0 mx-auto" style="max-width: 414px"> <div id="playerControls" class="absolute right-0 bottom-0 mx-auto" style="max-width: 414px">
<div class="flex items-center max-w-full" :class="playerSettings.lockUi ? 'justify-center' : 'justify-between'"> <div class="flex items-center max-w-full" :class="playerSettings.lockUi ? 'justify-center' : 'justify-between'">
<span v-show="showFullscreen && !playerSettings.lockUi" class="material-symbols next-icon text-fg cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpChapterStart">first_page</span> <span v-show="showFullscreen && !playerSettings.lockUi" class="material-symbols next-icon text-fg cursor-pointer" :class="showLoadingState ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpChapterStart">first_page</span>
<span v-show="!playerSettings.lockUi" class="material-symbols jump-icon text-fg cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpBackwards">{{ jumpBackwardsIcon }}</span> <div v-show="!playerSettings.lockUi" class="jump-icon text-fg cursor-pointer flex flex-col items-center" :class="showLoadingState ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpBackwards">
<span class="material-symbols text-3xl leading-none">replay</span>
<span v-if="showFullscreen" class="jump-label text-[10px] font-semibold leading-tight">{{ jumpBackwardsLabel }}</span>
</div>
<div class="play-btn cursor-pointer shadow-sm flex items-center justify-center rounded-full text-primary mx-4 relative overflow-hidden" :style="{ backgroundColor: coverRgb }" :class="{ 'animate-spin': seekLoading }" @mousedown.prevent @mouseup.prevent @click.stop="playPauseClick"> <div class="play-btn cursor-pointer shadow-sm flex items-center justify-center rounded-full text-primary mx-4 relative overflow-hidden" :style="{ backgroundColor: coverRgb }" :class="{ 'animate-spin': seekLoading }" @mousedown.prevent @mouseup.prevent @click.stop="playPauseClick">
<div v-if="!coverBgIsLight" class="absolute top-0 left-0 w-full h-full bg-white bg-opacity-20 pointer-events-none" /> <div v-if="!coverBgIsLight" class="absolute top-0 left-0 w-full h-full bg-white bg-opacity-20 pointer-events-none" />
<span v-if="!isLoading" class="material-symbols fill" :class="{ 'text-white': coverRgb && !coverBgIsLight }">{{ seekLoading ? 'autorenew' : !isPlaying ? 'play_arrow' : 'pause' }}</span> <span v-if="!showLoadingState" class="material-symbols fill" :class="{ 'text-white': coverRgb && !coverBgIsLight }">{{ seekLoading ? 'autorenew' : !isPlaying ? 'play_arrow' : 'pause' }}</span>
<widgets-spinner-icon v-else class="h-8 w-8" /> <widgets-spinner-icon v-else class="h-8 w-8" />
</div> </div>
<span v-show="!playerSettings.lockUi" class="material-symbols jump-icon text-fg cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpForward">{{ jumpForwardIcon }}</span> <div v-show="!playerSettings.lockUi" class="jump-icon text-fg cursor-pointer flex flex-col items-center" :class="showLoadingState ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpForward">
<span v-show="showFullscreen && !playerSettings.lockUi" class="material-symbols next-icon text-fg cursor-pointer" :class="nextChapter && !isLoading ? 'text-opacity-75' : 'text-opacity-10'" @click.stop="jumpNextChapter">last_page</span> <span class="material-symbols text-3xl leading-none">forward_media</span>
<span v-if="showFullscreen" class="jump-label text-[10px] font-semibold leading-tight">{{ jumpForwardLabel }}</span>
</div>
<span v-show="showFullscreen && !playerSettings.lockUi" class="material-symbols next-icon text-fg cursor-pointer" :class="nextChapter && !showLoadingState ? 'text-opacity-75' : 'text-opacity-10'" @click.stop="jumpNextChapter">last_page</span>
</div> </div>
</div> </div>
@@ -88,7 +94,7 @@
<div class="flex-grow" /> <div class="flex-grow" />
<p class="font-mono text-fg" style="font-size: 0.8rem">{{ timeRemainingPretty }}</p> <p class="font-mono text-fg" style="font-size: 0.8rem">{{ timeRemainingPretty }}</p>
</div> </div>
<div ref="track" class="h-1.5 w-full bg-track/50 relative rounded-full" :class="{ 'animate-pulse': isLoading }" @click.stop> <div ref="track" class="h-1.5 w-full bg-track/50 relative rounded-full" :class="{ 'animate-pulse': showLoadingState }" @click.stop>
<div ref="readyTrack" class="h-full bg-track-buffered absolute top-0 left-0 rounded-full pointer-events-none" /> <div ref="readyTrack" class="h-full bg-track-buffered absolute top-0 left-0 rounded-full pointer-events-none" />
<div ref="bufferedTrack" class="h-full bg-track absolute top-0 left-0 rounded-full pointer-events-none" /> <div ref="bufferedTrack" class="h-full bg-track absolute top-0 left-0 rounded-full pointer-events-none" />
<div ref="playedTrack" class="h-full bg-track-cursor absolute top-0 left-0 rounded-full pointer-events-none" /> <div ref="playedTrack" class="h-full bg-track-cursor absolute top-0 left-0 rounded-full pointer-events-none" />
@@ -108,8 +114,9 @@
import { Capacitor } from '@capacitor/core' import { Capacitor } from '@capacitor/core'
import { AbsAudioPlayer } from '@/plugins/capacitor' import { AbsAudioPlayer } from '@/plugins/capacitor'
import { Dialog } from '@capacitor/dialog' import { Dialog } from '@capacitor/dialog'
import { FastAverageColor } from 'fast-average-color' import { getAverageColorFromCoverUrl } from '@/utils/coverAverageColor'
import WrappingMarquee from '@/assets/WrappingMarquee.js' import WrappingMarquee from '@/assets/WrappingMarquee.js'
import jumpLabelMixin from '@/mixins/jumpLabel'
export default { export default {
props: { props: {
@@ -121,6 +128,7 @@ export default {
sleepTimeRemaining: Number, sleepTimeRemaining: Number,
serverLibraryItemId: String serverLibraryItemId: String
}, },
mixins: [jumpLabelMixin],
data() { data() {
return { return {
windowHeight: 0, windowHeight: 0,
@@ -149,6 +157,7 @@ export default {
lockUi: false lockUi: false
}, },
isLoading: false, isLoading: false,
isCheckingServerProgress: false,
isDraggingCursor: false, isDraggingCursor: false,
draggingTouchStartX: 0, draggingTouchStartX: 0,
draggingTouchStartTime: 0, draggingTouchStartTime: 0,
@@ -221,11 +230,11 @@ export default {
return items return items
}, },
jumpForwardIcon() { jumpForwardLabel() {
return this.$store.getters['globals/getJumpForwardIcon'](this.jumpForwardTime) return this.getJumpLabel(this.jumpForwardTime)
}, },
jumpBackwardsIcon() { jumpBackwardsLabel() {
return this.$store.getters['globals/getJumpBackwardsIcon'](this.jumpBackwardsTime) return this.getJumpLabel(this.jumpBackwardsTime)
}, },
jumpForwardTime() { jumpForwardTime() {
return this.$store.getters['getJumpForwardTime'] return this.$store.getters['getJumpForwardTime']
@@ -262,6 +271,9 @@ export default {
return 190 * heightScale return 190 * heightScale
} }
}, },
showLoadingState() {
return this.isLoading || this.isCheckingServerProgress
},
showCastBtn() { showCastBtn() {
return this.$store.state.isCastAvailable return this.$store.state.isCastAvailable
}, },
@@ -400,17 +412,14 @@ export default {
}, },
async coverImageLoaded(fullCoverUrl) { async coverImageLoaded(fullCoverUrl) {
if (!fullCoverUrl) return if (!fullCoverUrl) return
const avg = await getAverageColorFromCoverUrl(this, fullCoverUrl)
const fac = new FastAverageColor() if (!avg) {
fac this.coverRgb = 'rgb(55, 56, 56)'
.getColorAsync(fullCoverUrl) this.coverBgIsLight = false
.then((color) => { } else {
this.coverRgb = color.rgba this.coverRgb = avg.rgba
this.coverBgIsLight = color.isLight this.coverBgIsLight = avg.isLight
}) }
.catch((e) => {
console.log(e)
})
}, },
clickTitleAndAuthor() { clickTitleAndAuthor() {
if (!this.showFullscreen) return if (!this.showFullscreen) return
@@ -453,13 +462,13 @@ export default {
}, },
async jumpNextChapter() { async jumpNextChapter() {
await this.$hapticsImpact() await this.$hapticsImpact()
if (this.isLoading) return if (this.showLoadingState) return
if (!this.nextChapter) return if (!this.nextChapter) return
this.seek(this.nextChapter.start) this.seek(this.nextChapter.start)
}, },
async jumpChapterStart() { async jumpChapterStart() {
await this.$hapticsImpact() await this.$hapticsImpact()
if (this.isLoading) return if (this.showLoadingState) return
if (!this.currentChapter) { if (!this.currentChapter) {
return this.restart() return this.restart()
} }
@@ -489,12 +498,12 @@ export default {
}, },
async jumpBackwards() { async jumpBackwards() {
await this.$hapticsImpact() await this.$hapticsImpact()
if (this.isLoading) return if (this.showLoadingState) return
AbsAudioPlayer.seekBackward({ value: this.jumpBackwardsTime }) AbsAudioPlayer.seekBackward({ value: this.jumpBackwardsTime })
}, },
async jumpForward() { async jumpForward() {
await this.$hapticsImpact() await this.$hapticsImpact()
if (this.isLoading) return if (this.showLoadingState) return
AbsAudioPlayer.seekForward({ value: this.jumpForwardTime }) AbsAudioPlayer.seekForward({ value: this.jumpForwardTime })
}, },
setStreamReady() { setStreamReady() {
@@ -581,8 +590,12 @@ export default {
} }
const ptWidth = Math.round(percentDone * this.trackWidth) const ptWidth = Math.round(percentDone * this.trackWidth)
if (this.$refs.playedTrack) {
this.$refs.playedTrack.style.width = ptWidth + 'px' this.$refs.playedTrack.style.width = ptWidth + 'px'
}
if (this.$refs.bufferedTrack) {
this.$refs.bufferedTrack.style.width = Math.round(bufferedPercent * this.trackWidth) + 'px' this.$refs.bufferedTrack.style.width = Math.round(bufferedPercent * this.trackWidth) + 'px'
}
if (this.$refs.trackCursor) { if (this.$refs.trackCursor) {
this.$refs.trackCursor.style.left = ptWidth - 14 + 'px' this.$refs.trackCursor.style.left = ptWidth - 14 + 'px'
@@ -594,7 +607,7 @@ export default {
} }
}, },
seek(time) { seek(time) {
if (this.isLoading) return if (this.showLoadingState) return
if (this.seekLoading) { if (this.seekLoading) {
console.error('Already seek loading', this.seekedTime) console.error('Already seek loading', this.seekedTime)
return return
@@ -626,11 +639,14 @@ export default {
}, },
async playPauseClick() { async playPauseClick() {
await this.$hapticsImpact() await this.$hapticsImpact()
if (this.isLoading) return if (this.showLoadingState) return
this.isPlaying = !!((await AbsAudioPlayer.playPause()) || {}).playing this.isPlaying = !!((await AbsAudioPlayer.playPause()) || {}).playing
this.isEnded = false this.isEnded = false
}, },
setIsCheckingServerProgress(value) {
this.isCheckingServerProgress = !!value
},
play() { play() {
AbsAudioPlayer.playPlayer() AbsAudioPlayer.playPlayer()
this.startPlayInterval() this.startPlayInterval()
@@ -1087,6 +1103,9 @@ export default {
margin: 0px 0px; margin: 0px 0px;
font-size: 1.6rem; font-size: 1.6rem;
} }
#playerControls .jump-label {
margin-top: 2px;
}
#playerControls .play-btn { #playerControls .play-btn {
transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1); transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1);
transition-property: padding, margin, height, width, min-width, min-height; transition-property: padding, margin, height, width, min-width, min-height;
+118 -32
View File
@@ -48,6 +48,9 @@ export default {
}, },
isIos() { isIos() {
return this.$platform === 'ios' return this.$platform === 'ios'
},
currentPlaybackSession() {
return this.$store.state.currentPlaybackSession
} }
}, },
methods: { methods: {
@@ -304,48 +307,129 @@ export default {
this.$refs.audioPlayer?.seek(currentTime) this.$refs.audioPlayer?.seek(currentTime)
}, },
/** /**
* When device gains focus then refresh the timestamps in the audio player * Fetch the current user's media progress from the server for a given library item / episode.
* Returns the server media progress object, or null if the request fails, times out, or the
* response doesn't match the requested library item.
*
* The audio player's loading state is shown while the request is in flight so the user
* doesn't tap play before we have a chance to update the timestamps. The request timeout
* is 7 seconds so a slow/unresponsive server doesn't block the user for long.
*/ */
deviceFocused(hasFocus) { async getServerMediaProgressForCurrentSession() {
if (!this.$store.state.currentPlaybackSession) return if (!this.$store.state.user.user || !this.$store.state.networkConnected) return null
const libraryItemId = this.currentPlaybackSession?.libraryItemId
const episodeId = this.currentPlaybackSession?.episodeId
if (!libraryItemId) return null
if (this.$refs.audioPlayer?.isCheckingServerProgress) {
console.log('[AudioPlayerContainer] getServerMediaProgressForCurrentSession: already checking server progress')
return null
}
const url = episodeId ? `/api/me/progress/${libraryItemId}/${episodeId}` : `/api/me/progress/${libraryItemId}`
this.$refs.audioPlayer?.setIsCheckingServerProgress(true)
try {
const data = await this.$nativeHttp.get(url, { connectTimeout: 7000, readTimeout: 7000 })
if (!data || data.libraryItemId !== libraryItemId) return null
return data
} catch (error) {
console.error('[AudioPlayerContainer] Failed to get server media progress', error)
return null
} finally {
this.$refs.audioPlayer?.setIsCheckingServerProgress(false)
}
},
getLocalMediaProgressForCurrentSession() {
if (!this.currentPlaybackSession) return null
return this.$store.getters['globals/getLocalMediaProgressById'](this.currentPlaybackSession.localLibraryItem?.id, this.currentPlaybackSession.localEpisodeId)
},
/**
* Sync the server media progress with the local media progress
*/
async syncServerMediaProgressWithLocalMediaProgress(localMediaProgressId, serverMediaProgress) {
try {
const newLocalMediaProgress = await this.$db.syncServerMediaProgressWithLocalMediaProgress({
localMediaProgressId,
mediaProgress: serverMediaProgress
})
if (newLocalMediaProgress?.id) {
this.$store.commit('globals/updateLocalMediaProgress', newLocalMediaProgress)
}
} catch (error) {
console.error('[AudioPlayerContainer] Failed to sync server progress with local media progress', error)
}
},
/**
* Check if the server media progress is more recent than the local media progress and sync if so
*/
async checkSyncServerProgressWithLocalProgress(localMediaProgress) {
if (!localMediaProgress) return
console.log('[AudioPlayerContainer] checkSyncServerProgressWithLocalProgress: checking server media progress for local media item open in player')
const serverMediaProgress = await this.getServerMediaProgressForCurrentSession()
if (!serverMediaProgress?.lastUpdate || serverMediaProgress.lastUpdate <= localMediaProgress.lastUpdate) return
console.log('[AudioPlayerContainer] checkSyncServerProgressWithLocalProgress: server progress is more recent than local progress. Server current time:', serverMediaProgress.currentTime, 'vs local', localMediaProgress.currentTime, `(server lastUpdate=${serverMediaProgress.lastUpdate} > local lastUpdate=${localMediaProgress.lastUpdate})`)
if (!this.$refs.audioPlayer?.isPlaying && serverMediaProgress.currentTime !== localMediaProgress.currentTime) {
// Use seek() so the native audio player's current session is updated
this.$refs.audioPlayer.seek(serverMediaProgress.currentTime)
}
await this.syncServerMediaProgressWithLocalMediaProgress(localMediaProgress.id, serverMediaProgress)
},
/**
* When socket is reconnected after a delay, if a local media item is open in the player (paused)
* we fetch the server media progress and sync it if it is more recent than the local progress
*
* If there is no socket connection we may have missed external progress updates
*/
async socketReconnected() {
if (!this.currentPlaybackSession) return
// dont update timestamps if player is playing
if (this.$refs.audioPlayer?.isPlaying) return
if (hasFocus) {
if (!this.$refs.audioPlayer?.isPlaying) {
const playbackSession = this.$store.state.currentPlaybackSession
if (this.$refs.audioPlayer.isLocalPlayMethod) { if (this.$refs.audioPlayer.isLocalPlayMethod) {
const localLibraryItemId = playbackSession.localLibraryItem?.id const localMediaProgress = this.getLocalMediaProgressForCurrentSession()
const localEpisodeId = playbackSession.localEpisodeId if (!localMediaProgress) {
if (!localLibraryItemId) { console.error('[AudioPlayerContainer] socket reconnected: Local media progress not found')
console.error('[AudioPlayerContainer] device visibility: no local library item for session', JSON.stringify(playbackSession))
return return
} }
const localMediaProgress = this.$store.state.globals.localMediaProgress.find((mp) => {
if (localEpisodeId) return mp.localEpisodeId === localEpisodeId await this.checkSyncServerProgressWithLocalProgress(localMediaProgress)
return mp.localLibraryItemId === localLibraryItemId }
}) },
if (localMediaProgress) { /**
* When device re-gains focus then refresh the timestamps in the audio player
* if local item is open then fetch the server media progress and update if more recent
*/
async deviceFocused(hasFocus) {
if (!this.currentPlaybackSession || !hasFocus) return
// dont update timestamps if player is playing
if (this.$refs.audioPlayer?.isPlaying) return
if (this.$refs.audioPlayer.isLocalPlayMethod) {
const localMediaProgress = this.getLocalMediaProgressForCurrentSession()
if (!localMediaProgress) {
console.error('[AudioPlayerContainer] device visibility: Local media progress not found')
return
}
console.log('[AudioPlayerContainer] device visibility: found local media progress', localMediaProgress.currentTime, 'last time in player is', this.currentTime) console.log('[AudioPlayerContainer] device visibility: found local media progress', localMediaProgress.currentTime, 'last time in player is', this.currentTime)
this.$refs.audioPlayer.currentTime = localMediaProgress.currentTime this.$refs.audioPlayer.currentTime = localMediaProgress.currentTime
this.$refs.audioPlayer.timeupdate() this.$refs.audioPlayer.timeupdate()
await this.checkSyncServerProgressWithLocalProgress(localMediaProgress)
} else { } else {
console.error('[AudioPlayerContainer] device visibility: Local media progress not found') // server item so fetch server media progress and update player time
} console.log('[AudioPlayerContainer] device visibility: checking server media progress for server media item open in player')
} else { const data = await this.getServerMediaProgressForCurrentSession()
const libraryItemId = playbackSession.libraryItemId if (!data) return
const episodeId = playbackSession.episodeId if (!this.$refs.audioPlayer?.isPlaying) {
const url = episodeId ? `/api/me/progress/${libraryItemId}/${episodeId}` : `/api/me/progress/${libraryItemId}`
this.$nativeHttp
.get(url)
.then((data) => {
if (!this.$refs.audioPlayer?.isPlaying && data.libraryItemId === libraryItemId) {
console.log('[AudioPlayerContainer] device visibility: got server media progress', data.currentTime, 'last time in player is', this.currentTime) console.log('[AudioPlayerContainer] device visibility: got server media progress', data.currentTime, 'last time in player is', this.currentTime)
this.$refs.audioPlayer.currentTime = data.currentTime // Only seek if the difference is greater than 1 second
this.$refs.audioPlayer.timeupdate() if (Math.abs(data.currentTime - this.currentTime) > 1) {
} // Use seek() so the native audio player's current session is updated
}) this.$refs.audioPlayer.seek(data.currentTime)
.catch((error) => {
console.error('[AudioPlayerContainer] device visibility: Failed to get progress', error)
})
} }
} }
} }
@@ -368,6 +452,7 @@ export default {
this.$eventBus.$on('user-settings', this.settingsUpdated) this.$eventBus.$on('user-settings', this.settingsUpdated)
this.$eventBus.$on('playback-time-update', this.playbackTimeUpdate) this.$eventBus.$on('playback-time-update', this.playbackTimeUpdate)
this.$eventBus.$on('device-focus-update', this.deviceFocused) this.$eventBus.$on('device-focus-update', this.deviceFocused)
this.$eventBus.$on('socket-reconnected', this.socketReconnected)
}, },
beforeDestroy() { beforeDestroy() {
this.onLocalMediaProgressUpdateListener?.remove() this.onLocalMediaProgressUpdateListener?.remove()
@@ -383,6 +468,7 @@ export default {
this.$eventBus.$off('user-settings', this.settingsUpdated) this.$eventBus.$off('user-settings', this.settingsUpdated)
this.$eventBus.$off('playback-time-update', this.playbackTimeUpdate) this.$eventBus.$off('playback-time-update', this.playbackTimeUpdate)
this.$eventBus.$off('device-focus-update', this.deviceFocused) this.$eventBus.$off('device-focus-update', this.deviceFocused)
this.$eventBus.$off('socket-reconnected', this.socketReconnected)
} }
} }
</script> </script>
+12 -10
View File
@@ -3,19 +3,16 @@
<div class="absolute top-0 left-0 w-full h-full bg-black transition-opacity duration-200" :class="show ? 'bg-opacity-60 pointer-events-auto' : 'bg-opacity-0'" @click="clickBackground" /> <div class="absolute top-0 left-0 w-full h-full bg-black transition-opacity duration-200" :class="show ? 'bg-opacity-60 pointer-events-auto' : 'bg-opacity-0'" @click="clickBackground" />
<div class="absolute top-0 right-0 w-64 h-full bg-bg transform transition-transform py-6 pointer-events-auto" :class="show ? '' : 'translate-x-64'" @click.stop> <div class="absolute top-0 right-0 w-64 h-full bg-bg transform transition-transform py-6 pointer-events-auto" :class="show ? '' : 'translate-x-64'" @click.stop>
<div class="px-6 mb-4"> <div class="px-6 mb-4">
<p v-if="user" class="text-base"> <p v-if="user" class="text-base" v-html="$getString('HeaderWelcome', [username])" />
Welcome,
<strong>{{ username }}</strong>
</p>
</div> </div>
<div class="w-full overflow-y-auto"> <div class="w-full overflow-y-auto">
<template v-for="item in navItems"> <template v-for="item in navItems">
<button v-if="item.action" :key="item.text" class="w-full hover:bg-bg/60 flex items-center py-3 px-6 text-fg-muted" @click="clickAction(item.action)"> <button v-if="item.action" :key="item.text" :tabindex="show ? 0 : -1" class="w-full hover:bg-bg/60 flex items-center py-3 px-6 text-fg-muted" @click="clickAction(item.action)">
<span class="material-symbols fill text-lg">{{ item.icon }}</span> <span class="material-symbols fill text-lg">{{ item.icon }}</span>
<p class="pl-4">{{ item.text }}</p> <p class="pl-4">{{ item.text }}</p>
</button> </button>
<nuxt-link v-else :to="item.to" :key="item.text" class="w-full hover:bg-bg/60 flex items-center py-3 px-6 text-fg" :class="currentRoutePath.startsWith(item.to) ? 'bg-bg-hover/50' : 'text-fg-muted'"> <nuxt-link v-else :to="item.to" :key="item.text" :tabindex="show ? 0 : -1" class="w-full hover:bg-bg/60 flex items-center py-3 px-6 text-fg" :class="currentRoutePath.startsWith(item.to) ? 'bg-bg-hover/50' : 'text-fg-muted'">
<span class="material-symbols fill text-lg">{{ item.icon }}</span> <span class="material-symbols fill text-lg">{{ item.icon }}</span>
<p class="pl-4">{{ item.text }}</p> <p class="pl-4">{{ item.text }}</p>
</nuxt-link> </nuxt-link>
@@ -134,14 +131,12 @@ export default {
to: '/settings' to: '/settings'
}) })
if (this.$platform !== 'ios') {
items.push({ items.push({
icon: 'bug_report', icon: 'bug_report',
iconOutlined: true, iconOutlined: true,
text: this.$strings.ButtonLogs, text: this.$strings.ButtonLogs,
to: '/logs' to: '/logs'
}) })
}
if (this.serverConnectionConfig) { if (this.serverConnectionConfig) {
items.push({ items.push({
@@ -185,11 +180,18 @@ export default {
await this.$hapticsImpact() await this.$hapticsImpact()
await this.logout() await this.logout()
// Redirect to home page
if (this.$route.name !== 'bookshelf') { if (this.$route.name !== 'bookshelf') {
this.$router.replace('/bookshelf') this.$router.replace('/bookshelf')
} else {
location.reload()
} }
// If player is open and not playing locally, then close the player
if (this.$store.getters['getIsPlayerOpen']) {
this.$eventBus.$emit('close-stream')
}
// Close side drawer
this.show = false
}, },
touchstart(e) { touchstart(e) {
this.touchEvent = new TouchEvent(e) this.touchEvent = new TouchEvent(e)
+69
View File
@@ -0,0 +1,69 @@
<template>
<div class="flex h-full px-1 overflow-hidden">
<covers-book-cover :library-item="libraryItem" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<div class="grow px-2 audiobookSearchCardContent">
<p class="truncate text-sm">{{ title }}</p>
<p v-if="subtitle" class="truncate text-xs text-gray-300">{{ subtitle }}</p>
<p class="text-xs text-gray-200 truncate">{{ $getString('LabelByAuthor', [authorName]) }}</p>
</div>
</div>
</template>
<script>
export default {
props: {
episode: {
type: Object,
default: () => {}
},
libraryItem: {
type: Object,
default: () => {}
}
},
data() {
return {}
},
computed: {
bookCoverAspectRatio() {
return this.$store.getters['libraries/getBookCoverAspectRatio']
},
coverWidth() {
if (this.bookCoverAspectRatio === 1) return 50 * 1.2
return 50
},
media() {
return this.libraryItem ? this.libraryItem.media || {} : {}
},
mediaMetadata() {
return this.media.metadata || {}
},
mediaType() {
return this.libraryItem ? this.libraryItem.mediaType : null
},
isPodcast() {
return this.mediaType === 'podcast'
},
title() {
return this.episode?.title || 'No Title'
},
subtitle() {
return this.mediaMetadata.title
},
authorName() {
return this.mediaMetadata.author
}
},
methods: {},
mounted() {}
}
</script>
<style>
.audiobookSearchCardContent {
width: calc(100% - 80px);
display: flex;
flex-direction: column;
justify-content: center;
}
</style>
+5 -42
View File
@@ -1,16 +1,10 @@
<template> <template>
<div class="flex h-full px-1 overflow-hidden"> <div class="flex h-full px-1 overflow-hidden">
<covers-book-cover :library-item="libraryItem" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" /> <covers-book-cover :library-item="libraryItem" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<div class="flex-grow px-2 audiobookSearchCardContent"> <div class="grow px-2 audiobookSearchCardContent">
<p v-if="matchKey !== 'title'" class="truncate text-sm">{{ title }}</p> <p class="truncate text-sm">{{ title }}</p>
<p v-else class="truncate text-sm" v-html="matchHtml" /> <p v-if="subtitle" class="truncate text-xs text-gray-300">{{ subtitle }}</p>
<p class="text-xs text-gray-200 truncate">{{ $getString('LabelByAuthor', [authorName]) }}</p>
<p v-if="matchKey === 'subtitle'" class="truncate text-xs text-fg-muted">{{ matchHtml }}</p>
<p v-if="matchKey !== 'authors'" class="text-xs text-fg truncate">by {{ authorName }}</p>
<p v-else class="truncate text-xs text-fg" v-html="matchHtml" />
<div v-if="matchKey === 'series' || matchKey === 'tags' || matchKey === 'isbn' || matchKey === 'asin' || matchKey === 'episode' || matchKey === 'narrators'" class="m-0 p-0 truncate text-xs" v-html="matchHtml" />
</div> </div>
</div> </div>
</template> </template>
@@ -22,9 +16,7 @@ export default {
type: Object, type: Object,
default: () => {} default: () => {}
}, },
search: String, search: String
matchKey: String,
matchText: String
}, },
data() { data() {
return {} return {}
@@ -58,35 +50,6 @@ export default {
authorName() { authorName() {
if (this.isPodcast) return this.mediaMetadata.author if (this.isPodcast) return this.mediaMetadata.author
return this.mediaMetadata.authorName return this.mediaMetadata.authorName
},
matchHtml() {
if (!this.matchText || !this.search) return ''
if (this.matchKey === 'subtitle') return ''
var matchSplit = this.matchText.toLowerCase().split(this.search.toLowerCase().trim())
if (matchSplit.length < 2) return ''
var html = ''
var totalLenSoFar = 0
for (let i = 0; i < matchSplit.length - 1; i++) {
var indexOf = matchSplit[i].length
var firstPart = this.matchText.substr(totalLenSoFar, indexOf)
var actualWasThere = this.matchText.substr(totalLenSoFar + indexOf, this.search.length)
totalLenSoFar += indexOf + this.search.length
html += `${firstPart}<strong class="text-warning">${actualWasThere}</strong>`
}
var lastPart = this.matchText.substr(totalLenSoFar)
html += lastPart
if (this.matchKey === 'episode') return `<p class="truncate">Episode: ${html}</p>`
if (this.matchKey === 'tags') return `<p class="truncate">Tags: ${html}</p>`
if (this.matchKey === 'subtitle') return `<p class="truncate">${html}</p>`
if (this.matchKey === 'authors') return `by ${html}`
if (this.matchKey === 'isbn') return `<p class="truncate">ISBN: ${html}</p>`
if (this.matchKey === 'asin') return `<p class="truncate">ASIN: ${html}</p>`
if (this.matchKey === 'series') return `<p class="truncate">Series: ${html}</p>`
if (this.matchKey === 'narrators') return `<p class="truncate">Narrator: ${html}</p>`
return `${html}`
} }
}, },
methods: {}, methods: {},
+1 -1
View File
@@ -1,5 +1,5 @@
<template> <template>
<div ref="card" :id="`book-card-${index}`" :style="{ minWidth: width + 'px', maxWidth: width + 'px', height: height + 'px' }" class="rounded-sm z-10 bg-primary cursor-pointer box-shadow-book" @click="clickCard"> <div ref="card" tabindex="0" :id="`book-card-${index}`" :style="{ minWidth: width + 'px', maxWidth: width + 'px', height: height + 'px' }" class="rounded-sm z-10 bg-primary cursor-pointer box-shadow-book" @click="clickCard">
<!-- When cover image does not fill --> <!-- When cover image does not fill -->
<div v-show="showCoverBg" class="absolute top-0 left-0 w-full h-full overflow-hidden rounded-sm bg-primary"> <div v-show="showCoverBg" class="absolute top-0 left-0 w-full h-full overflow-hidden rounded-sm bg-primary">
<div class="absolute cover-bg" ref="coverBg" /> <div class="absolute cover-bg" ref="coverBg" />
+51 -33
View File
@@ -4,13 +4,16 @@
<!-- list of server connection configs --> <!-- list of server connection configs -->
<template v-if="!showForm"> <template v-if="!showForm">
<div v-for="config in serverConnectionConfigs" :key="config.id" class="border-b border-fg/10 py-4"> <div v-for="config in serverConnectionConfigs" :key="config.id" class="border-b border-fg/10 py-4">
<div class="flex items-center my-1 relative" @click="connectToServer(config)"> <div class="flex items-center my-1 relative space-x-2" @click="connectToServer(config)">
<span class="material-symbols text-xl text-fg-muted">dns</span> <div class="grow inline-flex items-center overflow-hidden">
<p class="pl-3 pr-6 text-base text-fg">{{ config.name }}</p> <p class="text-base text-fg truncate">{{ config.name }}</p>
</div>
<div class="absolute top-0 right-0 h-full px-4 flex items-center" @click.stop="editServerConfig(config)"> <div class="h-full w-6 flex items-center" @click.stop="editServerConfig(config)">
<span class="material-symbols text-2xl text-fg-muted">more_vert</span> <span class="material-symbols text-2xl text-fg-muted">more_vert</span>
</div> </div>
<div class="h-full w-6 flex items-center" @click.stop="removeServerConfigClick(config)">
<span class="material-symbols fill text-1.5xl text-fg-muted">delete</span>
</div>
</div> </div>
<!-- warning message if server connection config is using an old user id --> <!-- warning message if server connection config is using an old user id -->
<div v-if="!checkIdUuid(config.userId)" class="flex flex-nowrap justify-between items-center space-x-4 pt-4"> <div v-if="!checkIdUuid(config.userId)" class="flex flex-nowrap justify-between items-center space-x-4 pt-4">
@@ -30,7 +33,7 @@
<!-- form to add a new server connection config --> <!-- form to add a new server connection config -->
<div v-else class="w-full"> <div v-else class="w-full">
<!-- server address input --> <!-- server address input -->
<form v-if="!showAuth" @submit.prevent="submit" novalidate class="w-full"> <form v-if="!showAuth" @submit.prevent="submit(false)" novalidate class="w-full">
<div v-if="serverConnectionConfigs.length" class="flex items-center mb-4" @click="showServerList"> <div v-if="serverConnectionConfigs.length" class="flex items-center mb-4" @click="showServerList">
<span class="material-symbols text-fg-muted">arrow_back</span> <span class="material-symbols text-fg-muted">arrow_back</span>
</div> </div>
@@ -57,7 +60,7 @@
<ui-text-input v-model="password" type="password" :disabled="processing" :placeholder="$strings.LabelPassword" class="w-full mb-2 text-lg" /> <ui-text-input v-model="password" type="password" :disabled="processing" :placeholder="$strings.LabelPassword" class="w-full mb-2 text-lg" />
<div class="flex items-center pt-2"> <div class="flex items-center pt-2">
<ui-icon-btn v-if="serverConfig.id" small bg-color="error" icon="delete" type="button" @click="removeServerConfigClick" /> <ui-icon-btn v-if="serverConfig.id" bg-color="error" icon="delete" type="button" @click="removeServerConfigClick(serverConfig)" />
<div class="flex-grow" /> <div class="flex-grow" />
<ui-btn :disabled="processing || !networkConnected" type="submit" class="mt-1 h-10">{{ networkConnected ? $strings.ButtonSubmit : $strings.MessageNoNetworkConnection }}</ui-btn> <ui-btn :disabled="processing || !networkConnected" type="submit" class="mt-1 h-10">{{ networkConnected ? $strings.ButtonSubmit : $strings.MessageNoNetworkConnection }}</ui-btn>
</div> </div>
@@ -76,9 +79,6 @@
<div :class="processing ? 'opacity-100' : 'opacity-0 pointer-events-none'" class="fixed w-full h-full top-0 left-0 bg-black/75 flex items-center justify-center z-30 transition-opacity duration-500"> <div :class="processing ? 'opacity-100' : 'opacity-0 pointer-events-none'" class="fixed w-full h-full top-0 left-0 bg-black/75 flex items-center justify-center z-30 transition-opacity duration-500">
<div> <div>
<div class="absolute top-0 left-0 w-full p-6 flex items-center flex-col justify-center z-0 short:hidden">
<img src="/Logo.png" class="h-20 w-20 mb-2" />
</div>
<svg class="animate-spin w-16 h-16" viewBox="0 0 24 24"> <svg class="animate-spin w-16 h-16" viewBox="0 0 24 24">
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" /> <path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
</svg> </svg>
@@ -412,7 +412,7 @@ export default {
throw new Error('Authentication failed with the provided token.') throw new Error('Authentication failed with the provided token.')
} }
const duplicateConfig = this.serverConnectionConfigs.find((scc) => scc.address === this.serverConfig.address && scc.username === payload.user.username) const duplicateConfig = this.serverConnectionConfigs.find((scc) => scc.address === this.serverConfig.address && scc.username === payload.user.username && scc.id !== this.serverConfig.id)
if (duplicateConfig) { if (duplicateConfig) {
throw new Error('Config already exists for this address and username.') throw new Error('Config already exists for this address and username.')
} }
@@ -471,22 +471,26 @@ export default {
// Will NOT include access token and refresh token // Will NOT include access token and refresh token
this.setUserAndConnection(payload) this.setUserAndConnection(payload)
} else { } else {
this.showAuth = true let error = this.error
if (await this.submit(true)) {
this.showForm = true
this.error = error
}
} }
}, },
async removeServerConfigClick() { async removeServerConfigClick(serverConfig) {
if (!this.serverConfig.id) return if (!serverConfig.id) return
await this.$hapticsImpact() await this.$hapticsImpact()
const { value } = await Dialog.confirm({ const { value } = await Dialog.confirm({
title: 'Confirm', title: this.$strings.HeaderConfirm,
message: `Remove this server config?` message: this.$strings.MessageConfirmDeleteServerConfig
}) })
if (value) { if (value) {
this.processing = true this.processing = true
await this.$db.removeServerConnectionConfig(this.serverConfig.id) await this.$db.removeServerConnectionConfig(serverConfig.id)
const updatedDeviceData = { ...this.deviceData } const updatedDeviceData = { ...this.deviceData }
updatedDeviceData.serverConnectionConfigs = this.deviceData.serverConnectionConfigs.filter((scc) => scc.id != this.serverConfig.id) updatedDeviceData.serverConnectionConfigs = this.deviceData.serverConnectionConfigs.filter((scc) => scc.id != serverConfig.id)
this.$store.commit('setDeviceData', updatedDeviceData) this.$store.commit('setDeviceData', updatedDeviceData)
this.serverConfig = { this.serverConfig = {
@@ -498,14 +502,17 @@ export default {
this.processing = false this.processing = false
this.showAuth = false this.showAuth = false
this.showForm = !this.serverConnectionConfigs.length this.showForm = !this.serverConnectionConfigs.length
this.error = null
} }
}, },
editServerConfig(serverConfig) { async editServerConfig(serverConfig) {
this.serverConfig = { this.serverConfig = {
...serverConfig ...serverConfig
} }
if (await this.submit(true)) {
this.showForm = true this.showForm = true
this.showAuth = true }
}, },
async newServerConfigClick() { async newServerConfigClick() {
await this.$hapticsImpact() await this.$hapticsImpact()
@@ -651,9 +658,8 @@ export default {
return false return false
}) })
}, },
async submit() { async submit(preventAutoLogin = false) {
if (!this.networkConnected) return if (!this.networkConnected || !this.serverConfig.address) return false
if (!this.serverConfig.address) return
const initialAddress = this.serverConfig.address const initialAddress = this.serverConfig.address
// Did the user specify a protocol? // Did the user specify a protocol?
@@ -666,6 +672,7 @@ export default {
this.authMethods = [] this.authMethods = []
try { try {
console.log('[ServerConnectForm] submit tryServerUrl: ' + this.serverConfig.address)
// Try the server URL. If it fails and the protocol was not provided, try with http instead of https // Try the server URL. If it fails and the protocol was not provided, try with http instead of https
const statusData = await this.tryServerUrl(this.serverConfig.address, !protocolProvided) const statusData = await this.tryServerUrl(this.serverConfig.address, !protocolProvided)
if (this.validateLoginFormResponse(statusData, this.serverConfig.address, protocolProvided)) { if (this.validateLoginFormResponse(statusData, this.serverConfig.address, protocolProvided)) {
@@ -674,11 +681,12 @@ export default {
this.oauth.buttonText = statusData.data.authFormData?.authOpenIDButtonText || 'Login with OpenID' this.oauth.buttonText = statusData.data.authFormData?.authOpenIDButtonText || 'Login with OpenID'
this.serverConfig.version = statusData.data.serverVersion this.serverConfig.version = statusData.data.serverVersion
if (statusData.data.authFormData?.authOpenIDAutoLaunch) { if (statusData.data.authFormData?.authOpenIDAutoLaunch && !preventAutoLogin) {
this.clickLoginWithOpenId() this.clickLoginWithOpenId()
} }
return true return true
} else { } else {
console.log('[ServerConnectForm] submit validateLoginFormResponse failed: ' + this.serverConfig.address)
return false return false
} }
} catch (error) { } catch (error) {
@@ -835,7 +843,7 @@ export default {
async setUserAndConnection({ user, userDefaultLibraryId, serverSettings, ereaderDevices }) { async setUserAndConnection({ user, userDefaultLibraryId, serverSettings, ereaderDevices }) {
if (!user) return if (!user) return
console.log('Successfully logged in', JSON.stringify(user)) console.log('Successfully logged in: ' + user.username)
this.$store.commit('setServerSettings', serverSettings) this.$store.commit('setServerSettings', serverSettings)
this.$store.commit('libraries/setEReaderDevices', ereaderDevices) this.$store.commit('libraries/setEReaderDevices', ereaderDevices)
@@ -852,7 +860,7 @@ export default {
} else { } else {
// Detect if the connection config is using the old token. If so, force re-login // Detect if the connection config is using the old token. If so, force re-login
if (this.serverConfig.token === user.token || user.isOldToken) { if (this.serverConfig.token === user.token || user.isOldToken) {
this.setForceReloginForNewAuth() this.setForceRelogin('oldAuthToken')
return return
} }
@@ -926,20 +934,30 @@ export default {
this.processing = false this.processing = false
return authRes return authRes
}, },
async setForceReloginForNewAuth() { async setForceRelogin(error) {
console.log('[ServerConnectForm] setForceRelogin', error, this.serverConfig.address)
// This calls /status on the server and sets the auth methods // This calls /status on the server and sets the auth methods
const result = await this.submit() const result = await this.submit(true)
if (result) { if (result) {
this.showForm = true
if (error === 'oldAuthToken') {
this.error = this.$strings.MessageOldServerAuthReLoginRequired this.error = this.$strings.MessageOldServerAuthReLoginRequired
} else if (error === 'refreshTokenFailed') {
this.error = this.$strings.MessageFailedToRefreshToken
}
} }
}, },
init() { init() {
// Handle force re-login for servers using new JWT auth but still using an old token in the server config if (this.$route.query.serverConnectionConfigId) {
if (this.$route.query.error === 'oldAuthToken' && this.$route.query.serverConnectionConfigId) { // Handle force re-login for servers using new JWT auth but still using an old token OR refresh token failed
this.serverConfig = this.serverConnectionConfigs.find((scc) => scc.id === this.$route.query.serverConnectionConfigId) const _serverConfig = this.serverConnectionConfigs.find((scc) => scc.id === this.$route.query.serverConnectionConfigId)
if (this.serverConfig) { if (_serverConfig) {
this.setForceReloginForNewAuth() this.serverConfig = { ..._serverConfig }
this.setForceRelogin(this.$route.query.error)
return return
} else {
console.error('[ServerConnectForm] init with serverConnectionConfigId but no serverConfig found', this.$route.query.serverConnectionConfigId)
} }
} }
+41 -10
View File
@@ -1,5 +1,5 @@
<template> <template>
<modals-modal v-model="show" :width="400" height="100%"> <modals-modal v-model="show" :width="400" max-width="95%" height="100%">
<template #outer> <template #outer>
<div v-if="currentChapter" class="absolute top-10 left-4 z-40 pt-1" style="max-width: 80%"> <div v-if="currentChapter" class="absolute top-10 left-4 z-40 pt-1" style="max-width: 80%">
<p class="text-white text-lg truncate">{{ chapters.length }} {{ $strings.LabelChapters }}</p> <p class="text-white text-lg truncate">{{ chapters.length }} {{ $strings.LabelChapters }}</p>
@@ -8,19 +8,31 @@
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false"> <div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
<div ref="container" class="w-full overflow-x-hidden overflow-y-auto bg-secondary rounded-lg border border-fg/20" style="max-height: 75%" @click.stop> <div ref="container" class="w-full overflow-x-hidden overflow-y-auto bg-secondary rounded-lg border border-fg/20" style="max-height: 75%" @click.stop>
<div class="sticky top-0 z-10 bg-secondary grid grid-cols-[1fr_auto_auto] gap-2 px-3 py-2 border-b border-fg/10 transition-shadow" :class="{ 'shadow-md': isScrolled }">
<div>
<p class="text-fg-muted text-sm">{{ $strings.LabelChapters }}</p>
</div>
<div class="text-right" style="min-width: 60px">
<p class="text-fg-muted text-sm" style="letter-spacing: -0.5px">{{ $strings.LabelStart }}</p>
</div>
<div class="text-right" style="min-width: 60px">
<p class="text-fg-muted text-sm" style="letter-spacing: -0.5px">{{ $strings.LabelDuration }}</p>
</div>
</div>
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label"> <ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<template v-for="chapter in chapters"> <li v-for="chapter in chapters" :key="chapter.id" :id="`chapter-row-${chapter.id}`" class="text-fg select-none relative cursor-pointer" :class="currentChapterId === chapter.id ? 'bg-primary bg-opacity-80' : ''" role="option" @click="clickedOption(chapter)">
<li :key="chapter.id" :id="`chapter-row-${chapter.id}`" class="text-fg select-none relative py-4 cursor-pointer" :class="currentChapterId === chapter.id ? 'bg-primary bg-opacity-80' : ''" role="option" @click="clickedOption(chapter)"> <div class="grid grid-cols-[1fr_auto_auto] gap-2 px-3 py-3 items-start">
<div class="relative flex items-center pl-3 pr-20"> <p class="font-normal line-clamp-2 text-sm text-fg/80">{{ chapter.title }}</p>
<p class="font-normal block truncate text-sm text-fg/80">{{ chapter.title }}</p> <div class="text-right" style="min-width: 60px">
<div class="absolute top-0 right-3 -mt-0.5"> <span class="font-mono text-fg-muted text-sm" style="letter-spacing: -0.5px">{{ $secondsToTimestamp(chapter.start / _playbackRate) }}</span>
<span class="font-mono text-fg-muted leading-3 text-sm" style="letter-spacing: -0.5px">{{ $secondsToTimestamp(chapter.start / _playbackRate) }}</span> </div>
<div class="text-right" style="min-width: 60px">
<span class="font-mono text-fg-muted text-sm" style="letter-spacing: -0.5px">{{ $secondsToTimestamp(Math.max(0, chapter.end - chapter.start) / _playbackRate) }}</span>
</div> </div>
</div> </div>
<div v-show="chapter.id === currentChapterId" class="w-0.5 h-full absolute top-0 left-0 bg-yellow-400" /> <div v-show="chapter.id === currentChapterId" class="w-0.5 h-full absolute top-0 left-0 bg-yellow-400" />
</li> </li>
</template>
</ul> </ul>
</div> </div>
</div> </div>
@@ -42,7 +54,9 @@ export default {
playbackRate: Number playbackRate: Number
}, },
data() { data() {
return {} return {
isScrolled: false
}
}, },
watch: { watch: {
value(newVal) { value(newVal) {
@@ -87,8 +101,25 @@ export default {
container.scrollTo({ top: offsetTop - containerHeight / 2 }) container.scrollTo({ top: offsetTop - containerHeight / 2 })
} }
} }
},
handleScroll() {
const container = this.$refs.container
if (container) {
this.isScrolled = container.scrollTop > 0
}
} }
}, },
mounted() {} mounted() {
const container = this.$refs.container
if (container) {
container.addEventListener('scroll', this.handleScroll)
}
},
beforeDestroy() {
const container = this.$refs.container
if (container) {
container.removeEventListener('scroll', this.handleScroll)
}
}
} }
</script> </script>
+1 -1
View File
@@ -1,5 +1,5 @@
<template> <template>
<div ref="wrapper" class="modal modal-bg w-screen fixed bottom-0 left-0 flex items-center justify-center z-50" :class="threeQuartersScreen ? 'h-[75vh] min-h-[400px]' : 'h-screen'" @click.stop @touchstart.stop @touchend.stop> <div ref="wrapper" class="modal modal-bg w-screen fixed bottom-0 left-0 flex items-center justify-center z-50" :class="threeQuartersScreen ? 'h-[75vh] min-h-[400px] short:min-h-0 short:h-screen' : 'h-screen'" @click.stop @touchstart.stop @touchend.stop>
<div ref="content" class="relative text-fg h-full w-full bg-bg"> <div ref="content" class="relative text-fg h-full w-full bg-bg">
<slot /> <slot />
</div> </div>
+4 -4
View File
@@ -303,7 +303,7 @@ export default {
// Show confirm if item has progress since it will reset // Show confirm if item has progress since it will reset
if (this.userItemProgress && this.userItemProgress.progress > 0 && !this.userIsFinished) { if (this.userItemProgress && this.userItemProgress.progress > 0 && !this.userIsFinished) {
const { value } = await Dialog.confirm({ const { value } = await Dialog.confirm({
title: 'Confirm', title: this.$strings.HeaderConfirm,
message: this.$strings.MessageConfirmMarkAsFinished message: this.$strings.MessageConfirmMarkAsFinished
}) })
if (!value) return if (!value) return
@@ -369,7 +369,7 @@ export default {
await this.$hapticsImpact() await this.$hapticsImpact()
const { value } = await Dialog.confirm({ const { value } = await Dialog.confirm({
title: 'Confirm', title: this.$strings.HeaderConfirm,
message: this.$strings.MessageConfirmDiscardProgress message: this.$strings.MessageConfirmDiscardProgress
}) })
if (value) { if (value) {
@@ -407,7 +407,7 @@ export default {
} }
const { value } = await Dialog.confirm({ const { value } = await Dialog.confirm({
title: 'Confirm', title: this.$strings.HeaderConfirm,
message: this.$getString('MessageConfirmDeleteLocalEpisode', [localFile.basePath]) message: this.$getString('MessageConfirmDeleteLocalEpisode', [localFile.basePath])
}) })
if (value) { if (value) {
@@ -432,7 +432,7 @@ export default {
await this.$hapticsImpact() await this.$hapticsImpact()
const { value } = await Dialog.confirm({ const { value } = await Dialog.confirm({
title: 'Confirm', title: this.$strings.HeaderConfirm,
message: this.$strings.MessageConfirmDeleteLocalFiles message: this.$strings.MessageConfirmDeleteLocalFiles
}) })
if (value) { if (value) {
+13 -1
View File
@@ -1,7 +1,7 @@
<template> <template>
<modals-modal v-model="show" width="90%"> <modals-modal v-model="show" width="90%">
<div class="w-full h-full bg-primary rounded-lg border border-fg/20"> <div class="w-full h-full bg-primary rounded-lg border border-fg/20">
<ul class="w-full rounded-lg text-base" role="listbox" aria-labelledby="listbox-label"> <ul class="w-full rounded-lg text-base max-h-[70vh] overflow-y-auto overscroll-contain" role="listbox" aria-labelledby="listbox-label">
<template v-for="item in items"> <template v-for="item in items">
<li :key="item.value" class="text-fg select-none relative py-4 pr-9 cursor-pointer" :class="item.value === selected ? 'bg-bg bg-opacity-50' : ''" role="option" @click="clickedOption(item.value)"> <li :key="item.value" class="text-fg select-none relative py-4 pr-9 cursor-pointer" :class="item.value === selected ? 'bg-bg bg-opacity-50' : ''" role="option" @click="clickedOption(item.value)">
<div class="flex items-center"> <div class="flex items-center">
@@ -64,6 +64,18 @@ export default {
text: this.$strings.LabelFileModified, text: this.$strings.LabelFileModified,
value: 'mtimeMs' value: 'mtimeMs'
}, },
{
text: this.$strings.LabelLibrarySortByProgress,
value: 'progress'
},
{
text: this.$strings.LabelLibrarySortByProgressStarted,
value: 'progress.createdAt'
},
{
text: this.$strings.LabelLibrarySortByProgressFinished,
value: 'progress.finishedAt'
},
{ {
text: this.$strings.LabelRandomly, text: this.$strings.LabelRandomly,
value: 'random' value: 'random'
+1 -1
View File
@@ -108,7 +108,7 @@ export default {
async cancelSleepTimer() { async cancelSleepTimer() {
if (this.isAuto) { if (this.isAuto) {
const { value } = await Dialog.confirm({ const { value } = await Dialog.confirm({
title: 'Confirm', title: this.$strings.HeaderConfirm,
message: this.$strings.MessageConfirmDisableAutoTimer message: this.$strings.MessageConfirmDisableAutoTimer
}) })
if (!value) return if (!value) return
+6 -2
View File
@@ -12,9 +12,9 @@
<div class="w-full relative"> <div class="w-full relative">
<h1 class="text-lg mb-4">{{ $strings.HeaderRSSFeedIsOpen }}</h1> <h1 class="text-lg mb-4">{{ $strings.HeaderRSSFeedIsOpen }}</h1>
<ui-text-input v-model="currentFeed.feedUrl" class="text-sm" readonly /> <ui-text-input v-model="fullFeedUrl" class="text-sm" readonly />
<span class="material-symbols absolute right-2 bottom-2 p-0.5 text-base" :class="linkCopied ? 'text-success' : 'text-fg-muted'" @click="copyToClipboard(currentFeed.feedUrl)">{{ linkCopied ? 'check' : 'content_copy' }}</span> <span class="material-symbols absolute right-2 bottom-2 p-0.5 text-base" :class="linkCopied ? 'text-success' : 'text-fg-muted'" @click="copyToClipboard(fullFeedUrl)">{{ linkCopied ? 'check' : 'content_copy' }}</span>
</div> </div>
<div v-if="currentFeed.meta" class="mt-5"> <div v-if="currentFeed.meta" class="mt-5">
@@ -119,6 +119,10 @@ export default {
demoFeedUrl() { demoFeedUrl() {
return `${this.serverAddress}/feed/${this.newFeedSlug}` return `${this.serverAddress}/feed/${this.newFeedSlug}`
}, },
fullFeedUrl() {
if (!this.currentFeed) return ''
return `${this.serverAddress}${this.currentFeed.feedUrl}`
},
isHttp() { isHttp() {
return !!this.serverAddress?.startsWith('http://') return !!this.serverAddress?.startsWith('http://')
} }
+2
View File
@@ -37,6 +37,7 @@ export default {
isRefreshingUI: false, isRefreshingUI: false,
ereaderSettings: { ereaderSettings: {
theme: 'dark', theme: 'dark',
font: 'serif',
fontScale: 100, fontScale: 100,
lineSpacing: 115, lineSpacing: 115,
textStroke: 0 textStroke: 0
@@ -134,6 +135,7 @@ export default {
const fontScale = settings.fontScale || 100 const fontScale = settings.fontScale || 100
this.rendition.themes.fontSize(`${fontScale}%`) this.rendition.themes.fontSize(`${fontScale}%`)
this.rendition.themes.font(settings.font)
this.rendition.spread(settings.spread || 'auto') this.rendition.spread(settings.spread || 'auto')
}, },
goToChapter(href) { goToChapter(href) {
+5 -3
View File
@@ -173,16 +173,18 @@ export default {
try { try {
console.log('[PdfReader] Handling refresh failure - logging out user') console.log('[PdfReader] Handling refresh failure - logging out user')
const serverConnectionConfigId = this.$store.getters['user/getServerConnectionConfigId']
// Clear store // Clear store
await this.$store.dispatch('user/logout') await this.$store.dispatch('user/logout')
if (this.$store.getters['user/getServerConnectionConfigId']) { if (serverConnectionConfigId) {
// Clear refresh token for server connection config // Clear refresh token for server connection config
await this.$db.clearRefreshToken(this.$store.getters['user/getServerConnectionConfigId']) await this.$db.clearRefreshToken(serverConnectionConfigId)
} }
if (window.location.pathname !== '/connect') { if (window.location.pathname !== '/connect') {
window.location.href = '/connect' window.location.href = '/connect?error=refreshTokenFailed&serverConnectionConfigId=' + serverConnectionConfigId
} }
} catch (error) { } catch (error) {
console.error('[PdfReader] Failed to handle refresh failure:', error) console.error('[PdfReader] Failed to handle refresh failure:', error)
+24 -2
View File
@@ -1,7 +1,7 @@
<template> <template>
<div v-if="show" :data-theme="ereaderTheme" class="group fixed top-0 left-0 right-0 layout-wrapper w-full z-40 pt-8 data-[theme=black]:bg-black data-[theme=black]:text-white data-[theme=dark]:bg-[#232323] data-[theme=dark]:text-white data-[theme=light]:bg-white data-[theme=light]:text-black" :class="{ 'reader-player-open': isPlayerOpen }"> <div v-if="show" :data-theme="ereaderTheme" class="group fixed top-0 left-0 right-0 layout-wrapper w-full z-40 pt-8 data-[theme=black]:bg-black data-[theme=black]:text-white data-[theme=dark]:bg-[#232323] data-[theme=dark]:text-white data-[theme=light]:bg-white data-[theme=light]:text-black" :class="{ 'reader-player-open': isPlayerOpen }">
<!-- toolbar --> <!-- toolbar -->
<div class="h-32 pt-10 w-full px-2 fixed top-0 left-0 z-30 transition-transform bg-bg text-fg" :class="showingToolbar ? 'translate-y-0' : '-translate-y-32'" :style="{ boxShadow: showingToolbar ? '0px 8px 8px #11111155' : '' }" @touchstart.stop @mousedown.stop @touchend.stop @mouseup.stop> <div class="w-full px-2 fixed top-0 left-0 z-30 transition-transform bg-bg text-fg" :class="`${showingToolbar ? 'translate-y-0' : '-translate-y-36'} ${isIos ? 'pt-14 h-36' : 'pt-10 h-32'}`" :style="{ boxShadow: showingToolbar ? '0px 8px 8px #11111155' : '' }" @touchstart.stop @mousedown.stop @touchend.stop @mouseup.stop>
<div class="flex items-center mb-2"> <div class="flex items-center mb-2">
<button type="button" class="inline-flex mx-2" @click.stop="show = false"> <button type="button" class="inline-flex mx-2" @click.stop="show = false">
<span class="material-symbols text-3xl text-fg">chevron_left</span> <span class="material-symbols text-3xl text-fg">chevron_left</span>
@@ -62,7 +62,7 @@
<span class="material-symbols">close</span> <span class="material-symbols">close</span>
</button> </button>
</div> </div>
<div class="w-full overflow-y-auto overflow-x-hidden h-full max-h-[calc(75vh-85px)]"> <div class="w-full overflow-y-auto overflow-x-hidden h-[calc(75vh-85px)] min-h-[320px] short:min-h-0 short:h-[calc(100vh-85px)]">
<div class="w-full h-full px-4"> <div class="w-full h-full px-4">
<div class="flex items-center mb-6"> <div class="flex items-center mb-6">
<div class="w-32"> <div class="w-32">
@@ -70,6 +70,12 @@
</div> </div>
<ui-toggle-btns v-model="ereaderSettings.theme" name="theme" :items="themeItems" @input="settingsUpdated" /> <ui-toggle-btns v-model="ereaderSettings.theme" name="theme" :items="themeItems" @input="settingsUpdated" />
</div> </div>
<div class="flex items-center mb-6">
<div class="w-32">
<p class="text-sm">{{ $strings.LabelFontFamily }}</p>
</div>
<ui-toggle-btns v-model="ereaderSettings.font" name="font" :items="fontItems" @input="settingsUpdated" />
</div>
<div class="flex items-center mb-6"> <div class="flex items-center mb-6">
<div class="w-32"> <div class="w-32">
<p class="text-sm">{{ $strings.LabelFontScale }}</p> <p class="text-sm">{{ $strings.LabelFontScale }}</p>
@@ -141,6 +147,7 @@ export default {
isInittingWatchVolume: false, isInittingWatchVolume: false,
ereaderSettings: { ereaderSettings: {
theme: 'dark', theme: 'dark',
font: 'serif',
fontScale: 100, fontScale: 100,
lineSpacing: 115, lineSpacing: 115,
spread: 'auto', spread: 'auto',
@@ -180,6 +187,9 @@ export default {
this.$store.commit('setShowReader', val) this.$store.commit('setShowReader', val)
} }
}, },
isIos() {
return this.$platform === 'ios'
},
title() { title() {
return this.mediaMetadata.title || 'No Title' return this.mediaMetadata.title || 'No Title'
}, },
@@ -252,6 +262,18 @@ export default {
} }
] ]
}, },
fontItems() {
return [
{
text: this.$strings.LabelFontFamilySans,
value: 'sans-serif'
},
{
text: this.$strings.LabelFontFamilySerif,
value: 'serif'
}
]
},
readerComponentName() { readerComponentName() {
if (this.ebookType === 'epub') return 'readers-epub-reader' if (this.ebookType === 'epub') return 'readers-epub-reader'
else if (this.ebookType === 'mobi') return 'readers-mobi-reader' else if (this.ebookType === 'mobi') return 'readers-mobi-reader'
+8 -2
View File
@@ -183,10 +183,16 @@ export default {
daysInARow() { daysInARow() {
var count = 0 var count = 0
while (true) { while (true) {
var _date = this.$addDaysToToday(count * -1) const _date = this.$addDaysToToday(count * -1 - 1)
var datestr = this.$formatJsDate(_date, 'yyyy-MM-dd') const datestr = this.$formatJsDate(_date, 'yyyy-MM-dd')
if (!this.listeningStatsDays[datestr] || this.listeningStatsDays[datestr] === 0) { if (!this.listeningStatsDays[datestr] || this.listeningStatsDays[datestr] === 0) {
// don't require listening today to count towards days in a row, but do count it if already listened today
const today = this.$formatJsDate(new Date(), 'yyyy-MM-dd')
if (this.listeningStatsDays[today]) {
count++
}
return count return count
} }
count++ count++
+4
View File
@@ -15,6 +15,7 @@
<tr> <tr>
<th class="text-left">{{ $strings.LabelTitle }}</th> <th class="text-left">{{ $strings.LabelTitle }}</th>
<th class="text-center w-16">{{ $strings.LabelStart }}</th> <th class="text-center w-16">{{ $strings.LabelStart }}</th>
<th class="text-center w-16">{{ $strings.LabelDuration }}</th>
</tr> </tr>
<tr v-for="chapter in chapters" :key="chapter.id"> <tr v-for="chapter in chapters" :key="chapter.id">
<td> <td>
@@ -23,6 +24,9 @@
<td class="font-mono text-center underline w-16" @click.stop="goToTimestamp(chapter.start)"> <td class="font-mono text-center underline w-16" @click.stop="goToTimestamp(chapter.start)">
{{ $secondsToTimestamp(chapter.start) }} {{ $secondsToTimestamp(chapter.start) }}
</td> </td>
<td class="font-mono text-center">
{{ $secondsToTimestamp(Math.max(0, chapter.end - chapter.start)) }}
</td>
</tr> </tr>
</table> </table>
</transition> </transition>
+2 -2
View File
@@ -228,8 +228,8 @@ export default {
methods: { methods: {
async clearDownloadQueue() { async clearDownloadQueue() {
const { value } = await Dialog.confirm({ const { value } = await Dialog.confirm({
title: 'Confirm', title: this.$strings.HeaderConfirm,
message: `Are you sure you want to clear episode download queue?` message: this.$strings.MessageConfirmDeleteEpisodeDownloadQueue
}) })
if (value) { if (value) {
@@ -0,0 +1 @@
<p>Zur Verwendung mit Ihrem Audiobookshelf-Server. Audiobookshelf ist ein kostenloser Open-Source-Hörbuch- und Podcast-Manager.</p><p>Verbinden Sie sich mit Ihrem selbst gehosteten Audiobookshelf-Server und streamen Sie Ihre Hörbücher und Podcasts direkt auf Ihr Gerät.</p>
@@ -0,0 +1 @@
Hörbuch- und Podcast-Player für Ihren selbst gehosteten Audiobookshelf-Server
@@ -0,0 +1 @@
<p>For use with your audiobookshelf server. Audiobookshelf is a free and open source audiobook and podcast manager.</p><p>Connect to your self-hosted audiobookshelf server and stream your audiobooks and podcasts directly to your device.</p>
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

@@ -0,0 +1 @@
Audiobook and podcast player for your self-hosted Audiobookshelf server
+8 -4
View File
@@ -36,6 +36,7 @@
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; }; 504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; }; 50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
65643F4DAA661FB0B247247E /* Pods_Audiobookshelf.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8F9A7CCC479333E44DC314BE /* Pods_Audiobookshelf.framework */; }; 65643F4DAA661FB0B247247E /* Pods_Audiobookshelf.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8F9A7CCC479333E44DC314BE /* Pods_Audiobookshelf.framework */; };
6F2516272E68DA3000F40541 /* LogEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2516262E68DA3000F40541 /* LogEntry.swift */; };
E9D5504628AC1A3900C746DD /* LibraryItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5504528AC1A3900C746DD /* LibraryItem.swift */; }; E9D5504628AC1A3900C746DD /* LibraryItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5504528AC1A3900C746DD /* LibraryItem.swift */; };
E9D5504828AC1A7A00C746DD /* MediaType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5504728AC1A7A00C746DD /* MediaType.swift */; }; E9D5504828AC1A7A00C746DD /* MediaType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5504728AC1A7A00C746DD /* MediaType.swift */; };
E9D5504A28AC1AA600C746DD /* Metadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5504928AC1AA600C746DD /* Metadata.swift */; }; E9D5504A28AC1AA600C746DD /* Metadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5504928AC1AA600C746DD /* Metadata.swift */; };
@@ -111,6 +112,7 @@
504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; }; 504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; 504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; }; 50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
6F2516262E68DA3000F40541 /* LogEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogEntry.swift; sourceTree = "<group>"; };
8F9A7CCC479333E44DC314BE /* Pods_Audiobookshelf.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Audiobookshelf.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 8F9A7CCC479333E44DC314BE /* Pods_Audiobookshelf.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Audiobookshelf.framework; sourceTree = BUILT_PRODUCTS_DIR; };
BE96D57E131924D520D57057 /* Pods-Audiobookshelf.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Audiobookshelf.debug.xcconfig"; path = "Target Support Files/Pods-Audiobookshelf/Pods-Audiobookshelf.debug.xcconfig"; sourceTree = "<group>"; }; BE96D57E131924D520D57057 /* Pods-Audiobookshelf.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Audiobookshelf.debug.xcconfig"; path = "Target Support Files/Pods-Audiobookshelf/Pods-Audiobookshelf.debug.xcconfig"; sourceTree = "<group>"; };
D2F7F575384A63F1C47DE984 /* Pods-Audiobookshelf.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Audiobookshelf.release.xcconfig"; path = "Target Support Files/Pods-Audiobookshelf/Pods-Audiobookshelf.release.xcconfig"; sourceTree = "<group>"; }; D2F7F575384A63F1C47DE984 /* Pods-Audiobookshelf.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Audiobookshelf.release.xcconfig"; path = "Target Support Files/Pods-Audiobookshelf/Pods-Audiobookshelf.release.xcconfig"; sourceTree = "<group>"; };
@@ -231,6 +233,7 @@
E9D5506128AC1CC900C746DD /* PlayerState.swift */, E9D5506128AC1CC900C746DD /* PlayerState.swift */,
4DF74911287105C600AC7814 /* DeviceSettings.swift */, 4DF74911287105C600AC7814 /* DeviceSettings.swift */,
E9D5507428AEF93100C746DD /* PlayerSettings.swift */, E9D5507428AEF93100C746DD /* PlayerSettings.swift */,
6F2516262E68DA3000F40541 /* LogEntry.swift */,
E9D5506328AC1D3F00C746DD /* server */, E9D5506328AC1D3F00C746DD /* server */,
E9D5506428AC1D5800C746DD /* local */, E9D5506428AC1D5800C746DD /* local */,
E9D5506D28AC1E7400C746DD /* download */, E9D5506D28AC1E7400C746DD /* download */,
@@ -533,6 +536,7 @@
3ABF580928059BAE005DFBE5 /* PlaybackSession.swift in Sources */, 3ABF580928059BAE005DFBE5 /* PlaybackSession.swift in Sources */,
E9D5506628AC1D7300C746DD /* LocalLibraryItem.swift in Sources */, E9D5506628AC1D7300C746DD /* LocalLibraryItem.swift in Sources */,
E9D5504628AC1A3900C746DD /* LibraryItem.swift in Sources */, E9D5504628AC1A3900C746DD /* LibraryItem.swift in Sources */,
6F2516272E68DA3000F40541 /* LogEntry.swift in Sources */,
3ABF618F2804325C0070250E /* PlayerHandler.swift in Sources */, 3ABF618F2804325C0070250E /* PlayerHandler.swift in Sources */,
3AD4FCED28044E6C006DB301 /* Store.swift in Sources */, 3AD4FCED28044E6C006DB301 /* Store.swift in Sources */,
4D66B958282EEA14008272D4 /* AbsFileSystem.swift in Sources */, 4D66B958282EEA14008272D4 /* AbsFileSystem.swift in Sources */,
@@ -736,12 +740,12 @@
ASSETCATALOG_COMPILER_APPICON_NAME = Icons; ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 39; CURRENT_PROJECT_VERSION = 43;
DEVELOPMENT_TEAM = 7UFJ7D8V6A; DEVELOPMENT_TEAM = 7UFJ7D8V6A;
INFOPLIST_FILE = App/Info.plist; INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.0; IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 0.10.0; MARKETING_VERSION = 0.13.0;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app.dev; PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app.dev;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
@@ -760,12 +764,12 @@
ASSETCATALOG_COMPILER_APPICON_NAME = Icons; ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 39; CURRENT_PROJECT_VERSION = 43;
DEVELOPMENT_TEAM = 7UFJ7D8V6A; DEVELOPMENT_TEAM = 7UFJ7D8V6A;
INFOPLIST_FILE = App/Info.plist; INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.0; IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 0.10.0; MARKETING_VERSION = 0.13.0;
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app; PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
+13 -15
View File
@@ -5,8 +5,6 @@ import RealmSwift
@UIApplicationMain @UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate { class AppDelegate: UIResponder, UIApplicationDelegate {
private let logger = AppLogger(category: "AppDelegate")
lazy var window: UIWindow? = UIWindow(frame: UIScreen.main.bounds) lazy var window: UIWindow? = UIWindow(frame: UIScreen.main.bounds)
var backgroundCompletionHandler: (() -> Void)? var backgroundCompletionHandler: (() -> Void)?
@@ -17,13 +15,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
schemaVersion: 20, schemaVersion: 20,
migrationBlock: { [weak self] migration, oldSchemaVersion in migrationBlock: { [weak self] migration, oldSchemaVersion in
if (oldSchemaVersion < 1) { if (oldSchemaVersion < 1) {
self?.logger.log("Realm schema version was \(oldSchemaVersion)") AbsLogger.info(message: "Realm schema version was \(oldSchemaVersion)")
migration.enumerateObjects(ofType: DeviceSettings.className()) { oldObject, newObject in migration.enumerateObjects(ofType: DeviceSettings.className()) { oldObject, newObject in
newObject?["enableAltView"] = false newObject?["enableAltView"] = false
} }
} }
if (oldSchemaVersion < 4) { if (oldSchemaVersion < 4) {
self?.logger.log("Realm schema version was \(oldSchemaVersion)... Reindexing server configs") AbsLogger.info(message: "Realm schema version was \(oldSchemaVersion)... Reindexing server configs")
var indexCounter = 1 var indexCounter = 1
migration.enumerateObjects(ofType: ServerConnectionConfig.className()) { oldObject, newObject in migration.enumerateObjects(ofType: ServerConnectionConfig.className()) { oldObject, newObject in
newObject?["index"] = indexCounter newObject?["index"] = indexCounter
@@ -31,44 +29,44 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
} }
} }
if (oldSchemaVersion < 5) { if (oldSchemaVersion < 5) {
self?.logger.log("Realm schema version was \(oldSchemaVersion)... Adding lockOrientation setting") AbsLogger.info(message: "Realm schema version was \(oldSchemaVersion)... Adding lockOrientation setting")
migration.enumerateObjects(ofType: DeviceSettings.className()) { oldObject, newObject in migration.enumerateObjects(ofType: DeviceSettings.className()) { oldObject, newObject in
newObject?["lockOrientation"] = "NONE" newObject?["lockOrientation"] = "NONE"
} }
} }
if (oldSchemaVersion < 6) { if (oldSchemaVersion < 6) {
self?.logger.log("Realm schema version was \(oldSchemaVersion)... Adding hapticFeedback setting") AbsLogger.info(message: "Realm schema version was \(oldSchemaVersion)... Adding hapticFeedback setting")
migration.enumerateObjects(ofType: DeviceSettings.className()) { oldObject, newObject in migration.enumerateObjects(ofType: DeviceSettings.className()) { oldObject, newObject in
newObject?["hapticFeedback"] = "LIGHT" newObject?["hapticFeedback"] = "LIGHT"
} }
} }
if (oldSchemaVersion < 15) { if (oldSchemaVersion < 15) {
self?.logger.log("Realm schema version was \(oldSchemaVersion)... Adding languageCode setting") AbsLogger.info(message: "Realm schema version was \(oldSchemaVersion)... Adding languageCode setting")
migration.enumerateObjects(ofType: DeviceSettings.className()) { oldObject, newObject in migration.enumerateObjects(ofType: DeviceSettings.className()) { oldObject, newObject in
newObject?["languageCode"] = "en-us" newObject?["languageCode"] = "en-us"
} }
} }
if (oldSchemaVersion < 16) { if (oldSchemaVersion < 16) {
self?.logger.log("Realm schema version was \(oldSchemaVersion)... Adding chapterTrack setting") AbsLogger.info(message: "Realm schema version was \(oldSchemaVersion)... Adding chapterTrack setting")
migration.enumerateObjects(ofType: PlayerSettings.className()) { oldObject, newObject in migration.enumerateObjects(ofType: PlayerSettings.className()) { oldObject, newObject in
newObject?["chapterTrack"] = false newObject?["chapterTrack"] = false
} }
} }
if (oldSchemaVersion < 17) { if (oldSchemaVersion < 17) {
self?.logger.log("Realm schema version was \(oldSchemaVersion)... Adding downloadUsingCellular and streamingUsingCellular settings") AbsLogger.info(message: "Realm schema version was \(oldSchemaVersion)... Adding downloadUsingCellular and streamingUsingCellular settings")
migration.enumerateObjects(ofType: PlayerSettings.className()) { oldObject, newObject in migration.enumerateObjects(ofType: PlayerSettings.className()) { oldObject, newObject in
newObject?["downloadUsingCellular"] = "ALWAYS" newObject?["downloadUsingCellular"] = "ALWAYS"
newObject?["streamingUsingCellular"] = "ALWAYS" newObject?["streamingUsingCellular"] = "ALWAYS"
} }
} }
if (oldSchemaVersion < 18) { if (oldSchemaVersion < 18) {
self?.logger.log("Realm schema version was \(oldSchemaVersion)... Adding disableSleepTimerFadeOut settings") AbsLogger.info(message: "Realm schema version was \(oldSchemaVersion)... Adding disableSleepTimerFadeOut settings")
migration.enumerateObjects(ofType: PlayerSettings.className()) { oldObject, newObject in migration.enumerateObjects(ofType: PlayerSettings.className()) { oldObject, newObject in
newObject?["disableSleepTimerFadeOut"] = false newObject?["disableSleepTimerFadeOut"] = false
} }
} }
if (oldSchemaVersion < 20) { if (oldSchemaVersion < 20) {
self?.logger.log("Realm schema version was \(oldSchemaVersion)... Adding version to ServerConnectionConfigs") AbsLogger.info(message: "Realm schema version was \(oldSchemaVersion)... Adding version to ServerConnectionConfigs")
migration.enumerateObjects(ofType: ServerConnectionConfig.className()) { oldObject, newObject in migration.enumerateObjects(ofType: ServerConnectionConfig.className()) { oldObject, newObject in
newObject?["version"] = "" newObject?["version"] = ""
} }
@@ -88,22 +86,22 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
func applicationDidEnterBackground(_ application: UIApplication) { func applicationDidEnterBackground(_ application: UIApplication) {
// Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later. // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later.
// If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits. // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits.
logger.log("Audiobookself is now in the background") AbsLogger.info(message: "Audiobookself is now in the background")
} }
func applicationWillEnterForeground(_ application: UIApplication) { func applicationWillEnterForeground(_ application: UIApplication) {
// Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background. // Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background.
logger.log("Audiobookself is now in the foreground") AbsLogger.info(message: "Audiobookself is now in the foreground")
} }
func applicationDidBecomeActive(_ application: UIApplication) { func applicationDidBecomeActive(_ application: UIApplication) {
// Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface. // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
logger.log("Audiobookself is now active") AbsLogger.info(message: "Audiobookself is now active")
} }
func applicationWillTerminate(_ application: UIApplication) { func applicationWillTerminate(_ application: UIApplication) {
// Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
logger.log("Audiobookself is terminating") AbsLogger.info(message: "Audiobookself is terminating")
} }
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
+13 -12
View File
@@ -34,8 +34,6 @@ public class AbsAudioPlayer: CAPPlugin, CAPBridgedPlugin {
CAPPluginMethod(name: "setSleepTimer", returnType: CAPPluginReturnPromise) CAPPluginMethod(name: "setSleepTimer", returnType: CAPPluginReturnPromise)
] ]
private let logger = AppLogger(category: "AbsAudioPlayer")
private var initialPlayWhenReady = false private var initialPlayWhenReady = false
private var monitor: NWPathMonitor? private var monitor: NWPathMonitor?
private let queue = DispatchQueue.global(qos: .background) private let queue = DispatchQueue.global(qos: .background)
@@ -82,7 +80,7 @@ public class AbsAudioPlayer: CAPPlugin, CAPBridgedPlugin {
let startTimeOverride = call.getDouble("startTime") let startTimeOverride = call.getDouble("startTime")
if libraryItemId == nil { if libraryItemId == nil {
logger.error("provide library item id") AbsLogger.error(message: "No id provided")
return call.resolve() return call.resolve()
} }
@@ -93,7 +91,7 @@ public class AbsAudioPlayer: CAPPlugin, CAPBridgedPlugin {
let item = Database.shared.getLocalLibraryItem(localLibraryItemId: libraryItemId!) let item = Database.shared.getLocalLibraryItem(localLibraryItemId: libraryItemId!)
let episode = item?.getPodcastEpisode(episodeId: episodeId) let episode = item?.getPodcastEpisode(episodeId: episodeId)
guard let playbackSession = item?.getPlaybackSession(episode: episode) else { guard let playbackSession = item?.getPlaybackSession(episode: episode) else {
logger.error("Failed to get local playback session") AbsLogger.error(message: "Failed to get local playback session")
return call.resolve([:]) return call.resolve([:])
} }
@@ -105,7 +103,7 @@ public class AbsAudioPlayer: CAPPlugin, CAPBridgedPlugin {
try self.startPlaybackSession(playbackSession, playWhenReady: playWhenReady, playbackRate: playbackRate) try self.startPlaybackSession(playbackSession, playWhenReady: playWhenReady, playbackRate: playbackRate)
call.resolve(try playbackSession.asDictionary()) call.resolve(try playbackSession.asDictionary())
} catch(let exception) { } catch(let exception) {
logger.error("Failed to start session") AbsLogger.error(message: "Failed to start session for local item: \(exception)")
debugPrint(exception) debugPrint(exception)
call.resolve([:]) call.resolve([:])
} }
@@ -119,7 +117,7 @@ public class AbsAudioPlayer: CAPPlugin, CAPBridgedPlugin {
try self?.startPlaybackSession(session, playWhenReady: playWhenReady, playbackRate: playbackRate) try self?.startPlaybackSession(session, playWhenReady: playWhenReady, playbackRate: playbackRate)
call.resolve(try session.asDictionary()) call.resolve(try session.asDictionary())
} catch(let exception) { } catch(let exception) {
self?.logger.error("Failed to start session") AbsLogger.error(message: "Failed to start streaming session")
debugPrint(exception) debugPrint(exception)
call.resolve([:]) call.resolve([:])
} }
@@ -127,19 +125,22 @@ public class AbsAudioPlayer: CAPPlugin, CAPBridgedPlugin {
} }
} }
/// Stops playback and closes session.
@objc func closePlayback(_ call: CAPPluginCall) { @objc func closePlayback(_ call: CAPPluginCall) {
logger.log("Close playback") AbsLogger.info(message: "Close playback")
PlayerHandler.stopPlayback() PlayerHandler.stopPlayback()
call.resolve() call.resolve()
} }
@objc func getCurrentTime(_ call: CAPPluginCall) { @objc func getCurrentTime(_ call: CAPPluginCall) {
call.resolve([ call.resolve([
"value": PlayerHandler.getCurrentTime() ?? 0, "value": PlayerHandler.getCurrentTime() ?? 0,
"bufferedTime": PlayerHandler.getCurrentTime() ?? 0, "bufferedTime": PlayerHandler.getCurrentTime() ?? 0,
]) ])
} }
@objc func setPlaybackSpeed(_ call: CAPPluginCall) { @objc func setPlaybackSpeed(_ call: CAPPluginCall) {
let playbackRate = call.getFloat("value", 1.0) let playbackRate = call.getFloat("value", 1.0)
let settings = PlayerSettings.main() let settings = PlayerSettings.main()
@@ -152,7 +153,7 @@ public class AbsAudioPlayer: CAPPlugin, CAPBridgedPlugin {
@objc func setChapterTrack(_ call: CAPPluginCall) { @objc func setChapterTrack(_ call: CAPPluginCall) {
let chapterTrack = call.getBool("enabled", true) let chapterTrack = call.getBool("enabled", true)
logger.log(String(chapterTrack)) AbsLogger.info(message: String(chapterTrack))
let settings = PlayerSettings.main() let settings = PlayerSettings.main()
try? settings.update { try? settings.update {
settings.chapterTrack = chapterTrack settings.chapterTrack = chapterTrack
@@ -225,7 +226,7 @@ public class AbsAudioPlayer: CAPPlugin, CAPBridgedPlugin {
let seconds = time / 1000 let seconds = time / 1000
logger.log("chapter time: \(isChapterTime)") AbsLogger.info(message: "chapter time: \(isChapterTime)")
if isChapterTime { if isChapterTime {
PlayerHandler.setChapterSleepTime(stopAt: seconds) PlayerHandler.setChapterSleepTime(stopAt: seconds)
return call.resolve([ "success": true ]) return call.resolve([ "success": true ])
@@ -262,7 +263,7 @@ public class AbsAudioPlayer: CAPPlugin, CAPBridgedPlugin {
guard let localMediaProgressId = PlayerHandler.getPlaybackSession()?.localMediaProgressId else { return } guard let localMediaProgressId = PlayerHandler.getPlaybackSession()?.localMediaProgressId else { return }
guard let localMediaProgress = Database.shared.getLocalMediaProgress(localMediaProgressId: localMediaProgressId) else { return } guard let localMediaProgress = Database.shared.getLocalMediaProgress(localMediaProgressId: localMediaProgressId) else { return }
guard let progressUpdate = try? localMediaProgress.asDictionary() else { return } guard let progressUpdate = try? localMediaProgress.asDictionary() else { return }
logger.log("Sending local progress back to the UI") AbsLogger.info(message: "Sending local progress back to the UI")
self.notifyListeners("onLocalMediaProgressUpdate", data: progressUpdate) self.notifyListeners("onLocalMediaProgressUpdate", data: progressUpdate)
} }
@@ -272,7 +273,7 @@ public class AbsAudioPlayer: CAPPlugin, CAPBridgedPlugin {
let playWhenReady = PlayerHandler.getPlayWhenReady() let playWhenReady = PlayerHandler.getPlayWhenReady()
let libraryItemId = session?.libraryItemId ?? "" let libraryItemId = session?.libraryItemId ?? ""
let episodeId = session?.episodeId ?? nil let episodeId = session?.episodeId ?? nil
logger.log("Forcing Transcode") AbsLogger.info(message: "Forcing Transcode")
// If direct playing then fallback to transcode // If direct playing then fallback to transcode
ApiClient.startPlaybackSession(libraryItemId: libraryItemId, episodeId: episodeId, forceTranscode: true) { [weak self] session in ApiClient.startPlaybackSession(libraryItemId: libraryItemId, episodeId: episodeId, forceTranscode: true) { [weak self] session in
@@ -283,7 +284,7 @@ public class AbsAudioPlayer: CAPPlugin, CAPBridgedPlugin {
self.sendPlaybackSession(session: try session.asDictionary()) self.sendPlaybackSession(session: try session.asDictionary())
self.sendMetadata() self.sendMetadata()
} catch(let exception) { } catch(let exception) {
self?.logger.error("Failed to start transcoded session") AbsLogger.error(message: "Failed to start transcoded session")
debugPrint(exception) debugPrint(exception)
} }
} }
+16 -17
View File
@@ -50,7 +50,6 @@ public class AbsDatabase: CAPPlugin, CAPBridgedPlugin {
CAPPluginMethod(name: "updateLocalEbookProgress", returnType: CAPPluginReturnPromise) CAPPluginMethod(name: "updateLocalEbookProgress", returnType: CAPPluginReturnPromise)
] ]
private let logger = AppLogger(category: "AbsDatabase")
private let secureStorage = SecureStorage() private let secureStorage = SecureStorage()
// Used to notify the webview frontend that the token has been refreshed // Used to notify the webview frontend that the token has been refreshed
@@ -73,14 +72,14 @@ public class AbsDatabase: CAPPlugin, CAPBridgedPlugin {
let name = "\(address) (\(username))" let name = "\(address) (\(username))"
if id == nil {
id = "\(address)@\(username)".toBase64()
}
if (refreshToken != "") { if (refreshToken != "") {
// Store refresh token securely if provided // Store refresh token securely if provided
let hasRefreshToken = secureStorage.storeRefreshToken(serverConnectionConfigId: id ?? "", refreshToken: refreshToken) let hasRefreshToken = secureStorage.storeRefreshToken(serverConnectionConfigId: id ?? "", refreshToken: refreshToken)
logger.log("Refresh token secured = \(hasRefreshToken)") AbsLogger.info(message: "Refresh token secured = \(hasRefreshToken)")
}
if id == nil {
id = "\(address)@\(username)".toBase64()
} }
let config = ServerConnectionConfig() let config = ServerConnectionConfig()
@@ -148,15 +147,16 @@ public class AbsDatabase: CAPPlugin, CAPBridgedPlugin {
let items = Database.shared.getLocalLibraryItems() let items = Database.shared.getLocalLibraryItems()
call.resolve([ "value": try items.asDictionaryArray()]) call.resolve([ "value": try items.asDictionaryArray()])
} catch(let exception) { } catch(let exception) {
logger.error("error while readling local library items") AbsLogger.error(message: "error reading local library items \(exception)")
debugPrint(exception) debugPrint(exception)
call.resolve() call.resolve()
} }
} }
@objc func getLocalLibraryItem(_ call: CAPPluginCall) { @objc func getLocalLibraryItem(_ call: CAPPluginCall) {
let id = call.getString("id") ?? ""
do { do {
let item = Database.shared.getLocalLibraryItem(localLibraryItemId: call.getString("id") ?? "") let item = Database.shared.getLocalLibraryItem(localLibraryItemId: id)
switch item { switch item {
case .some(let foundItem): case .some(let foundItem):
call.resolve(try foundItem.asDictionary()) call.resolve(try foundItem.asDictionary())
@@ -164,7 +164,7 @@ public class AbsDatabase: CAPPlugin, CAPBridgedPlugin {
call.resolve() call.resolve()
} }
} catch(let exception) { } catch(let exception) {
logger.error("error while readling local library items") AbsLogger.error(message: "error reading local library item[\(id)] \(exception)")
debugPrint(exception) debugPrint(exception)
call.resolve() call.resolve()
} }
@@ -180,8 +180,7 @@ public class AbsDatabase: CAPPlugin, CAPBridgedPlugin {
call.resolve() call.resolve()
} }
} catch(let exception) { } catch(let exception) {
logger.error("error while readling local library items") AbsLogger.error(message: "error while readling local library items: \(exception)", error: exception)
debugPrint(exception)
call.resolve() call.resolve()
} }
} }
@@ -194,8 +193,7 @@ public class AbsDatabase: CAPPlugin, CAPBridgedPlugin {
do { do {
call.resolve([ "value": try Database.shared.getAllLocalMediaProgress().asDictionaryArray() ]) call.resolve([ "value": try Database.shared.getAllLocalMediaProgress().asDictionaryArray() ])
} catch { } catch {
logger.error("Error while loading local media progress") AbsLogger.error(message: "Error while loading local media progress", error: error)
debugPrint(error)
call.resolve(["value": []]) call.resolve(["value": []])
} }
} }
@@ -212,7 +210,7 @@ public class AbsDatabase: CAPPlugin, CAPBridgedPlugin {
@objc func syncLocalSessionsWithServer(_ call: CAPPluginCall) { @objc func syncLocalSessionsWithServer(_ call: CAPPluginCall) {
let isFirstSync = call.getBool("isFirstSync", false) let isFirstSync = call.getBool("isFirstSync", false)
logger.log("syncLocalSessionsWithServer: Starting (First sync: \(isFirstSync))") AbsLogger.info(message: "Starting syncLocalSessionsWithServer isFirstSync=\(isFirstSync)")
guard Store.serverConfig != nil else { guard Store.serverConfig != nil else {
call.reject("syncLocalSessionsWithServer not connected to server") call.reject("syncLocalSessionsWithServer not connected to server")
return call.resolve() return call.resolve()
@@ -244,12 +242,13 @@ public class AbsDatabase: CAPPlugin, CAPBridgedPlugin {
return return
} }
logger.log("syncServerMediaProgressWithLocalMediaProgress: Saving local media progress") AbsLogger.info(message: "Saving local media progress \(serverMediaProgress)")
try localMediaProgress.updateFromServerMediaProgress(serverMediaProgress) try localMediaProgress.updateFromServerMediaProgress(serverMediaProgress)
call.resolve(try localMediaProgress.asDictionary()) call.resolve(try localMediaProgress.asDictionary())
} catch { } catch {
call.reject("Failed to sync media progress") call.reject("Failed to sync media progress")
AbsLogger.error(message: "Failed to sync: \(error)")
debugPrint(error) debugPrint(error)
} }
} }
@@ -264,7 +263,7 @@ public class AbsDatabase: CAPPlugin, CAPBridgedPlugin {
localMediaProgressId += "-\(localEpisodeId ?? "")" localMediaProgressId += "-\(localEpisodeId ?? "")"
} }
logger.log("updateLocalMediaProgressFinished \(localMediaProgressId) | Is Finished: \(isFinished)") AbsLogger.info(message: "\(localMediaProgressId): isFinished=\(isFinished)")
do { do {
let localMediaProgress = try LocalMediaProgress.fetchOrCreateLocalMediaProgress(localMediaProgressId: localMediaProgressId, localLibraryItemId: localLibraryItemId, localEpisodeId: localEpisodeId) let localMediaProgress = try LocalMediaProgress.fetchOrCreateLocalMediaProgress(localMediaProgressId: localMediaProgressId, localLibraryItemId: localLibraryItemId, localEpisodeId: localEpisodeId)
@@ -337,7 +336,7 @@ public class AbsDatabase: CAPPlugin, CAPBridgedPlugin {
let ebookLocation = call.getString("ebookLocation", "") let ebookLocation = call.getString("ebookLocation", "")
let ebookProgress = call.getDouble("ebookProgress", 0.0) let ebookProgress = call.getDouble("ebookProgress", 0.0)
logger.log("updateLocalEbookProgress \(localLibraryItemId ?? "Unknown") | ebookLocation: \(ebookLocation) | ebookProgress: \(ebookProgress)") AbsLogger.info(message: "\(localLibraryItemId ?? "Unknown"): ebookLocation=\(ebookLocation) ebookProgress=\(ebookProgress)")
do { do {
let localMediaProgress = try LocalMediaProgress.fetchOrCreateLocalMediaProgress(localMediaProgressId: localLibraryItemId, localLibraryItemId: localLibraryItemId, localEpisodeId: nil) let localMediaProgress = try LocalMediaProgress.fetchOrCreateLocalMediaProgress(localMediaProgressId: localLibraryItemId, localLibraryItemId: localLibraryItemId, localEpisodeId: nil)
+98 -34
View File
@@ -19,8 +19,6 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
static private let downloadsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] static private let downloadsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
private let logger = AppLogger(category: "AbsDownloader")
private lazy var session: URLSession = { private lazy var session: URLSession = {
let config = URLSessionConfiguration.background(withIdentifier: "AbsDownloader") let config = URLSessionConfiguration.background(withIdentifier: "AbsDownloader")
let queue = OperationQueue() let queue = OperationQueue()
@@ -31,31 +29,69 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
private var downloadItemProgress = [String: DownloadItem]() private var downloadItemProgress = [String: DownloadItem]()
private var monitoringProgressTimer: Timer? private var monitoringProgressTimer: Timer?
// Download queue management
private let downloadQueueLock = NSLock()
private var pendingDownloadTasks: [DownloadItemPartTask] = []
private var activeDownloadTasks: Set<String> = [] // Track active task IDs
private let maxConcurrentDownloads = 3
// MARK: - Download Queue Management
private func startNextDownloadInQueue() {
downloadQueueLock.lock()
defer { downloadQueueLock.unlock() }
// Start downloads up to the max concurrent limit
while activeDownloadTasks.count < maxConcurrentDownloads && !pendingDownloadTasks.isEmpty {
let nextTask = pendingDownloadTasks.removeFirst()
activeDownloadTasks.insert(nextTask.partId)
AbsLogger.info(message: "Starting download for \(nextTask.filename) (\(activeDownloadTasks.count)/\(maxConcurrentDownloads) active, \(pendingDownloadTasks.count) pending)")
nextTask.task.resume()
}
}
private func markDownloadTaskCompleted(_ taskId: String) {
downloadQueueLock.lock()
activeDownloadTasks.remove(taskId)
downloadQueueLock.unlock()
// Try to start the next download
startNextDownloadInQueue()
}
// MARK: - Progress handling // MARK: - Progress handling
public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
handleDownloadTaskUpdate(downloadTask: downloadTask) { downloadItem, downloadItemPart in handleDownloadTaskUpdate(downloadTask: downloadTask) { downloadItem, downloadItemPart in
let realm = try Realm() let realm = try Realm()
let partId = downloadItemPart.id
// Get fresh reference to the object in this realm
guard let liveDownloadItemPart = realm.object(ofType: DownloadItemPart.self, forPrimaryKey: partId) else {
throw LibraryItemDownloadError.downloadItemPartNotFound
}
try realm.write { try realm.write {
downloadItemPart.bytesDownloaded = downloadItemPart.fileSize liveDownloadItemPart.bytesDownloaded = liveDownloadItemPart.fileSize
downloadItemPart.progress = 100 liveDownloadItemPart.progress = 100
downloadItemPart.completed = true liveDownloadItemPart.completed = true
} }
do { do {
// Move the downloaded file into place // Move the downloaded file into place
guard let destinationUrl = downloadItemPart.destinationURL else { guard let destinationUrl = liveDownloadItemPart.destinationURL else {
throw LibraryItemDownloadError.downloadItemPartDestinationUrlNotDefined throw LibraryItemDownloadError.downloadItemPartDestinationUrlNotDefined
} }
try? FileManager.default.removeItem(at: destinationUrl) try? FileManager.default.removeItem(at: destinationUrl)
try FileManager.default.moveItem(at: location, to: destinationUrl) try FileManager.default.moveItem(at: location, to: destinationUrl)
try realm.write { try realm.write {
downloadItemPart.moved = true liveDownloadItemPart.moved = true
} }
} catch { } catch {
try realm.write { try realm.write {
downloadItemPart.failed = true liveDownloadItemPart.failed = true
} }
throw error throw error
} }
@@ -65,13 +101,26 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
handleDownloadTaskUpdate(downloadTask: task) { downloadItem, downloadItemPart in handleDownloadTaskUpdate(downloadTask: task) { downloadItem, downloadItemPart in
if let error = error { if let error = error {
try Realm().write { let realm = try Realm()
downloadItemPart.completed = true let partId = downloadItemPart.id
downloadItemPart.failed = true
// Get fresh reference to the object in this realm
guard let liveDownloadItemPart = realm.object(ofType: DownloadItemPart.self, forPrimaryKey: partId) else {
throw LibraryItemDownloadError.downloadItemPartNotFound
}
try realm.write {
liveDownloadItemPart.completed = true
liveDownloadItemPart.failed = true
} }
throw error throw error
} }
} }
// Mark this task as completed and start the next download in queue
if let taskId = task.taskDescription {
markDownloadTaskCompleted(taskId)
}
} }
public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) { public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
@@ -81,9 +130,17 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
// Only update the progress if we received accurate progress data // Only update the progress if we received accurate progress data
if percentDownloaded >= 0.0 && percentDownloaded <= 100.0 { if percentDownloaded >= 0.0 && percentDownloaded <= 100.0 {
try Realm().write { let realm = try Realm()
downloadItemPart.bytesDownloaded = Double(totalBytesWritten) let partId = downloadItemPart.id
downloadItemPart.progress = percentDownloaded
// Get fresh reference to the object in this realm
guard let liveDownloadItemPart = realm.object(ofType: DownloadItemPart.self, forPrimaryKey: partId) else {
throw LibraryItemDownloadError.downloadItemPartNotFound
}
try realm.write {
liveDownloadItemPart.bytesDownloaded = Double(totalBytesWritten)
liveDownloadItemPart.progress = percentDownloaded
} }
} }
} }
@@ -104,7 +161,7 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
private func handleDownloadTaskUpdate(downloadTask: URLSessionTask, progressHandler: DownloadProgressHandler) { private func handleDownloadTaskUpdate(downloadTask: URLSessionTask, progressHandler: DownloadProgressHandler) {
do { do {
guard let downloadItemPartId = downloadTask.taskDescription else { throw LibraryItemDownloadError.noTaskDescription } guard let downloadItemPartId = downloadTask.taskDescription else { throw LibraryItemDownloadError.noTaskDescription }
logger.log("Received download update for \(downloadItemPartId)") AbsLogger.info(message: "Received download update for \(downloadItemPartId)")
// Find the download item // Find the download item
let downloadItem = Database.shared.getDownloadItem(downloadItemPartId: downloadItemPartId) let downloadItem = Database.shared.getDownloadItem(downloadItemPartId: downloadItemPartId)
@@ -119,7 +176,7 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
try progressHandler(downloadItem, part) try progressHandler(downloadItem, part)
try? self.notifyListeners("onDownloadItemPartUpdate", data: part.asDictionary()) try? self.notifyListeners("onDownloadItemPartUpdate", data: part.asDictionary())
} catch { } catch {
logger.error("Error while processing progress") AbsLogger.error(message: "Error while processing progress")
debugPrint(error) debugPrint(error)
} }
@@ -130,7 +187,7 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
} }
self.notifyDownloadProgress() self.notifyDownloadProgress()
} catch { } catch {
logger.error("DownloadItemError") AbsLogger.error(message: "DownloadItemError")
debugPrint(error) debugPrint(error)
} }
} }
@@ -138,18 +195,18 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
// We want to handle updating the UI in the background and throttled so we don't overload the UI with progress updates // We want to handle updating the UI in the background and throttled so we don't overload the UI with progress updates
private func notifyDownloadProgress() { private func notifyDownloadProgress() {
if self.monitoringProgressTimer?.isValid ?? false { if self.monitoringProgressTimer?.isValid ?? false {
logger.log("Already monitoring progress, no need to start timer again") AbsLogger.info(message: "Already monitoring progress, no need to start timer again")
} else { } else {
DispatchQueue.runOnMainQueue { DispatchQueue.runOnMainQueue {
self.monitoringProgressTimer = Timer.scheduledTimer(withTimeInterval: 0.2, repeats: true, block: { [unowned self] t in self.monitoringProgressTimer = Timer.scheduledTimer(withTimeInterval: 0.2, repeats: true, block: { [unowned self] t in
self.logger.log("Starting monitoring download progress...") AbsLogger.info(message: "Starting monitoring download progress...")
// Fetch active downloads in a thread-safe way // Fetch active downloads in a thread-safe way
func fetchActiveDownloads() -> [String: DownloadItem]? { func fetchActiveDownloads() -> [String: DownloadItem]? {
self.progressStatusQueue.sync { self.progressStatusQueue.sync {
let activeDownloads = self.downloadItemProgress let activeDownloads = self.downloadItemProgress
if activeDownloads.isEmpty { if activeDownloads.isEmpty {
logger.log("Finishing monitoring download progress...") AbsLogger.info(message: "Finishing monitoring download progress...")
t.invalidate() t.invalidate()
} }
return activeDownloads return activeDownloads
@@ -184,7 +241,7 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
if ( downloadItem.didDownloadSuccessfully() ) { if ( downloadItem.didDownloadSuccessfully() ) {
ApiClient.getLibraryItemWithProgress(libraryItemId: downloadItem.libraryItemId!, episodeId: downloadItem.episodeId) { [weak self] libraryItem in ApiClient.getLibraryItemWithProgress(libraryItemId: downloadItem.libraryItemId!, episodeId: downloadItem.episodeId) { [weak self] libraryItem in
guard let libraryItem = libraryItem else { self?.logger.error("LibraryItem not found"); return } guard let libraryItem = libraryItem else { AbsLogger.error(message: "LibraryItem not found"); return }
let localDirectory = libraryItem.id let localDirectory = libraryItem.id
var coverFile: String? var coverFile: String?
@@ -231,12 +288,12 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
var episodeId = call.getString("episodeId") var episodeId = call.getString("episodeId")
if ( episodeId == "null" ) { episodeId = nil } if ( episodeId == "null" ) { episodeId = nil }
logger.log("Download library item \(libraryItemId ?? "N/A") / episode \(episodeId ?? "N/A")") AbsLogger.info(message: "Download library item \(libraryItemId ?? "N/A") / episode \(episodeId ?? "N/A")")
guard let libraryItemId = libraryItemId else { return call.resolve(["error": "libraryItemId not specified"]) } guard let libraryItemId = libraryItemId else { return call.resolve(["error": "libraryItemId not specified"]) }
ApiClient.getLibraryItemWithProgress(libraryItemId: libraryItemId, episodeId: episodeId) { [weak self] libraryItem in ApiClient.getLibraryItemWithProgress(libraryItemId: libraryItemId, episodeId: episodeId) { [weak self] libraryItem in
if let libraryItem = libraryItem { if let libraryItem = libraryItem {
self?.logger.log("Got library item from server \(libraryItem.id)") AbsLogger.info(message: "Got library item from server \(libraryItem.id)")
do { do {
if let episodeId = episodeId { if let episodeId = episodeId {
// Download a podcast episode // Download a podcast episode
@@ -310,14 +367,19 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
// Persist in the database before status start coming in // Persist in the database before status start coming in
try Database.shared.saveDownloadItem(downloadItem) try Database.shared.saveDownloadItem(downloadItem)
// Start all the downloads // Add all tasks to the download queue
for task in tasks { downloadQueueLock.lock()
task.task.resume() pendingDownloadTasks.append(contentsOf: tasks)
} downloadQueueLock.unlock()
AbsLogger.info(message: "Added \(tasks.count) tasks to download queue. Starting downloads...")
// Start downloading (up to maxConcurrentDownloads at a time)
startNextDownloadInQueue()
} }
private func startLibraryItemTrackDownload(downloadItemId: String, item: LibraryItem, position: Int, track: AudioTrack, episode: PodcastEpisode?) throws -> DownloadItemPartTask { private func startLibraryItemTrackDownload(downloadItemId: String, item: LibraryItem, position: Int, track: AudioTrack, episode: PodcastEpisode?) throws -> DownloadItemPartTask {
logger.log("TRACK \(track.contentUrl!)") AbsLogger.info(message: "TRACK \(track.contentUrl!)")
// If we don't name metadata, then we can't proceed // If we don't name metadata, then we can't proceed
guard let filename = track.metadata?.filename else { guard let filename = track.metadata?.filename else {
@@ -334,7 +396,7 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
// Store the id on the task so the download item can be pulled from the database later // Store the id on the task so the download item can be pulled from the database later
task.taskDescription = part.id task.taskDescription = part.id
return DownloadItemPartTask(part: part, task: task) return DownloadItemPartTask(part: part, task: task, partId: part.id, filename: filename)
} }
private func startLibraryItemEbookDownload(downloadItemId: String, item: LibraryItem, ebookFile: EBookFile) throws -> DownloadItemPartTask { private func startLibraryItemEbookDownload(downloadItemId: String, item: LibraryItem, ebookFile: EBookFile) throws -> DownloadItemPartTask {
@@ -349,7 +411,7 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
// Store the id on the task so the download item can be pulled from the database later // Store the id on the task so the download item can be pulled from the database later
task.taskDescription = part.id task.taskDescription = part.id
return DownloadItemPartTask(part: part, task: task) return DownloadItemPartTask(part: part, task: task, partId: part.id, filename: filename)
} }
private func startLibraryItemCoverDownload(downloadItemId: String, item: LibraryItem) throws -> DownloadItemPartTask { private func startLibraryItemCoverDownload(downloadItemId: String, item: LibraryItem) throws -> DownloadItemPartTask {
@@ -369,7 +431,7 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
// Store the id on the task so the download item can be pulled from the database later // Store the id on the task so the download item can be pulled from the database later
task.taskDescription = part.id task.taskDescription = part.id
return DownloadItemPartTask(part: part, task: task) return DownloadItemPartTask(part: part, task: task, partId: part.id, filename: filename)
} }
private func urlForTrack(item: LibraryItem, track: AudioTrack) -> URL { private func urlForTrack(item: LibraryItem, track: AudioTrack) -> URL {
@@ -393,10 +455,10 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
private func createLibraryItemFileDirectory(item: LibraryItem) throws -> String { private func createLibraryItemFileDirectory(item: LibraryItem) throws -> String {
let itemDirectory = item.id let itemDirectory = item.id
logger.log("ITEM DIR \(itemDirectory)") AbsLogger.info(message: "ITEM DIR \(itemDirectory)")
guard AbsDownloader.itemDownloadFolder(path: itemDirectory) != nil else { guard AbsDownloader.itemDownloadFolder(path: itemDirectory) != nil else {
logger.error("Failed to CREATE LI DIRECTORY \(itemDirectory)") AbsLogger.error(message: "Failed to CREATE LI DIRECTORY \(itemDirectory)")
throw LibraryItemDownloadError.failedDirectory throw LibraryItemDownloadError.failedDirectory
} }
@@ -418,7 +480,7 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
return itemFolder return itemFolder
} catch { } catch {
AppLogger().error("Failed to CREATE LI DIRECTORY \(error)") AbsLogger.error(message: "Failed to CREATE LI DIRECTORY \(error)", error: error)
return nil return nil
} }
} }
@@ -433,6 +495,8 @@ typealias DownloadProgressHandler = (_ downloadItem: DownloadItem, _ downloadIte
struct DownloadItemPartTask { struct DownloadItemPartTask {
let part: DownloadItemPart let part: DownloadItemPart
let task: URLSessionDownloadTask let task: URLSessionDownloadTask
let partId: String // Cache the ID to avoid cross-thread Realm access
let filename: String // Cache the filename to avoid cross-thread Realm access
} }
enum LibraryItemDownloadError: String, Error { enum LibraryItemDownloadError: String, Error {
+11 -13
View File
@@ -23,12 +23,10 @@ public class AbsFileSystem: CAPPlugin, CAPBridgedPlugin {
CAPPluginMethod(name: "deleteTrackFromItem", returnType: CAPPluginReturnPromise) CAPPluginMethod(name: "deleteTrackFromItem", returnType: CAPPluginReturnPromise)
] ]
private let logger = AppLogger(category: "AbsFileSystem")
@objc func selectFolder(_ call: CAPPluginCall) { @objc func selectFolder(_ call: CAPPluginCall) {
let mediaType = call.getString("mediaType") let mediaType = call.getString("mediaType")
logger.log("Select Folder for media type \(mediaType ?? "UNSET")") AbsLogger.info(message: "Select Folder for media type \(mediaType ?? "UNSET")")
call.unavailable("Not available on iOS") call.unavailable("Not available on iOS")
} }
@@ -36,7 +34,7 @@ public class AbsFileSystem: CAPPlugin, CAPBridgedPlugin {
@objc func checkFolderPermission(_ call: CAPPluginCall) { @objc func checkFolderPermission(_ call: CAPPluginCall) {
let folderUrl = call.getString("folderUrl") let folderUrl = call.getString("folderUrl")
logger.log("checkFolderPermission for folder \(folderUrl ?? "UNSET")") AbsLogger.info(message: "checkFolderPermission for folder \(folderUrl ?? "UNSET")")
call.unavailable("Not available on iOS") call.unavailable("Not available on iOS")
} }
@@ -45,7 +43,7 @@ public class AbsFileSystem: CAPPlugin, CAPBridgedPlugin {
let folderId = call.getString("folderId") let folderId = call.getString("folderId")
let forceAudioProbe = call.getBool("forceAudioProbe", false) let forceAudioProbe = call.getBool("forceAudioProbe", false)
logger.log("scanFolder \(folderId ?? "UNSET") | Force Probe = \(forceAudioProbe)") AbsLogger.info(message: "scanFolder \(folderId ?? "UNSET") | Force Probe = \(forceAudioProbe)")
call.unavailable("Not available on iOS") call.unavailable("Not available on iOS")
} }
@@ -53,7 +51,7 @@ public class AbsFileSystem: CAPPlugin, CAPBridgedPlugin {
@objc func removeFolder(_ call: CAPPluginCall) { @objc func removeFolder(_ call: CAPPluginCall) {
let folderId = call.getString("folderId") let folderId = call.getString("folderId")
logger.log("removeFolder \(folderId ?? "UNSET")") AbsLogger.info(message: "removeFolder \(folderId ?? "UNSET")")
call.unavailable("Not available on iOS") call.unavailable("Not available on iOS")
} }
@@ -61,7 +59,7 @@ public class AbsFileSystem: CAPPlugin, CAPBridgedPlugin {
@objc func removeLocalLibraryItem(_ call: CAPPluginCall) { @objc func removeLocalLibraryItem(_ call: CAPPluginCall) {
let localLibraryItemId = call.getString("localLibraryItemId") let localLibraryItemId = call.getString("localLibraryItemId")
logger.log("removeLocalLibraryItem \(localLibraryItemId ?? "UNSET")") AbsLogger.info(message: "removeLocalLibraryItem \(localLibraryItemId ?? "UNSET")")
call.unavailable("Not available on iOS") call.unavailable("Not available on iOS")
} }
@@ -70,7 +68,7 @@ public class AbsFileSystem: CAPPlugin, CAPBridgedPlugin {
let localLibraryItemId = call.getString("localLibraryItemId") let localLibraryItemId = call.getString("localLibraryItemId")
let forceAudioProbe = call.getBool("forceAudioProbe", false) let forceAudioProbe = call.getBool("forceAudioProbe", false)
logger.log("scanLocalLibraryItem \(localLibraryItemId ?? "UNSET") | Force Probe = \(forceAudioProbe)") AbsLogger.info(message: "scanLocalLibraryItem \(localLibraryItemId ?? "UNSET") | Force Probe = \(forceAudioProbe)")
call.unavailable("Not available on iOS") call.unavailable("Not available on iOS")
} }
@@ -79,7 +77,7 @@ public class AbsFileSystem: CAPPlugin, CAPBridgedPlugin {
let localLibraryItemId = call.getString("id") let localLibraryItemId = call.getString("id")
let contentUrl = call.getString("contentUrl") let contentUrl = call.getString("contentUrl")
logger.log("deleteItem \(localLibraryItemId ?? "UNSET") url \(contentUrl ?? "UNSET")") AbsLogger.info(message: "deleteItem \(localLibraryItemId ?? "UNSET") url \(contentUrl ?? "UNSET")")
var success = false var success = false
do { do {
@@ -89,7 +87,7 @@ public class AbsFileSystem: CAPPlugin, CAPBridgedPlugin {
success = true success = true
} }
} catch { } catch {
logger.error("Failed to delete \(error)") AbsLogger.error(message: "Failed to delete \(error)")
success = false success = false
} }
@@ -100,7 +98,7 @@ public class AbsFileSystem: CAPPlugin, CAPBridgedPlugin {
let localLibraryItemId = call.getString("id") let localLibraryItemId = call.getString("id")
let trackLocalFileId = call.getString("trackLocalFileId") let trackLocalFileId = call.getString("trackLocalFileId")
logger.log("deleteTrackFromItem \(localLibraryItemId ?? "UNSET") track file \(trackLocalFileId ?? "UNSET")") AbsLogger.info(message: "deleteTrackFromItem \(localLibraryItemId ?? "UNSET") track file \(trackLocalFileId ?? "UNSET")")
var success = false var success = false
if let localLibraryItemId = localLibraryItemId, let trackLocalFileId = trackLocalFileId, let item = Database.shared.getLocalLibraryItem(localLibraryItemId: localLibraryItemId) { if let localLibraryItemId = localLibraryItemId, let trackLocalFileId = trackLocalFileId, let item = Database.shared.getLocalLibraryItem(localLibraryItemId: localLibraryItemId) {
@@ -120,12 +118,12 @@ public class AbsFileSystem: CAPPlugin, CAPBridgedPlugin {
success = true success = true
} }
} catch { } catch {
logger.error("Failed to delete \(error)") AbsLogger.error(message: "Failed to delete \(error)")
success = false success = false
} }
} }
} catch { } catch {
logger.error("Failed to delete \(error)") AbsLogger.error(message: "Failed to delete \(error)")
success = false success = false
} }
} }
+82 -5
View File
@@ -8,6 +8,16 @@
import Foundation import Foundation
import Capacitor import Capacitor
enum AbsLogLevel {
case info
case warn
case error
}
func fileName(file: String = #file) -> String {
return (file as NSString).lastPathComponent
}
@objc(AbsLogger) @objc(AbsLogger)
public class AbsLogger: CAPPlugin, CAPBridgedPlugin { public class AbsLogger: CAPPlugin, CAPBridgedPlugin {
public var identifier = "AbsLoggerPlugin" public var identifier = "AbsLoggerPlugin"
@@ -19,29 +29,96 @@ public class AbsLogger: CAPPlugin, CAPBridgedPlugin {
CAPPluginMethod(name: "clearLogs", returnType: CAPPluginReturnPromise) CAPPluginMethod(name: "clearLogs", returnType: CAPPluginReturnPromise)
] ]
private let logger = AppLogger(category: "AbsLogger") private static let shared: AbsLogger = {
AbsLogger()
}()
public static func info(_ tag: String = #file, message: String) {
try? shared.info(tag: fileName(file: tag), message: message)
}
public static func error(_ tag: String = #file, message: String, error: Error? = nil) {
try? shared.error(tag: fileName(file: tag), message: message)
}
private let loggers: Dictionary<String, AppLogger> = [:]
private func _logger(_ tag: String) -> AppLogger {
return loggers[tag, default: AppLogger(category: tag)]
}
private func saveLogEntry(_ level: AbsLogLevel, tag: String, message: String) throws {
let entry = LogEntry()
entry.tag = tag
entry.message = message
entry.level = "\(level)"
entry.timestamp = Int(Date().timeIntervalSince1970 * 1000)
try Database.shared.saveLog(entry)
self.notifyListeners("onLog", data: [
"id": entry.id,
"tag": entry.tag,
"message": entry.message,
"level": entry.level,
"timestamp": entry.timestamp
])
}
public func info(tag: String = "\(#file)", message: String) throws {
_logger(tag).log("[\(tag)] \(message)")
try saveLogEntry(.info, tag: tag, message: message)
}
public func error(tag: String = "\(#file)", message: String, error: Error? = nil) throws {
_logger(tag).error("[\(tag)] \(message)")
if let error = error { _logger(tag).error(error) }
try saveLogEntry(.error, tag: tag, message: message)
}
@objc func info(_ call: CAPPluginCall) { @objc func info(_ call: CAPPluginCall) {
let message = call.getString("message") ?? "" let message = call.getString("message") ?? ""
let tag = call.getString("tag") ?? "" let tag = call.getString("tag") ?? ""
logger.log("[\(tag)] \(message)") do {
try info(tag: tag, message: message)
call.resolve() call.resolve()
} catch {
call.reject("Failed to log \(message)", "101", error)
}
} }
@objc func error(_ call: CAPPluginCall) { @objc func error(_ call: CAPPluginCall) {
let message = call.getString("message") ?? "" let message = call.getString("message") ?? ""
let tag = call.getString("tag") ?? "" let tag = call.getString("tag") ?? ""
logger.error("[\(tag)] \(message)") do {
try error(tag: tag, message: message)
call.resolve() call.resolve()
} catch {
call.reject("Failed to log \(message)", "101", error)
}
} }
@objc func getAllLogs(_ call: CAPPluginCall) { @objc func getAllLogs(_ call: CAPPluginCall) {
call.unimplemented("Not implemented on iOS") do {
let logs = Database.shared.getAllLogs()
call.resolve([ "value": try logs.asDictionaryArray()])
} catch {
call.reject("Failed to get logs", "100", error)
}
} }
@objc func clearLogs(_ call: CAPPluginCall) { @objc func clearLogs(_ call: CAPPluginCall) {
call.unimplemented("Not implemented on iOS") do {
try Database.shared.clearLogs()
call.resolve()
} catch {
call.reject("Failed to clear logs", "100", error)
}
} }
} }
+20 -20
View File
@@ -25,11 +25,11 @@ PODS:
- Capacitor - Capacitor
- CordovaPlugins (6.2.1): - CordovaPlugins (6.2.1):
- CapacitorCordova - CapacitorCordova
- Realm (10.54.4): - Realm (10.54.6):
- Realm/Headers (= 10.54.4) - Realm/Headers (= 10.54.6)
- Realm/Headers (10.54.4) - Realm/Headers (10.54.6)
- RealmSwift (10.54.4): - RealmSwift (10.54.6):
- Realm (= 10.54.4) - Realm (= 10.54.6)
- WebnativellcCapacitorFilesharer (7.0.4): - WebnativellcCapacitorFilesharer (7.0.4):
- Capacitor - Capacitor
@@ -89,22 +89,22 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS: SPEC CHECKSUMS:
Alamofire: 7193b3b92c74a07f85569e1a6c4f4237291e7496 Alamofire: 7193b3b92c74a07f85569e1a6c4f4237291e7496
Capacitor: 106e7a4205f4618d582b886a975657c61179138d Capacitor: 03bc7cbdde6a629a8b910a9d7d78c3cc7ed09ea7
CapacitorApp: d63334c052278caf5d81585d80b21905c6f93f39 CapacitorApp: febecbb9582cb353aed037e18ec765141f880fe9
CapacitorBrowser: 081852cf532acf77b9d2953f3a88fe5b9711fb06 CapacitorBrowser: 6299776d496e968505464884d565992faa20444a
CapacitorClipboard: b98aead5dc7ec595547fc2c5d75bacd2ae3338bc CapacitorClipboard: 70bfdb42b877b320a6e511ab94fa7a6a55d57ecb
CapacitorCommunityKeepAwake: 00dfd8fa3cca0df003c9a3e2cd7bee678aeec68b CapacitorCommunityKeepAwake: ae762ce29b53147d28cfcaae5273cd1db0c38fc4
CapacitorCommunityVolumeButtons: 8a0443a202ed659688d85f4d44d66f42f62f2b56 CapacitorCommunityVolumeButtons: 1b84f7abf29cd9476cef9e8979b2854a64d2eed5
CapacitorCordova: 5967b9ba03915ef1d585469d6e31f31dc49be96f CapacitorCordova: 5967b9ba03915ef1d585469d6e31f31dc49be96f
CapacitorDialog: 9b934329026b2b0ffa56939bb06df3c67541a2ab CapacitorDialog: 0e09f242f6c3f5e82e4dc76b20f2a056be57a579
CapacitorHaptics: 70e47470fa1a6bd6338cd102552e3846b7f9a1b3 CapacitorHaptics: 1f1e17041f435d8ead9ff2a34edd592c6aa6a8d6
CapacitorNetwork: 07ec4c69c1bb696f41c23e00d31bda1bbb221bba CapacitorNetwork: 15cb4385f0913a8ceb5e9a4d7af1ec554bdb8de8
CapacitorPreferences: cbf154e5e5519b7f5ab33817a334dda1e98387f9 CapacitorPreferences: 6c98117d4d7508034a4af9db64d6b26fc75d7b94
CapacitorStatusBar: 275cbf2f4dfc00388f519ef80c7ec22edda342c9 CapacitorStatusBar: 6e7af040d8fc4dd655999819625cae9c2d74c36f
CordovaPlugins: 5a72a85b45469e68556bb172409f1b6d57b27236 CordovaPlugins: 2ecbba09775516c41764dbf78ade612427311b7e
Realm: 8b5cda39a41f17a1734da2f39c6004eb8745587a Realm: b1b3bc68162fa242132eb7eefbf91d7c40f36a85
RealmSwift: 0b4f808fed6898f1f6c26f501f740efd80dff0b4 RealmSwift: 456cfd82a4f23dff8e3456980999331ab69bbf3e
WebnativellcCapacitorFilesharer: 10b111373d4dc49608935600dcbcc14605258c73 WebnativellcCapacitorFilesharer: e3a5930240633db3335040251d66aac6762ff111
PODFILE CHECKSUM: 498821c0cfa2508609567fa95d7244c01cbef538 PODFILE CHECKSUM: 498821c0cfa2508609567fa95d7244c01cbef538
+17
View File
@@ -0,0 +1,17 @@
//
// LogEntry.swift
// Audiobookshelf
//
// Created by Christopher Jensen-Reimann on 9/3/25.
//
import Foundation
import RealmSwift
class LogEntry: Object, Codable {
@Persisted(primaryKey: true) var id: String = UUID().uuidString
@Persisted var tag: String = ""
@Persisted var level: String = ""
@Persisted var message: String = ""
@Persisted var timestamp: Int = 0
}
+25 -28
View File
@@ -26,7 +26,6 @@ enum PlayerStatus: Int {
class AudioPlayer: NSObject { class AudioPlayer: NSObject {
internal let queue = DispatchQueue(label: "ABSAudioPlayerQueue") internal let queue = DispatchQueue(label: "ABSAudioPlayerQueue")
internal let logger = AppLogger(category: "AudioPlayer")
private var status: PlayerStatus private var status: PlayerStatus
internal var rateManager: AudioPlayerRateManager internal var rateManager: AudioPlayerRateManager
@@ -81,7 +80,7 @@ class AudioPlayer: NSObject {
let playbackSession = self.getPlaybackSession() let playbackSession = self.getPlaybackSession()
guard let playbackSession = playbackSession else { guard let playbackSession = playbackSession else {
logger.error("Failed to fetch playback session. Player will not initialize") AbsLogger.error(message:"Failed to fetch playback session. Player will not initialize")
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.failed.rawValue), object: nil) NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.failed.rawValue), object: nil)
return return
} }
@@ -115,10 +114,10 @@ class AudioPlayer: NSObject {
} }
self.currentTrackIndex = getItemIndexForTime(time: playbackSession.currentTime) self.currentTrackIndex = getItemIndexForTime(time: playbackSession.currentTime)
logger.log("Starting track index \(self.currentTrackIndex) for start time \(playbackSession.currentTime)") AbsLogger.info(message:"Starting track index \(self.currentTrackIndex) for start time \(playbackSession.currentTime)")
let playerItems = self.allPlayerItems[self.currentTrackIndex..<self.allPlayerItems.count] let playerItems = self.allPlayerItems[self.currentTrackIndex..<self.allPlayerItems.count]
logger.log("Setting player items \(playerItems.count)") AbsLogger.info(message:"Setting player items \(playerItems.count)")
for item in Array(playerItems) { for item in Array(playerItems) {
self.audioPlayer.insert(item, after:self.audioPlayer.items().last) self.audioPlayer.insert(item, after:self.audioPlayer.items().last)
@@ -128,7 +127,7 @@ class AudioPlayer: NSObject {
setupQueueObserver() setupQueueObserver()
setupQueueItemStatusObserver() setupQueueItemStatusObserver()
logger.log("Audioplayer ready") AbsLogger.info(message:"Audioplayer ready")
} }
deinit { deinit {
@@ -146,8 +145,7 @@ class AudioPlayer: NSObject {
do { do {
try AVAudioSession.sharedInstance().setActive(false) try AVAudioSession.sharedInstance().setActive(false)
} catch { } catch {
logger.error("Failed to set AVAudioSession inactive") AbsLogger.error(message: "Failed to set AVAudioSession inactive", error: error)
logger.error(error)
} }
self.removeAudioSessionNotifications() self.removeAudioSessionNotifications()
@@ -253,14 +251,14 @@ class AudioPlayer: NSObject {
self.audioPlayer.currentItem.map { item in self.audioPlayer.currentItem.map { item in
self.currentTrackIndex = self.allPlayerItems.firstIndex(of:item) ?? 0 self.currentTrackIndex = self.allPlayerItems.firstIndex(of:item) ?? 0
if (self.currentTrackIndex != prevTrackIndex) { if (self.currentTrackIndex != prevTrackIndex) {
self.logger.log("New Current track index \(self.currentTrackIndex)") AbsLogger.info(message:"New Current track index \(self.currentTrackIndex)")
} }
} }
} }
} }
private func setupQueueItemStatusObserver() { private func setupQueueItemStatusObserver() {
logger.log("queueStatusObserver: Setting up") AbsLogger.info(message:"queueStatusObserver: Setting up")
// Listen for player item updates // Listen for player item updates
self.queueItemStatusObserver?.invalidate() self.queueItemStatusObserver?.invalidate()
@@ -275,13 +273,13 @@ class AudioPlayer: NSObject {
} }
private func handleQueueItemStatus(playerItem: AVPlayerItem) { private func handleQueueItemStatus(playerItem: AVPlayerItem) {
logger.log("queueStatusObserver: Current item status changed") AbsLogger.info(message:"queueStatusObserver: Current item status changed")
guard let playbackSession = self.getPlaybackSession() else { guard let playbackSession = self.getPlaybackSession() else {
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.failed.rawValue), object: nil) NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.failed.rawValue), object: nil)
return return
} }
if (playerItem.status == .readyToPlay) { if (playerItem.status == .readyToPlay) {
logger.log("queueStatusObserver: Current Item Ready to play. PlayWhenReady: \(self.playWhenReady)") AbsLogger.info(message:"queueStatusObserver: Current Item Ready to play. PlayWhenReady: \(self.playWhenReady)")
// Seek the player before initializing, so a currentTime of 0 does not appear in MediaProgress / session // Seek the player before initializing, so a currentTime of 0 does not appear in MediaProgress / session
let firstReady = self.status == .uninitialized let firstReady = self.status == .uninitialized
@@ -300,7 +298,7 @@ class AudioPlayer: NSObject {
self.status = .paused self.status = .paused
} }
} else if (playerItem.status == .failed) { } else if (playerItem.status == .failed) {
logger.error("queueStatusObserver: FAILED \(playerItem.error?.localizedDescription ?? "")") AbsLogger.error(message:"queueStatusObserver: FAILED \(playerItem.error?.localizedDescription ?? "")")
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.failed.rawValue), object: nil) NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.failed.rawValue), object: nil)
} }
} }
@@ -332,7 +330,7 @@ class AudioPlayer: NSObject {
} }
private func resumePlayback() { private func resumePlayback() {
logger.log("PLAY: Resuming playback") AbsLogger.info(message:"PLAY: Resuming playback")
self.markAudioSessionAs(active: true) self.markAudioSessionAs(active: true)
DispatchQueue.runOnMainQueue { DispatchQueue.runOnMainQueue {
@@ -351,7 +349,7 @@ class AudioPlayer: NSObject {
public func pause() { public func pause() {
guard self.isInitialized() else { return } guard self.isInitialized() else { return }
logger.log("PAUSE: Pausing playback") AbsLogger.info(message:"PAUSE: Pausing playback")
DispatchQueue.runOnMainQueue { DispatchQueue.runOnMainQueue {
self.audioPlayer.pause() self.audioPlayer.pause()
} }
@@ -371,7 +369,7 @@ class AudioPlayer: NSObject {
public func startFadeOut() { public func startFadeOut() {
guard self.isInitialized() else { return } guard self.isInitialized() else { return }
guard let currentTime = self.getCurrentTime() else { return } guard let currentTime = self.getCurrentTime() else { return }
logger.log("fadeOut: Fading out playback") AbsLogger.info(message:"fadeOut: Fading out playback")
// Define fade parameters. // Define fade parameters.
let fadeDuration: Float = 60.0 // total fade duration in seconds let fadeDuration: Float = 60.0 // total fade duration in seconds
@@ -409,7 +407,7 @@ class AudioPlayer: NSObject {
// Ensure volume is exactly zero and end fade. // Ensure volume is exactly zero and end fade.
self.audioPlayer.volume = targetVolume self.audioPlayer.volume = targetVolume
t.invalidate() t.invalidate()
self.logger.log("Fadeout: Fade complete, pausing playback") AbsLogger.info(message:"Fadeout: Fade complete, pausing playback")
self.pause() self.pause()
self.audioPlayer.volume = initialVolume self.audioPlayer.volume = initialVolume
self.seek(currentTime, from: "fadeOut") self.seek(currentTime, from: "fadeOut")
@@ -419,12 +417,12 @@ class AudioPlayer: NSObject {
} }
public func seek(_ to: Double, from: String) { public func seek(_ to: Double, from: String) {
logger.log("SEEK: Seek to \(to) from \(from)") AbsLogger.info(message:"SEEK: Seek to \(to) from \(from)")
guard let playbackSession = self.getPlaybackSession() else { return } guard let playbackSession = self.getPlaybackSession() else { return }
let indexOfSeek = getItemIndexForTime(time: to) let indexOfSeek = getItemIndexForTime(time: to)
logger.log("SEEK: Seek to index \(indexOfSeek) | Current index \(self.currentTrackIndex)") AbsLogger.info(message:"SEEK: Seek to index \(indexOfSeek) | Current index \(self.currentTrackIndex)")
if self.audioPlayer.currentItem == nil { if self.audioPlayer.currentItem == nil {
self.currentTrackIndex = indexOfSeek self.currentTrackIndex = indexOfSeek
@@ -488,16 +486,16 @@ class AudioPlayer: NSObject {
let currentTrack = playbackSession.audioTracks[self.currentTrackIndex] let currentTrack = playbackSession.audioTracks[self.currentTrackIndex]
let ctso = currentTrack.startOffset ?? 0.0 let ctso = currentTrack.startOffset ?? 0.0
let trackEnd = ctso + currentTrack.duration let trackEnd = ctso + currentTrack.duration
logger.log("SEEK: Seeking in current item \(to) (track START = \(ctso) END = \(trackEnd))") AbsLogger.info(message:"SEEK: Seeking in current item \(to) (track START = \(ctso) END = \(trackEnd))")
let boundedTime = min(max(to, ctso), trackEnd) let boundedTime = min(max(to, ctso), trackEnd)
let seekTime = boundedTime - ctso let seekTime = boundedTime - ctso
DispatchQueue.runOnMainQueue { DispatchQueue.runOnMainQueue {
self.audioPlayer.seek(to: CMTime(seconds: seekTime, preferredTimescale: 1000)) { [weak self] completed in self.audioPlayer.seek(to: CMTime(seconds: seekTime, preferredTimescale: 1000)) { [weak self] completed in
self?.logger.log("SEEK: Completion handler called") AbsLogger.info(message:"SEEK: Completion handler called")
guard completed else { guard completed else {
self?.logger.log("SEEK: WARNING: seeking not completed (to \(seekTime)") AbsLogger.info(message:"SEEK: WARNING: seeking not completed (to \(seekTime)")
return return
} }
guard let self = self else { return } guard let self = self else { return }
@@ -574,7 +572,7 @@ class AudioPlayer: NSObject {
} else if (playbackSession.playMethod == PlayMethod.local.rawValue) { } else if (playbackSession.playMethod == PlayMethod.local.rawValue) {
guard let localFile = track.getLocalFile() else { guard let localFile = track.getLocalFile() else {
// Worst case we can stream the file // Worst case we can stream the file
logger.log("Unable to play local file. Resulting to streaming \(track.localFileId ?? "Unknown")") AbsLogger.info(message:"Unable to play local file. Resulting to streaming \(track.localFileId ?? "Unknown")")
let urlstr = "\(Store.serverConfig!.address)/api/items/\(itemId)/file/\(ino)?token=\(Store.serverConfig!.token)" let urlstr = "\(Store.serverConfig!.address)/api/items/\(itemId)/file/\(ino)?token=\(Store.serverConfig!.token)"
let url = URL(string: urlstr)! let url = URL(string: urlstr)!
return AVURLAsset(url: url) return AVURLAsset(url: url)
@@ -594,8 +592,7 @@ class AudioPlayer: NSObject {
do { do {
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio) try AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio)
} catch { } catch {
logger.error("Failed to set AVAudioSession category") AbsLogger.error(message: "Failed to set AVAudioSession category", error: error)
logger.error(error)
} }
} }
@@ -603,7 +600,7 @@ class AudioPlayer: NSObject {
do { do {
try AVAudioSession.sharedInstance().setActive(active) try AVAudioSession.sharedInstance().setActive(active)
} catch { } catch {
logger.error("Failed to set audio session as active=\(active)") AbsLogger.error(message:"Failed to set audio session as active=\(active)")
} }
} }
@@ -620,7 +617,7 @@ class AudioPlayer: NSObject {
let reasonValue = userInfo[AVAudioSessionInterruptionReasonKey] as? UInt ?? 0 let reasonValue = userInfo[AVAudioSessionInterruptionReasonKey] as? UInt ?? 0
let reason = AVAudioSession.InterruptionReason(rawValue: reasonValue) let reason = AVAudioSession.InterruptionReason(rawValue: reasonValue)
if (reason == .appWasSuspended) { if (reason == .appWasSuspended) {
logger.log("AVAudioSession was suspended") AbsLogger.info(message:"AVAudioSession was suspended")
return return
} }
} }
@@ -812,14 +809,14 @@ class AudioPlayer: NSObject {
if context == &playerContext { if context == &playerContext {
if keyPath == #keyPath(AVPlayer.currentItem) { if keyPath == #keyPath(AVPlayer.currentItem) {
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.update.rawValue), object: nil) NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.update.rawValue), object: nil)
logger.log("WARNING: Item ended") AbsLogger.info(message:"WARNING: Item ended")
if audioPlayer.currentItem == nil { if audioPlayer.currentItem == nil {
// if the queue is rebuilding, we expect the current item may be nil // if the queue is rebuilding, we expect the current item may be nil
if self.isRebuildingQueue { if self.isRebuildingQueue {
return return
} }
logger.log("Player ended or next item is nil, marking ended") AbsLogger.info(message:"Player ended or next item is nil, marking ended")
self.markAudioSessionAs(active: false) self.markAudioSessionAs(active: false)
} }
} }
@@ -31,7 +31,7 @@ extension AudioPlayer {
} }
public func setSleepTimer(secondsUntilSleep: Double) { public func setSleepTimer(secondsUntilSleep: Double) {
logger.log("SLEEP TIMER: Sleeping in \(secondsUntilSleep) seconds") AbsLogger.info(message: "SLEEP TIMER: Sleeping in \(secondsUntilSleep) seconds")
self.removeSleepTimer() self.removeSleepTimer()
self.sleepTimeRemaining = secondsUntilSleep self.sleepTimeRemaining = secondsUntilSleep
@@ -53,7 +53,7 @@ extension AudioPlayer {
guard let currentTime = self.getCurrentTime() else { return } guard let currentTime = self.getCurrentTime() else { return }
guard stopAt >= currentTime else { return } guard stopAt >= currentTime else { return }
logger.log("SLEEP TIMER: Scheduling for chapter end \(stopAt)") AbsLogger.info(message: "SLEEP TIMER: Scheduling for chapter end \(stopAt)")
// Schedule the observation time // Schedule the observation time
self.sleepTimeChapterStopAt = stopAt self.sleepTimeChapterStopAt = stopAt
@@ -138,7 +138,7 @@ extension AudioPlayer {
} }
private func handleSleepEnd() { private func handleSleepEnd() {
logger.log("SLEEP TIMER: Pausing audio") AbsLogger.info(message: "SLEEP TIMER: Pausing audio")
self.pause() self.pause()
self.removeSleepTimer() self.removeSleepTimer()
} }
@@ -10,7 +10,6 @@ import AVFoundation
@available(iOS 16.0, *) @available(iOS 16.0, *)
class DefaultedAudioPlayerRateManager: NSObject, AudioPlayerRateManager { class DefaultedAudioPlayerRateManager: NSObject, AudioPlayerRateManager {
internal let logger = AppLogger(category: "DefaultedAudioPlayerRateManager")
internal var audioPlayer: AVPlayer internal var audioPlayer: AVPlayer
@@ -9,7 +9,6 @@ import Foundation
import AVFoundation import AVFoundation
class LegacyAudioPlayerRateManager: NSObject, AudioPlayerRateManager { class LegacyAudioPlayerRateManager: NSObject, AudioPlayerRateManager {
internal let logger = AppLogger(category: "AudioPlayer")
internal var audioPlayer: AVPlayer internal var audioPlayer: AVPlayer
@@ -54,7 +53,7 @@ class LegacyAudioPlayerRateManager: NSObject, AudioPlayerRateManager {
let playbackSpeedChanged = rate > 0.0 && rate != self.defaultRate && !(observed && rate == 1) let playbackSpeedChanged = rate > 0.0 && rate != self.defaultRate && !(observed && rate == 1)
if self.audioPlayer.rate != rate { if self.audioPlayer.rate != rate {
logger.log("setPlaybakRate rate changed from \(self.audioPlayer.rate) to \(rate)") AbsLogger.info(message: "setPlaybakRate rate changed from \(self.audioPlayer.rate) to \(rate)")
DispatchQueue.runOnMainQueue { DispatchQueue.runOnMainQueue {
self.audioPlayer.rate = rate self.audioPlayer.rate = rate
} }
@@ -73,7 +72,7 @@ class LegacyAudioPlayerRateManager: NSObject, AudioPlayerRateManager {
public override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) { public override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
if context == &managerContext { if context == &managerContext {
if keyPath == #keyPath(AVPlayer.rate) { if keyPath == #keyPath(AVPlayer.rate) {
logger.log("playerContext observer player rate") AbsLogger.info(message: "playerContext observer player rate")
self.handlePlaybackRateChange(change?[.newKey] as? Float ?? 1.0, observed: true) self.handlePlaybackRateChange(change?[.newKey] as? Float ?? 1.0, observed: true)
} }
} else { } else {
+10 -13
View File
@@ -14,8 +14,6 @@ class PlayerProgress {
private static var TIME_BETWEEN_SESSION_SYNC_IN_SECONDS = 15.0 private static var TIME_BETWEEN_SESSION_SYNC_IN_SECONDS = 15.0
private let logger = AppLogger(category: "PlayerProgress")
private init() {} private init() {}
@@ -30,8 +28,7 @@ class PlayerProgress {
try await updateServerSessionFromLocalSession(session, rateLimitSync: !isStopping) try await updateServerSessionFromLocalSession(session, rateLimitSync: !isStopping)
} }
} catch { } catch {
logger.error("Failed to syncFromPlayer") AbsLogger.error(message: "Failed to syncFromPlayer \(error)", error: error)
logger.error(error)
} }
await UIApplication.shared.endBackgroundTask(backgroundToken) await UIApplication.shared.endBackgroundTask(backgroundToken)
} }
@@ -76,7 +73,7 @@ class PlayerProgress {
try localMediaProgress.updateFromPlaybackSession(session) try localMediaProgress.updateFromPlaybackSession(session)
logger.log("Local progress saved to the database") AbsLogger.info(message:"Local progress saved to the database")
// Send the local progress back to front-end // Send the local progress back to front-end
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.localProgress.rawValue), object: nil) NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.localProgress.rawValue), object: nil)
@@ -126,7 +123,7 @@ class PlayerProgress {
session = session.freeze() session = session.freeze()
guard safeToSync else { return } guard safeToSync else { return }
logger.log("Sending sessionId(\(session.id)) to server with currentTime(\(session.currentTime))") AbsLogger.info(message:"Sending sessionId(\(session.id)) to server with currentTime(\(session.currentTime))")
var success = false var success = false
if session.isLocal { if session.isLocal {
@@ -155,25 +152,25 @@ class PlayerProgress {
// TODO: Unused for now // TODO: Unused for now
private func updateLocalSessionFromServerMediaProgress() async throws { private func updateLocalSessionFromServerMediaProgress() async throws {
logger.log("updateLocalSessionFromServerMediaProgress: Checking if local media progress was updated on server") AbsLogger.info(message:"updateLocalSessionFromServerMediaProgress: Checking if local media progress was updated on server")
guard let session = try Realm(queue: nil).objects(PlaybackSession.self).last(where: { guard let session = try Realm(queue: nil).objects(PlaybackSession.self).last(where: {
$0.isActiveSession == true && $0.serverConnectionConfigId == Store.serverConfig?.id $0.isActiveSession == true && $0.serverConnectionConfigId == Store.serverConfig?.id
})?.freeze() else { })?.freeze() else {
logger.log("updateLocalSessionFromServerMediaProgress: Failed to get session") AbsLogger.info(message:"updateLocalSessionFromServerMediaProgress: Failed to get session")
return return
} }
// Fetch the current progress // Fetch the current progress
let progress = await ApiClient.getMediaProgress(libraryItemId: session.libraryItemId!, episodeId: session.episodeId) let progress = await ApiClient.getMediaProgress(libraryItemId: session.libraryItemId!, episodeId: session.episodeId)
guard let progress = progress else { guard let progress = progress else {
logger.log("updateLocalSessionFromServerMediaProgress: No progress object") AbsLogger.info(message:"updateLocalSessionFromServerMediaProgress: No progress object")
return return
} }
// Determine which session is newer // Determine which session is newer
let serverLastUpdate = progress.lastUpdate let serverLastUpdate = progress.lastUpdate
guard let localLastUpdate = session.updatedAt else { guard let localLastUpdate = session.updatedAt else {
logger.log("updateLocalSessionFromServerMediaProgress: No local session updatedAt") AbsLogger.info(message:"updateLocalSessionFromServerMediaProgress: No local session updatedAt")
return return
} }
let serverCurrentTime = progress.currentTime let serverCurrentTime = progress.currentTime
@@ -184,16 +181,16 @@ class PlayerProgress {
// Update the session, if needed // Update the session, if needed
if serverIsNewerThanLocal && currentTimeIsDifferent { if serverIsNewerThanLocal && currentTimeIsDifferent {
logger.log("updateLocalSessionFromServerMediaProgress: Server has newer time than local serverLastUpdate=\(serverLastUpdate) localLastUpdate=\(localLastUpdate)") AbsLogger.info(message:"updateLocalSessionFromServerMediaProgress: Server has newer time than local serverLastUpdate=\(serverLastUpdate) localLastUpdate=\(localLastUpdate)")
guard let session = session.thaw() else { return } guard let session = session.thaw() else { return }
try session.update { try session.update {
session.currentTime = serverCurrentTime session.currentTime = serverCurrentTime
session.updatedAt = serverLastUpdate session.updatedAt = serverLastUpdate
} }
logger.log("updateLocalSessionFromServerMediaProgress: Updated session currentTime newCurrentTime=\(serverCurrentTime) previousCurrentTime=\(localCurrentTime)") AbsLogger.info(message:"updateLocalSessionFromServerMediaProgress: Updated session currentTime newCurrentTime=\(serverCurrentTime) previousCurrentTime=\(localCurrentTime)")
PlayerHandler.seek(amount: session.currentTime) PlayerHandler.seek(amount: session.currentTime)
} else { } else {
logger.log("updateLocalSessionFromServerMediaProgress: Local session does not need updating; local has latest progress") AbsLogger.info(message:"updateLocalSessionFromServerMediaProgress: Local session does not need updating; local has latest progress")
} }
} }
+51 -52
View File
@@ -9,7 +9,6 @@ import Foundation
import Alamofire import Alamofire
class ApiClient { class ApiClient {
private static let logger = AppLogger(category: "ApiClient")
private static let secureStorage = SecureStorage() private static let secureStorage = SecureStorage()
public static func getData(from url: URL, completion: @escaping (UIImage?) -> Void) { public static func getData(from url: URL, completion: @escaping (UIImage?) -> Void) {
@@ -22,7 +21,7 @@ class ApiClient {
public static func postResource<T: Decodable>(endpoint: String, parameters: [String: Any], decodable: T.Type = T.self, callback: ((_ param: T) -> Void)?) { public static func postResource<T: Decodable>(endpoint: String, parameters: [String: Any], decodable: T.Type = T.self, callback: ((_ param: T) -> Void)?) {
if (Store.serverConfig == nil) { if (Store.serverConfig == nil) {
logger.error("Server config not set") AbsLogger.error(message: "Server config not set")
return return
} }
@@ -35,7 +34,7 @@ class ApiClient {
case .success(let obj): case .success(let obj):
callback?(obj) callback?(obj)
case .failure(let error): case .failure(let error):
logger.error("api request to \(endpoint) failed") AbsLogger.error(message: "api request to \(endpoint) failed")
print(error) print(error)
} }
} }
@@ -43,7 +42,7 @@ class ApiClient {
public static func postResource<T: Encodable, U: Decodable>(endpoint: String, parameters: T, decodable: U.Type = U.self, callback: ((_ param: U) -> Void)?) { public static func postResource<T: Encodable, U: Decodable>(endpoint: String, parameters: T, decodable: U.Type = U.self, callback: ((_ param: U) -> Void)?) {
if (Store.serverConfig == nil) { if (Store.serverConfig == nil) {
logger.error("Server config not set") AbsLogger.error(message: "Server config not set")
return return
} }
@@ -56,7 +55,7 @@ class ApiClient {
case .success(let obj): case .success(let obj):
callback?(obj) callback?(obj)
case .failure(let error): case .failure(let error):
logger.error("api request to \(endpoint) failed") AbsLogger.error(message: "api request to \(endpoint) failed")
print(error) print(error)
} }
} }
@@ -72,7 +71,7 @@ class ApiClient {
public static func postResource<T:Encodable>(endpoint: String, parameters: T, callback: ((_ success: Bool) -> Void)?) { public static func postResource<T:Encodable>(endpoint: String, parameters: T, callback: ((_ success: Bool) -> Void)?) {
if (Store.serverConfig == nil) { if (Store.serverConfig == nil) {
logger.error("Server config not set") AbsLogger.error(message: "Server config not set")
callback?(false) callback?(false)
return return
} }
@@ -86,7 +85,7 @@ class ApiClient {
case .success(_): case .success(_):
callback?(true) callback?(true)
case .failure(let error): case .failure(let error):
logger.error("api request to \(endpoint) failed") AbsLogger.error(message: "api request to \(endpoint) failed")
print(error) print(error)
callback?(false) callback?(false)
@@ -96,7 +95,7 @@ class ApiClient {
public static func patchResource<T: Encodable>(endpoint: String, parameters: T, callback: ((_ success: Bool) -> Void)?) { public static func patchResource<T: Encodable>(endpoint: String, parameters: T, callback: ((_ success: Bool) -> Void)?) {
if (Store.serverConfig == nil) { if (Store.serverConfig == nil) {
logger.error("Server config not set") AbsLogger.error(message: "Server config not set")
callback?(false) callback?(false)
return return
} }
@@ -110,7 +109,7 @@ class ApiClient {
case .success(_): case .success(_):
callback?(true) callback?(true)
case .failure(let error): case .failure(let error):
logger.error("api request to \(endpoint) failed") AbsLogger.error(message: "api request to \(endpoint) failed")
print(error) print(error)
callback?(false) callback?(false)
} }
@@ -127,7 +126,7 @@ class ApiClient {
public static func getResource<T: Decodable>(endpoint: String, decodable: T.Type = T.self, callback: ((_ param: T?) -> Void)?) { public static func getResource<T: Decodable>(endpoint: String, decodable: T.Type = T.self, callback: ((_ param: T?) -> Void)?) {
if (Store.serverConfig == nil) { if (Store.serverConfig == nil) {
logger.error("Server config not set") AbsLogger.error(message: "Server config not set")
callback?(nil) callback?(nil)
return return
} }
@@ -141,7 +140,7 @@ class ApiClient {
case .success(let obj): case .success(let obj):
callback?(obj) callback?(obj)
case .failure(let error): case .failure(let error):
logger.error("api request to \(endpoint) failed") AbsLogger.error(message: "api request to \(endpoint) failed")
print(error) print(error)
} }
} }
@@ -160,22 +159,22 @@ class ApiClient {
*/ */
private static func handleTokenRefresh<T: Decodable>(originalRequest: DataRequest, endpoint: String, method: HTTPMethod, parameters: Any?, decodable: T.Type, callback: ((_ param: T?) -> Void)?) { private static func handleTokenRefresh<T: Decodable>(originalRequest: DataRequest, endpoint: String, method: HTTPMethod, parameters: Any?, decodable: T.Type, callback: ((_ param: T?) -> Void)?) {
guard let serverConfig = Store.serverConfig else { guard let serverConfig = Store.serverConfig else {
logger.error("handleTokenRefresh: No server config available") AbsLogger.error(message: "handleTokenRefresh: No server config available")
callback?(nil) callback?(nil)
return return
} }
logger.log("handleTokenRefresh: Attempting to refresh auth tokens for server \(serverConfig.name)") AbsLogger.info(message: "handleTokenRefresh: Attempting to refresh auth tokens for server \(serverConfig.name)")
// Get refresh token from secure storage // Get refresh token from secure storage
guard let refreshToken = secureStorage.getRefreshToken(serverConnectionConfigId: serverConfig.id) else { guard let refreshToken = secureStorage.getRefreshToken(serverConnectionConfigId: serverConfig.id) else {
logger.error("handleTokenRefresh: No refresh token available for server \(serverConfig.name)") AbsLogger.error(message: "handleTokenRefresh: No refresh token available for server \(serverConfig.name)")
handleRefreshFailure() handleRefreshFailure()
callback?(nil) callback?(nil)
return return
} }
logger.log("handleTokenRefresh: Retrieved refresh token, attempting to refresh access token") AbsLogger.info(message: "handleTokenRefresh: Retrieved refresh token, attempting to refresh access token")
// Create refresh token request // Create refresh token request
let refreshHeaders: HTTPHeaders = [ let refreshHeaders: HTTPHeaders = [
@@ -190,23 +189,23 @@ class ApiClient {
case .success(let refreshResponse): case .success(let refreshResponse):
guard let user = refreshResponse.user, guard let user = refreshResponse.user,
!user.accessToken.isEmpty else { !user.accessToken.isEmpty else {
logger.error("handleTokenRefresh: No access token in refresh response for server \(serverConfig.name)") AbsLogger.error(message: "handleTokenRefresh: No access token in refresh response for server \(serverConfig.name)")
handleRefreshFailure() handleRefreshFailure()
callback?(nil) callback?(nil)
return return
} }
logger.log("handleTokenRefresh: Successfully obtained new access token") AbsLogger.info(message: "handleTokenRefresh: Successfully obtained new access token")
// Update tokens in secure storage and store // Update tokens in secure storage and store
updateTokens(newAccessToken: user.accessToken, newRefreshToken: user.refreshToken ?? refreshToken, serverConnectionConfigId: serverConfig.id) updateTokens(newAccessToken: user.accessToken, newRefreshToken: user.refreshToken ?? refreshToken, serverConnectionConfigId: serverConfig.id)
// Retry the original request with the new access token // Retry the original request with the new access token
logger.log("handleTokenRefresh: Retrying original request with new token") AbsLogger.info(message: "handleTokenRefresh: Retrying original request with new token")
retryOriginalRequest(endpoint: endpoint, method: method, parameters: parameters, decodable: decodable, newAccessToken: user.accessToken, callback: callback) retryOriginalRequest(endpoint: endpoint, method: method, parameters: parameters, decodable: decodable, newAccessToken: user.accessToken, callback: callback)
case .failure(let error): case .failure(let error):
logger.error("handleTokenRefresh: Refresh request failed for server \(serverConfig.name): \(error)") AbsLogger.error(message: "handleTokenRefresh: Refresh request failed for server \(serverConfig.name): \(error)")
handleRefreshFailure() handleRefreshFailure()
callback?(nil) callback?(nil)
} }
@@ -220,14 +219,14 @@ class ApiClient {
// Update the refresh token in secure storage if it's new // Update the refresh token in secure storage if it's new
if newRefreshToken != secureStorage.getRefreshToken(serverConnectionConfigId: serverConnectionConfigId) { if newRefreshToken != secureStorage.getRefreshToken(serverConnectionConfigId: serverConnectionConfigId) {
let hasStored = secureStorage.storeRefreshToken(serverConnectionConfigId: serverConnectionConfigId, refreshToken: newRefreshToken) let hasStored = secureStorage.storeRefreshToken(serverConnectionConfigId: serverConnectionConfigId, refreshToken: newRefreshToken)
logger.log("updateTokens: Updated refresh token in secure storage. Stored=\(hasStored)") AbsLogger.info(message: "updateTokens: Updated refresh token in secure storage. Stored=\(hasStored)")
} }
// Update access token on server connection config // Update access token on server connection config
Database.shared.updateServerConnectionConfigToken(newToken: newAccessToken) Database.shared.updateServerConnectionConfigToken(newToken: newAccessToken)
logger.log("updateTokens: Updated access token in server connection config") AbsLogger.info(message: "updateTokens: Updated access token in server connection config")
logger.log("updateTokens: Successfully refreshed auth tokens for server \(Store.serverConfig?.name ?? "unknown")") AbsLogger.info(message: "updateTokens: Successfully refreshed auth tokens for server \(Store.serverConfig?.name ?? "unknown")")
// Notify webview frontend about token refresh // Notify webview frontend about token refresh
if let callback = AbsDatabase.tokenRefreshCallback { if let callback = AbsDatabase.tokenRefreshCallback {
@@ -241,7 +240,7 @@ class ApiClient {
*/ */
private static func retryOriginalRequest<T: Decodable>(endpoint: String, method: HTTPMethod, parameters: Any?, decodable: T.Type, newAccessToken: String, callback: ((_ param: T?) -> Void)?) { private static func retryOriginalRequest<T: Decodable>(endpoint: String, method: HTTPMethod, parameters: Any?, decodable: T.Type, newAccessToken: String, callback: ((_ param: T?) -> Void)?) {
guard let serverConfig = Store.serverConfig else { guard let serverConfig = Store.serverConfig else {
logger.error("retryOriginalRequest: No server config available") AbsLogger.error(message: "retryOriginalRequest: No server config available")
callback?(nil) callback?(nil)
return return
} }
@@ -270,7 +269,7 @@ class ApiClient {
retryRequest = AF.request("\(serverConfig.address)/\(endpoint)", method: .patch, headers: headers) retryRequest = AF.request("\(serverConfig.address)/\(endpoint)", method: .patch, headers: headers)
} }
default: default:
logger.error("retryOriginalRequest: Unsupported method \(method)") AbsLogger.error(message: "retryOriginalRequest: Unsupported method \(method)")
callback?(nil) callback?(nil)
return return
} }
@@ -282,7 +281,7 @@ class ApiClient {
if let data = response.data, !data.isEmpty { if let data = response.data, !data.isEmpty {
// If it is a string return nil (e.g. express returns OK for 200 status codes) // If it is a string return nil (e.g. express returns OK for 200 status codes)
if let responseString = String(data: data, encoding: .utf8) { if let responseString = String(data: data, encoding: .utf8) {
logger.log("retryOriginalRequest: Got string response '\(responseString)'") AbsLogger.info(message: "retryOriginalRequest: Got string response '\(responseString)'")
callback?(nil) callback?(nil)
return return
} }
@@ -292,16 +291,16 @@ class ApiClient {
let decodedObject = try JSONDecoder().decode(decodable, from: data) let decodedObject = try JSONDecoder().decode(decodable, from: data)
callback?(decodedObject) callback?(decodedObject)
} catch { } catch {
logger.error("retryOriginalRequest: JSON decode failed: \(error)") AbsLogger.error(message: "retryOriginalRequest: JSON decode failed: \(error)", error: error)
callback?(nil) callback?(nil)
} }
} else { } else {
// Empty response // Empty response
logger.log("retryOriginalRequest: Empty response with success status \(statusCode)") AbsLogger.info(message: "retryOriginalRequest: Empty response with success status \(statusCode)")
callback?(nil) callback?(nil)
} }
} else { } else {
logger.error("retryOriginalRequest: Request failed with status \(response.response?.statusCode ?? 0)") AbsLogger.error(message: "retryOriginalRequest: Request failed with status \(response.response?.statusCode ?? 0)")
callback?(nil) callback?(nil)
} }
} }
@@ -312,7 +311,7 @@ class ApiClient {
* This will clear the current server connection and notify webview * This will clear the current server connection and notify webview
*/ */
private static func handleRefreshFailure() { private static func handleRefreshFailure() {
logger.log("handleRefreshFailure: Token refresh failed, clearing session") AbsLogger.info(message: "handleRefreshFailure: Token refresh failed, clearing session")
// Clear the current server connection // Clear the current server connection
Store.serverConfig = nil Store.serverConfig = nil
@@ -332,7 +331,7 @@ class ApiClient {
public static func getResourceWithTokenRefresh<T: Decodable>(endpoint: String, decodable: T.Type = T.self, callback: ((_ param: T?) -> Void)?) { public static func getResourceWithTokenRefresh<T: Decodable>(endpoint: String, decodable: T.Type = T.self, callback: ((_ param: T?) -> Void)?) {
if (Store.serverConfig == nil) { if (Store.serverConfig == nil) {
logger.error("Server config not set") AbsLogger.error(message: "Server config not set")
callback?(nil) callback?(nil)
return return
} }
@@ -345,14 +344,14 @@ class ApiClient {
request.responseDecodable(of: decodable) { response in request.responseDecodable(of: decodable) { response in
if let statusCode = response.response?.statusCode, statusCode == 401 { if let statusCode = response.response?.statusCode, statusCode == 401 {
logger.log("getResourceWithTokenRefresh: 401 Unauthorized for request to \(endpoint) - attempting token refresh") AbsLogger.info(message: "getResourceWithTokenRefresh: 401 Unauthorized for request to \(endpoint) - attempting token refresh")
handleTokenRefresh(originalRequest: request, endpoint: endpoint, method: .get, parameters: nil, decodable: decodable, callback: callback) handleTokenRefresh(originalRequest: request, endpoint: endpoint, method: .get, parameters: nil, decodable: decodable, callback: callback)
} else { } else {
switch response.result { switch response.result {
case .success(let obj): case .success(let obj):
callback?(obj) callback?(obj)
case .failure(let error): case .failure(let error):
logger.error("api request to \(endpoint) failed") AbsLogger.error(message: "api request to \(endpoint) failed")
print(error) print(error)
callback?(nil) callback?(nil)
} }
@@ -362,7 +361,7 @@ class ApiClient {
public static func postResourceWithTokenRefresh<T: Encodable, U: Decodable>(endpoint: String, parameters: T, decodable: U.Type = U.self, callback: ((_ param: U?) -> Void)?) { public static func postResourceWithTokenRefresh<T: Encodable, U: Decodable>(endpoint: String, parameters: T, decodable: U.Type = U.self, callback: ((_ param: U?) -> Void)?) {
if (Store.serverConfig == nil) { if (Store.serverConfig == nil) {
logger.error("Server config not set") AbsLogger.error(message: "Server config not set")
callback?(nil) callback?(nil)
return return
} }
@@ -375,14 +374,14 @@ class ApiClient {
request.responseDecodable(of: decodable) { response in request.responseDecodable(of: decodable) { response in
if let statusCode = response.response?.statusCode, statusCode == 401 { if let statusCode = response.response?.statusCode, statusCode == 401 {
logger.log("postResourceWithTokenRefresh: 401 Unauthorized for request to \(endpoint) - attempting token refresh") AbsLogger.info(message: "postResourceWithTokenRefresh: 401 Unauthorized for request to \(endpoint) - attempting token refresh")
handleTokenRefresh(originalRequest: request, endpoint: endpoint, method: .post, parameters: parameters, decodable: decodable, callback: callback) handleTokenRefresh(originalRequest: request, endpoint: endpoint, method: .post, parameters: parameters, decodable: decodable, callback: callback)
} else { } else {
switch response.result { switch response.result {
case .success(let obj): case .success(let obj):
callback?(obj) callback?(obj)
case .failure(let error): case .failure(let error):
logger.error("api request to \(endpoint) failed") AbsLogger.error(message: "api request to \(endpoint) failed")
print(error) print(error)
callback?(nil) callback?(nil)
} }
@@ -395,7 +394,7 @@ class ApiClient {
*/ */
public static func postResourceWithTokenRefresh<T: Encodable>(endpoint: String, parameters: T, callback: ((_ success: Bool) -> Void)?) { public static func postResourceWithTokenRefresh<T: Encodable>(endpoint: String, parameters: T, callback: ((_ success: Bool) -> Void)?) {
if (Store.serverConfig == nil) { if (Store.serverConfig == nil) {
logger.error("Server config not set") AbsLogger.error(message: "Server config not set")
callback?(false) callback?(false)
return return
} }
@@ -408,7 +407,7 @@ class ApiClient {
request.response { response in request.response { response in
if let statusCode = response.response?.statusCode, statusCode == 401 { if let statusCode = response.response?.statusCode, statusCode == 401 {
logger.log("postResourceWithTokenRefresh: 401 Unauthorized for request to \(endpoint) - attempting token refresh") AbsLogger.info(message: "postResourceWithTokenRefresh: 401 Unauthorized for request to \(endpoint) - attempting token refresh")
handleTokenRefresh(originalRequest: request, endpoint: endpoint, method: .post, parameters: parameters, decodable: EmptyResponse.self) { result in handleTokenRefresh(originalRequest: request, endpoint: endpoint, method: .post, parameters: parameters, decodable: EmptyResponse.self) { result in
callback?(result != nil) callback?(result != nil)
} }
@@ -417,7 +416,7 @@ class ApiClient {
case .success(_): case .success(_):
callback?(true) callback?(true)
case .failure(let error): case .failure(let error):
logger.error("api request to \(endpoint) failed") AbsLogger.error(message: "api request to \(endpoint) failed")
print(error) print(error)
callback?(false) callback?(false)
} }
@@ -427,7 +426,7 @@ class ApiClient {
public static func patchResourceWithTokenRefresh<T: Encodable>(endpoint: String, parameters: T, callback: ((_ success: Bool) -> Void)?) { public static func patchResourceWithTokenRefresh<T: Encodable>(endpoint: String, parameters: T, callback: ((_ success: Bool) -> Void)?) {
if (Store.serverConfig == nil) { if (Store.serverConfig == nil) {
logger.error("Server config not set") AbsLogger.error(message: "Server config not set")
callback?(false) callback?(false)
return return
} }
@@ -440,7 +439,7 @@ class ApiClient {
request.response { response in request.response { response in
if let statusCode = response.response?.statusCode, statusCode == 401 { if let statusCode = response.response?.statusCode, statusCode == 401 {
logger.log("patchResourceWithTokenRefresh: 401 Unauthorized for request to \(endpoint) - attempting token refresh") AbsLogger.info(message: "patchResourceWithTokenRefresh: 401 Unauthorized for request to \(endpoint) - attempting token refresh")
handleTokenRefresh(originalRequest: request, endpoint: endpoint, method: .patch, parameters: parameters, decodable: EmptyResponse.self) { result in handleTokenRefresh(originalRequest: request, endpoint: endpoint, method: .patch, parameters: parameters, decodable: EmptyResponse.self) { result in
callback?(result != nil) callback?(result != nil)
} }
@@ -449,7 +448,7 @@ class ApiClient {
case .success(_): case .success(_):
callback?(true) callback?(true)
case .failure(let error): case .failure(let error):
logger.error("api request to \(endpoint) failed") AbsLogger.error(message: "api request to \(endpoint) failed")
print(error) print(error)
callback?(false) callback?(false)
} }
@@ -489,7 +488,7 @@ class ApiClient {
// Use the new token refresh-enabled method // Use the new token refresh-enabled method
postResourceWithTokenRefresh(endpoint: endpoint, parameters: parameters, decodable: PlaybackSession.self) { session in postResourceWithTokenRefresh(endpoint: endpoint, parameters: parameters, decodable: PlaybackSession.self) { session in
guard let session = session else { guard let session = session else {
logger.error("startPlaybackSession: Failed to create playback session") AbsLogger.error(message: "startPlaybackSession: Failed to create playback session")
callback(PlaybackSession()) // Return empty session on failure callback(PlaybackSession()) // Return empty session on failure
return return
} }
@@ -533,14 +532,14 @@ class ApiClient {
let localMediaProgressList = Database.shared.getAllLocalMediaProgress().filter { let localMediaProgressList = Database.shared.getAllLocalMediaProgress().filter {
$0.serverConnectionConfigId == Store.serverConfig?.id $0.serverConnectionConfigId == Store.serverConfig?.id
}.map { $0.freeze() } }.map { $0.freeze() }
logger.log("syncLocalSessionsWithServer: Found \(localMediaProgressList.count) local media progress for server") AbsLogger.info(message: "syncLocalSessionsWithServer: Found \(localMediaProgressList.count) local media progress for server")
if (localMediaProgressList.isEmpty) { if (localMediaProgressList.isEmpty) {
logger.log("syncLocalSessionsWithServer: No local progress to sync") AbsLogger.info(message: "syncLocalSessionsWithServer: No local progress to sync")
} else { } else {
let currentUser = await ApiClient.getCurrentUser() let currentUser = await ApiClient.getCurrentUser()
guard let currentUser = currentUser else { guard let currentUser = currentUser else {
logger.log("syncLocalSessionsWithServer: No User") AbsLogger.info(message: "syncLocalSessionsWithServer: No User")
return return
} }
try currentUser.mediaProgress.forEach { mediaProgress in try currentUser.mediaProgress.forEach { mediaProgress in
@@ -552,12 +551,12 @@ class ApiClient {
} }
} }
if (localMediaProgress != nil && mediaProgress.lastUpdate > localMediaProgress!.lastUpdate) { if (localMediaProgress != nil && mediaProgress.lastUpdate > localMediaProgress!.lastUpdate) {
logger.log("syncLocalSessionsWithServer: Updating local media progress \(localMediaProgress!.id) with server media progress") AbsLogger.info(message: "syncLocalSessionsWithServer: Updating local media progress \(localMediaProgress!.id) with server media progress")
if let localMediaProgress = localMediaProgress?.thaw() { if let localMediaProgress = localMediaProgress?.thaw() {
try localMediaProgress.updateFromServerMediaProgress(mediaProgress) try localMediaProgress.updateFromServerMediaProgress(mediaProgress)
} }
} else if (localMediaProgress != nil) { } else if (localMediaProgress != nil) {
logger.log("syncLocalSessionsWithServer: Local progress for \(localMediaProgress!.id) is more recent then server progress") AbsLogger.info(message: "syncLocalSessionsWithServer: Local progress for \(localMediaProgress!.id) is more recent then server progress")
} }
} }
} }
@@ -566,13 +565,13 @@ class ApiClient {
let playbackSessions = Database.shared.getAllPlaybackSessions().filter { let playbackSessions = Database.shared.getAllPlaybackSessions().filter {
$0.serverConnectionConfigId == Store.serverConfig?.id $0.serverConnectionConfigId == Store.serverConfig?.id
}.map { $0.freeze() } }.map { $0.freeze() }
logger.log("syncLocalSessionsWithServer: Found \(playbackSessions.count) playback sessions for server (first sync: \(isFirstSync))") AbsLogger.info(message: "syncLocalSessionsWithServer: Found \(playbackSessions.count) playback sessions for server (first sync: \(isFirstSync))")
if (!playbackSessions.isEmpty) { if (!playbackSessions.isEmpty) {
let success = await ApiClient.reportAllLocalPlaybackSessions(playbackSessions) let success = await ApiClient.reportAllLocalPlaybackSessions(playbackSessions)
if (success) { if (success) {
// Remove sessions from db // Remove sessions from db
try playbackSessions.forEach { session in try playbackSessions.forEach { session in
logger.log("syncLocalSessionsWithServer: Handling \(session.displayTitle ?? "") (\(session.id)) \(session.isActiveSession)") AbsLogger.info(message: "syncLocalSessionsWithServer: Handling \(session.displayTitle ?? "") (\(session.id)) \(session.isActiveSession)")
// On first sync then remove all sessions // On first sync then remove all sessions
if (!session.isActiveSession || isFirstSync) { if (!session.isActiveSession || isFirstSync) {
if let session = session.thaw() { if let session = session.thaw() {
@@ -589,7 +588,7 @@ class ApiClient {
} }
public static func updateMediaProgress<T:Encodable>(libraryItemId: String, episodeId: String?, payload: T, callback: @escaping () -> Void) { public static func updateMediaProgress<T:Encodable>(libraryItemId: String, episodeId: String?, payload: T, callback: @escaping () -> Void) {
logger.log("updateMediaProgress \(libraryItemId) \(episodeId ?? "NIL") \(payload)") AbsLogger.info(message: "updateMediaProgress \(libraryItemId) \(episodeId ?? "NIL") \(payload)")
let endpoint = episodeId?.isEmpty ?? true ? "api/me/progress/\(libraryItemId)" : "api/me/progress/\(libraryItemId)/\(episodeId ?? "")" let endpoint = episodeId?.isEmpty ?? true ? "api/me/progress/\(libraryItemId)" : "api/me/progress/\(libraryItemId)/\(episodeId ?? "")"
patchResourceWithTokenRefresh(endpoint: endpoint, parameters: payload) { _ in patchResourceWithTokenRefresh(endpoint: endpoint, parameters: payload) { _ in
callback() callback()
@@ -597,7 +596,7 @@ class ApiClient {
} }
public static func getMediaProgress(libraryItemId: String, episodeId: String?) async -> MediaProgress? { public static func getMediaProgress(libraryItemId: String, episodeId: String?) async -> MediaProgress? {
logger.log("getMediaProgress \(libraryItemId) \(episodeId ?? "NIL")") AbsLogger.info(message: "getMediaProgress \(libraryItemId) \(episodeId ?? "NIL")")
let endpoint = episodeId?.isEmpty ?? true ? "api/me/progress/\(libraryItemId)" : "api/me/progress/\(libraryItemId)/\(episodeId ?? "")" let endpoint = episodeId?.isEmpty ?? true ? "api/me/progress/\(libraryItemId)" : "api/me/progress/\(libraryItemId)/\(episodeId ?? "")"
return await withCheckedContinuation { continuation in return await withCheckedContinuation { continuation in
getResourceWithTokenRefresh(endpoint: endpoint, decodable: MediaProgress.self) { result in getResourceWithTokenRefresh(endpoint: endpoint, decodable: MediaProgress.self) { result in
@@ -607,7 +606,7 @@ class ApiClient {
} }
public static func getCurrentUser() async -> User? { public static func getCurrentUser() async -> User? {
logger.log("getCurrentUser") AbsLogger.info(message: "getCurrentUser")
return await withCheckedContinuation { continuation in return await withCheckedContinuation { continuation in
getResourceWithTokenRefresh(endpoint: "api/me", decodable: User.self) { result in getResourceWithTokenRefresh(endpoint: "api/me", decodable: User.self) { result in
continuation.resume(returning: result) continuation.resume(returning: result)
+62 -8
View File
@@ -13,9 +13,13 @@ class Database {
return Database() return Database()
}() }()
private let logger = AppLogger(category: "Database") private init() {
do {
private init() {} try cleanExpiredLogs()
} catch {
debugPrint(error)
}
}
public func setServerConnectionConfig(config: ServerConnectionConfig) { public func setServerConnectionConfig(config: ServerConnectionConfig) {
let config = config let config = config
@@ -33,7 +37,7 @@ class Database {
existing.token = config.token existing.token = config.token
} }
} catch { } catch {
logger.error("failed to update server config") AbsLogger.error("setServerConn", message: "failed to update server config")
debugPrint(error) debugPrint(error)
} }
@@ -54,7 +58,7 @@ class Database {
realm.add(config) realm.add(config)
} }
} catch(let exception) { } catch(let exception) {
logger.error("failed to save server config") AbsLogger.error(message: "failed to save server config")
debugPrint(exception) debugPrint(exception)
} }
@@ -86,7 +90,7 @@ class Database {
} }
} }
} catch(let exception) { } catch(let exception) {
logger.error("failed to delete server config") AbsLogger.error(message: "failed to delete server config")
debugPrint(exception) debugPrint(exception)
} }
} }
@@ -117,7 +121,7 @@ class Database {
} }
} }
} catch(let exception) { } catch(let exception) {
logger.error("failed to save server config active index") AbsLogger.error(message: "failed to save server config active index")
debugPrint(exception) debugPrint(exception)
} }
} }
@@ -137,7 +141,7 @@ class Database {
realm.add(deviceSettings) realm.add(deviceSettings)
} }
} catch { } catch {
logger.error("failed to save device settings") AbsLogger.error(message: "failed to save device settings")
} }
} }
@@ -275,4 +279,54 @@ class Database {
return nil return nil
} }
} }
public func saveLog(_ log: LogEntry) throws {
let realm = try Realm()
return try realm.write { realm.add(log) }
}
public func getAllLogs() -> [LogEntry] {
do {
let realm = try Realm()
return realm.objects(LogEntry.self).toArray()
} catch {
debugPrint(error)
return []
}
}
public func clearLogs() throws {
do {
let realm = try! Realm()
try realm.write {
realm.objects(LogEntry.self).forEach { log in
realm.delete(log)
}
}
} catch {
AbsLogger.error(message: "\(error)", error: error)
throw error
}
}
private func cleanExpiredLogs() throws {
let realm = try Realm()
let numberOfHoursToKeep = 48
let keepLogCutoff = Date().addingTimeInterval(TimeInterval(-1 * numberOfHoursToKeep * 3600))
let allLogs = getAllLogs()
var logsRemoved = 0
try? realm.write {
allLogs.forEach { log in
if log.timestamp < Int(keepLogCutoff.timeIntervalSince1970) {
realm.delete(log)
logsRemoved += 1
}
}
}
if logsRemoved > 0 {
AbsLogger.info(message: "cleanLogs: Removed \(logsRemoved) logs older than \(numberOfHoursToKeep) hours")
}
}
} }
+78 -2
View File
@@ -65,8 +65,7 @@ extension CAPPluginCall {
let json = try JSONSerialization.data(withJSONObject: value) let json = try JSONSerialization.data(withJSONObject: value)
return try JSONDecoder().decode(type, from: json) return try JSONDecoder().decode(type, from: json)
} catch { } catch {
AppLogger().error("Failed to get json for \(key)") AbsLogger.error(message: "Failed to get json for \(key)", error: error)
debugPrint(error)
return nil return nil
} }
} }
@@ -106,3 +105,80 @@ extension URL {
return attributes?[.creationDate] as? Date return attributes?[.creationDate] as? Date
} }
} }
// MARK: - RealmSwift helpers
// From https://github.com/realm/realm-swift/issues/5859#issuecomment-589026869
extension Results {
func toArray<T: Object>() -> [T] {
var array = [T]()
for i in 0 ..< self.count {
if let result = self[i] as? T {
array.append(result.detached())
}
}
return array
}
}
extension List {
func toArray<T: Object>() -> [T] {
var array = [T]()
for i in 0 ..< self.count {
if let result = self[i] as? T {
array.append(result.detached())
}
}
return array
}
}
protocol DetachableObject: AnyObject {
func detached() -> Self
}
extension Object: DetachableObject {
func detached() -> Self {
let detached = type(of: self).init()
for property in objectSchema.properties {
guard let value = value(forKey: property.name) else { continue }
if property.isArray == true {
//Realm List property support
let detachable = value as? DetachableObject
detached.setValue(detachable?.detached(), forKey: property.name)
} else if property.type == .object {
//Realm Object property support
let detachable = value as? DetachableObject
detached.setValue(detachable?.detached(), forKey: property.name)
} else {
detached.setValue(value, forKey: property.name)
}
}
return detached
}
}
extension List: DetachableObject {
func detached() -> List<Element> {
let result = List<Element>()
forEach {
if let detachable = $0 as? DetachableObject {
let detached = detachable.detached() as! Element
result.append(detached)
} else {
result.append($0) //Primtives are pass by value; don't need to recreate
}
}
return result
}
func toArray() -> [Element] {
return Array(self.detached())
}
}
+27 -1
View File
@@ -24,6 +24,7 @@ export default {
inittingLibraries: false, inittingLibraries: false,
hasMounted: false, hasMounted: false,
disconnectTime: 0, disconnectTime: 0,
socketDisconnectedTime: 0,
timeLostFocus: 0, timeLostFocus: 0,
currentLang: null currentLang: null
} }
@@ -44,7 +45,7 @@ export default {
} else { } else {
var timeSinceDisconnect = Date.now() - this.disconnectTime var timeSinceDisconnect = Date.now() - this.disconnectTime
if (timeSinceDisconnect > 5000) { if (timeSinceDisconnect > 5000) {
console.log('Time since disconnect was', timeSinceDisconnect, 'sync with server') console.log('[default] Time since disconnect was', timeSinceDisconnect, 'sync with server')
setTimeout(() => { setTimeout(() => {
this.syncLocalSessions(false) this.syncLocalSessions(false)
}, 4000) }, 4000)
@@ -55,6 +56,28 @@ export default {
this.disconnectTime = Date.now() this.disconnectTime = Date.now()
} }
} }
},
socketConnected: {
handler(newVal, oldVal) {
if (!this.hasMounted) {
// watcher runs before mount, handling libraries/connection should be handled in mount
return
}
if (newVal) {
// if we havent been receiving socket events then external progress updates may have been missed
const timeSinceDisconnect = Date.now() - this.socketDisconnectedTime
if (timeSinceDisconnect > 30000 && this.isPlayerOpen) {
console.log('[default] socket reconnected after ' + timeSinceDisconnect + 'ms and player is open, triggering server media progress sync')
// used for triggering a server media progress sync if local media item is open in player
this.$eventBus.$emit('socket-reconnected')
} else {
console.log('[default] socket reconnected after ' + timeSinceDisconnect + 'ms')
}
} else {
console.log('[default] socket disconnected')
this.socketDisconnectedTime = Date.now()
}
}
} }
}, },
computed: { computed: {
@@ -67,6 +90,9 @@ export default {
networkConnected() { networkConnected() {
return this.$store.state.networkConnected return this.$store.state.networkConnected
}, },
socketConnected() {
return this.$store.state.socketConnected
},
user() { user() {
return this.$store.state.user.user return this.$store.state.user.user
}, },
+1 -1
View File
@@ -33,7 +33,7 @@ export default {
this.$strings.MessageConfirmStreamingUsingCellular this.$strings.MessageConfirmStreamingUsingCellular
const { value } = await Dialog.confirm({ const { value } = await Dialog.confirm({
title: 'Confirm', title: this.$strings.HeaderConfirm,
message message
}) })
return value return value
+12
View File
@@ -0,0 +1,12 @@
export default {
methods: {
getJumpLabel(seconds) {
const val = Number(seconds)
if (isNaN(val)) return ''
const useMinutes = val >= 120 // keep 60s as seconds
const key = useMinutes ? 'UnitMinutesShort' : 'UnitSecondsShort'
const unitValue = useMinutes ? val / 60 : val
return this.$getString(key, [this.$formatNumber(unitValue)])
}
}
}
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "audiobookshelf-app", "name": "audiobookshelf-app",
"version": "0.10.0-beta", "version": "0.13.0-beta",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "audiobookshelf-app", "name": "audiobookshelf-app",
"version": "0.10.0-beta", "version": "0.13.0-beta",
"dependencies": { "dependencies": {
"@capacitor-community/keep-awake": "^7.0.0", "@capacitor-community/keep-awake": "^7.0.0",
"@capacitor-community/volume-buttons": "^7.0.0", "@capacitor-community/volume-buttons": "^7.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf-app", "name": "audiobookshelf-app",
"version": "0.10.0-beta", "version": "0.13.0-beta",
"author": "advplyr", "author": "advplyr",
"scripts": { "scripts": {
"dev": "nuxt --hostname 0.0.0.0 --port 1337", "dev": "nuxt --hostname 0.0.0.0 --port 1337",
+6
View File
@@ -74,6 +74,12 @@ export default {
console.log(`[categories] networkConnected false so fetching categories`) console.log(`[categories] networkConnected false so fetching categories`)
this.fetchCategories() this.fetchCategories()
} }
},
user(newVal, oldVal) {
if ((newVal && !oldVal) || (!newVal && oldVal)) {
console.log(`[categories] user changed so fetching categories`)
this.fetchCategories()
}
} }
}, },
computed: { computed: {
+1 -1
View File
@@ -148,7 +148,7 @@ export default {
// Show confirmation dialog and start downloading if user chooses so // Show confirmation dialog and start downloading if user chooses so
const { value } = await Dialog.confirm({ const { value } = await Dialog.confirm({
title: 'Confirm', title: this.$strings.HeaderConfirm,
message: startDownloadMessage message: startDownloadMessage
}) })
if (value) { if (value) {
+1 -1
View File
@@ -37,9 +37,9 @@ export default {
computed: {}, computed: {},
methods: { methods: {
async init() { async init() {
await this.$store.dispatch('setupNetworkListener')
this.deviceData = await this.$db.getDeviceData() this.deviceData = await this.$db.getDeviceData()
this.$store.commit('setDeviceData', this.deviceData) this.$store.commit('setDeviceData', this.deviceData)
await this.$store.dispatch('setupNetworkListener')
} }
}, },
mounted() { mounted() {
+1 -1
View File
@@ -11,7 +11,7 @@
<span v-else class="font-semibold text-fg">{{ Math.round(itemPart.progress) }}%</span> <span v-else class="font-semibold text-fg">{{ Math.round(itemPart.progress) }}%</span>
</div> </div>
<div class="flex-grow px-2"> <div class="flex-grow px-2">
<p class="truncate">{{ itemPart.filename }}</p> <p class="break-all">{{ itemPart.filename }}</p>
</div> </div>
</div> </div>
+4 -4
View File
@@ -346,7 +346,7 @@ export default {
} }
const { value } = await Dialog.confirm({ const { value } = await Dialog.confirm({
title: 'Confirm', title: this.$strings.HeaderConfirm,
message: this.$getString('MessageConfirmDeleteLocalEpisode', [localFile.basePath]) message: this.$getString('MessageConfirmDeleteLocalEpisode', [localFile.basePath])
}) })
if (value) { if (value) {
@@ -514,7 +514,7 @@ export default {
await this.$hapticsImpact() await this.$hapticsImpact()
const { value } = await Dialog.confirm({ const { value } = await Dialog.confirm({
title: 'Confirm', title: this.$strings.HeaderConfirm,
message: this.$strings.MessageConfirmDiscardProgress message: this.$strings.MessageConfirmDiscardProgress
}) })
if (value) { if (value) {
@@ -575,8 +575,8 @@ export default {
await this.$hapticsImpact() await this.$hapticsImpact()
const { value } = await Dialog.confirm({ const { value } = await Dialog.confirm({
title: 'Confirm', title: this.$strings.HeaderConfirm,
message: `Are you sure you want to delete episode "${this.title}" from the server?\nWarning: This will delete the audio file.` message: this.$getString('MessageConfirmDeleteServerEpisode', [this.title])
}) })
if (value) { if (value) {
+13 -16
View File
@@ -13,7 +13,7 @@
</div> </div>
</div> </div>
<div class="relative" @click="showFullscreenCover = true"> <div class="relative" @click="showFullscreenCover = true">
<covers-book-cover :library-item="libraryItem" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" no-bg raw @imageLoaded="coverImageLoaded" /> <covers-book-cover :library-item="libraryItem" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" no-bg raw />
<div v-if="!isPodcast" class="absolute bottom-0 left-0 h-1 z-10 box-shadow-progressbar" :class="userIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: coverWidth * progressPercent + 'px' }"></div> <div v-if="!isPodcast" class="absolute bottom-0 left-0 h-1 z-10 box-shadow-progressbar" :class="userIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: coverWidth * progressPercent + 'px' }"></div>
</div> </div>
</div> </div>
@@ -172,7 +172,7 @@
<script> <script>
import { Dialog } from '@capacitor/dialog' import { Dialog } from '@capacitor/dialog'
import { AbsFileSystem, AbsDownloader } from '@/plugins/capacitor' import { AbsFileSystem, AbsDownloader } from '@/plugins/capacitor'
import { FastAverageColor } from 'fast-average-color' import { getAverageColorFromCoverUrl } from '@/utils/coverAverageColor'
import cellularPermissionHelpers from '@/mixins/cellularPermissionHelpers' import cellularPermissionHelpers from '@/mixins/cellularPermissionHelpers'
export default { export default {
@@ -496,17 +496,10 @@ export default {
}, },
async coverImageLoaded(fullCoverUrl) { async coverImageLoaded(fullCoverUrl) {
if (!fullCoverUrl) return if (!fullCoverUrl) return
const avg = await getAverageColorFromCoverUrl(this, fullCoverUrl)
const fac = new FastAverageColor() if (!avg) return
fac this.coverRgb = avg.rgba
.getColorAsync(fullCoverUrl) this.coverBgIsLight = avg.isLight
.then((color) => {
this.coverRgb = color.rgba
this.coverBgIsLight = color.isLight
})
.catch((e) => {
console.log(e)
})
}, },
moreButtonPress() { moreButtonPress() {
this.showMoreMenu = true this.showMoreMenu = true
@@ -535,7 +528,11 @@ export default {
if (this.isPodcast) { if (this.isPodcast) {
this.episodes.sort((a, b) => { this.episodes.sort((a, b) => {
if (this.podcastType === 'serial') {
return String(a.publishedAt).localeCompare(String(b.publishedAt), undefined, { numeric: true, sensitivity: 'base' })
} else {
return String(b.publishedAt).localeCompare(String(a.publishedAt), undefined, { numeric: true, sensitivity: 'base' }) return String(b.publishedAt).localeCompare(String(a.publishedAt), undefined, { numeric: true, sensitivity: 'base' })
}
}) })
let episode = this.episodes.find((ep) => { let episode = this.episodes.find((ep) => {
@@ -584,8 +581,8 @@ export default {
// If start time and is not already streaming then ask for confirmation // If start time and is not already streaming then ask for confirmation
if (startTime !== null && startTime !== undefined && !this.$store.getters['getIsMediaStreaming'](libraryItemId, null)) { if (startTime !== null && startTime !== undefined && !this.$store.getters['getIsMediaStreaming'](libraryItemId, null)) {
const { value } = await Dialog.confirm({ const { value } = await Dialog.confirm({
title: 'Confirm', title: this.$strings.HeaderConfirm,
message: `Start playback for "${this.title}" at ${this.$secondsToTimestamp(startTime)}?` message: this.$getString('MessageConfirmPlaybackTime', [this.title, this.$secondsToTimestamp(startTime)])
}) })
if (!value) return if (!value) return
} }
@@ -673,7 +670,7 @@ export default {
} }
} }
const { value } = await Dialog.confirm({ const { value } = await Dialog.confirm({
title: 'Confirm', title: this.$strings.HeaderConfirm,
message: startDownloadMessage message: startDownloadMessage
}) })
if (value) { if (value) {
+1 -1
View File
@@ -100,7 +100,7 @@ export default {
deleteMessage = `Are you sure you want to remove this folder and ${this.localLibraryItems.length} items? (does not delete anything in your file system)` deleteMessage = `Are you sure you want to remove this folder and ${this.localLibraryItems.length} items? (does not delete anything in your file system)`
} }
const { value } = await Dialog.confirm({ const { value } = await Dialog.confirm({
title: 'Confirm', title: this.$strings.HeaderConfirm,
message: deleteMessage message: deleteMessage
}) })
if (value) { if (value) {
+1 -1
View File
@@ -22,7 +22,7 @@
</div> </div>
<div v-if="!isAndroid10OrBelow || overrideFolderRestriction" class="flex border-t border-fg/10 my-4 py-4"> <div v-if="!isAndroid10OrBelow || overrideFolderRestriction" class="flex border-t border-fg/10 my-4 py-4">
<div class="flex-grow pr-1"> <div class="flex-grow pr-1">
<ui-dropdown v-model="newFolderMediaType" placeholder="Select media type" :items="mediaTypeItems" /> <ui-dropdown v-model="newFolderMediaType" :placeholder="$strings.LabelSelectMediaType" :items="mediaTypeItems" />
</div> </div>
<ui-btn small class="w-28" color="success" @click="selectFolder">{{ $strings.ButtonNewFolder }}</ui-btn> <ui-btn small class="w-28" color="success" @click="selectFolder">{{ $strings.ButtonNewFolder }}</ui-btn>
</div> </div>
+3 -3
View File
@@ -369,7 +369,7 @@ export default {
confirmMessage += ' The file on the server will be unaffected.' confirmMessage += ' The file on the server will be unaffected.'
} }
const { value } = await Dialog.confirm({ const { value } = await Dialog.confirm({
title: 'Confirm', title: this.$strings.HeaderConfirm,
message: confirmMessage message: confirmMessage
}) })
if (value) { if (value) {
@@ -395,7 +395,7 @@ export default {
confirmMessage += ' The file on the server will be unaffected.' confirmMessage += ' The file on the server will be unaffected.'
} }
const { value } = await Dialog.confirm({ const { value } = await Dialog.confirm({
title: 'Confirm', title: this.$strings.HeaderConfirm,
message: confirmMessage message: confirmMessage
}) })
if (value) { if (value) {
@@ -412,7 +412,7 @@ export default {
confirmMessage += ' The files on the server and your progress will be unaffected.' confirmMessage += ' The files on the server and your progress will be unaffected.'
} }
const { value } = await Dialog.confirm({ const { value } = await Dialog.confirm({
title: 'Confirm', title: this.$strings.HeaderConfirm,
message: confirmMessage message: confirmMessage
}) })
if (value) { if (value) {
+1 -1
View File
@@ -21,7 +21,7 @@
<div class="flex-grow"></div> <div class="flex-grow"></div>
<div class="text-xs text-gray-400">{{ log.tag }}</div> <div class="text-xs text-gray-400">{{ log.tag }}</div>
</div> </div>
<div class="text-xs">{{ maskServerAddress ? log.maskedMessage : log.message }}</div> <div class="text-xs break-words">{{ maskServerAddress ? log.maskedMessage : log.message }}</div>
</div> </div>
</div> </div>
+16 -4
View File
@@ -14,16 +14,25 @@
<template v-for="item in bookResults"> <template v-for="item in bookResults">
<div :key="item.libraryItem.id" class="w-full h-16 py-1"> <div :key="item.libraryItem.id" class="w-full h-16 py-1">
<nuxt-link :to="`/item/${item.libraryItem.id}`"> <nuxt-link :to="`/item/${item.libraryItem.id}`">
<cards-item-search-card :library-item="item.libraryItem" :match-key="item.matchKey" :match-text="item.matchText" :search="lastSearch" /> <cards-item-search-card :library-item="item.libraryItem" :search="lastSearch" />
</nuxt-link> </nuxt-link>
</div> </div>
</template> </template>
<p v-if="podcastResults.length" class="uppercase text-xs text-fg-muted my-1 px-1 font-semibold">{{ $strings.LabelPodcasts }}</p> <p v-if="podcastResults.length" class="font-semibold text-sm mb-1 mt-2">{{ $strings.LabelPodcasts }}</p>
<template v-for="item in podcastResults"> <template v-for="item in podcastResults">
<div :key="item.libraryItem.id" class="text-fg select-none relative py-1"> <div :key="item.libraryItem.id" class="text-fg select-none relative py-1">
<nuxt-link :to="`/item/${item.libraryItem.id}`"> <nuxt-link :to="`/item/${item.libraryItem.id}`">
<cards-item-search-card :library-item="item.libraryItem" :match-key="item.matchKey" :match-text="item.matchText" :search="lastSearch" /> <cards-item-search-card :library-item="item.libraryItem" :search="lastSearch" />
</nuxt-link>
</div>
</template>
<p v-if="episodeResults.length" class="font-semibold text-sm mb-1 mt-2">{{ $strings.HeaderEpisodes }}</p>
<template v-for="item in episodeResults">
<div :key="item.libraryItem.recentEpisode.id" class="text-fg select-none relative py-1">
<nuxt-link :to="`/item/${item.libraryItem.id}/${item.libraryItem.recentEpisode.id}`">
<cards-episode-search-card :episode="item.libraryItem.recentEpisode" :library-item="item.libraryItem" />
</nuxt-link> </nuxt-link>
</div> </div>
</template> </template>
@@ -77,6 +86,7 @@ export default {
isFetching: false, isFetching: false,
bookResults: [], bookResults: [],
podcastResults: [], podcastResults: [],
episodeResults: [],
seriesResults: [], seriesResults: [],
authorResults: [], authorResults: [],
narratorResults: [], narratorResults: [],
@@ -91,7 +101,7 @@ export default {
return this.$store.getters['libraries/getBookCoverAspectRatio'] return this.$store.getters['libraries/getBookCoverAspectRatio']
}, },
totalResults() { totalResults() {
return this.bookResults.length + this.seriesResults.length + this.authorResults.length + this.podcastResults.length + this.narratorResults.length + this.tagResults.length return this.bookResults.length + this.seriesResults.length + this.authorResults.length + this.podcastResults.length + this.narratorResults.length + this.tagResults.length + this.episodeResults.length
} }
}, },
methods: { methods: {
@@ -104,6 +114,7 @@ export default {
if (!this.lastSearch) { if (!this.lastSearch) {
this.bookResults = [] this.bookResults = []
this.podcastResults = [] this.podcastResults = []
this.episodeResults = []
this.seriesResults = [] this.seriesResults = []
this.authorResults = [] this.authorResults = []
this.narratorResults = [] this.narratorResults = []
@@ -125,6 +136,7 @@ export default {
this.bookResults = results?.book || [] this.bookResults = results?.book || []
this.podcastResults = results?.podcast || [] this.podcastResults = results?.podcast || []
this.episodeResults = results?.episodes || []
this.seriesResults = results?.series || [] this.seriesResults = results?.series || []
this.authorResults = results?.authors || [] this.authorResults = results?.authors || []
this.narratorResults = results?.narrators || [] this.narratorResults = results?.narrators || []
+61 -42
View File
@@ -36,24 +36,24 @@
<!-- Playback settings --> <!-- Playback settings -->
<p class="uppercase text-xs font-semibold text-fg-muted mb-2 mt-10">{{ $strings.HeaderPlaybackSettings }}</p> <p class="uppercase text-xs font-semibold text-fg-muted mb-2 mt-10">{{ $strings.HeaderPlaybackSettings }}</p>
<div class="py-3 flex items-center">
<p class="pr-4 w-36">{{ $strings.LabelJumpBackwardsTime }}</p>
<div @click.stop="showJumpBackwardsOptions">
<ui-text-input :value="jumpBackwardsOption" readonly append-icon="expand_more" style="width: 145px; max-width: 145px" />
</div>
</div>
<div class="py-3 flex items-center">
<p class="pr-4 w-36">{{ $strings.LabelJumpForwardsTime }}</p>
<div @click.stop="showJumpForwardOptions">
<ui-text-input :value="jumpForwardOption" readonly append-icon="expand_more" style="width: 145px; max-width: 145px" />
</div>
</div>
<div class="flex items-center py-3"> <div class="flex items-center py-3">
<div class="w-10 flex justify-center" @click="toggleDisableAutoRewind"> <div class="w-10 flex justify-center" @click="toggleDisableAutoRewind">
<ui-toggle-switch v-model="settings.disableAutoRewind" @input="saveSettings" /> <ui-toggle-switch v-model="settings.disableAutoRewind" @input="saveSettings" />
</div> </div>
<p class="pl-4">{{ $strings.LabelDisableAutoRewind }}</p> <p class="pl-4">{{ $strings.LabelDisableAutoRewind }}</p>
</div> </div>
<div class="flex items-center py-3">
<div class="w-10 flex justify-center" @click="toggleJumpBackwards">
<span class="material-symbols text-4xl">{{ currentJumpBackwardsTimeIcon }}</span>
</div>
<p class="pl-4">{{ $strings.LabelJumpBackwardsTime }}</p>
</div>
<div class="flex items-center py-3">
<div class="w-10 flex justify-center" @click="toggleJumpForward">
<span class="material-symbols text-4xl">{{ currentJumpForwardTimeIcon }}</span>
</div>
<p class="pl-4">{{ $strings.LabelJumpForwardsTime }}</p>
</div>
<div v-if="!isiOS" class="flex items-center py-3"> <div v-if="!isiOS" class="flex items-center py-3">
<div class="w-10 flex justify-center" @click="toggleEnableMp3IndexSeeking"> <div class="w-10 flex justify-center" @click="toggleEnableMp3IndexSeeking">
<ui-toggle-switch v-model="settings.enableMp3IndexSeeking" @input="saveSettings" /> <ui-toggle-switch v-model="settings.enableMp3IndexSeeking" @input="saveSettings" />
@@ -179,7 +179,7 @@
<ui-loading-indicator /> <ui-loading-indicator />
</div> </div>
<modals-dialog v-model="showMoreMenuDialog" :items="moreMenuItems" @action="clickMenuAction" /> <modals-dialog v-model="showMoreMenuDialog" :items="moreMenuItems" :selected="moreMenuSelected" @action="clickMenuAction" />
<modals-sleep-timer-length-modal v-model="showSleepTimerLengthModal" @change="sleepTimerLengthModalSelection" /> <modals-sleep-timer-length-modal v-model="showSleepTimerLengthModal" @change="sleepTimerLengthModalSelection" />
<modals-auto-sleep-timer-rewind-length-modal v-model="showAutoSleepTimerRewindLengthModal" @change="showAutoSleepTimerRewindLengthModalSelection" /> <modals-auto-sleep-timer-rewind-length-modal v-model="showAutoSleepTimerRewindLengthModal" @change="showAutoSleepTimerRewindLengthModalSelection" />
</div> </div>
@@ -187,8 +187,10 @@
<script> <script>
import { Dialog } from '@capacitor/dialog' import { Dialog } from '@capacitor/dialog'
import jumpLabelMixin from '@/mixins/jumpLabel'
export default { export default {
mixins: [jumpLabelMixin],
data() { data() {
return { return {
loading: false, loading: false,
@@ -352,15 +354,21 @@ export default {
isiOS() { isiOS() {
return this.$platform === 'ios' return this.$platform === 'ios'
}, },
jumpForwardItems() { jumpForwardSecondsOptions() {
return this.$store.state.globals.jumpForwardItems || [] return this.$store.state.globals.jumpForwardSecondsOptions || []
}, },
jumpBackwardsItems() { jumpBackwardsSecondsOptions() {
return this.$store.state.globals.jumpBackwardsItems || [] return this.$store.state.globals.jumpBackwardsSecondsOptions || []
}, },
languageOptionItems() { languageOptionItems() {
return this.$languageCodeOptions || [] return this.$languageCodeOptions || []
}, },
jumpForwardOption() {
return this.getJumpLabel(this.settings.jumpForwardTime)
},
jumpBackwardsOption() {
return this.getJumpLabel(this.settings.jumpBackwardsTime)
},
themeOptionItems() { themeOptionItems() {
return [ return [
{ {
@@ -377,20 +385,6 @@ export default {
} }
] ]
}, },
currentJumpForwardTimeIcon() {
return this.jumpForwardItems[this.currentJumpForwardTimeIndex].icon
},
currentJumpForwardTimeIndex() {
var index = this.jumpForwardItems.findIndex((jfi) => jfi.value === this.settings.jumpForwardTime)
return index >= 0 ? index : 1
},
currentJumpBackwardsTimeIcon() {
return this.jumpBackwardsItems[this.currentJumpBackwardsTimeIndex].icon
},
currentJumpBackwardsTimeIndex() {
var index = this.jumpBackwardsItems.findIndex((jfi) => jfi.value === this.settings.jumpBackwardsTime)
return index >= 0 ? index : 1
},
shakeSensitivityOption() { shakeSensitivityOption() {
const item = this.shakeSensitivityItems.find((i) => i.value === this.settings.shakeSensitivity) const item = this.shakeSensitivityItems.find((i) => i.value === this.settings.shakeSensitivity)
return item?.text || 'Error' return item?.text || 'Error'
@@ -434,7 +428,29 @@ export default {
else if (this.moreMenuSetting === 'downloadUsingCellular') return this.downloadUsingCellularItems else if (this.moreMenuSetting === 'downloadUsingCellular') return this.downloadUsingCellularItems
else if (this.moreMenuSetting === 'streamingUsingCellular') return this.streamingUsingCellularItems else if (this.moreMenuSetting === 'streamingUsingCellular') return this.streamingUsingCellularItems
else if (this.moreMenuSetting === 'androidAutoBrowseSeriesSequenceOrder') return this.androidAutoBrowseSeriesSequenceOrderItems else if (this.moreMenuSetting === 'androidAutoBrowseSeriesSequenceOrder') return this.androidAutoBrowseSeriesSequenceOrderItems
else if (this.moreMenuSetting === 'jumpForward')
return this.jumpForwardSecondsOptions.map((value) => ({
text: this.getJumpLabel(value),
value: value
}))
else if (this.moreMenuSetting === 'jumpBackwards')
return this.jumpBackwardsSecondsOptions.map((value) => ({
text: this.getJumpLabel(value),
value: value
}))
return [] return []
},
moreMenuSelected() {
if (this.moreMenuSetting === 'jumpForward') return this.settings.jumpForwardTime
if (this.moreMenuSetting === 'jumpBackwards') return this.settings.jumpBackwardsTime
if (this.moreMenuSetting === 'language') return this.settings.languageCode
if (this.moreMenuSetting === 'theme') return this.theme
if (this.moreMenuSetting === 'downloadUsingCellular') return this.settings.downloadUsingCellular
if (this.moreMenuSetting === 'streamingUsingCellular') return this.settings.streamingUsingCellular
if (this.moreMenuSetting === 'androidAutoBrowseSeriesSequenceOrder') return this.settings.androidAutoBrowseSeriesSequenceOrder
if (this.moreMenuSetting === 'shakeSensitivity') return this.settings.shakeSensitivity
if (this.moreMenuSetting === 'hapticFeedback') return this.settings.hapticFeedback
return null
} }
}, },
methods: { methods: {
@@ -468,6 +484,14 @@ export default {
this.moreMenuSetting = 'theme' this.moreMenuSetting = 'theme'
this.showMoreMenuDialog = true this.showMoreMenuDialog = true
}, },
showJumpForwardOptions() {
this.moreMenuSetting = 'jumpForward'
this.showMoreMenuDialog = true
},
showJumpBackwardsOptions() {
this.moreMenuSetting = 'jumpBackwards'
this.showMoreMenuDialog = true
},
showDownloadUsingCellularOptions() { showDownloadUsingCellularOptions() {
this.moreMenuSetting = 'downloadUsingCellular' this.moreMenuSetting = 'downloadUsingCellular'
this.showMoreMenuDialog = true this.showMoreMenuDialog = true
@@ -503,6 +527,12 @@ export default {
} else if (this.moreMenuSetting === 'androidAutoBrowseSeriesSequenceOrder') { } else if (this.moreMenuSetting === 'androidAutoBrowseSeriesSequenceOrder') {
this.settings.androidAutoBrowseSeriesSequenceOrder = action this.settings.androidAutoBrowseSeriesSequenceOrder = action
this.saveSettings() this.saveSettings()
} else if (this.moreMenuSetting === 'jumpForward') {
this.settings.jumpForwardTime = action
this.saveSettings()
} else if (this.moreMenuSetting === 'jumpBackwards') {
this.settings.jumpBackwardsTime = action
this.saveSettings()
} }
}, },
saveTheme(theme) { saveTheme(theme) {
@@ -598,17 +628,6 @@ export default {
this.$setOrientationLock(this.settings.lockOrientation) this.$setOrientationLock(this.settings.lockOrientation)
this.saveSettings() this.saveSettings()
}, },
toggleJumpForward() {
var next = (this.currentJumpForwardTimeIndex + 1) % 3
this.settings.jumpForwardTime = this.jumpForwardItems[next].value
this.saveSettings()
},
toggleJumpBackwards() {
var next = (this.currentJumpBackwardsTimeIndex + 4) % 3
if (next > 2) return
this.settings.jumpBackwardsTime = this.jumpBackwardsItems[next].value
this.saveSettings()
},
async saveSettings() { async saveSettings() {
await this.$hapticsImpact() await this.$hapticsImpact()
const updatedDeviceData = await this.$db.updateDeviceSettings({ ...this.settings }) const updatedDeviceData = await this.$db.updateDeviceSettings({ ...this.settings })
+1 -1
View File
@@ -32,7 +32,7 @@ export default function ({ $axios, store, $db }) {
} }
if (window.location.pathname !== '/connect') { if (window.location.pathname !== '/connect') {
window.location.href = '/connect' window.location.href = '/connect?error=refreshTokenFailed&serverConnectionConfigId=' + serverConnectionConfigId
} }
} catch (error) { } catch (error) {
console.error('[axios] Failed to handle refresh failure:', error) console.error('[axios] Failed to handle refresh failure:', error)
+2 -2
View File
@@ -51,7 +51,7 @@ class AbsDatabaseWeb extends WebPlugin {
ssc.customHeaders = serverConnectionConfig.customHeaders || {} ssc.customHeaders = serverConnectionConfig.customHeaders || {}
if (serverConnectionConfig.refreshToken) { if (serverConnectionConfig.refreshToken) {
console.log('[AbsDatabase] Updating refresh token...', serverConnectionConfig.refreshToken) console.log('[AbsDatabase] Updating refresh token...')
// Only using local storage for web version that is only used for testing // Only using local storage for web version that is only used for testing
localStorage.setItem(`refresh_token_${ssc.id}`, serverConnectionConfig.refreshToken) localStorage.setItem(`refresh_token_${ssc.id}`, serverConnectionConfig.refreshToken)
} }
@@ -71,7 +71,7 @@ class AbsDatabaseWeb extends WebPlugin {
} }
if (serverConnectionConfig.refreshToken) { if (serverConnectionConfig.refreshToken) {
console.log('[AbsDatabase] Setting refresh token...', serverConnectionConfig.refreshToken) console.log('[AbsDatabase] Setting refresh token...')
// Only using local storage for web version that is only used for testing // Only using local storage for web version that is only used for testing
localStorage.setItem(`refresh_token_${ssc.id}`, serverConnectionConfig.refreshToken) localStorage.setItem(`refresh_token_${ssc.id}`, serverConnectionConfig.refreshToken)
} }
+2 -3
View File
@@ -128,17 +128,16 @@ export default ({ app, store }, inject) => {
// Listen for token refresh events from native app // Listen for token refresh events from native app
AbsDatabase.addListener('onTokenRefresh', (data) => { AbsDatabase.addListener('onTokenRefresh', (data) => {
console.log('[db] onTokenRefresh', data)
store.commit('user/setAccessToken', data.accessToken) store.commit('user/setAccessToken', data.accessToken)
}) })
// Listen for token refresh failure events from native app // Listen for token refresh failure events from native app
AbsDatabase.addListener('onTokenRefreshFailure', async (data) => { AbsDatabase.addListener('onTokenRefreshFailure', async (data) => {
console.log('[db] onTokenRefreshFailure', data) console.log('[db] onTokenRefreshFailure')
// Clear store and redirect to login page // Clear store and redirect to login page
await store.dispatch('user/logout') await store.dispatch('user/logout')
if (window.location.pathname !== '/connect') { if (window.location.pathname !== '/connect') {
window.location.href = '/connect' window.location.href = '/connect?error=refreshTokenFailed&serverConnectionConfigId=' + data.serverConnectionConfigId || ''
} }
}) })
} }
+3
View File
@@ -5,7 +5,9 @@ const defaultCode = 'en-us'
let $localStore = null let $localStore = null
const languageCodeMap = { const languageCodeMap = {
be: { label: 'Беларуская', dateFnsLocale: 'be' },
bn: { label: 'বাংলা', dateFnsLocale: 'bn' }, bn: { label: 'বাংলা', dateFnsLocale: 'bn' },
bg: { label: 'Български', dateFnsLocale: 'bg' },
ca: { label: 'Català', dateFnsLocale: 'ca' }, ca: { label: 'Català', dateFnsLocale: 'ca' },
cs: { label: 'Čeština', dateFnsLocale: 'cs' }, cs: { label: 'Čeština', dateFnsLocale: 'cs' },
da: { label: 'Dansk', dateFnsLocale: 'da' }, da: { label: 'Dansk', dateFnsLocale: 'da' },
@@ -25,6 +27,7 @@ const languageCodeMap = {
ru: { label: 'Русский', dateFnsLocale: 'ru' }, ru: { label: 'Русский', dateFnsLocale: 'ru' },
sl: { label: 'Slovenščina', dateFnsLocale: 'sl' }, sl: { label: 'Slovenščina', dateFnsLocale: 'sl' },
sv: { label: 'Svenska', dateFnsLocale: 'sv' }, sv: { label: 'Svenska', dateFnsLocale: 'sv' },
tr: { label: 'Türkçe', dateFnsLocale: 'tr' },
uk: { label: 'Українська', dateFnsLocale: 'uk' }, uk: { label: 'Українська', dateFnsLocale: 'uk' },
'vi-vn': { label: 'Tiếng Việt', dateFnsLocale: 'vi' }, 'vi-vn': { label: 'Tiếng Việt', dateFnsLocale: 'vi' },
'zh-cn': { label: '简体中文 (Simplified Chinese)', dateFnsLocale: 'zhCN' } 'zh-cn': { label: '简体中文 (Simplified Chinese)', dateFnsLocale: 'zhCN' }
+2 -2
View File
@@ -314,8 +314,8 @@ export default ({ store, app }, inject) => {
} }
if (!canGoBack) { if (!canGoBack) {
const { value } = await Dialog.confirm({ const { value } = await Dialog.confirm({
title: 'Confirm', title: eventBus.$strings.HeaderConfirm,
message: `Did you want to exit the app?` message: eventBus.$strings.MessageConfirmAppExit
}) })
if (value) { if (value) {
App.exitApp() App.exitApp()
+5 -3
View File
@@ -43,7 +43,8 @@ export default function ({ store, $db, $socket }, inject) {
} }
if (res.status >= 400) { if (res.status >= 400) {
console.error(`[nativeHttp] ${res.status} status for url "${url}"`) console.error(`[nativeHttp] ${res.status} status for url "${url}"`)
throw new Error(res.data) const message = typeof res.data === 'string' ? res.data : `HTTP ${res.status}`
throw new Error(message)
} }
return res.data return res.data
}) })
@@ -100,7 +101,8 @@ export default function ({ store, $db, $socket }, inject) {
if (retryResponse.status >= 400) { if (retryResponse.status >= 400) {
console.error(`[nativeHttp] Retry request failed with status ${retryResponse.status}`) console.error(`[nativeHttp] Retry request failed with status ${retryResponse.status}`)
throw new Error(retryResponse.data) const message = typeof retryResponse.data === 'string' ? retryResponse.data : `HTTP ${retryResponse.status}`
throw new Error(message)
} }
return retryResponse.data return retryResponse.data
@@ -221,7 +223,7 @@ export default function ({ store, $db, $socket }, inject) {
// Redirect to login page // Redirect to login page
if (window.location.pathname !== '/connect') { if (window.location.pathname !== '/connect') {
window.location.href = '/connect' window.location.href = '/connect?error=refreshTokenFailed&serverConnectionConfigId=' + serverConnectionConfigId
} }
} catch (error) { } catch (error) {
console.error('[nativeHttp] Failed to handle refresh failure:', error) console.error('[nativeHttp] Failed to handle refresh failure:', error)
+1 -1
View File
@@ -110,7 +110,7 @@ class ServerSocket extends EventEmitter {
} }
onAuthFailed(data) { onAuthFailed(data) {
console.log('[SOCKET] Auth failed', data) console.log('[SOCKET] Auth failed: ' + (data?.message || 'Unknown reason'))
this.isAuthenticated = false this.isAuthenticated = false
} }
+12
View File
@@ -144,6 +144,12 @@ npx cap open android
Start coding! Start coding!
After making changes to the JS layer you need to rebuild the nuxt pages and sync them to the native shells:
```shell
npm run sync
```
### Mac Environment Setup for iOS ### Mac Environment Setup for iOS
Required Software: Required Software:
@@ -213,3 +219,9 @@ npx cap open ios
<br> <br>
Start coding! Start coding!
After making changes to the JS layer you need to rebuild the nuxt pages and sync them to the native shells:
```shell
npm run sync
```
+2 -36
View File
@@ -5,34 +5,8 @@ export const state = () => ({
series: null, series: null,
localMediaProgress: [], localMediaProgress: [],
lastSearch: null, lastSearch: null,
jumpForwardItems: [ jumpForwardSecondsOptions: [5, 10, 15, 30, 60, 120, 300],
{ jumpBackwardsSecondsOptions: [5, 10, 15, 30, 60, 120, 300],
icon: 'forward_5',
value: 5
},
{
icon: 'forward_10',
value: 10
},
{
icon: 'forward_30',
value: 30
}
],
jumpBackwardsItems: [
{
icon: 'replay_5',
value: 5
},
{
icon: 'replay_10',
value: 10
},
{
icon: 'replay_30',
value: 30
}
],
libraryIcons: ['database', 'audiobookshelf', 'books-1', 'books-2', 'book-1', 'microphone-1', 'microphone-3', 'radio', 'podcast', 'rss', 'headphones', 'music', 'file-picture', 'rocket', 'power', 'star', 'heart'], libraryIcons: ['database', 'audiobookshelf', 'books-1', 'books-2', 'book-1', 'microphone-1', 'microphone-3', 'radio', 'podcast', 'rss', 'headphones', 'music', 'file-picture', 'rocket', 'power', 'star', 'heart'],
selectedPlaylistItems: [], selectedPlaylistItems: [],
showPlaylistsAddCreateModal: false, showPlaylistsAddCreateModal: false,
@@ -111,14 +85,6 @@ export const getters = {
if (episodeId != null && lmp.episodeId != episodeId) return false if (episodeId != null && lmp.episodeId != episodeId) return false
return lmp.libraryItemId == libraryItemId return lmp.libraryItemId == libraryItemId
}) })
},
getJumpForwardIcon: (state) => (jumpForwardTime) => {
const item = state.jumpForwardItems.find((i) => i.value == jumpForwardTime)
return item ? item.icon : 'forward_10'
},
getJumpBackwardsIcon: (state) => (jumpBackwardsTime) => {
const item = state.jumpBackwardsItems.find((i) => i.value == jumpBackwardsTime)
return item ? item.icon : 'replay_10'
} }
} }
-1
View File
@@ -231,7 +231,6 @@ export const mutations = {
state.user = user state.user = user
}, },
setAccessToken(state, accessToken) { setAccessToken(state, accessToken) {
console.log('[user] setAccessToken', accessToken)
state.accessToken = accessToken state.accessToken = accessToken
}, },
removeMediaProgress(state, id) { removeMediaProgress(state, id) {
+10 -1
View File
@@ -7,7 +7,7 @@
"ButtonCancelTimer": "إلغاء المؤقت", "ButtonCancelTimer": "إلغاء المؤقت",
"ButtonClearFilter": "تصفية الفرز", "ButtonClearFilter": "تصفية الفرز",
"ButtonClearLogs": "مسح السجل", "ButtonClearLogs": "مسح السجل",
"ButtonCloseFeed": "إغلاق", "ButtonCloseFeed": "إغلاق الموجز",
"ButtonCollections": "المجموعات", "ButtonCollections": "المجموعات",
"ButtonConnect": "اتصل", "ButtonConnect": "اتصل",
"ButtonConnectToServer": "اتصال بالخادم", "ButtonConnectToServer": "اتصال بالخادم",
@@ -63,6 +63,7 @@
"HeaderChapters": "الفصول", "HeaderChapters": "الفصول",
"HeaderCollection": "مجموعة", "HeaderCollection": "مجموعة",
"HeaderCollectionItems": "عناصر المجموعة", "HeaderCollectionItems": "عناصر المجموعة",
"HeaderConfirm": "تأكيد",
"HeaderConnectionStatus": "حالة الاتصال", "HeaderConnectionStatus": "حالة الاتصال",
"HeaderDataSettings": "إعدادات البيانات", "HeaderDataSettings": "إعدادات البيانات",
"HeaderDetails": "التفاصيل", "HeaderDetails": "التفاصيل",
@@ -91,6 +92,7 @@
"HeaderStatsRecentSessions": "الجلسات الأخيرة", "HeaderStatsRecentSessions": "الجلسات الأخيرة",
"HeaderTableOfContents": "جدول المحتويات", "HeaderTableOfContents": "جدول المحتويات",
"HeaderUserInterfaceSettings": "إعدادات واجهة المستخدم", "HeaderUserInterfaceSettings": "إعدادات واجهة المستخدم",
"HeaderWelcome": "مرحبا, <strong>{0}</strong>",
"HeaderYourStats": "إحصائياتك", "HeaderYourStats": "إحصائياتك",
"LabelAddToPlaylist": "أضف إلى قائمة التشغيل", "LabelAddToPlaylist": "أضف إلى قائمة التشغيل",
"LabelAddedAt": "أضيفت على", "LabelAddedAt": "أضيفت على",
@@ -146,6 +148,7 @@
"LabelEndOfChapter": "نهاية الفصل", "LabelEndOfChapter": "نهاية الفصل",
"LabelEndTime": "وقت النهاية", "LabelEndTime": "وقت النهاية",
"LabelEpisode": "الحلقة", "LabelEpisode": "الحلقة",
"LabelExplicit": "محتوى صريح",
"LabelFeedURL": "عنوان التغذية", "LabelFeedURL": "عنوان التغذية",
"LabelFile": "الملف", "LabelFile": "الملف",
"LabelFileBirthtime": "وقت انشاء الملف", "LabelFileBirthtime": "وقت انشاء الملف",
@@ -154,6 +157,9 @@
"LabelFinished": "المنجزة", "LabelFinished": "المنجزة",
"LabelFolder": "المجلد", "LabelFolder": "المجلد",
"LabelFontBoldness": "تعريض الخط", "LabelFontBoldness": "تعريض الخط",
"LabelFontFamily": "عائلة الخطوط",
"LabelFontFamilySans": "سانس",
"LabelFontFamilySerif": "سارف",
"LabelFontScale": "نطاق الخط", "LabelFontScale": "نطاق الخط",
"LabelGenre": "التصنيف", "LabelGenre": "التصنيف",
"LabelGenres": "التصانيف", "LabelGenres": "التصانيف",
@@ -297,6 +303,7 @@
"MessageDownloading": "جاري التنزيل...", "MessageDownloading": "جاري التنزيل...",
"MessageDownloadingEpisode": "جاري تنزيل الحلقة", "MessageDownloadingEpisode": "جاري تنزيل الحلقة",
"MessageEpisodesQueuedForDownload": "تمت إضافة {0} حلقة (حلقات) إلى قائمة انتظار التنزيل", "MessageEpisodesQueuedForDownload": "تمت إضافة {0} حلقة (حلقات) إلى قائمة انتظار التنزيل",
"MessageFailedToRefreshToken": "فشل في تحديث الرمز، إعادة تسجيل الدخول مطلوب",
"MessageFeedURLWillBe": "سيكون رابط التغذية هو {0}", "MessageFeedURLWillBe": "سيكون رابط التغذية هو {0}",
"MessageFetching": "جاري الجلب...", "MessageFetching": "جاري الجلب...",
"MessageFollowTheProjectOnGithub": "تابع المشروع على Github", "MessageFollowTheProjectOnGithub": "تابع المشروع على Github",
@@ -324,6 +331,8 @@
"MessageNoSeries": "لا توجد سلاسل", "MessageNoSeries": "لا توجد سلاسل",
"MessageNoUpdatesWereNecessary": "لا حاجة لأي تحديثات", "MessageNoUpdatesWereNecessary": "لا حاجة لأي تحديثات",
"MessageNoUserPlaylists": "ليس لديك أي قوائم تشغيل", "MessageNoUserPlaylists": "ليس لديك أي قوائم تشغيل",
"MessageOldServerAuthReLoginRequired": "تم تحسين المصادقة في الخادم v2.26.0 لضمان الأمان. يُطلب من جميع المستخدمين إعادة تسجيل الدخول.",
"MessageOldServerAuthWarning": "الخادم يستخدم مصادقة قديمة",
"MessageOldServerConnectionWarning": "تكوين اتصال الخادم يستخدم معرّف مستخدم قديم. يرجى حذف اتصال الخادم هذا وإعادة إضافته.", "MessageOldServerConnectionWarning": "تكوين اتصال الخادم يستخدم معرّف مستخدم قديم. يرجى حذف اتصال الخادم هذا وإعادة إضافته.",
"MessageOldServerConnectionWarningHelp": "لقد قمتَ بإعداد الاتصال بهذا الخادم قبل ترحيل قاعدة البيانات في الإصدار 2.3.0، الصادر في يونيو 2023. سيؤدي تحديث الخادم المستقبلي إلى إلغاء إمكانية تسجيل الدخول باستخدام هذا الاتصال القديم. يُرجى حذف اتصال الخادم الحالي والاتصال مرة أخرى (باستخدام نفس عنوان الخادم وبيانات الاعتماد). إذا كانت لديك أي وسائط مُنزّلة على هذا الجهاز، فيجب تنزيلها مرة أخرى لمزامنتها مع الخادم.", "MessageOldServerConnectionWarningHelp": "لقد قمتَ بإعداد الاتصال بهذا الخادم قبل ترحيل قاعدة البيانات في الإصدار 2.3.0، الصادر في يونيو 2023. سيؤدي تحديث الخادم المستقبلي إلى إلغاء إمكانية تسجيل الدخول باستخدام هذا الاتصال القديم. يُرجى حذف اتصال الخادم الحالي والاتصال مرة أخرى (باستخدام نفس عنوان الخادم وبيانات الاعتماد). إذا كانت لديك أي وسائط مُنزّلة على هذا الجهاز، فيجب تنزيلها مرة أخرى لمزامنتها مع الخادم.",
"MessagePodcastSearchField": "أدخل مصطلح البحث أو عنوان URL الخاص بتغذية RSS", "MessagePodcastSearchField": "أدخل مصطلح البحث أو عنوان URL الخاص بتغذية RSS",
+107 -76
View File
@@ -6,15 +6,16 @@
"ButtonCancel": "Скасаваць", "ButtonCancel": "Скасаваць",
"ButtonCancelTimer": "Скасаваць таймер", "ButtonCancelTimer": "Скасаваць таймер",
"ButtonClearFilter": "Ачысціць фільтр", "ButtonClearFilter": "Ачысціць фільтр",
"ButtonClearLogs": "Ачысціць журналы",
"ButtonCloseFeed": "Закрыць стужку", "ButtonCloseFeed": "Закрыць стужку",
"ButtonCollections": "Калекцыі", "ButtonCollections": "Калекцыі",
"ButtonConnect": "Падлучыцца", "ButtonConnect": "Падлучыцца",
"ButtonConnectToServer": "Падлучыцца да сервера", "ButtonConnectToServer": "Падлучыцца да сервера",
"ButtonCreate": "Ствараць", "ButtonCreate": "Стварыць",
"ButtonCreateBookmark": "Стварыць закладку", "ButtonCreateBookmark": "Стварыць закладку",
"ButtonCreateNewPlaylist": "Стварыць новы спіс прайгравання", "ButtonCreateNewPlaylist": "Стварыць новы плэй-ліст",
"ButtonDelete": "Выдаліць", "ButtonDelete": "Выдаліць",
"ButtonDeleteLocalEpisode": "Выдаліць лакальны эпізод", "ButtonDeleteLocalEpisode": "Выдаліць лакальны выпуск",
"ButtonDeleteLocalFile": "Выдаліць лакальны файл", "ButtonDeleteLocalFile": "Выдаліць лакальны файл",
"ButtonDeleteLocalItem": "Выдаліць лакальны элемент", "ButtonDeleteLocalItem": "Выдаліць лакальны элемент",
"ButtonDisableAutoTimer": "Адключыць аўтаматычны таймер", "ButtonDisableAutoTimer": "Адключыць аўтаматычны таймер",
@@ -26,16 +27,18 @@
"ButtonLatest": "Апошняе", "ButtonLatest": "Апошняе",
"ButtonLibrary": "Бібліятэка", "ButtonLibrary": "Бібліятэка",
"ButtonLocalMedia": "Лакальныя медыя", "ButtonLocalMedia": "Лакальныя медыя",
"ButtonLogs": "Журналы",
"ButtonManageLocalFiles": "Кіраванне лакальнымі файламі", "ButtonManageLocalFiles": "Кіраванне лакальнымі файламі",
"ButtonNewFolder": "Новая тэчка", "ButtonMaskServerAddress": "Схаваць адрас сервера",
"ButtonNextEpisode": "Наступны эпізод", "ButtonNewFolder": "Новая папка",
"ButtonOk": "Добра", "ButtonNextEpisode": "Наступны выпуск",
"ButtonOk": "ОК",
"ButtonOpenFeed": "Адкрыць стужку", "ButtonOpenFeed": "Адкрыць стужку",
"ButtonOverride": "Пераазначыць", "ButtonOverride": "Пераазначыць",
"ButtonPause": аўза", "ButtonPause": рыпыніць",
"ButtonPlay": "Прайграць", "ButtonPlay": "Прайграць",
"ButtonPlayEpisode": "Прайграць эпізод", "ButtonPlayEpisode": "Прайграць выпуск",
"ButtonPlaylists": "Спісы прайгравання", "ButtonPlaylists": "Плэй-лісты",
"ButtonRead": "Чытаць", "ButtonRead": "Чытаць",
"ButtonReadLess": "Чытаць менш", "ButtonReadLess": "Чытаць менш",
"ButtonReadMore": "Чытаць больш", "ButtonReadMore": "Чытаць больш",
@@ -50,36 +53,38 @@
"ButtonStream": "Трансляцыя", "ButtonStream": "Трансляцыя",
"ButtonSubmit": "Адправіць", "ButtonSubmit": "Адправіць",
"ButtonSwitchServerUser": "Змяніць Сервер/Карыстальніка", "ButtonSwitchServerUser": "Змяніць Сервер/Карыстальніка",
"ButtonUnmaskServerAddress": "Паказаць адрас сервера",
"ButtonUserStats": "Статыстыка карыстальніка", "ButtonUserStats": "Статыстыка карыстальніка",
"ButtonYes": "Так", "ButtonYes": "Так",
"HeaderAccount": "Уліковы запіс", "HeaderAccount": "Уліковы запіс",
"HeaderAdvanced": "Дадаткова", "HeaderAdvanced": "Дадаткова",
"HeaderAndroidAutoSettings": "Налады Android Auto", "HeaderAndroidAutoSettings": "Налады Android Auto",
"HeaderAudioTracks": "Аўдыядарожкі", "HeaderAudioTracks": "Аўдыятрэкі",
"HeaderChapters": "Раздзелы", "HeaderChapters": "Раздзелы",
"HeaderCollection": "Калекцыя", "HeaderCollection": "Калекцыя",
"HeaderCollectionItems": "Элементы калекцыі", "HeaderCollectionItems": "Элементы калекцыі",
"HeaderConfirm": "Пацвердзіць",
"HeaderConnectionStatus": "Стан злучэння", "HeaderConnectionStatus": "Стан злучэння",
"HeaderDataSettings": "Налады дадзеных", "HeaderDataSettings": "Налады даных",
"HeaderDetails": "Падрабязнасці", "HeaderDetails": "Падрабязнасці",
"HeaderDownloads": "Спампоўкі", "HeaderDownloads": "Спампоўкі",
"HeaderEbookFiles": "Файлы электронных кніг", "HeaderEbookFiles": "Файлы электронных кніг",
"HeaderEpisodes": "Эпізоды", "HeaderEpisodes": "Выпускі",
"HeaderEreaderSettings": "Налады прылады для чытання", "HeaderEreaderSettings": "Налады прылады для чытання",
"HeaderLatestEpisodes": "Апошнія эпізоды", "HeaderLatestEpisodes": "Апошнія выпускі",
"HeaderLibraries": "Бібліятэкі", "HeaderLibraries": "Бібліятэкі",
"HeaderLocalFolders": "Лакальныя тэчкі", "HeaderLocalFolders": "Лакальныя папкі",
"HeaderLocalLibraryItems": "Элементы лакальнай бібліятэкі", "HeaderLocalLibraryItems": "Элементы лакальнай бібліятэкі",
"HeaderNewPlaylist": "Новы спіс прайгравання", "HeaderNewPlaylist": "Новы плэй-ліст",
"HeaderOpenRSSFeed": "Адкрыць RSS-стужку", "HeaderOpenRSSFeed": "Адкрыць RSS-стужку",
"HeaderPlaybackSettings": "Налады прайгравання", "HeaderPlaybackSettings": "Налады прайгравання",
"HeaderPlaylist": "Спіс прайгравання", "HeaderPlaylist": "Плэй-ліст",
"HeaderPlaylistItems": "Элементы спіса прайгравання", "HeaderPlaylistItems": "Элементы плэй-ліста",
"HeaderProgressSyncFailed": "Не ўдалося сінхранізаваць прагрэс", "HeaderProgressSyncFailed": "Не ўдалося сінхранізаваць прагрэс",
"HeaderRSSFeed": "RSS-стужка", "HeaderRSSFeed": "RSS-стужка",
"HeaderRSSFeedGeneral": "Падрабязнасці RSS", "HeaderRSSFeedGeneral": "Падрабязнасці RSS",
"HeaderRSSFeedIsOpen": "RSS-стужка адкрыта", "HeaderRSSFeedIsOpen": "RSS-стужка адкрытая",
"HeaderSelectDownloadLocation": "Абраць месцазнаходжання для спампоўкі", "HeaderSelectDownloadLocation": "Выберыце размяшчэнне для спампоўкі",
"HeaderSettings": "Налады", "HeaderSettings": "Налады",
"HeaderSleepTimer": "Таймер сну", "HeaderSleepTimer": "Таймер сну",
"HeaderSleepTimerSettings": "Налады таймера сну", "HeaderSleepTimerSettings": "Налады таймера сну",
@@ -87,8 +92,9 @@
"HeaderStatsRecentSessions": "Апошнія сеансы", "HeaderStatsRecentSessions": "Апошнія сеансы",
"HeaderTableOfContents": "Змест", "HeaderTableOfContents": "Змест",
"HeaderUserInterfaceSettings": "Налады інтэрфейсу карыстальніка", "HeaderUserInterfaceSettings": "Налады інтэрфейсу карыстальніка",
"HeaderWelcome": "Вітаем, <strong>{0}</strong>",
"HeaderYourStats": "Ваша статыстыка", "HeaderYourStats": "Ваша статыстыка",
"LabelAddToPlaylist": "Дадаць у спіс прайгравання", "LabelAddToPlaylist": "Дадаць у плэй-ліст",
"LabelAddedAt": "Дата дабаўлення", "LabelAddedAt": "Дата дабаўлення",
"LabelAddedDate": "Дададзена {0}", "LabelAddedDate": "Дададзена {0}",
"LabelAll": "Усе", "LabelAll": "Усе",
@@ -99,57 +105,62 @@
"LabelAndroidAutoBrowseSeriesSequenceOrder": "Парадак кніг у серыі", "LabelAndroidAutoBrowseSeriesSequenceOrder": "Парадак кніг у серыі",
"LabelAskConfirmation": "Запытваць пацверджанне", "LabelAskConfirmation": "Запытваць пацверджанне",
"LabelAuthor": "Аўтар", "LabelAuthor": "Аўтар",
"LabelAuthorFirstLast": "Аўтар (Імя Прозвішча)", "LabelAuthorFirstLast": "Аўтар (імя, прозвішча)",
"LabelAuthorLastFirst": "Аўтар (Прозвішча, Імя)", "LabelAuthorLastFirst": "Аўтар (прозвішча, імя)",
"LabelAuthors": "Аўтары", "LabelAuthors": "Аўтары",
"LabelAutoDownloadEpisodes": "Аўтаматычнае спампаванне эпізодаў", "LabelAutoDownloadEpisodes": "Аўтаматычна спампоўваць выпускі",
"LabelAutoRewindTime": "Час аўтаматычнай перамоткі назад", "LabelAutoRewindTime": "Час аўтаматычнай перамоткі назад",
"LabelAutoSleepTimer": "Аўтаматычны таймер сну", "LabelAutoSleepTimer": "Аўтаматычны таймер сну",
"LabelAutoSleepTimerAutoRewind": "Аўтаматычная перамотка таймера сну", "LabelAutoSleepTimerAutoRewind": "Аўтаматычная перамотка таймера сну",
"LabelAutoSleepTimerAutoRewindHelp": "Калі таймер аўтаматычнага сну скончыцца, паўторнае прайграванне элемента аўтаматычна перамотвае вашу пазіцыю.", "LabelAutoSleepTimerAutoRewindHelp": "Пасля заканчэння аўтаматычнага таймера сну паўторнае прайграванне элемента аўтаматычна перамотвае вашу пазіцыю.",
"LabelAutoSleepTimerHelp": "Калі медыя прайграецца ў вызначаны час паміж пачаткам і канцом, таймер сну будзе запушчаны аўтаматычна.", "LabelAutoSleepTimerHelp": "Калі медыя прайграецца ў вызначаны час паміж пачаткам і канцом, таймер сну будзе запушчаны аўтаматычна.",
"LabelBooks": "Кнігі", "LabelBooks": "Кнігі",
"LabelChapterTrack": "Дарожка раздзела", "LabelByAuthor": " {0}",
"LabelChapterTrack": "Трэк раздзела",
"LabelChapters": "Раздзелы", "LabelChapters": "Раздзелы",
"LabelClosePlayer": "Зачыніць прайгравальнік", "LabelClosePlayer": "Зачыніць прайгравальнік",
"LabelCollapseSeries": "Згарнуць серыі", "LabelCollapseSeries": "Згарнуць серыі",
"LabelComplete": "Завершана", "LabelComplete": "Завяршыць",
"LabelContinueBooks": "Працягнуць кнігі", "LabelContinueBooks": "Працягнуць кнігі",
"LabelContinueEpisodes": "Працягнуць эпізоды", "LabelContinueEpisodes": "Працягнуць выпускі",
"LabelContinueListening": "Працягваць слухаць", "LabelContinueListening": "Працяг праслухоўвання",
"LabelContinueReading": "Працягнуць чытанне", "LabelContinueReading": "Працягнуць чытанне",
"LabelContinueSeries": "Працягнуць серыі", "LabelContinueSeries": "Працягнуць серыі",
"LabelCustomTime": "Свой час", "LabelCustomTime": "Свой час",
"LabelDescription": "Апісанне", "LabelDescription": "Апісанне",
"LabelDisableAudioFadeOut": "Адключыць змяншэнне гучнасці", "LabelDisableAudioFadeOut": "Адключыць змяншэнне гучнасці",
"LabelDisableAudioFadeOutHelp": "Гучнасць аўдыё пачне змяншацца, калі на таймеры сну застанецца менш за 1 хвіліну. Уключыце гэтую наладу, каб пазбегнуць змяншэння гучнасці.", "LabelDisableAudioFadeOutHelp": "Гучнасць гуку пачне змяншацца, калі на таймеры сну застанецца менш за 1 хвіліну. Уключыце гэтую наладу, каб пазбегнуць змяншэння гучнасці.",
"LabelDisableAutoRewind": "Адключыць аўтаматычную перамотку назад", "LabelDisableAutoRewind": "Адключыць аўтаматычную перамотку назад",
"LabelDisableShakeToReset": "Адключыць скід страсеннем", "LabelDisableShakeToReset": "Адключыць скід страсеннем",
"LabelDisableShakeToResetHelp": "Калі патрасці прыладу падчас працы таймера АБО на працягу 2 хвілін пасля заканчэння таймера, таймер сну скідваецца. Уключыце гэтую наладу, каб адключыць скід страсеннем.", "LabelDisableShakeToResetHelp": "Калі патрасці прыладу падчас працы таймера АБО на працягу 2 хвілін пасля заканчэння таймера, таймер сну скідваецца. Уключыце гэтую наладу, каб адключыць скід страсеннем.",
"LabelDisableVibrateOnReset": "Адключыць вібрацыю пры скідзе", "LabelDisableVibrateOnReset": "Адключыць вібрацыю пры скідзе",
"LabelDisableVibrateOnResetHelp": "Калі таймер сну скідваецца, ваша прылада будзе вібраваць. Уключыце гэтую наладу, каб адключыць вібрацыю пры скідзе таймера сну.", "LabelDisableVibrateOnResetHelp": "Калі таймер сну скідваецца, ваша прылада будзе вібраваць. Уключыце гэтую наладу, каб адключыць вібрацыю пры скідзе таймера сну.",
"LabelDiscover": "Знайсці", "LabelDiscover": "Знаходкі",
"LabelDownload": "Спампаваць", "LabelDownload": "Спампаваць",
"LabelDownloadUsingCellular": "Спампоўка праз мабільны інтэрнэт", "LabelDownloadUsingCellular": "Спампоўваць праз мабільны інтэрнэт",
"LabelDownloaded": "Спампавана", "LabelDownloaded": "Спампавана",
"LabelDuration": "Працягласць", "LabelDuration": "Працягласць",
"LabelEbook": "Электронная кніга", "LabelEbook": "Электронная кніга",
"LabelEbooks": "Электронныя кнігі", "LabelEbooks": "Электронныя кнігі",
"LabelEnable": "Уключыць", "LabelEnable": "Уключыць",
"LabelEnableMp3IndexSeeking": "Уключыць індэксаванне для перамоткі MP3", "LabelEnableMp3IndexSeeking": "Уключыць пошук па індэксе mp3",
"LabelEnableMp3IndexSeekingHelp": "Гэтую наладу варта ўключаць толькі, калі ў вас ёсць MP3-файлы, якія няправільна перамотваюцца. Хутчэй за ўсё, гэта выклікана файламі з зменным бітрэйтам (VBR). Гэтая налада прымусова ўключае індэксаванне для перамоткі, пры якім падчас чытання файла ствараецца адпаведнасць паміж часам і байтамі. У некаторых выпадках з вялікімі MP3-файламі можа быць затрымка пры перемотцы да канца файла.", "LabelEnableMp3IndexSeekingHelp": "Гэтую наладу варта ўключаць толькі, калі ў вас ёсць MP3-файлы, якія няправільна перамотваюцца. Хутчэй за ўсё, гэта выклікана файламі з зменным бітрэйтам (VBR). Гэтая налада прымусова ўключае індэксаванне для перамоткі, пры якім падчас чытання файла ствараецца адпаведнасць паміж часам і байтамі. У некаторых выпадках з вялікімі MP3-файламі можа быць затрымка пры перемотцы да канца файла.",
"LabelEnd": "Канец", "LabelEnd": "Канец",
"LabelEndOfChapter": "Канец раздзела", "LabelEndOfChapter": "Канец раздзела",
"LabelEndTime": "Час заканчэння", "LabelEndTime": "Час заканчэння",
"LabelEpisode": "Эпізод", "LabelEpisode": "Выпуск",
"LabelExplicit": "Непрыстойнае",
"LabelFeedURL": "URL стужкі", "LabelFeedURL": "URL стужкі",
"LabelFile": "Файл", "LabelFile": "Файл",
"LabelFileBirthtime": "Час стварэння файла", "LabelFileBirthtime": "Час стварэння файла",
"LabelFileModified": "Час змянення файла", "LabelFileModified": "Час змянення файла",
"LabelFilename": "Імя файла", "LabelFilename": "Назва файла",
"LabelFinished": "Скончана", "LabelFinished": "Завершана",
"LabelFolder": "Тэчка", "LabelFolder": "Папка",
"LabelFontBoldness": "Таўшчыня шрыфта", "LabelFontBoldness": "Таўшчыня шрыфта",
"LabelFontFamily": "Сямейства шрыфтоў",
"LabelFontFamilySans": "Sans",
"LabelFontFamilySerif": "Serif",
"LabelFontScale": "Памер шрыфту", "LabelFontScale": "Памер шрыфту",
"LabelGenre": "Жанр", "LabelGenre": "Жанр",
"LabelGenres": "Жанры", "LabelGenres": "Жанры",
@@ -161,7 +172,7 @@
"LabelHost": "Хост", "LabelHost": "Хост",
"LabelInProgress": "У працэсе", "LabelInProgress": "У працэсе",
"LabelIncomplete": "Незавершана", "LabelIncomplete": "Незавершана",
"LabelInternalAppStorage": "Унутраная памяць прыкладання", "LabelInternalAppStorage": "Унутраная памяць праграмы",
"LabelJumpBackwardsTime": "Час пераходу назад", "LabelJumpBackwardsTime": "Час пераходу назад",
"LabelJumpForwardsTime": "Час пераходу наперад", "LabelJumpForwardsTime": "Час пераходу наперад",
"LabelKeepScreenAwake": "Трымаць экран уключаным", "LabelKeepScreenAwake": "Трымаць экран уключаным",
@@ -169,6 +180,9 @@
"LabelLayout": "Знешні выгляд", "LabelLayout": "Знешні выгляд",
"LabelLayoutAuto": "Аўто", "LabelLayoutAuto": "Аўто",
"LabelLayoutSinglePage": "Аднабаковы", "LabelLayoutSinglePage": "Аднабаковы",
"LabelLibrarySortByProgress": "Прагрэс: апошняе абнаўленне",
"LabelLibrarySortByProgressFinished": "Прагрэс: завершана",
"LabelLibrarySortByProgressStarted": "Прагрэс: пачата",
"LabelLight": "Святло", "LabelLight": "Святло",
"LabelLineSpacing": "Міжрадковы інтэрвал", "LabelLineSpacing": "Міжрадковы інтэрвал",
"LabelListenAgain": "Паслухаць зноў", "LabelListenAgain": "Паслухаць зноў",
@@ -182,9 +196,9 @@
"LabelMissing": "Адсутнічае", "LabelMissing": "Адсутнічае",
"LabelMore": "Больш", "LabelMore": "Больш",
"LabelMoreInfo": "Больш інфармацыі", "LabelMoreInfo": "Больш інфармацыі",
"LabelName": "Імя", "LabelName": "Назва",
"LabelNarrator": "Чытальнік", "LabelNarrator": "Дыктар",
"LabelNarrators": "Чытальнікі", "LabelNarrators": "Дыктары",
"LabelNavigateWithVolume": "Навігацыя з дапамогай клавіш гучнасці", "LabelNavigateWithVolume": "Навігацыя з дапамогай клавіш гучнасці",
"LabelNavigateWithVolumeMirrored": "Люстраваны", "LabelNavigateWithVolumeMirrored": "Люстраваны",
"LabelNavigateWithVolumeWhilePlaying": "Дазволіць клавішам гучнасці кіраваць навігацыяй падчас прайгравання", "LabelNavigateWithVolumeWhilePlaying": "Дазволіць клавішам гучнасці кіраваць навігацыяй падчас прайгравання",
@@ -192,13 +206,13 @@
"LabelNavigateWithVolumeWhilePlayingEnabled": "Уключана", "LabelNavigateWithVolumeWhilePlayingEnabled": "Уключана",
"LabelNever": "Ніколі", "LabelNever": "Ніколі",
"LabelNewestAuthors": "Новыя аўтары", "LabelNewestAuthors": "Новыя аўтары",
"LabelNewestEpisodes": "Новыя эпізоды", "LabelNewestEpisodes": "Найноўшыя выпускі",
"LabelNo": "Не", "LabelNo": "Не",
"LabelNotFinished": "Не скончана", "LabelNotFinished": "Незавершана",
"LabelNotStarted": "Не пачата", "LabelNotStarted": "Не пачата",
"LabelNumEpisodes": "{0} эпізодаў", "LabelNumEpisodes": "{0} выпускаў",
"LabelNumEpisodesIncomplete": "{0} эпізодаў, {1} незавершаных", "LabelNumEpisodesIncomplete": "{0} выпускаў, {1} незавершаных",
"LabelNumberOfEpisodes": "# з эпізодаў", "LabelNumberOfEpisodes": "Колькасць выпускаў",
"LabelOff": "Выключына", "LabelOff": "Выключына",
"LabelOn": "Уключана", "LabelOn": "Уключана",
"LabelPassword": "Пароль", "LabelPassword": "Пароль",
@@ -216,6 +230,7 @@
"LabelPublishedDate": "Апублікавана {0}", "LabelPublishedDate": "Апублікавана {0}",
"LabelRSSFeedCustomOwnerEmail": "Карыстальніцкая электронная пошта ўладальніка", "LabelRSSFeedCustomOwnerEmail": "Карыстальніцкая электронная пошта ўладальніка",
"LabelRSSFeedCustomOwnerName": "Карыстальніцкае імя ўладальніка", "LabelRSSFeedCustomOwnerName": "Карыстальніцкае імя ўладальніка",
"LabelRSSFeedOpen": "RSS-стужка адкрыта",
"LabelRSSFeedPreventIndexing": "Прадухіліць індэксацыю", "LabelRSSFeedPreventIndexing": "Прадухіліць індэксацыю",
"LabelRSSFeedSlug": "Ідэнтыфікатар RSS-стужкі", "LabelRSSFeedSlug": "Ідэнтыфікатар RSS-стужкі",
"LabelRandomly": "Выпадкова", "LabelRandomly": "Выпадкова",
@@ -223,10 +238,11 @@
"LabelReadAgain": "Чытаць зноў", "LabelReadAgain": "Чытаць зноў",
"LabelRecentSeries": "Апошнія серыі", "LabelRecentSeries": "Апошнія серыі",
"LabelRecentlyAdded": "Нядаўна дададзеныя", "LabelRecentlyAdded": "Нядаўна дададзеныя",
"LabelRemoveFromPlaylist": "Выдаліць з спіса прайгравання", "LabelRemoveFromPlaylist": "Выдаліць з плэй-ліста",
"LabelScaleElapsedTimeBySpeed": "Прыстасаваць мінулы час да хуткасці", "LabelScaleElapsedTimeBySpeed": "Прыстасаваць мінулы час да хуткасці",
"LabelSeason": "Сезон", "LabelSeason": "Сезон",
"LabelSelectADevice": "Выбраць прыладу", "LabelSelectADevice": "Выбраць прыладу",
"LabelSelectMediaType": "Выберыце тып медыя",
"LabelSequenceAscending": "Паслядоўнасць па ўзрастанні", "LabelSequenceAscending": "Паслядоўнасць па ўзрастанні",
"LabelSequenceDescending": "Паслядоўнасць па змяншэнні", "LabelSequenceDescending": "Паслядоўнасць па змяншэнні",
"LabelSeries": "Серыі", "LabelSeries": "Серыі",
@@ -237,14 +253,16 @@
"LabelShowAll": "Паказаць усё", "LabelShowAll": "Паказаць усё",
"LabelSize": "Памер", "LabelSize": "Памер",
"LabelSleepTimer": "Таймер сну", "LabelSleepTimer": "Таймер сну",
"LabelStart": "Пачаць", "LabelSleepTimerAlmostDoneChime": "Гукавы сігнал, калі амаль звершаны",
"LabelSleepTimerAlmostDoneChimeHelp": "Памякчыць звонак, калі на таймеры сну застанецца 30 секунд",
"LabelStart": "Пачатак",
"LabelStartTime": "Час пачатку", "LabelStartTime": "Час пачатку",
"LabelStatsBestDay": "Лепшы дзень", "LabelStatsBestDay": "Найлепшы дзень",
"LabelStatsDailyAverage": "У сярэднім за дзень", "LabelStatsDailyAverage": "У сярэднім за дзень",
"LabelStatsDays": "Дзён", "LabelStatsDays": "Дзён",
"LabelStatsDaysListened": "Дзён праслухана", "LabelStatsDaysListened": "Дзён праслухана",
"LabelStatsInARow": "без перапынку", "LabelStatsInARow": "без перапынку",
"LabelStatsItemsFinished": "Скончаныя элементы", "LabelStatsItemsFinished": "Элементаў завершана",
"LabelStatsMinutes": "хвілін", "LabelStatsMinutes": "хвілін",
"LabelStatsMinutesListening": "Хвілін праслухоўвання", "LabelStatsMinutesListening": "Хвілін праслухоўвання",
"LabelStatsWeekListening": "Праслухана за тыдзень", "LabelStatsWeekListening": "Праслухана за тыдзень",
@@ -252,17 +270,18 @@
"LabelTag": "Метка", "LabelTag": "Метка",
"LabelTags": "Меткі", "LabelTags": "Меткі",
"LabelTheme": "Тэма", "LabelTheme": "Тэма",
"LabelThemeBlack": "Чорны",
"LabelThemeDark": "Цёмная", "LabelThemeDark": "Цёмная",
"LabelThemeLight": "Светлая", "LabelThemeLight": "Светлая",
"LabelTimeRemaining": "Засталося {0}", "LabelTimeRemaining": "Засталося {0}",
"LabelTitle": "Назва", "LabelTitle": "Загаловак",
"LabelTotalSize": "Агульны памер", "LabelTotalSize": "Агульны памер",
"LabelTotalTrack": "Агульная колькасць дарожак", "LabelTotalTrack": "Усяго трэкаў",
"LabelTracks": "Дарожкі", "LabelTracks": "Трэкі",
"LabelType": "Тып", "LabelType": "Тып",
"LabelUnknown": "Невядома", "LabelUnknown": "Невядома",
"LabelUnlockPlayer": "Разблакаваць прайгравальнік", "LabelUnlockPlayer": "Разблакаваць прайгравальнік",
"LabelUseBookshelfView": "Выкарыстоўваць выгляд кніжнай паліцы", "LabelUseBookshelfView": "Паказваць кніжныя паліцы",
"LabelUser": "Карыстальнік", "LabelUser": "Карыстальнік",
"LabelUsername": "Імя карыстальніка", "LabelUsername": "Імя карыстальніка",
"LabelVeryHigh": "Вельмі высокі", "LabelVeryHigh": "Вельмі высокі",
@@ -271,24 +290,30 @@
"LabelYearReviewShow": "Азнаёміцца з вынікамі года", "LabelYearReviewShow": "Азнаёміцца з вынікамі года",
"LabelYourBookmarks": "Вашы закладкі", "LabelYourBookmarks": "Вашы закладкі",
"LabelYourProgress": "Ваш прагрэс", "LabelYourProgress": "Ваш прагрэс",
"MessageAndroid10Downloads": "На Android 10 і ніжэй для спампоўкі будзе выкарыстоўвацца ўнутраная памяць прыкладання.", "MessageAndroid10Downloads": "На Android 10 і ніжэй для спамповак будзе выкарыстоўвацца ўнутраная памяць праграмы.",
"MessageAttemptingServerConnection": "Спроба злучэння з серверам...", "MessageAttemptingServerConnection": "Спроба злучэння з серверам...",
"MessageAudiobookshelfServerNotConnected": "Сервер Audiobookshelf не падлучаны", "MessageAudiobookshelfServerNotConnected": "Сервер Audiobookshelf не падлучаны",
"MessageAudiobookshelfServerRequired": "<strong>Важна!</strong> Гэта праграма прызначана для працы з серверам Audiobookshelf, які вы ці хтосьці іншы абслугоўваеце. Праграма не змяшчае ўласнага кантэнту.", "MessageAudiobookshelfServerRequired": "<strong>Важна!</strong> Гэта праграма прызначана для працы з серверам Audiobookshelf, які вы ці хтосьці іншы абслугоўваеце. Праграма не змяшчае ўласнага кантэнту.",
"MessageBookshelfEmpty": "Кніжная паліца пустая", "MessageBookshelfEmpty": "Кніжная паліца пустая",
"MessageConfirmDeleteLocalEpisode": "Выдаліць лакальны эпізод \"{0}\" з вашай прылады? Файл на серверы застанецца нязменным.", "MessageConfirmAppExit": "Вы хацелі выйсці з праграмы?",
"MessageConfirmDeleteLocalFiles": "Выдаліць лакальныя файлы гэтага элемента з вашай прылады? Файлы на серверы і ваш прагрэс застануцца нязменнымі.", "MessageConfirmDeleteEpisodeDownloadQueue": "Вы ўпэўнены, што хочаце ачысціць чаргу спампоўвання выпускаў?",
"MessageConfirmDisableAutoTimer": "Вы ўпэўнены, што жадаеце адключыць аўтаматычны таймер на сённяшні дзень? Таймер будзе зноў уключаны па заканчэнні гэтага перыяду аўтаматычнага таймера сну або пры перазапуску праграмы.", "MessageConfirmDeleteLocalEpisode": "Выдаліць лакальны выпуск \"{0}\" з вашай прылады? Файл на серверы застанецца нязменным.",
"MessageConfirmDiscardProgress": "Вы ўпэўнены, што жадаеце скінуць свой прагрэс?", "MessageConfirmDeleteLocalFiles": "Выдаліць лакальныя файлы гэтага элемента з вашай прылады? Файлы на серверы і прагрэс застануцца нязменнымі.",
"MessageConfirmDownloadUsingCellular": "Вы збіраецеся спампаваць, выкарыстоўваючы мабільны інтэрнэт. Гэта можа прывесці да дадатковых плат ад аператара. Вы жадаеце працягнуць?", "MessageConfirmDeleteServerConfig": "Выдаліць гэту канфігурацыю сервера?",
"MessageConfirmMarkAsFinished": "Вы ўпэўнены, што жадаеце пазначыць гэты элемент як скончаны?", "MessageConfirmDeleteServerEpisode": "Вы ўпэўнены, што хочаце выдаліць выпуск \"{0}\" з сервера?\nУвага: будзе выдалены аўдыяфайл.",
"MessageConfirmRemoveBookmark": "Вы ўпэўнены, што жадаеце выдаліць закладку?", "MessageConfirmDisableAutoTimer": "Вы ўпэўнены, што хочаце адключыць аўтаматычны таймер на сённяшні дзень? Таймер будзе зноў уключаны па заканчэнні перыяду гэтага аўтаматычнага таймера сну або пры перазапуску праграмы.",
"MessageConfirmStreamingUsingCellular": "Вы збіраецеся трансляваць кантэнт праз мабільны інтэрнэт. Гэта можа прывесці да дадатковых платаў ад вашага аператара. Вы жадаеце працягнуць?", "MessageConfirmDiscardProgress": "Вы ўпэўнены, што хочаце скінуць прагрэс?",
"MessageConfirmDownloadUsingCellular": "Вы збіраецеся спампаваць праз мабільны інтэрнэт. Гэта можа прывесці да дадатковых плат ад аператара. Хочаце працягнуць?",
"MessageConfirmMarkAsFinished": "Вы ўпэўнены, што хочаце пазначыць гэты элемент як завершаны?",
"MessageConfirmPlaybackTime": "Пачаць прайграванне \"{0}\" у {1}?",
"MessageConfirmRemoveBookmark": "Вы ўпэўнены, што хочаце выдаліць закладку?",
"MessageConfirmStreamingUsingCellular": "Вы збіраецеся трансляваць кантэнт праз мабільны інтэрнэт. Гэта можа прывесці да дадатковых плат ад аператара. Хочаце працягнуць?",
"MessageDiscardProgress": "Скінуць прагрэс", "MessageDiscardProgress": "Скінуць прагрэс",
"MessageDownloadCompleteProcessing": "Спампоўка завершана. Апрацоўка...", "MessageDownloadCompleteProcessing": "Спампоўванне завершана. Апрацоўка...",
"MessageDownloading": "Спампоўванне...", "MessageDownloading": "Спампоўванне...",
"MessageDownloadingEpisode": "Спампоўка эпізоду", "MessageDownloadingEpisode": "Спампоўванне выпуску",
"MessageEpisodesQueuedForDownload": "{0} эпізод(аў) у чарзе для спампоўкі", "MessageEpisodesQueuedForDownload": "{0} выпуск(-аў) у чарзе спампоўвання",
"MessageFailedToRefreshToken": "Не ўдалося абнавіць токен, патрабуецца паўторны ўваход",
"MessageFeedURLWillBe": "URL стужкі будзе {0}", "MessageFeedURLWillBe": "URL стужкі будзе {0}",
"MessageFetching": "Атрыманне...", "MessageFetching": "Атрыманне...",
"MessageFollowTheProjectOnGithub": "Сачыце за праектам на Github", "MessageFollowTheProjectOnGithub": "Сачыце за праектам на Github",
@@ -296,8 +321,8 @@
"MessageItemMissing": "Элемент адсутнічае і патрабуе выпраўлення на серверы. Звычайна элемент пазначаецца як адсутны, таму што файлавыя шляхі недаступныя.", "MessageItemMissing": "Элемент адсутнічае і патрабуе выпраўлення на серверы. Звычайна элемент пазначаецца як адсутны, таму што файлавыя шляхі недаступныя.",
"MessageLoading": "Загрузка...", "MessageLoading": "Загрузка...",
"MessageLoadingServerData": "Загрузка даных з сервера...", "MessageLoadingServerData": "Загрузка даных з сервера...",
"MessageLocalFolderDescription": "\"Унутраная памяць прыкладання\" даступная толькі для гэтага прыкладання. Гэта прыкладанне працуе толькі з медыя, якія былі спампаваныя непасрэдна праз яго. Каб іншыя прыкладанні маглі атрымаць доступ да медыя, спампаванага гэтым прыкладаннем, можна выкарыстоўваць агульныя тэчкі.", "MessageLocalFolderDescription": "\"Унутраная памяць праграмы\" даступная толькі для гэтай праграмы. Гэта праграма працуе толькі з медыя, якія былі спампаваныя непасрэдна праз яго. Каб іншыя праграмы маглі атрымаць доступ да медыя, спампаванага гэтай праграмай, можна выкарыстоўваць абагуленыя папкі.",
"MessageMarkAsFinished": "Пазначыць як скончана", "MessageMarkAsFinished": "Пазначыць як завершаную",
"MessageMediaLinkedToADifferentServer": "Медыя звязаны з серверам Audiobookshelf па іншым адрасе ({0}). Сінхранізацыя прагрэсу адбудзецца пры падключэнні да гэтага адрасу сервера.", "MessageMediaLinkedToADifferentServer": "Медыя звязаны з серверам Audiobookshelf па іншым адрасе ({0}). Сінхранізацыя прагрэсу адбудзецца пры падключэнні да гэтага адрасу сервера.",
"MessageMediaLinkedToADifferentUser": "Медыя звязаны з гэтым серверам, але быў спампаваны іншым карыстальнікам. Прагрэс будзе сінхранізавацца толькі для карыстальніка, які яго спампаваў.", "MessageMediaLinkedToADifferentUser": "Медыя звязаны з гэтым серверам, але быў спампаваны іншым карыстальнікам. Прагрэс будзе сінхранізавацца толькі для карыстальніка, які яго спампаваў.",
"MessageMediaLinkedToServer": "Звязана з серверам {0}", "MessageMediaLinkedToServer": "Звязана з серверам {0}",
@@ -309,19 +334,23 @@
"MessageNoItems": "Няма элементаў", "MessageNoItems": "Няма элементаў",
"MessageNoItemsFound": "Элементы не знойдзены", "MessageNoItemsFound": "Элементы не знойдзены",
"MessageNoListeningSessions": "Няма сеансаў праслухоўвання", "MessageNoListeningSessions": "Няма сеансаў праслухоўвання",
"MessageNoMediaFolders": "Няма тэчак з медыя", "MessageNoLogs": "Няма журналаў",
"MessageNoMediaFolders": "Няма папак з медыя",
"MessageNoNetworkConnection": "Няма падлучэння да сеткі", "MessageNoNetworkConnection": "Няма падлучэння да сеткі",
"MessageNoPodcastsFound": "Падкасты не знойдзены", "MessageNoPodcastsFound": "Падкасты не знойдзены",
"MessageNoSeries": "Няма серый", "MessageNoSeries": "Няма серый",
"MessageNoUpdatesWereNecessary": "Абнаўленні не патрабаваліся", "MessageNoUpdatesWereNecessary": "Абнаўленні не патрабаваліся",
"MessageNoUserPlaylists": "У вас няма спісаў прайгравання", "MessageNoUserPlaylists": "У вас няма плэй-лістоў",
"MessageOldServerAuthReLoginRequired": "Аўтэнтыфікацыя была палепшана для бяспекі ў серверы v2.26.0. Усім карыстальнікам патрабуецца паўторна ўвайсці ў сістэму.",
"MessageOldServerAuthWarning": "Сервер выкарыстоўвае састарэлую аўтэнтыфікацыю",
"MessageOldServerAuthWarningHelp": "Гэты сервер працуе пад версіяй, старэйшай за 2.26.0. У версіі 2.26.0 была дададзена больш бяспечная сістэма аўтэнтыфікацыі. Настойліва рэкамендуецца абнавіць сервер да апошняй версіі. Калі вы ўжо абнавілі сервер, увайдзіце зноў, каб выкарыстоўваць новую аўтэнтыфікацыю.",
"MessageOldServerConnectionWarning": "Канфігурацыя падлучэння да сервера выкарыстоўвае стары ідэнтыфікатар карыстальніка. Калі ласка, выдаліце і зноў дадайце гэта падлучэнне да сервера.", "MessageOldServerConnectionWarning": "Канфігурацыя падлучэння да сервера выкарыстоўвае стары ідэнтыфікатар карыстальніка. Калі ласка, выдаліце і зноў дадайце гэта падлучэнне да сервера.",
"MessageOldServerConnectionWarningHelp": "Вы першапачаткова наладзілі падключэнне да гэтага сервера да міграцыі базы даных у версіі 2.3.0, якая выйшла ў чэрвені 2023 года. У будучым абнаўленні сервера магчымасць уваходу праз гэтае старое падключэнне будзе выдалена. Калі ласка, выдаліце існуючае падключэнне да сервера і падключыцеся зноў (выкарыстоўваючы той жа адрас сервера і ўліковыя даныя). Калі на гэтай прыладзе ёсць спампаваныя медыяфайлы, іх трэба будзе спампаваць зноў для сінхранізацыі з серверам.", "MessageOldServerConnectionWarningHelp": "Вы першапачаткова наладзілі падключэнне да гэтага сервера да міграцыі базы даных у версіі 2.3.0, якая выйшла ў чэрвені 2023 года. У будучым абнаўленні сервера магчымасць уваходу праз гэтае старое падключэнне будзе выдалена. Калі ласка, выдаліце існуючае падключэнне да сервера і падключыцеся зноў (выкарыстоўваючы той жа адрас сервера і ўліковыя даныя). Калі на гэтай прыладзе ёсць спампаваныя медыяфайлы, іх трэба будзе спампаваць зноў для сінхранізацыі з серверам.",
"MessagePodcastSearchField": "Увядзіце пошукавы запыт або URL RSS-стужкі", "MessagePodcastSearchField": "Увядзіце пошукавы запыт або URL RSS-стужкі",
"MessageProgressSyncFailed": "Апошняя спроба адправіць ваш прагрэс праслухоўвання на сервер не ўдалася. Спробы сінхранізаваць прагрэс будуць працягвацца кожныя 15 секунд на працягу 1 хвіліны падчас прайгравання медыяфайла.", "MessageProgressSyncFailed": "Апошняя спроба адправіць ваш прагрэс праслухоўвання на сервер не ўдалася. Спробы сінхранізаваць прагрэс будуць працягвацца кожныя 15 секунд на працягу 1 хвіліны падчас прайгравання медыяфайла.",
"MessageReportBugsAndContribute": "Паведамляйце пра памылкі, прапануйце новыя функцыі і ўдзельнічайце на", "MessageReportBugsAndContribute": "Паведамляйце пра памылкі, прапануйце функцыі і ўносьце свой уклад на",
"MessageSeriesAlreadyDownloaded": "Вы ўжо спампавалі ўсе кнігі з гэтай серыі.", "MessageSeriesAlreadyDownloaded": "Вы ўжо спампавалі ўсе кнігі з гэтай серыі.",
"MessageSeriesDownloadConfirm": "Спампаваць адсутныя {0} кніг(і) з {1} файл(амі), агульным памерам {2}, у тэчку {3}?", "MessageSeriesDownloadConfirm": "Спампаваць адсутныя {0} кніг(і) з {1} файлам(-і), агульным памерам {2}, у папку {3}?",
"MessageSeriesDownloadConfirmIos": "Спампаваць адсутныя {0} кніг(і) з {1} файл(амі), агульным памерам {2}?", "MessageSeriesDownloadConfirmIos": "Спампаваць адсутныя {0} кніг(і) з {1} файл(амі), агульным памерам {2}?",
"MessageSocketConnectedOverMeteredCellular": "Сокет падлучаны праз мабільны інтэрнэт з абмежаваным трафікам", "MessageSocketConnectedOverMeteredCellular": "Сокет падлучаны праз мабільны інтэрнэт з абмежаваным трафікам",
"MessageSocketConnectedOverMeteredWifi": "Сокет падлучаны праз Wi-Fi з абмежаваным трафікам", "MessageSocketConnectedOverMeteredWifi": "Сокет падлучаны праз Wi-Fi з абмежаваным трафікам",
@@ -329,17 +358,19 @@
"MessageSocketConnectedOverUnmeteredWifi": "Сокет падлучаны праз Wi-Fi без абмежавання трафіку", "MessageSocketConnectedOverUnmeteredWifi": "Сокет падлучаны праз Wi-Fi без абмежавання трафіку",
"MessageSocketNotConnected": "Сокет не падлучаны", "MessageSocketNotConnected": "Сокет не падлучаны",
"NoteRSSFeedPodcastAppsHttps": "Папярэджанне: большасць праграм для падкастаў патрабуюць, каб URL RSS-стужкі выкарыстоўваў HTTPS", "NoteRSSFeedPodcastAppsHttps": "Папярэджанне: большасць праграм для падкастаў патрабуюць, каб URL RSS-стужкі выкарыстоўваў HTTPS",
"NoteRSSFeedPodcastAppsPubDate": "Папярэджанне: адзін ці больш вашых эпізодаў не маюць даты публікацыі. Некаторыя праграмы для падкастаў патрабуюць гэтага.", "NoteRSSFeedPodcastAppsPubDate": "Папярэджанне: адзін ці больш выпускаў не маюць даты публікацыі. Некаторыя праграмы для падкастаў патрабуюць гэтага.",
"ToastBookmarkCreateFailed": "Не ўдалося стварыць закладку", "ToastBookmarkCreateFailed": "Не ўдалося стварыць закладку",
"ToastBookmarkRemoveFailed": "Не ўдалося выдаліць закладку", "ToastBookmarkRemoveFailed": "Не ўдалося выдаліць закладку",
"ToastBookmarkUpdateFailed": "Не ўдалося абнавіць закладку", "ToastBookmarkUpdateFailed": "Не ўдалося абнавіць закладку",
"ToastDownloadNotAllowedOnCellular": "Спампоўванне забаронена праз мабільны інтэрнэт", "ToastDownloadNotAllowedOnCellular": "Спампоўванне забаронена праз мабільны інтэрнэт",
"ToastItemMarkedAsFinishedFailed": "Не ўдалося пазначыць як Скончана", "ToastItemMarkedAsFinishedFailed": "Не ўдалося пазначыць як завершаны",
"ToastItemMarkedAsNotFinishedFailed": "Не ўдалося пазначыць як Незавершанае", "ToastItemMarkedAsNotFinishedFailed": "Не ўдалося пазначыць як незавершаны",
"ToastPlaylistCreateFailed": "Не ўдалося стварыць спіс прайгравання", "ToastPlaylistCreateFailed": "Не ўдалося стварыць плэй-ліст",
"ToastPodcastCreateFailed": "Не ўдалося стварыць падкаст", "ToastPodcastCreateFailed": "Не ўдалося стварыць падкаст",
"ToastPodcastCreateSuccess": "Падкаст паспяхова створаны", "ToastPodcastCreateSuccess": "Падкаст паспяхова створаны",
"ToastRSSFeedCloseFailed": "Не ўдалося закрыць RSS-стужку", "ToastRSSFeedCloseFailed": "Не ўдалося закрыць RSS-стужку",
"ToastRSSFeedCloseSuccess": "RSS-стужка закрыта", "ToastRSSFeedCloseSuccess": "RSS-стужка закрыта",
"ToastStreamingNotAllowedOnCellular": "Патокавая перадача праз мабільную сувязь забаронена" "ToastStreamingNotAllowedOnCellular": "Патокавая перадача праз мабільную сувязь забаронена",
"UnitMinutesShort": "{0} хв",
"UnitSecondsShort": "{0} с"
} }
+34 -2
View File
@@ -6,6 +6,7 @@
"ButtonCancel": "Отказ", "ButtonCancel": "Отказ",
"ButtonCancelTimer": "Отмени Таймер", "ButtonCancelTimer": "Отмени Таймер",
"ButtonClearFilter": "Изчисти филтър", "ButtonClearFilter": "Изчисти филтър",
"ButtonClearLogs": "Изчисти логове",
"ButtonCloseFeed": "Затвори стената", "ButtonCloseFeed": "Затвори стената",
"ButtonCollections": "Колекции", "ButtonCollections": "Колекции",
"ButtonConnect": "Свържи се", "ButtonConnect": "Свържи се",
@@ -26,7 +27,9 @@
"ButtonLatest": "Последни", "ButtonLatest": "Последни",
"ButtonLibrary": "Библиотека", "ButtonLibrary": "Библиотека",
"ButtonLocalMedia": "Локална медия", "ButtonLocalMedia": "Локална медия",
"ButtonLogs": "Логове",
"ButtonManageLocalFiles": "Управление на локални файлове", "ButtonManageLocalFiles": "Управление на локални файлове",
"ButtonMaskServerAddress": "Маскирай адреса на сървърът",
"ButtonNewFolder": "Създай нова папка", "ButtonNewFolder": "Създай нова папка",
"ButtonNextEpisode": "Следващ епизод", "ButtonNextEpisode": "Следващ епизод",
"ButtonOk": "Приемам", "ButtonOk": "Приемам",
@@ -50,6 +53,7 @@
"ButtonStream": "Стрийм", "ButtonStream": "Стрийм",
"ButtonSubmit": "Изпрати", "ButtonSubmit": "Изпрати",
"ButtonSwitchServerUser": "Смени сървър/потребител", "ButtonSwitchServerUser": "Смени сървър/потребител",
"ButtonUnmaskServerAddress": "Демаскирай адреса на съвърът",
"ButtonUserStats": "Потребителски статистики", "ButtonUserStats": "Потребителски статистики",
"ButtonYes": "Да", "ButtonYes": "Да",
"HeaderAccount": "Профил", "HeaderAccount": "Профил",
@@ -59,6 +63,7 @@
"HeaderChapters": "Глави", "HeaderChapters": "Глави",
"HeaderCollection": "Колекция", "HeaderCollection": "Колекция",
"HeaderCollectionItems": "Елемент в колекция", "HeaderCollectionItems": "Елемент в колекция",
"HeaderConfirm": "Потвърди",
"HeaderConnectionStatus": "Състояние на връзката", "HeaderConnectionStatus": "Състояние на връзката",
"HeaderDataSettings": "Настройки на данните", "HeaderDataSettings": "Настройки на данните",
"HeaderDetails": "Детайли", "HeaderDetails": "Детайли",
@@ -87,6 +92,7 @@
"HeaderStatsRecentSessions": "Последни сесии", "HeaderStatsRecentSessions": "Последни сесии",
"HeaderTableOfContents": "Съдържание", "HeaderTableOfContents": "Съдържание",
"HeaderUserInterfaceSettings": "Настройки на потребителския интерфейс", "HeaderUserInterfaceSettings": "Настройки на потребителския интерфейс",
"HeaderWelcome": "Добре дошли, <strong>{0}</strong>",
"HeaderYourStats": "Вашата статистика", "HeaderYourStats": "Вашата статистика",
"LabelAddToPlaylist": "Добави в плейлист", "LabelAddToPlaylist": "Добави в плейлист",
"LabelAddedAt": "Добавено в", "LabelAddedAt": "Добавено в",
@@ -109,9 +115,10 @@
"LabelAutoSleepTimerAutoRewindHelp": "Когато автоматичният таймер на изключване изтече, повторното пускане на елемента автоматично ще върне позицията ви назад.", "LabelAutoSleepTimerAutoRewindHelp": "Когато автоматичният таймер на изключване изтече, повторното пускане на елемента автоматично ще върне позицията ви назад.",
"LabelAutoSleepTimerHelp": "Когато се възпроизвеждате медия между посочените начален и краен час, автоматично ще стартира таймер за изключване.", "LabelAutoSleepTimerHelp": "Когато се възпроизвеждате медия между посочените начален и краен час, автоматично ще стартира таймер за изключване.",
"LabelBooks": "Книги", "LabelBooks": "Книги",
"LabelByAuthor": "от {0}",
"LabelChapterTrack": "Трак на глава", "LabelChapterTrack": "Трак на глава",
"LabelChapters": "Глави", "LabelChapters": "Глави",
"LabelClosePlayer": "Затвори", "LabelClosePlayer": "Затвори плейъра",
"LabelCollapseSeries": "Скрий сериите", "LabelCollapseSeries": "Скрий сериите",
"LabelComplete": "Приключено", "LabelComplete": "Приключено",
"LabelContinueBooks": "Продължи книги", "LabelContinueBooks": "Продължи книги",
@@ -142,6 +149,7 @@
"LabelEndOfChapter": "Край на глава", "LabelEndOfChapter": "Край на глава",
"LabelEndTime": "Оставащо време", "LabelEndTime": "Оставащо време",
"LabelEpisode": "Епизод", "LabelEpisode": "Епизод",
"LabelExplicit": "Експлицитно",
"LabelFeedURL": "URL на емисия", "LabelFeedURL": "URL на емисия",
"LabelFile": "Файл", "LabelFile": "Файл",
"LabelFileBirthtime": "Дата на създаване на файла", "LabelFileBirthtime": "Дата на създаване на файла",
@@ -150,6 +158,9 @@
"LabelFinished": "Дата на приключване", "LabelFinished": "Дата на приключване",
"LabelFolder": "Папка", "LabelFolder": "Папка",
"LabelFontBoldness": "Дебелина на шрифта", "LabelFontBoldness": "Дебелина на шрифта",
"LabelFontFamily": "Шрифт",
"LabelFontFamilySans": "Sans",
"LabelFontFamilySerif": "Serif",
"LabelFontScale": "Мащаб на шрифта", "LabelFontScale": "Мащаб на шрифта",
"LabelGenre": "Жанр", "LabelGenre": "Жанр",
"LabelGenres": "Жанрове", "LabelGenres": "Жанрове",
@@ -169,6 +180,9 @@
"LabelLayout": "Оформление", "LabelLayout": "Оформление",
"LabelLayoutAuto": "Авто", "LabelLayoutAuto": "Авто",
"LabelLayoutSinglePage": "Единична страница", "LabelLayoutSinglePage": "Единична страница",
"LabelLibrarySortByProgress": "Прогрес: Последно обновление",
"LabelLibrarySortByProgressFinished": "Прогрес: Приключено",
"LabelLibrarySortByProgressStarted": "Прогрес: Започнато",
"LabelLight": "Светло", "LabelLight": "Светло",
"LabelLineSpacing": "Междуредие", "LabelLineSpacing": "Междуредие",
"LabelListenAgain": "Слушай отново", "LabelListenAgain": "Слушай отново",
@@ -216,6 +230,7 @@
"LabelPublishedDate": "Публикувани {0}", "LabelPublishedDate": "Публикувани {0}",
"LabelRSSFeedCustomOwnerEmail": "Персонализиран имейл на собственика", "LabelRSSFeedCustomOwnerEmail": "Персонализиран имейл на собственика",
"LabelRSSFeedCustomOwnerName": "Персонализирано име на собственика", "LabelRSSFeedCustomOwnerName": "Персонализирано име на собственика",
"LabelRSSFeedOpen": "RSS Feed е отворен",
"LabelRSSFeedPreventIndexing": "Предотвратете индексиране", "LabelRSSFeedPreventIndexing": "Предотвратете индексиране",
"LabelRSSFeedSlug": "идентификатор на RSS емисия", "LabelRSSFeedSlug": "идентификатор на RSS емисия",
"LabelRandomly": "Случайно", "LabelRandomly": "Случайно",
@@ -227,6 +242,7 @@
"LabelScaleElapsedTimeBySpeed": "Скалируй изминалото време спрямо скоростта", "LabelScaleElapsedTimeBySpeed": "Скалируй изминалото време спрямо скоростта",
"LabelSeason": "Сезон", "LabelSeason": "Сезон",
"LabelSelectADevice": "Избери устройство", "LabelSelectADevice": "Избери устройство",
"LabelSelectMediaType": "Изберете тип на медията",
"LabelSequenceAscending": "Възходяща последователност", "LabelSequenceAscending": "Възходяща последователност",
"LabelSequenceDescending": "Низходяща последователност", "LabelSequenceDescending": "Низходяща последователност",
"LabelSeries": "От сериите", "LabelSeries": "От сериите",
@@ -237,6 +253,8 @@
"LabelShowAll": "Покажи всички", "LabelShowAll": "Покажи всички",
"LabelSize": "Размер", "LabelSize": "Размер",
"LabelSleepTimer": "Таймер за изключване", "LabelSleepTimer": "Таймер за изключване",
"LabelSleepTimerAlmostDoneChime": "Възпроизведи звуков сигнал малко преди да приключи",
"LabelSleepTimerAlmostDoneChimeHelp": "Възпроизведи звуков сигнал, 30 секунди преди изтичане на таймера за заспиване",
"LabelStart": "Старт", "LabelStart": "Старт",
"LabelStartTime": "Време на стартиране", "LabelStartTime": "Време на стартиране",
"LabelStatsBestDay": "Най-добър ден", "LabelStatsBestDay": "Най-добър ден",
@@ -252,6 +270,7 @@
"LabelTag": "Таг", "LabelTag": "Таг",
"LabelTags": "Тагове", "LabelTags": "Тагове",
"LabelTheme": "Тема", "LabelTheme": "Тема",
"LabelThemeBlack": "Черен",
"LabelThemeDark": "Тъмна", "LabelThemeDark": "Тъмна",
"LabelThemeLight": "Светла", "LabelThemeLight": "Светла",
"LabelTimeRemaining": "{0} оставащи", "LabelTimeRemaining": "{0} оставащи",
@@ -276,12 +295,17 @@
"MessageAudiobookshelfServerNotConnected": "Audiobookshelf сървър не е включен", "MessageAudiobookshelfServerNotConnected": "Audiobookshelf сървър не е включен",
"MessageAudiobookshelfServerRequired": "<strong>ВАЖНО!</strong> Това приложение е създадено да работи с Audiobookshelf сървър, който вие или някой, когото познавате, хоства. Това приложение не предоставя никакво съдържание.", "MessageAudiobookshelfServerRequired": "<strong>ВАЖНО!</strong> Това приложение е създадено да работи с Audiobookshelf сървър, който вие или някой, когото познавате, хоства. Това приложение не предоставя никакво съдържание.",
"MessageBookshelfEmpty": "Библиотеката е празна", "MessageBookshelfEmpty": "Библиотеката е празна",
"MessageConfirmAppExit": "Искате ли да излезете от апликацията?",
"MessageConfirmDeleteEpisodeDownloadQueue": "Сигурни ли сте, че искате да изчистите опашката за сваляне на епизоди?",
"MessageConfirmDeleteLocalEpisode": "Премахни \"{0}\" епизода от устройството? Файловете на сървъра няма да бъдат премахнати.", "MessageConfirmDeleteLocalEpisode": "Премахни \"{0}\" епизода от устройството? Файловете на сървъра няма да бъдат премахнати.",
"MessageConfirmDeleteLocalFiles": "Да премахна ли локалните файлове на този елемент от вашето устройство? Файловете на сървъра и вашият напредък няма да бъдат засегнати.", "MessageConfirmDeleteLocalFiles": "Да премахна ли локалните файлове на този елемент от вашето устройство? Файловете на сървъра и вашият напредък няма да бъдат засегнати.",
"MessageConfirmDeleteServerConfig": "Премахнете тази сървърна настройка?",
"MessageConfirmDeleteServerEpisode": "Сигурни ли сте че искате да изтрийете епизод \"{0}\" от сървъра?\nПредупреждение: Това ще изтрийе и аудио файла.",
"MessageConfirmDisableAutoTimer": "Сигурни ли сте, че искате да деактивирате автоматичния таймер за остатъка от днешния ден? Таймерът ще бъде активиран отново в края на този период на автоматичния таймер за изключване или ако рестартирате приложението.", "MessageConfirmDisableAutoTimer": "Сигурни ли сте, че искате да деактивирате автоматичния таймер за остатъка от днешния ден? Таймерът ще бъде активиран отново в края на този период на автоматичния таймер за изключване или ако рестартирате приложението.",
"MessageConfirmDiscardProgress": "Сигурни ли сте, че искате да нулирате напредъка си?", "MessageConfirmDiscardProgress": "Сигурни ли сте, че искате да нулирате напредъка си?",
"MessageConfirmDownloadUsingCellular": "Ще изтегляте, използвайки мобилни данни. Това може да включва такси за данни от оператора. Искате ли да продължите?", "MessageConfirmDownloadUsingCellular": "Ще изтегляте, използвайки мобилни данни. Това може да включва такси за данни от оператора. Искате ли да продължите?",
"MessageConfirmMarkAsFinished": "Сигурни ли сте, че искате да маркирате този елемент като завършен?", "MessageConfirmMarkAsFinished": "Сигурни ли сте, че искате да маркирате този елемент като завършен?",
"MessageConfirmPlaybackTime": "Започни възпроизвеждане на \"{0}\" в {1}?",
"MessageConfirmRemoveBookmark": "Сигурни ли сте, че искате да премахнете отметката?", "MessageConfirmRemoveBookmark": "Сигурни ли сте, че искате да премахнете отметката?",
"MessageConfirmStreamingUsingCellular": "Ще стриймвате, използвайки мобилни данни. Това може да включва такси за данни от оператора. Искате ли да продължите?", "MessageConfirmStreamingUsingCellular": "Ще стриймвате, използвайки мобилни данни. Това може да включва такси за данни от оператора. Искате ли да продължите?",
"MessageDiscardProgress": "Нулирай прогреса", "MessageDiscardProgress": "Нулирай прогреса",
@@ -289,6 +313,7 @@
"MessageDownloading": "Сваляне...", "MessageDownloading": "Сваляне...",
"MessageDownloadingEpisode": "Сваля епизод", "MessageDownloadingEpisode": "Сваля епизод",
"MessageEpisodesQueuedForDownload": "{0} Епизод(и) са сложени за сваляне", "MessageEpisodesQueuedForDownload": "{0} Епизод(и) са сложени за сваляне",
"MessageFailedToRefreshToken": "Неуспешно опресняване на токена, налага се да се впишете отново",
"MessageFeedURLWillBe": "Адресът на емисията ще бъде {0}", "MessageFeedURLWillBe": "Адресът на емисията ще бъде {0}",
"MessageFetching": "Извличане...", "MessageFetching": "Извличане...",
"MessageFollowTheProjectOnGithub": "Последвайте проекта в Github", "MessageFollowTheProjectOnGithub": "Последвайте проекта в Github",
@@ -296,6 +321,7 @@
"MessageItemMissing": "Елементът липсва и трябва да бъде поправен на сървъра. Обикновено един елемент се маркира като липсващ, защото пътищата към файловете не са достъпни.", "MessageItemMissing": "Елементът липсва и трябва да бъде поправен на сървъра. Обикновено един елемент се маркира като липсващ, защото пътищата към файловете не са достъпни.",
"MessageLoading": "Зарежда...", "MessageLoading": "Зарежда...",
"MessageLoadingServerData": "Зарежда данни от сървъра...", "MessageLoadingServerData": "Зарежда данни от сървъра...",
"MessageLocalFolderDescription": "'Вътрешното хранилище на приложението' е достъпно само през това приложение. Това приложение поддържа само медийни файлове, изтеглени директно през него. Споделените папки за съхранение могат да се използват, за да се позволи на други приложения да имат достъп до медийните файлове, изтеглени чрез това приложение.",
"MessageMarkAsFinished": "Маркирай като завършено", "MessageMarkAsFinished": "Маркирай като завършено",
"MessageMediaLinkedToADifferentServer": "Медията е свързана със сървър на Audiobookshelf на различен адрес ({0}). Напредъкът ще бъде синхронизиран, когато се свържете с този сървърен адрес.", "MessageMediaLinkedToADifferentServer": "Медията е свързана със сървър на Audiobookshelf на различен адрес ({0}). Напредъкът ще бъде синхронизиран, когато се свържете с този сървърен адрес.",
"MessageMediaLinkedToADifferentUser": "Медията е свързана с този сървър, но е изтеглена от друг потребител. Напредъкът ще бъде синхронизиран само с потребителя, който я е изтеглил.", "MessageMediaLinkedToADifferentUser": "Медията е свързана с този сървър, но е изтеглена от друг потребител. Напредъкът ще бъде синхронизиран само с потребителя, който я е изтеглил.",
@@ -308,12 +334,16 @@
"MessageNoItems": "Няма елементи", "MessageNoItems": "Няма елементи",
"MessageNoItemsFound": "Няма намерени елементи", "MessageNoItemsFound": "Няма намерени елементи",
"MessageNoListeningSessions": "Няма сесии за слушане", "MessageNoListeningSessions": "Няма сесии за слушане",
"MessageNoLogs": "Няма логове",
"MessageNoMediaFolders": "Няма медии папки", "MessageNoMediaFolders": "Няма медии папки",
"MessageNoNetworkConnection": "Няма връзка с интернет", "MessageNoNetworkConnection": "Няма връзка с интернет",
"MessageNoPodcastsFound": "Няма намерени подкасти", "MessageNoPodcastsFound": "Няма намерени подкасти",
"MessageNoSeries": "Няма серии", "MessageNoSeries": "Няма серии",
"MessageNoUpdatesWereNecessary": "Няма нужда от обновяване", "MessageNoUpdatesWereNecessary": "Няма нужда от обновяване",
"MessageNoUserPlaylists": "Нямате създадени плейлисти", "MessageNoUserPlaylists": "Нямате създадени плейлисти",
"MessageOldServerAuthReLoginRequired": "За осигуряването на по-добра сигурност, автентикация беше подобрена във v.2.26.0. Всеки потребител ще трябва да се автентикира наново.",
"MessageOldServerAuthWarning": "Сървърът използва остаряла форма на автентикация",
"MessageOldServerAuthWarningHelp": "Този сървър е на версия, която е по-стара от v2.26.0. Във версия v2.26.0 беше добавена по-сигурна автентикация. Силно е препоръчително да обновите сървъра до последната версия. Аковече сте обновили сървъра, влезде наново, за да ползвате новата автентикация.",
"MessageOldServerConnectionWarning": "Конфигурацията за връзка със сървъра използва старо потребителско ID. Моля, изтрийте и добавете отново тази връзка със сървъра.", "MessageOldServerConnectionWarning": "Конфигурацията за връзка със сървъра използва старо потребителско ID. Моля, изтрийте и добавете отново тази връзка със сървъра.",
"MessageOldServerConnectionWarningHelp": "Първоначално сте настроили връзката към този сървър преди миграцията на базата данни във версия 2.3.0, пусната през юни 2023 г. Бъдеща актуализация на сървъра ще премахне възможността за влизане с тази стара връзка. Моля, изтрийте съществуващата връзка със сървъра и се свържете отново (използвайки същия сървърен адрес и идентификационни данни). Ако имате изтеглена медия на това устройство, тя ще трябва да бъде изтеглена отново, за да се синхронизира със сървъра.", "MessageOldServerConnectionWarningHelp": "Първоначално сте настроили връзката към този сървър преди миграцията на базата данни във версия 2.3.0, пусната през юни 2023 г. Бъдеща актуализация на сървъра ще премахне възможността за влизане с тази стара връзка. Моля, изтрийте съществуващата връзка със сървъра и се свържете отново (използвайки същия сървърен адрес и идентификационни данни). Ако имате изтеглена медия на това устройство, тя ще трябва да бъде изтеглена отново, за да се синхронизира със сървъра.",
"MessagePodcastSearchField": "Въведи какво да търся или RSS емисия адрес", "MessagePodcastSearchField": "Въведи какво да търся или RSS емисия адрес",
@@ -340,5 +370,7 @@
"ToastPodcastCreateSuccess": "Подкаст успешно създаден", "ToastPodcastCreateSuccess": "Подкаст успешно създаден",
"ToastRSSFeedCloseFailed": "Неуспешно затваряне на RSS емисията", "ToastRSSFeedCloseFailed": "Неуспешно затваряне на RSS емисията",
"ToastRSSFeedCloseSuccess": "RSS емисията е затворена", "ToastRSSFeedCloseSuccess": "RSS емисията е затворена",
"ToastStreamingNotAllowedOnCellular": "Стриймването не е разрешено чрез мобилни данни" "ToastStreamingNotAllowedOnCellular": "Стриймването не е разрешено чрез мобилни данни",
"UnitMinutesShort": "{0}мин",
"UnitSecondsShort": "{0}сек"
} }
+18 -1
View File
@@ -63,6 +63,7 @@
"HeaderChapters": "Capítols", "HeaderChapters": "Capítols",
"HeaderCollection": "Col·lecció", "HeaderCollection": "Col·lecció",
"HeaderCollectionItems": "Elements de la col·lecció", "HeaderCollectionItems": "Elements de la col·lecció",
"HeaderConfirm": "Confirma",
"HeaderConnectionStatus": "Estat de la connexió", "HeaderConnectionStatus": "Estat de la connexió",
"HeaderDataSettings": "Paràmetres de dades", "HeaderDataSettings": "Paràmetres de dades",
"HeaderDetails": "Detalls", "HeaderDetails": "Detalls",
@@ -91,6 +92,7 @@
"HeaderStatsRecentSessions": "Sessions recents", "HeaderStatsRecentSessions": "Sessions recents",
"HeaderTableOfContents": "Sumari", "HeaderTableOfContents": "Sumari",
"HeaderUserInterfaceSettings": "Paràmetres de la interfície d'usuari", "HeaderUserInterfaceSettings": "Paràmetres de la interfície d'usuari",
"HeaderWelcome": "Benvingut, <strong>{0}</strong>",
"HeaderYourStats": "Les vostres estadístiques", "HeaderYourStats": "Les vostres estadístiques",
"LabelAddToPlaylist": "Afegeix a la llista de reproducció", "LabelAddToPlaylist": "Afegeix a la llista de reproducció",
"LabelAddedAt": "Afegit a", "LabelAddedAt": "Afegit a",
@@ -113,6 +115,7 @@
"LabelAutoSleepTimerAutoRewindHelp": "Quan el temporitzador d'auto sleep acaba, reproduir l'element altra vegada farà que es rebobine automàticament a la teua posició.", "LabelAutoSleepTimerAutoRewindHelp": "Quan el temporitzador d'auto sleep acaba, reproduir l'element altra vegada farà que es rebobine automàticament a la teua posició.",
"LabelAutoSleepTimerHelp": "Quan es reprodueix entre hores especificades, el temporitzador s'activarà automàticament.", "LabelAutoSleepTimerHelp": "Quan es reprodueix entre hores especificades, el temporitzador s'activarà automàticament.",
"LabelBooks": "Llibres", "LabelBooks": "Llibres",
"LabelByAuthor": "per {0}",
"LabelChapterTrack": "Seguiment de capítol", "LabelChapterTrack": "Seguiment de capítol",
"LabelChapters": "Capítols", "LabelChapters": "Capítols",
"LabelClosePlayer": "Tanca el reproductor", "LabelClosePlayer": "Tanca el reproductor",
@@ -126,11 +129,12 @@
"LabelCustomTime": "Temps personalitzat", "LabelCustomTime": "Temps personalitzat",
"LabelDescription": "Descripció", "LabelDescription": "Descripció",
"LabelDisableAudioFadeOut": "Desactivar desaparició d'àudio", "LabelDisableAudioFadeOut": "Desactivar desaparició d'àudio",
"LabelDisableAudioFadeOutHelp": "El volum de l'audio començara a reduïr-se quan quede menys d'1 minut del temporitzador d'anar a dormir. Habilita este ajustament per a no reduïr el volum.", "LabelDisableAudioFadeOutHelp": "El volum de l'audio començara a reduïr-se quan quedi menys d'1 minut del temporitzador d'anar a dormir. Habilita aquest ajustament per a no reduïr el volum.",
"LabelDisableAutoRewind": "Desactivar rebobinatge automàtic", "LabelDisableAutoRewind": "Desactivar rebobinatge automàtic",
"LabelDisableShakeToReset": "Desactivar sacsejar per reiniciar", "LabelDisableShakeToReset": "Desactivar sacsejar per reiniciar",
"LabelDisableShakeToResetHelp": "Si sacseges el teu dispositiu quan el temporitzador és actiu o durant els 2 minuts després que hagi expirat, el temporitzador es reinicia. Activa aquesta opció per evitar-ho.", "LabelDisableShakeToResetHelp": "Si sacseges el teu dispositiu quan el temporitzador és actiu o durant els 2 minuts després que hagi expirat, el temporitzador es reinicia. Activa aquesta opció per evitar-ho.",
"LabelDisableVibrateOnReset": "Desactivar la vibració al reiniciar", "LabelDisableVibrateOnReset": "Desactivar la vibració al reiniciar",
"LabelDisableVibrateOnResetHelp": "Quan el temporitzador d'apagat es reinicia, el dispositiu vibra. Activa aquesta opció perquè no vibri quan es reiniciï el temporitzador.",
"LabelDiscover": "Descobrir", "LabelDiscover": "Descobrir",
"LabelDownload": "Descarregar", "LabelDownload": "Descarregar",
"LabelDownloadUsingCellular": "Descarregar utilitzant dades mòbils", "LabelDownloadUsingCellular": "Descarregar utilitzant dades mòbils",
@@ -144,6 +148,7 @@
"LabelEndOfChapter": "Final del capítol", "LabelEndOfChapter": "Final del capítol",
"LabelEndTime": "Hora de finalització", "LabelEndTime": "Hora de finalització",
"LabelEpisode": "Episodi", "LabelEpisode": "Episodi",
"LabelExplicit": "Explícit",
"LabelFeedURL": "Font URL", "LabelFeedURL": "Font URL",
"LabelFile": "Fitxer", "LabelFile": "Fitxer",
"LabelFileBirthtime": "Fitxer creat a", "LabelFileBirthtime": "Fitxer creat a",
@@ -152,22 +157,31 @@
"LabelFinished": "Acabat", "LabelFinished": "Acabat",
"LabelFolder": "Carpeta", "LabelFolder": "Carpeta",
"LabelFontBoldness": "Gruix de la lletra", "LabelFontBoldness": "Gruix de la lletra",
"LabelFontFamily": "Família tipogràfica",
"LabelFontFamilySans": "Sans",
"LabelFontFamilySerif": "Serif",
"LabelFontScale": "Escala de la lletra", "LabelFontScale": "Escala de la lletra",
"LabelGenre": "Gènere", "LabelGenre": "Gènere",
"LabelGenres": "Gèneres", "LabelGenres": "Gèneres",
"LabelHapticFeedback": "Resposta hàptica", "LabelHapticFeedback": "Resposta hàptica",
"LabelHasEbook": "Té llibre electrònic", "LabelHasEbook": "Té llibre electrònic",
"LabelHasSupplementaryEbook": "Té llibre electrònic suplementari", "LabelHasSupplementaryEbook": "Té llibre electrònic suplementari",
"LabelHeavy": "Pesat",
"LabelHigh": "Alt", "LabelHigh": "Alt",
"LabelHost": "Amfitrió", "LabelHost": "Amfitrió",
"LabelInProgress": "En procés", "LabelInProgress": "En procés",
"LabelIncomplete": "Incomplet", "LabelIncomplete": "Incomplet",
"LabelInternalAppStorage": "Emmagatzematge intern de l'aplicació", "LabelInternalAppStorage": "Emmagatzematge intern de l'aplicació",
"LabelJumpBackwardsTime": "Saltar enrere", "LabelJumpBackwardsTime": "Saltar enrere",
"LabelJumpForwardsTime": "Salt endavant en el temps",
"LabelKeepScreenAwake": "Manté la pantalla encesa",
"LabelLanguage": "Llengua", "LabelLanguage": "Llengua",
"LabelLayout": "Disposició", "LabelLayout": "Disposició",
"LabelLayoutAuto": "Automàtic", "LabelLayoutAuto": "Automàtic",
"LabelLayoutSinglePage": "Pàgina única", "LabelLayoutSinglePage": "Pàgina única",
"LabelLibrarySortByProgress": "Progrés: Última actualització",
"LabelLibrarySortByProgressFinished": "Progrés: Finalitzat",
"LabelLibrarySortByProgressStarted": "Progrés: Començat",
"LabelLight": "Clar", "LabelLight": "Clar",
"LabelLineSpacing": "Interlineat", "LabelLineSpacing": "Interlineat",
"LabelListenAgain": "Escoltar de nou", "LabelListenAgain": "Escoltar de nou",
@@ -175,14 +189,17 @@
"LabelLocalPodcasts": "Pòdcasts locals", "LabelLocalPodcasts": "Pòdcasts locals",
"LabelLockOrientation": "Bloca l'orientació", "LabelLockOrientation": "Bloca l'orientació",
"LabelLockPlayer": "Bloca el reproductor", "LabelLockPlayer": "Bloca el reproductor",
"LabelLow": "Baix",
"LabelMediaType": "Tipus de mitjà", "LabelMediaType": "Tipus de mitjà",
"LabelMedium": "Mitjà", "LabelMedium": "Mitjà",
"LabelMissing": "Falta",
"LabelMore": "Més", "LabelMore": "Més",
"LabelMoreInfo": "Més informació", "LabelMoreInfo": "Més informació",
"LabelName": "Nom", "LabelName": "Nom",
"LabelNarrator": "Narrador", "LabelNarrator": "Narrador",
"LabelNarrators": "Narradors", "LabelNarrators": "Narradors",
"LabelNavigateWithVolume": "Navega amb les tecles de volum", "LabelNavigateWithVolume": "Navega amb les tecles de volum",
"LabelNavigateWithVolumeMirrored": "Emmirallat",
"LabelNavigateWithVolumeWhilePlayingDisabled": "⭘", "LabelNavigateWithVolumeWhilePlayingDisabled": "⭘",
"LabelNavigateWithVolumeWhilePlayingEnabled": "", "LabelNavigateWithVolumeWhilePlayingEnabled": "",
"LabelNever": "Mai", "LabelNever": "Mai",
+31 -10
View File
@@ -41,7 +41,7 @@
"ButtonPlaylists": "Seznamy skladeb", "ButtonPlaylists": "Seznamy skladeb",
"ButtonRead": "Číst", "ButtonRead": "Číst",
"ButtonReadLess": "Číst méně", "ButtonReadLess": "Číst méně",
"ButtonReadMore": "Číst další", "ButtonReadMore": "Číst více",
"ButtonRemove": "Odstranit", "ButtonRemove": "Odstranit",
"ButtonRemoveFromServer": "Odstranit ze serveru", "ButtonRemoveFromServer": "Odstranit ze serveru",
"ButtonSave": "Uložit", "ButtonSave": "Uložit",
@@ -63,6 +63,7 @@
"HeaderChapters": "Kapitoly", "HeaderChapters": "Kapitoly",
"HeaderCollection": "Kolekce", "HeaderCollection": "Kolekce",
"HeaderCollectionItems": "Položky kolekce", "HeaderCollectionItems": "Položky kolekce",
"HeaderConfirm": "Potvrzení",
"HeaderConnectionStatus": "Stav připojení", "HeaderConnectionStatus": "Stav připojení",
"HeaderDataSettings": "Nastavení dat", "HeaderDataSettings": "Nastavení dat",
"HeaderDetails": "Podrobnosti", "HeaderDetails": "Podrobnosti",
@@ -82,7 +83,7 @@
"HeaderProgressSyncFailed": "Chyba při synchronizaci pokroku", "HeaderProgressSyncFailed": "Chyba při synchronizaci pokroku",
"HeaderRSSFeed": "RSS kanál", "HeaderRSSFeed": "RSS kanál",
"HeaderRSSFeedGeneral": "Detaily RSS", "HeaderRSSFeedGeneral": "Detaily RSS",
"HeaderRSSFeedIsOpen": "Kanál RSS je otevřen", "HeaderRSSFeedIsOpen": "Informační kanál RSS je otevřený",
"HeaderSelectDownloadLocation": "Vyberte umístění pro stažení", "HeaderSelectDownloadLocation": "Vyberte umístění pro stažení",
"HeaderSettings": "Nastavení", "HeaderSettings": "Nastavení",
"HeaderSleepTimer": "Časovač spánku", "HeaderSleepTimer": "Časovač spánku",
@@ -91,6 +92,7 @@
"HeaderStatsRecentSessions": "Poslední sezení", "HeaderStatsRecentSessions": "Poslední sezení",
"HeaderTableOfContents": "Obsah", "HeaderTableOfContents": "Obsah",
"HeaderUserInterfaceSettings": "Nastavení uživatelského rozhraní", "HeaderUserInterfaceSettings": "Nastavení uživatelského rozhraní",
"HeaderWelcome": "Vítejte, <strong>{0}</strong>",
"HeaderYourStats": "Vaše statistiky", "HeaderYourStats": "Vaše statistiky",
"LabelAddToPlaylist": "Přidat do seznamu skladeb", "LabelAddToPlaylist": "Přidat do seznamu skladeb",
"LabelAddedAt": "Přidáno v", "LabelAddedAt": "Přidáno v",
@@ -113,6 +115,7 @@
"LabelAutoSleepTimerAutoRewindHelp": "Když dojde k uspání automatickým časovačem spánku, pozice přehrávání je posunuta zpět o vybraný čas.", "LabelAutoSleepTimerAutoRewindHelp": "Když dojde k uspání automatickým časovačem spánku, pozice přehrávání je posunuta zpět o vybraný čas.",
"LabelAutoSleepTimerHelp": "Během přehrávání média v časovém rozmezí \"Od\" a \"Do\" se automaticky spustí časovač spánku.", "LabelAutoSleepTimerHelp": "Během přehrávání média v časovém rozmezí \"Od\" a \"Do\" se automaticky spustí časovač spánku.",
"LabelBooks": "Knihy", "LabelBooks": "Knihy",
"LabelByAuthor": "od {0}",
"LabelChapterTrack": "Stopa kapitoly", "LabelChapterTrack": "Stopa kapitoly",
"LabelChapters": "Kapitoly", "LabelChapters": "Kapitoly",
"LabelClosePlayer": "Zavřít přehrávač", "LabelClosePlayer": "Zavřít přehrávač",
@@ -146,7 +149,7 @@
"LabelEndOfChapter": "Konec kapitoly", "LabelEndOfChapter": "Konec kapitoly",
"LabelEndTime": "Do", "LabelEndTime": "Do",
"LabelEpisode": "Epizoda", "LabelEpisode": "Epizoda",
"LabelExplicit": "Explicitně", "LabelExplicit": "Explicitní",
"LabelFeedURL": "URL kanálu", "LabelFeedURL": "URL kanálu",
"LabelFile": "Soubor", "LabelFile": "Soubor",
"LabelFileBirthtime": "Čas vytvoření souboru", "LabelFileBirthtime": "Čas vytvoření souboru",
@@ -155,13 +158,16 @@
"LabelFinished": "Dokončeno", "LabelFinished": "Dokončeno",
"LabelFolder": "Složka", "LabelFolder": "Složka",
"LabelFontBoldness": "Výraznost písma", "LabelFontBoldness": "Výraznost písma",
"LabelFontScale": "Měřítko písma", "LabelFontFamily": "Rodina písem",
"LabelFontFamilySans": "Bezpatkové",
"LabelFontFamilySerif": "Patkové",
"LabelFontScale": "Velikost písma",
"LabelGenre": "Žánr", "LabelGenre": "Žánr",
"LabelGenres": "Žánry", "LabelGenres": "Žánry",
"LabelHapticFeedback": "Haptická odezva", "LabelHapticFeedback": "Haptická odezva",
"LabelHasEbook": "Má e-knihu", "LabelHasEbook": "Má e-knihu",
"LabelHasSupplementaryEbook": " doplňkovou e-knihu", "LabelHasSupplementaryEbook": "Obsahuje doplňkovou e-knihu",
"LabelHeavy": "Těžké", "LabelHeavy": "Silná",
"LabelHigh": "Vysoké", "LabelHigh": "Vysoké",
"LabelHost": "Hostitel", "LabelHost": "Hostitel",
"LabelInProgress": "Probíhá", "LabelInProgress": "Probíhá",
@@ -174,7 +180,10 @@
"LabelLayout": "Rozvržení", "LabelLayout": "Rozvržení",
"LabelLayoutAuto": "Automatické", "LabelLayoutAuto": "Automatické",
"LabelLayoutSinglePage": "Jedna stránka", "LabelLayoutSinglePage": "Jedna stránka",
"LabelLight": "Světlé", "LabelLibrarySortByProgress": "Pokrok: naposledy aktualizováno",
"LabelLibrarySortByProgressFinished": "Pokrok: dokončeno",
"LabelLibrarySortByProgressStarted": "Pokrok: začato",
"LabelLight": "Slabá",
"LabelLineSpacing": "Řádkování", "LabelLineSpacing": "Řádkování",
"LabelListenAgain": "Poslouchat znovu", "LabelListenAgain": "Poslouchat znovu",
"LabelLocalBooks": "Místní knihy", "LabelLocalBooks": "Místní knihy",
@@ -204,7 +213,7 @@
"LabelNumEpisodes": "{0} epizod", "LabelNumEpisodes": "{0} epizod",
"LabelNumEpisodesIncomplete": "{0} epizod, {1} nekompletní", "LabelNumEpisodesIncomplete": "{0} epizod, {1} nekompletní",
"LabelNumberOfEpisodes": "Počet epizod", "LabelNumberOfEpisodes": "Počet epizod",
"LabelOff": "Vypnout", "LabelOff": "Vypnutá",
"LabelOn": "Zapnuto", "LabelOn": "Zapnuto",
"LabelPassword": "Heslo", "LabelPassword": "Heslo",
"LabelPath": "Cesta", "LabelPath": "Cesta",
@@ -233,6 +242,7 @@
"LabelScaleElapsedTimeBySpeed": "Škálovat uplynulý čas podle rychlosti", "LabelScaleElapsedTimeBySpeed": "Škálovat uplynulý čas podle rychlosti",
"LabelSeason": "Sezóna", "LabelSeason": "Sezóna",
"LabelSelectADevice": "Vyberte zařízení", "LabelSelectADevice": "Vyberte zařízení",
"LabelSelectMediaType": "Vyberte typ média",
"LabelSequenceAscending": "Řadit vzestupně", "LabelSequenceAscending": "Řadit vzestupně",
"LabelSequenceDescending": "Řadit sestupně", "LabelSequenceDescending": "Řadit sestupně",
"LabelSeries": "Série", "LabelSeries": "Série",
@@ -285,12 +295,17 @@
"MessageAudiobookshelfServerNotConnected": "Server Audiobookshelf není připojen", "MessageAudiobookshelfServerNotConnected": "Server Audiobookshelf není připojen",
"MessageAudiobookshelfServerRequired": "<strong>Důležité!</strong> Tato aplikace je navržena, aby pracovala se serverem Audiobookshelf, který hostuje buď vy, nebo někdo, koho znáte. Tato aplikace neposkytuje žádný obsah.", "MessageAudiobookshelfServerRequired": "<strong>Důležité!</strong> Tato aplikace je navržena, aby pracovala se serverem Audiobookshelf, který hostuje buď vy, nebo někdo, koho znáte. Tato aplikace neposkytuje žádný obsah.",
"MessageBookshelfEmpty": "Knihovna je prázdná", "MessageBookshelfEmpty": "Knihovna je prázdná",
"MessageConfirmAppExit": "Opravdu chcete opustit aplikaci?",
"MessageConfirmDeleteEpisodeDownloadQueue": "Přejete si skutečně vymazat frontu stahování epizod?",
"MessageConfirmDeleteLocalEpisode": "Odebrat místní epizodu „{0}“ ze zařízení? Soubor na serveru zůstane nezměněný.", "MessageConfirmDeleteLocalEpisode": "Odebrat místní epizodu „{0}“ ze zařízení? Soubor na serveru zůstane nezměněný.",
"MessageConfirmDeleteLocalFiles": "Odebrat místní soubory této položky ze zařízení? Soubory na serveru a váš pokrok nebudou ovlivněny.", "MessageConfirmDeleteLocalFiles": "Odebrat místní soubory této položky ze zařízení? Soubory na serveru a váš pokrok nebudou ovlivněny.",
"MessageConfirmDeleteServerConfig": "Odebrat tuto konfiguraci serveru?",
"MessageConfirmDeleteServerEpisode": "Přejete si skutečně odstranit epizodu \"{0}\" z serveru?\nUpozornění: Toto odstraní zvukový soubor.",
"MessageConfirmDisableAutoTimer": "Určitě chcete vypnout automatický časovač pro zbytek dnešního dne? Časovač bude opět aktivován po uběhnutí doby automatického spánku nebo po restartování aplikace.", "MessageConfirmDisableAutoTimer": "Určitě chcete vypnout automatický časovač pro zbytek dnešního dne? Časovač bude opět aktivován po uběhnutí doby automatického spánku nebo po restartování aplikace.",
"MessageConfirmDiscardProgress": "Opravdu chcete zahodit svůj pokrok?", "MessageConfirmDiscardProgress": "Opravdu chcete zahodit svůj pokrok?",
"MessageConfirmDownloadUsingCellular": "Chystáte se stahovat přes mobilní data. Toto může zahrnovat poplatky za mobilní data. Chcete pokračovat?", "MessageConfirmDownloadUsingCellular": "Chystáte se stahovat přes mobilní data. Toto může zahrnovat poplatky za mobilní data. Chcete pokračovat?",
"MessageConfirmMarkAsFinished": "Opravdu chcete tuto položku označit jako dokončenou?", "MessageConfirmMarkAsFinished": "Opravdu chcete tuto položku označit jako dokončenou?",
"MessageConfirmPlaybackTime": "Spustit přehrávání pro \"{0}\" v {1}?",
"MessageConfirmRemoveBookmark": "Opravdu chcete odebrat záložku?", "MessageConfirmRemoveBookmark": "Opravdu chcete odebrat záložku?",
"MessageConfirmStreamingUsingCellular": "Chystáte se přehrávat přes mobilní data. Toto může zahrnovat poplatky za mobilní data. Chcete pokračovat?", "MessageConfirmStreamingUsingCellular": "Chystáte se přehrávat přes mobilní data. Toto může zahrnovat poplatky za mobilní data. Chcete pokračovat?",
"MessageDiscardProgress": "Zahodit pokrok", "MessageDiscardProgress": "Zahodit pokrok",
@@ -298,6 +313,7 @@
"MessageDownloading": "Stahuje se...", "MessageDownloading": "Stahuje se...",
"MessageDownloadingEpisode": "Stahuji epizodu", "MessageDownloadingEpisode": "Stahuji epizodu",
"MessageEpisodesQueuedForDownload": "{0} Epizody zařazené do fronty ke stažení", "MessageEpisodesQueuedForDownload": "{0} Epizody zařazené do fronty ke stažení",
"MessageFailedToRefreshToken": "Nepovedlo se obnovit token, je nutné se znovu přihlásit",
"MessageFeedURLWillBe": "URL zdroje bude {0}", "MessageFeedURLWillBe": "URL zdroje bude {0}",
"MessageFetching": "Načítání...", "MessageFetching": "Načítání...",
"MessageFollowTheProjectOnGithub": "Sledujte projekt na GitHubu", "MessageFollowTheProjectOnGithub": "Sledujte projekt na GitHubu",
@@ -325,13 +341,16 @@
"MessageNoSeries": "Žádné série", "MessageNoSeries": "Žádné série",
"MessageNoUpdatesWereNecessary": "Nebyly nutné žádné aktualizace", "MessageNoUpdatesWereNecessary": "Nebyly nutné žádné aktualizace",
"MessageNoUserPlaylists": "Nemáte žádné seznamy skladeb", "MessageNoUserPlaylists": "Nemáte žádné seznamy skladeb",
"MessageOldServerAuthReLoginRequired": "V rámci aktualizace serveru v2.26.0 bylo vylepšeno zabezpečení autentizace. Všichni uživatelé se musí znovu přihlásit.",
"MessageOldServerAuthWarning": "Server používá zastaralou autentizaci",
"MessageOldServerAuthWarningHelp": "Server běží na verzi starší než v2.26.0. Bezpečnější způsob autentizace byl přidán ve verzi v2.26.0. Důrazně se doporučuje aktualizovat server na nejnovější verzi. Pokud jste již server aktualizovali, znovu se přihlašte pro použití nové autentizace.",
"MessageOldServerConnectionWarning": "Konfigurace připojení k serveru používá staré ID uživatele. Odstraňte a znovu přidejte toto připojení k serveru.", "MessageOldServerConnectionWarning": "Konfigurace připojení k serveru používá staré ID uživatele. Odstraňte a znovu přidejte toto připojení k serveru.",
"MessageOldServerConnectionWarningHelp": "Připojení k tomuto serveru jste původně nastavili před migrací databáze ve verzi 2.3.0 vydané v červnu 2023. Budoucí aktualizace serveru odstraní možnost přihlášení pomocí tohoto starého připojení. Odstraňte prosím stávající připojení k serveru a připojte se znovu (pomocí stejné adresy serveru a přihlašovacích údajů). Pokud máte v tomto zařízení stažená média, bude nutné je pro synchronizaci se serverem stáhnout znovu.", "MessageOldServerConnectionWarningHelp": "Připojení k tomuto serveru jste původně nastavili před migrací databáze ve verzi 2.3.0 vydané v červnu 2023. Budoucí aktualizace serveru odstraní možnost přihlášení pomocí tohoto starého připojení. Odstraňte prosím stávající připojení k serveru a připojte se znovu (pomocí stejné adresy serveru a přihlašovacích údajů). Pokud máte v tomto zařízení stažená média, bude nutné je pro synchronizaci se serverem stáhnout znovu.",
"MessagePodcastSearchField": "Zadejte hledaný pojem pro RSS feed URL", "MessagePodcastSearchField": "Zadejte hledaný pojem pro RSS feed URL",
"MessageProgressSyncFailed": "Poslední pokus o nahlášení průběhu poslechu na server se nezdařil. Během přehrávání médií se budou požadavky na synchronizaci pokroku nadále pokoušet každých 15 až 60 sekund.", "MessageProgressSyncFailed": "Poslední pokus o nahlášení průběhu poslechu na server se nezdařil. Během přehrávání médií se budou požadavky na synchronizaci pokroku nadále pokoušet každých 15 až 60 sekund.",
"MessageReportBugsAndContribute": "Nahlašte chyby, vyžádejte si funkce a přispěte na", "MessageReportBugsAndContribute": "Nahlašte chyby, vyžádejte si funkce a přispěte na",
"MessageSeriesAlreadyDownloaded": "Všechny knihy v této sérii jste již stáhli.", "MessageSeriesAlreadyDownloaded": "Všechny knihy v této sérii jste již stáhli.",
"MessageSeriesDownloadConfirm": "Stáhnout chybějící {0} knihu(y) s {1} souborem(y), celkem {2} do složky {3}?", "MessageSeriesDownloadConfirm": "Stáhnout {0} chybějících knih s {1} souborem/soubory, celkem {2}, do složky {3}?",
"MessageSeriesDownloadConfirmIos": "Stáhnout chybějící {0} knihu(y) s {1} souborem(y), celkem {2}?", "MessageSeriesDownloadConfirmIos": "Stáhnout chybějící {0} knihu(y) s {1} souborem(y), celkem {2}?",
"MessageSocketConnectedOverMeteredCellular": "Socket připojen přes měřenou mobilní síť", "MessageSocketConnectedOverMeteredCellular": "Socket připojen přes měřenou mobilní síť",
"MessageSocketConnectedOverMeteredWifi": "Socket připojen přes měřenou Wi-Fi", "MessageSocketConnectedOverMeteredWifi": "Socket připojen přes měřenou Wi-Fi",
@@ -351,5 +370,7 @@
"ToastPodcastCreateSuccess": "Podcast byl úspěšně vytvořen", "ToastPodcastCreateSuccess": "Podcast byl úspěšně vytvořen",
"ToastRSSFeedCloseFailed": "Nepodařilo se zavřít RSS kanál", "ToastRSSFeedCloseFailed": "Nepodařilo se zavřít RSS kanál",
"ToastRSSFeedCloseSuccess": "RSS kanál uzavřen", "ToastRSSFeedCloseSuccess": "RSS kanál uzavřen",
"ToastStreamingNotAllowedOnCellular": "Přehrávání přes mobilní data není povoleno" "ToastStreamingNotAllowedOnCellular": "Přehrávání přes mobilní data není povoleno",
"UnitMinutesShort": "{0}m",
"UnitSecondsShort": "{0}s"
} }
+25 -5
View File
@@ -63,6 +63,7 @@
"HeaderChapters": "Kapitler", "HeaderChapters": "Kapitler",
"HeaderCollection": "Samling", "HeaderCollection": "Samling",
"HeaderCollectionItems": "Samlingselementer", "HeaderCollectionItems": "Samlingselementer",
"HeaderConfirm": "Bekræft",
"HeaderConnectionStatus": "Forbindelsesstatus", "HeaderConnectionStatus": "Forbindelsesstatus",
"HeaderDataSettings": "Data indstillinger", "HeaderDataSettings": "Data indstillinger",
"HeaderDetails": "Detaljer", "HeaderDetails": "Detaljer",
@@ -91,6 +92,7 @@
"HeaderStatsRecentSessions": "Seneste Sessions", "HeaderStatsRecentSessions": "Seneste Sessions",
"HeaderTableOfContents": "Indholdsfortegnelse", "HeaderTableOfContents": "Indholdsfortegnelse",
"HeaderUserInterfaceSettings": "Indstillinger for brugergrænseflade", "HeaderUserInterfaceSettings": "Indstillinger for brugergrænseflade",
"HeaderWelcome": "Velkommen, <strong>{0}</strong>",
"HeaderYourStats": "Dine Statistikker", "HeaderYourStats": "Dine Statistikker",
"LabelAddToPlaylist": "Tilføj til Afspilningsliste", "LabelAddToPlaylist": "Tilføj til Afspilningsliste",
"LabelAddedAt": "Tilføjet", "LabelAddedAt": "Tilføjet",
@@ -108,11 +110,12 @@
"LabelAuthors": "Forfattere", "LabelAuthors": "Forfattere",
"LabelAutoDownloadEpisodes": "Auto Download Episoder", "LabelAutoDownloadEpisodes": "Auto Download Episoder",
"LabelAutoRewindTime": "Automatisk tilbagespolingstid", "LabelAutoRewindTime": "Automatisk tilbagespolingstid",
"LabelAutoSleepTimer": "Auto sleep timer", "LabelAutoSleepTimer": "Auto søvn-timer",
"LabelAutoSleepTimerAutoRewind": "Automatisk sleep timer automatisk tilbagespoling", "LabelAutoSleepTimerAutoRewind": "Automatisk sleep timer automatisk tilbagespoling",
"LabelAutoSleepTimerAutoRewindHelp": "Når den automatiske sleep-timer er færdig, vil genafspilning af elementet automatisk spole din position tilbage.", "LabelAutoSleepTimerAutoRewindHelp": "Når den automatiske sleep-timer er færdig, vil genafspilning af elementet automatisk spole din position tilbage.",
"LabelAutoSleepTimerHelp": "Når der afspilles medie mellem de angivne start- og sluttidspunkter, starter en sleep-timer automatisk.", "LabelAutoSleepTimerHelp": "Når der afspilles medie mellem de angivne start- og sluttidspunkter, starter en sleep-timer automatisk.",
"LabelBooks": "Bøger", "LabelBooks": "Bøger",
"LabelByAuthor": "Efter Forfatter",
"LabelChapterTrack": "Kapitel spor", "LabelChapterTrack": "Kapitel spor",
"LabelChapters": "Kapitler", "LabelChapters": "Kapitler",
"LabelClosePlayer": "Luk afspiller", "LabelClosePlayer": "Luk afspiller",
@@ -146,6 +149,7 @@
"LabelEndOfChapter": "Slutningen af kapitel", "LabelEndOfChapter": "Slutningen af kapitel",
"LabelEndTime": "Sluttidspunkt", "LabelEndTime": "Sluttidspunkt",
"LabelEpisode": "Afsnit", "LabelEpisode": "Afsnit",
"LabelExplicit": "Eksplisit",
"LabelFeedURL": "Feed URL", "LabelFeedURL": "Feed URL",
"LabelFile": "Fil", "LabelFile": "Fil",
"LabelFileBirthtime": "Oprettelsestidspunkt for fil", "LabelFileBirthtime": "Oprettelsestidspunkt for fil",
@@ -154,6 +158,9 @@
"LabelFinished": "Færdig", "LabelFinished": "Færdig",
"LabelFolder": "Mappe", "LabelFolder": "Mappe",
"LabelFontBoldness": "Skrift tykkelse", "LabelFontBoldness": "Skrift tykkelse",
"LabelFontFamily": "Skrifttypefamilie",
"LabelFontFamilySans": "Font Sans",
"LabelFontFamilySerif": "Font Serif",
"LabelFontScale": "Skriftstørrelse", "LabelFontScale": "Skriftstørrelse",
"LabelGenre": "Genre", "LabelGenre": "Genre",
"LabelGenres": "Genrer", "LabelGenres": "Genrer",
@@ -173,6 +180,9 @@
"LabelLayout": "Layout", "LabelLayout": "Layout",
"LabelLayoutAuto": "Automatisk", "LabelLayoutAuto": "Automatisk",
"LabelLayoutSinglePage": "Enkel Side", "LabelLayoutSinglePage": "Enkel Side",
"LabelLibrarySortByProgress": "Fremgang: Sidst opdateret",
"LabelLibrarySortByProgressFinished": "Fremgang: Afsluttet",
"LabelLibrarySortByProgressStarted": "Fremgang: Startet",
"LabelLight": "Lys", "LabelLight": "Lys",
"LabelLineSpacing": "Linjeafstand", "LabelLineSpacing": "Linjeafstand",
"LabelListenAgain": "Lyt Igen", "LabelListenAgain": "Lyt Igen",
@@ -232,6 +242,7 @@
"LabelScaleElapsedTimeBySpeed": "Skalér Forløbet Tid med Hastighed", "LabelScaleElapsedTimeBySpeed": "Skalér Forløbet Tid med Hastighed",
"LabelSeason": "Sæson", "LabelSeason": "Sæson",
"LabelSelectADevice": "Vælg en Enhed", "LabelSelectADevice": "Vælg en Enhed",
"LabelSelectMediaType": "Vælg medietype",
"LabelSequenceAscending": "Sekvens Stigende", "LabelSequenceAscending": "Sekvens Stigende",
"LabelSequenceDescending": "Sekvens Faldende", "LabelSequenceDescending": "Sekvens Faldende",
"LabelSeries": "Serie", "LabelSeries": "Serie",
@@ -282,10 +293,13 @@
"MessageAndroid10Downloads": "Android 10 og mindre vil bruge intern lagerplads til downloads.", "MessageAndroid10Downloads": "Android 10 og mindre vil bruge intern lagerplads til downloads.",
"MessageAttemptingServerConnection": "Prøver at tilgå server...", "MessageAttemptingServerConnection": "Prøver at tilgå server...",
"MessageAudiobookshelfServerNotConnected": "Audiobookshel server er ikke tilsluttet", "MessageAudiobookshelfServerNotConnected": "Audiobookshel server er ikke tilsluttet",
"MessageAudiobookshelfServerRequired": "<strong>Vigtig</strong> Denne app er designet til at fungere med en Audiobookshelf server, som du eller en du kender hoster. Denne app levere ikke noget indhold.", "MessageAudiobookshelfServerRequired": "<strong>Vigtigt!</strong> Denne app er designet til at fungere med en Audiobookshelf-server, som du eller en du kender hoster. Denne app leverer ikke noget indhold.",
"MessageBookshelfEmpty": "Bogreol Tom", "MessageBookshelfEmpty": "Bogreol Tom",
"MessageConfirmAppExit": "Ville du lukke appen?",
"MessageConfirmDeleteEpisodeDownloadQueue": "Er du sikker på, at du vil rydde downloadkøen for episoder?",
"MessageConfirmDeleteLocalEpisode": "Slet lokal episode \"{0}\" fra din enhed? Filen på serveren vil ikke blive påvirket.", "MessageConfirmDeleteLocalEpisode": "Slet lokal episode \"{0}\" fra din enhed? Filen på serveren vil ikke blive påvirket.",
"MessageConfirmDeleteLocalFiles": "Slet lokale filer af denne type fra din enhed? Filerne på serveren og din fremgang vil ikke blive påvirket.", "MessageConfirmDeleteLocalFiles": "Slet lokale filer af denne type fra din enhed? Filerne på serveren og din fremgang vil ikke blive påvirket.",
"MessageConfirmDeleteServerConfig": "Fjern denne serverkonfiguration?",
"MessageConfirmDisableAutoTimer": "Er du sikker på at du vil slå auto-timer fra resten af dagen? Timeren vil blive slået til ved slutningen af auto-sleep timer perioden eller appen genstartes.", "MessageConfirmDisableAutoTimer": "Er du sikker på at du vil slå auto-timer fra resten af dagen? Timeren vil blive slået til ved slutningen af auto-sleep timer perioden eller appen genstartes.",
"MessageConfirmDiscardProgress": "Er du sikker på at du vil nulstille din fremgang?", "MessageConfirmDiscardProgress": "Er du sikker på at du vil nulstille din fremgang?",
"MessageConfirmDownloadUsingCellular": "Du er ved at downloade via mobildata. Dette kan medføre ekstra omkostninger fra din teleoperatør. Vil du fortsætte?", "MessageConfirmDownloadUsingCellular": "Du er ved at downloade via mobildata. Dette kan medføre ekstra omkostninger fra din teleoperatør. Vil du fortsætte?",
@@ -297,6 +311,7 @@
"MessageDownloading": "Downloader...", "MessageDownloading": "Downloader...",
"MessageDownloadingEpisode": "Downloader episode", "MessageDownloadingEpisode": "Downloader episode",
"MessageEpisodesQueuedForDownload": "{0} episoder er sat i kø til download", "MessageEpisodesQueuedForDownload": "{0} episoder er sat i kø til download",
"MessageFailedToRefreshToken": "Mislykkedes med at opdatere token, nyt loginforsøg nødvendigt",
"MessageFeedURLWillBe": "Feed-URL vil være {0}", "MessageFeedURLWillBe": "Feed-URL vil være {0}",
"MessageFetching": "Henter...", "MessageFetching": "Henter...",
"MessageFollowTheProjectOnGithub": "Følg projektet på GitHub", "MessageFollowTheProjectOnGithub": "Følg projektet på GitHub",
@@ -324,6 +339,9 @@
"MessageNoSeries": "Ingen serier", "MessageNoSeries": "Ingen serier",
"MessageNoUpdatesWereNecessary": "Ingen opdateringer var nødvendige", "MessageNoUpdatesWereNecessary": "Ingen opdateringer var nødvendige",
"MessageNoUserPlaylists": "Du har ingen afspilningslister", "MessageNoUserPlaylists": "Du har ingen afspilningslister",
"MessageOldServerAuthReLoginRequired": "Autentificering er blevet forbedret af sikkerhedsmæssige årsager i server v2.26.0. Alle brugere skal logge ind igen.",
"MessageOldServerAuthWarning": "Serveren bruger forældet autentificering",
"MessageOldServerAuthWarningHelp": "Denne server kører en version ældre end v2.26.0. Et mere sikkert autentificeringssystem blev tilføjet i v2.26.0. Det anbefales kraftigt at opdatere serveren til den nyeste version. Hvis du allerede har opdateret serveren, skal du logge ind igen for at bruge den nye autentificering.",
"MessageOldServerConnectionWarning": "Server forbindelseskonfiguration anvender et gammelt bruger ID. Fjern venligst og gentilføj denne server forbindelse.", "MessageOldServerConnectionWarning": "Server forbindelseskonfiguration anvender et gammelt bruger ID. Fjern venligst og gentilføj denne server forbindelse.",
"MessageOldServerConnectionWarningHelp": "Du har oprindeligt sat forbindelsen til denne server op før databasemigreringen i 2.3.0, udgivet i juni 2023. En senere serveropdatering vil fjerne muligheden for at logge ind med genne gamle metode. Sørg venligst for at slette den eksisterende serverforbindelse og opret forbindelse igen (ved anvendelse af den samme server adresse og login oplysninger). Hvis du har downloaded noget medie på denne enhed vil dette være nødsaget til at bive downloaded igen efter serversynkronisering.", "MessageOldServerConnectionWarningHelp": "Du har oprindeligt sat forbindelsen til denne server op før databasemigreringen i 2.3.0, udgivet i juni 2023. En senere serveropdatering vil fjerne muligheden for at logge ind med genne gamle metode. Sørg venligst for at slette den eksisterende serverforbindelse og opret forbindelse igen (ved anvendelse af den samme server adresse og login oplysninger). Hvis du har downloaded noget medie på denne enhed vil dette være nødsaget til at bive downloaded igen efter serversynkronisering.",
"MessagePodcastSearchField": "Indtast søgeterm eller RSS URL", "MessagePodcastSearchField": "Indtast søgeterm eller RSS URL",
@@ -343,12 +361,14 @@
"ToastBookmarkRemoveFailed": "Mislykkedes fjernelse af bogmærke", "ToastBookmarkRemoveFailed": "Mislykkedes fjernelse af bogmærke",
"ToastBookmarkUpdateFailed": "Mislykkedes opdatering af bogmærke", "ToastBookmarkUpdateFailed": "Mislykkedes opdatering af bogmærke",
"ToastDownloadNotAllowedOnCellular": "Det er ikke tillad at downloade over mobildata", "ToastDownloadNotAllowedOnCellular": "Det er ikke tillad at downloade over mobildata",
"ToastItemMarkedAsFinishedFailed": "Mislykkedes markering som afsluttet", "ToastItemMarkedAsFinishedFailed": "Markering som afsluttet mislykkedes",
"ToastItemMarkedAsNotFinishedFailed": "Mislykkedes markering som ikke afsluttet", "ToastItemMarkedAsNotFinishedFailed": "Markering som ikke afsluttet mislykkedes",
"ToastPlaylistCreateFailed": "Mislykkedes oprettelse af afspilningsliste", "ToastPlaylistCreateFailed": "Mislykkedes oprettelse af afspilningsliste",
"ToastPodcastCreateFailed": "Mislykkedes oprettelse af podcast", "ToastPodcastCreateFailed": "Mislykkedes oprettelse af podcast",
"ToastPodcastCreateSuccess": "Podcast oprettet med succes", "ToastPodcastCreateSuccess": "Podcast oprettet med succes",
"ToastRSSFeedCloseFailed": "Mislykkedes lukning af RSS-feed", "ToastRSSFeedCloseFailed": "Mislykkedes lukning af RSS-feed",
"ToastRSSFeedCloseSuccess": "RSS-feed lukket", "ToastRSSFeedCloseSuccess": "RSS-feed lukket",
"ToastStreamingNotAllowedOnCellular": "Det er ikke tillad at streame over mobildata" "ToastStreamingNotAllowedOnCellular": "Det er ikke tillad at streame over mobildata",
"UnitMinutesShort": "{0}m",
"UnitSecondsShort": "{0}s"
} }

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