Compare commits

...
Author SHA1 Message Date
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
advplyr a53a96ecf5 Update web player track index to fallback to 1 2025-07-22 14:46:31 -05:00
advplyr 82cddde15f Fix track URL used for transcodes on Android #1635 2025-07-22 14:16:35 -05:00
advplyr def47fd5e2 Update gh issue template app versions 2025-07-21 08:57:56 -05:00
advplyr 04c70f54aa iOS version bump 0.10.0 2025-07-19 17:17:27 -05:00
advplyr 03e2feddb3 Version bump v0.10.0-beta 2025-07-19 16:55:54 -05:00
advplyrandGitHub fc69909dd0 Merge pull request #1625 from weblate/weblate-audiobookshelf-abs-mobile-app
Translations update from Hosted Weblate
2025-07-19 16:47:20 -05:00
FiendFEARingandHosted Weblate 15fa42986a Translated using Weblate (Chinese (Simplified Han script))
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/zh_Hans/
2025-07-19 15:03:05 +02:00
SunSpringandHosted Weblate d8b4cf98dd Translated using Weblate (Chinese (Simplified Han script))
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/zh_Hans/
2025-07-19 15:03:04 +02:00
Максим ГорпинічandHosted Weblate ce62f3b273 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-07-19 15:03:03 +02:00
biuklijaandHosted Weblate 8be2e3af0c Translated using Weblate (Croatian)
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/hr/
2025-07-19 15:03:01 +02:00
GernomalyandHosted Weblate e44c574068 Translated using Weblate (German)
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/de/
2025-07-19 15:03:01 +02:00
SunSpringandHosted Weblate c31aa17ce2 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (353 of 353 strings)

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

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/uk/
2025-07-17 22:45:48 +00:00
Grzegorz OrlowskiandHosted Weblate 2d9604aa07 Translated using Weblate (Polish)
Currently translated at 98.8% (349 of 353 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/pl/
2025-07-17 22:45:47 +00:00
Fredrik LindqvistandHosted Weblate 79c4ffa6c5 Translated using Weblate (Swedish)
Currently translated at 99.7% (352 of 353 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/sv/
2025-07-17 22:45:47 +00:00
JannikandHosted Weblate 571b3a2c86 Translated using Weblate (German)
Currently translated at 99.7% (352 of 353 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/de/
2025-07-17 22:45:46 +00:00
FiendFEARingandHosted Weblate fa31fba586 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (353 of 353 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/zh_Hans/
2025-07-17 22:45:45 +00:00
Simple16andHosted Weblate 2f3b261392 Translated using Weblate (Russian)
Currently translated at 100.0% (353 of 353 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/ru/
2025-07-17 22:45:45 +00:00
biuklijaandHosted Weblate afd3cd6716 Translated using Weblate (Croatian)
Currently translated at 100.0% (353 of 353 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/hr/
2025-07-17 22:45:44 +00:00
FiendFEARingandHosted Weblate be99ef87ad Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (353 of 353 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/zh_Hans/
2025-07-17 22:45:44 +00:00
Kabika82andHosted Weblate 572fc8545f Translated using Weblate (Hungarian)
Currently translated at 100.0% (353 of 353 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/hu/
2025-07-17 22:45:43 +00:00
advplyrandGitHub 239a943172 Merge pull request #1618 from advplyr/new_jwt_auth
Update auth to handle refresh tokens
2025-07-17 17:45:33 -05:00
advplyr 87614bc78a Update auth message, update force re-login to pull auth methods to support oidc 2025-07-17 17:37:41 -05:00
advplyr 224d75fac5 Update old auth alert messages, add link to github discussion 2025-07-15 17:41:30 -05:00
advplyr f4e0a6121f Update readers to handle token refresh 2025-07-15 17:22:45 -05:00
advplyr 80ee88b488 Merge branch 'master' into new_jwt_auth 2025-07-11 16:58:50 -05:00
advplyrandGitHub beb654825f Merge pull request #1623 from weblate/weblate-audiobookshelf-abs-mobile-app
Translations update from Hosted Weblate
2025-07-11 16:58:32 -05:00
advplyr 79d8ccbf52 Update login query param to x-return-tokens header 2025-07-11 16:00:48 -05:00
thehijackerandHosted Weblate b48c74eca0 Translated using Weblate (Slovenian)
Currently translated at 100.0% (353 of 353 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/sl/
2025-07-11 00:18:44 +02:00
FiendFEARingandHosted Weblate 19e3dee706 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (353 of 353 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/zh_Hans/
2025-07-11 00:18:44 +02:00
SunSpringandHosted Weblate e1be86c3ce Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (353 of 353 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/zh_Hans/
2025-07-11 00:18:44 +02:00
SunSpringandHosted Weblate 11f7eddd7b Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (353 of 353 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/zh_Hans/
2025-07-11 00:18:44 +02:00
RajandHosted Weblate 7f28d2ea70 Translated using Weblate (Gujarati)
Currently translated at 9.3% (33 of 353 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/gu/
2025-07-11 00:18:44 +02:00
Richard PožgayandHosted Weblate 4607a8e274 Translated using Weblate (Czech)
Currently translated at 100.0% (353 of 353 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/cs/
2025-07-11 00:18:44 +02:00
MichalandHosted Weblate 26c559104a Translated using Weblate (Slovak)
Currently translated at 99.7% (352 of 353 strings)

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

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/uk/
2025-07-11 00:18:43 +02:00
advplyr b29401909f Fix comic extracted metadata icon 2025-07-10 17:18:38 -05:00
advplyr beb5e1a56c OIDC to support new access tokens 2025-07-07 17:20:22 -05:00
advplyr be08efeca3 iOS update retry request handler to handle string bodies 2025-07-07 06:15:51 -05:00
advplyr 4534ffaead Handle re-authenticating socket 2025-07-06 11:32:21 -05:00
advplyr d35dd2df1a Merge branch 'master' into new_jwt_auth 2025-07-06 09:07:17 -05:00
advplyr 2f3a9a5d96 Update play button on list book to check if player is starting 2025-07-05 18:06:24 -05:00
advplyr fab94cd363 Handle native app token refresh failure notification 2025-07-05 17:40:57 -05:00
advplyr b06274866d iOS update to use new track endpoint and remove token from cover url depending on version 2025-07-05 17:31:25 -05:00
advplyr 5766c49f61 Update iOS ApiClient to handle token refresh 2025-07-05 16:46:37 -05:00
advplyr bc927d4c35 Add SecureStorage to iOS and implement refresh token methods 2025-07-05 14:52:41 -05:00
advplyr 5804c54656 Add version to ServerConnectionConfig on iOS 2025-07-05 14:28:02 -05:00
advplyr 52f86cbce9 Fix plugin listener handlers, add message for configs using old server auth, show server version on account page 2025-07-05 12:20:27 -05:00
advplyr f6e2e4010f Update to not logout from server when switching servers, force users to re-login if using old auth 2025-07-05 11:21:20 -05:00
advplyr 44613e12f1 Update serverConnectionConfig to include server version, update server track URL and server cover image URL based on server version 2025-07-05 09:28:40 -05:00
advplyr b99e0b112b Merge branch 'master' into new_jwt_auth 2025-07-05 08:06:40 -05:00
advplyrandGitHub 80b565c23f Merge pull request #1622 from advplyr/play_from_library_list_rows
Add play button to book library list view
2025-07-04 18:22:30 -05:00
advplyr bee88c43e5 Add play button to book library list view 2025-07-04 18:07:05 -05:00
advplyr 467fedbfe7 Update to use x-refresh-token header, update logout to clear refresh token, add AbsLogger logs for android refresh 2025-07-04 17:41:19 -05:00
advplyr ba4e9ab7e3 Add explicit library filters 2025-07-03 17:40:02 -05:00
advplyrandGitHub 1b4fa5e44a Merge pull request #1610 from weblate/weblate-audiobookshelf-abs-mobile-app
Translations update from Hosted Weblate
2025-07-03 17:32:02 -05:00
FiendFEARingandHosted Weblate e92c9b2990 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (352 of 352 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/zh_Hans/
2025-07-02 20:16:41 +02:00
FiendFEARingandHosted Weblate f23f8e9ade Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (352 of 352 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/zh_Hans/
2025-07-02 20:16:39 +02:00
DavevanIerselandHosted Weblate dc00aeece4 Translated using Weblate (Dutch)
Currently translated at 100.0% (352 of 352 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/nl/
2025-07-02 20:16:37 +02:00
advplyr d8cdb7073e Update auth to handle refresh tokens 2025-07-01 11:33:51 -05:00
Vito0912andHosted Weblate 4fdea9b0ec Translated using Weblate (German)
Currently translated at 99.7% (351 of 352 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/de/
2025-06-30 00:32:39 +02:00
Vito0912andHosted Weblate 3ded7687f7 Translated using Weblate (German)
Currently translated at 99.7% (351 of 352 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/de/
2025-06-30 00:32:38 +02:00
Eigen_artandHosted Weblate 42119425af Translated using Weblate (Ukrainian)
Currently translated at 100.0% (352 of 352 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/uk/
2025-06-30 00:32:38 +02:00
Dan JohansenandHosted Weblate 93c7e1c44a Translated using Weblate (Danish)
Currently translated at 100.0% (352 of 352 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/da/
2025-06-30 00:32:37 +02:00
Michael FörsterandHosted Weblate 381fd84839 Translated using Weblate (German)
Currently translated at 99.7% (351 of 352 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/de/
2025-06-30 00:32:36 +02:00
advplyr 67bab72783 Update podcast library latest page number of episodes to 50 #1615 2025-06-29 17:32:29 -05:00
advplyrandGitHub b9c084a442 Merge pull request #1612 from advplyr/create_bookmarks_focus
Update create bookmark to not autofocus input and use placeholder
2025-06-22 17:51:26 -05:00
advplyr a77f345596 Update create bookmark to not autofocus input and leave empty with placeholder 2025-06-22 17:36:09 -05:00
advplyrandGitHub 6a2f487ed5 Merge pull request #1608 from NickSkier/ui_black_theme
Add: UI Black/OLED theme
2025-06-20 16:55:41 -05:00
advplyr 616cf9029d Fix audio player colors for black theme 2025-06-20 16:51:19 -05:00
NickSkier 3f21b4172b Add: UI Black/OLED theme 2025-06-20 01:05:46 +03:00
advplyrandGitHub 62f6a11522 Merge pull request #1589 from weblate/weblate-audiobookshelf-abs-mobile-app
Translations update from Hosted Weblate
2025-06-19 16:24:42 -05:00
burghy86andHosted Weblate ae1dce455d Translated using Weblate (Italian)
Currently translated at 100.0% (352 of 352 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/it/
2025-06-19 23:01:48 +02:00
B0raxandHosted Weblate 7b5dc52620 Translated using Weblate (German)
Currently translated at 99.7% (351 of 352 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/de/
2025-06-19 23:01:47 +02:00
Mathias FrancoandHosted Weblate 0bf50de54d Translated using Weblate (Dutch)
Currently translated at 100.0% (352 of 352 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/nl/
2025-06-17 22:01:57 +02:00
biuklijaandHosted Weblate 1eec470902 Translated using Weblate (Croatian)
Currently translated at 100.0% (352 of 352 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/hr/
2025-06-17 22:01:56 +02:00
petr-prikrylandHosted Weblate 867dc69479 Translated using Weblate (Czech)
Currently translated at 99.1% (349 of 352 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/cs/
2025-06-17 22:01:54 +02:00
ugyesandHosted Weblate 9792f4ebc1 Translated using Weblate (Hungarian)
Currently translated at 100.0% (352 of 352 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/hu/
2025-06-12 11:02:03 +02:00
Dawid KuźnickiandHosted Weblate a44fc41caa Translated using Weblate (Polish)
Currently translated at 98.2% (346 of 352 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/pl/
2025-06-10 09:06:17 +00:00
Grzegorz OrlowskiandHosted Weblate 1927dbf0ad Translated using Weblate (Polish)
Currently translated at 98.2% (346 of 352 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/pl/
2025-06-10 09:06:16 +00:00
RekentekandHosted Weblate 7052674f32 Translated using Weblate (Dutch)
Currently translated at 98.0% (345 of 352 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/nl/
2025-06-10 09:06:16 +00:00
Daniel SchosserandHosted Weblate 9a487f3bb6 Translated using Weblate (German)
Currently translated at 100.0% (352 of 352 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/de/
2025-06-10 09:06:15 +00:00
David Havndrup MunchandHosted Weblate d6242c350d Translated using Weblate (Danish)
Currently translated at 98.0% (345 of 352 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/da/
2025-06-10 09:06:14 +00:00
PlazecandHosted Weblate 4cc4aceb41 Translated using Weblate (Czech)
Currently translated at 100.0% (352 of 352 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/cs/
2025-06-10 09:06:13 +00:00
peter cernyandHosted Weblate dd8f4d6201 Translated using Weblate (Slovak)
Currently translated at 99.7% (351 of 352 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/sk/
2025-06-07 21:01:56 +02:00
Usama KhalilandHosted Weblate f44dcdf124 Translated using Weblate (Arabic)
Currently translated at 100.0% (352 of 352 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/ar/
2025-06-07 21:01:54 +02:00
thehijackerandHosted Weblate 65e8f2317e Translated using Weblate (Slovenian)
Currently translated at 100.0% (352 of 352 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/sl/
2025-06-07 21:01:51 +02:00
SunSpringandHosted Weblate 1c4f43db7c Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (352 of 352 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/zh_Hans/
2025-06-07 21:01:49 +02:00
max grakovandHosted Weblate 24b9df6fbd Translated using Weblate (Russian)
Currently translated at 100.0% (352 of 352 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/ru/
2025-06-07 21:01:47 +02:00
Максим ГорпинічandHosted Weblate c46891ecb0 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (352 of 352 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/uk/
2025-06-06 00:01:47 +02:00
Anders NormanandHosted Weblate e723601f6b Translated using Weblate (Norwegian Bokmål)
Currently translated at 71.5% (251 of 351 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/nb_NO/
2025-06-04 00:17:08 +02:00
DRandHosted Weblate 77545df174 Translated using Weblate (Hebrew)
Currently translated at 100.0% (351 of 351 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/he/
2025-06-04 00:17:07 +02:00
Grzegorz OrlowskiandHosted Weblate 7e136b17a5 Translated using Weblate (Polish)
Currently translated at 98.2% (345 of 351 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/pl/
2025-06-04 00:17:07 +02:00
Adolfo Jayme BarrientosandHosted Weblate a970a06f57 Translated using Weblate (Catalan)
Currently translated at 80.0% (281 of 351 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/ca/
2025-06-04 00:17:06 +02:00
ABS translatorandHosted Weblate 95dd78d4aa Translated using Weblate (Arabic)
Currently translated at 100.0% (351 of 351 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/ar/
2025-06-04 00:17:05 +02:00
Grzegorz OrlowskiandHosted Weblate eeaf33be20 Translated using Weblate (Polish)
Currently translated at 96.8% (340 of 351 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/pl/
2025-06-04 00:17:04 +02:00
Adolfo Jayme BarrientosandHosted Weblate 7ed0cd26fe Translated using Weblate (Spanish)
Currently translated at 100.0% (351 of 351 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/es/
2025-06-04 00:17:03 +02:00
advplyr d26403c800 Add book/podcast library filter for RSS Feed Open 2025-06-03 17:16:54 -05:00
advplyrandGitHub 5ed6e3a6a3 Merge pull request #1573 from weblate/weblate-audiobookshelf-abs-mobile-app
Translations update from Hosted Weblate
2025-05-24 17:11:31 -05:00
peter cernyandHosted Weblate 7e83405370 Translated using Weblate (Slovak)
Currently translated at 99.7% (350 of 351 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/sk/
2025-05-24 00:19:32 +02:00
biuklijaandHosted Weblate a5de31da2b Translated using Weblate (Croatian)
Currently translated at 100.0% (351 of 351 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/hr/
2025-05-24 00:19:31 +02:00
peter cernyandHosted Weblate 39c06005ed Translated using Weblate (Slovak)
Currently translated at 93.7% (329 of 351 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/sk/
2025-05-24 00:19:30 +02:00
Usama KhalilandHosted Weblate 9479160044 Translated using Weblate (Arabic)
Currently translated at 100.0% (351 of 351 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/ar/
2025-05-24 00:19:30 +02:00
Usama KhalilandHosted Weblate 46ec7369e5 Translated using Weblate (Arabic)
Currently translated at 100.0% (351 of 351 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/ar/
2025-05-24 00:19:29 +02:00
ABS translatorandHosted Weblate 424e7d742b Translated using Weblate (Arabic)
Currently translated at 100.0% (351 of 351 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/ar/
2025-05-24 00:19:28 +02:00
Usama KhalilandHosted Weblate 44036399b9 Translated using Weblate (Arabic)
Currently translated at 100.0% (351 of 351 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/ar/
2025-05-24 00:19:28 +02:00
Adolfo Jayme BarrientosandHosted Weblate e4394da467 Translated using Weblate (Catalan)
Currently translated at 79.4% (279 of 351 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/ca/
2025-05-24 00:19:27 +02:00
Usama KhalilandHosted Weblate b837ada3f8 Translated using Weblate (Arabic)
Currently translated at 76.6% (269 of 351 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/ar/
2025-05-24 00:19:27 +02:00
Adolfo Jayme BarrientosandHosted Weblate a623de89aa Translated using Weblate (Spanish)
Currently translated at 100.0% (351 of 351 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/es/
2025-05-24 00:19:26 +02:00
peter cernyandHosted Weblate 2768407907 Translated using Weblate (Slovak)
Currently translated at 85.4% (300 of 351 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/sk/
2025-05-24 00:19:26 +02:00
polarwoodandHosted Weblate 64ed35d2b1 Translated using Weblate (Turkish)
Currently translated at 77.7% (273 of 351 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/tr/
2025-05-24 00:19:25 +02:00
J. LavoieandHosted Weblate b92f4dfa02 Translated using Weblate (Italian)
Currently translated at 100.0% (351 of 351 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/it/
2025-05-24 00:19:25 +02:00
J. LavoieandHosted Weblate e4f02b7a51 Translated using Weblate (French)
Currently translated at 100.0% (351 of 351 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/fr/
2025-05-24 00:19:24 +02:00
J. LavoieandHosted Weblate 24c3f45018 Translated using Weblate (German)
Currently translated at 100.0% (351 of 351 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/de/
2025-05-24 00:19:23 +02:00
MichalandHosted Weblate fcf55eb581 Translated using Weblate (Slovak)
Currently translated at 54.4% (191 of 351 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/sk/
2025-05-24 00:19:23 +02:00
peter cernyandHosted Weblate 9ade7add32 Translated using Weblate (Slovak)
Currently translated at 54.4% (191 of 351 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/sk/
2025-05-24 00:19:22 +02:00
Jaakko RantamäkiandHosted Weblate d16dfcb4b4 Translated using Weblate (Finnish)
Currently translated at 98.2% (345 of 351 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/fi/
2025-05-24 00:19:22 +02:00
Максим ГорпинічandHosted Weblate 37c58a5d32 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (351 of 351 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/uk/
2025-05-24 00:19:21 +02:00
peter cernyandHosted Weblate e6cdde4f70 Translated using Weblate (Slovak)
Currently translated at 13.6% (48 of 351 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/sk/
2025-05-24 00:19:20 +02:00
Oleg IvasenkoandHosted Weblate 93c0adecbd Translated using Weblate (Russian)
Currently translated at 100.0% (351 of 351 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/ru/
2025-05-24 00:19:20 +02:00
advplyrandGitHub 33d44a5181 Merge pull request #1584 from advplyr/abridged_indicator
Add abridged indicator #1477
2025-05-23 17:19:11 -05:00
advplyr 80e6fa7e7a Add abridged indicator #1477 2025-05-23 17:04:26 -05:00
advplyrandGitHub b8eed48112 Merge pull request #1582 from advplyr/handle_android_edgetoedge
Fix android system bars overlapping UI #1564 #1574
2025-05-21 17:52:19 -05:00
advplyr 8d563dcfed Remove unnecessary styles.xml 2025-05-21 17:38:05 -05:00
advplyr 1cf36e5549 Fix android system bars overlapping UI #1564 #1574 2025-05-21 17:26:31 -05:00
advplyrandGitHub 1357a0628f Merge pull request #1577 from golinski/jackson-reuse
Reuse the existing ObjectMapper
2025-05-18 17:13:58 -05:00
advplyr c4fe0680f3 Remove unused imports 2025-05-18 17:06:06 -05:00
advplyr 16694aa932 Update progress bar box shadow for visibility #1576 2025-05-17 17:06:28 -05:00
Michał Goliński ea59ad2953 Reuse the existing ObjectMapper 2025-05-17 20:37:59 +02:00
advplyrandGitHub cf2f684e80 Merge pull request #1557 from weblate/weblate-audiobookshelf-abs-mobile-app
Translations update from Hosted Weblate
2025-05-10 16:56:47 -05:00
Nicholas WandHosted Weblate 01522eda43 Added translation using Weblate (Slovak) 2025-05-10 15:44:43 +02:00
Adolfo Jayme BarrientosandHosted Weblate a200ee1310 Translated using Weblate (Catalan)
Currently translated at 78.9% (277 of 351 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/ca/
2025-05-10 15:44:42 +02:00
Adolfo Jayme BarrientosandHosted Weblate 579a07baa2 Translated using Weblate (Catalan)
Currently translated at 78.6% (276 of 351 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/ca/
2025-05-09 14:11:21 +02:00
Adolfo Jayme BarrientosandHosted Weblate a82dd88b1f Translated using Weblate (Spanish)
Currently translated at 100.0% (351 of 351 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/es/
2025-05-09 14:11:21 +02:00
thehijackerandHosted Weblate 36ba98920a Translated using Weblate (Slovenian)
Currently translated at 100.0% (351 of 351 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/sl/
2025-05-07 00:01:46 +02:00
Adolfo Jayme BarrientosandHosted Weblate 47cc0c34eb Translated using Weblate (Spanish)
Currently translated at 100.0% (351 of 351 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/es/
2025-05-07 00:01:45 +02:00
Adolfo Jayme BarrientosandHosted Weblate 53eaca8724 Translated using Weblate (Spanish)
Currently translated at 96.8% (340 of 351 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/es/
2025-05-04 23:19:23 +02:00
ABS translatorandHosted Weblate 8a2814b391 Translated using Weblate (Arabic)
Currently translated at 69.8% (245 of 351 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/ar/
2025-05-04 23:19:23 +02:00
burghy86andHosted Weblate 345c8ab217 Translated using Weblate (Italian)
Currently translated at 100.0% (351 of 351 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/it/
2025-05-04 23:19:23 +02:00
ABS translatorandHosted Weblate 7c010895e0 Translated using Weblate (Arabic)
Currently translated at 59.8% (210 of 351 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/ar/
2025-05-04 23:19:23 +02:00
SunSpringandHosted Weblate e716cfd19b Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (351 of 351 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/zh_Hans/
2025-05-04 23:19:23 +02:00
Sergey PonomarevandHosted Weblate 461a6c8be5 Translated using Weblate (Russian)
Currently translated at 100.0% (351 of 351 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/ru/
2025-05-04 23:19:23 +02:00
Максим ГорпинічandHosted Weblate 3360f1a3f6 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (351 of 351 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/uk/
2025-05-04 23:19:23 +02:00
Robin StolpeandHosted Weblate 8b939e8a02 Translated using Weblate (Swedish)
Currently translated at 100.0% (351 of 351 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/sv/
2025-05-04 23:19:23 +02:00
Fredrik LindqvistandHosted Weblate 3a8998081c Translated using Weblate (Swedish)
Currently translated at 100.0% (351 of 351 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/sv/
2025-05-04 23:19:23 +02:00
dvc05andHosted Weblate 9b986a7b15 Translated using Weblate (Norwegian Bokmål)
Currently translated at 71.5% (251 of 351 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/nb_NO/
2025-05-04 23:19:23 +02:00
Kabika82andHosted Weblate 69ab395798 Translated using Weblate (Hungarian)
Currently translated at 100.0% (351 of 351 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/hu/
2025-05-04 23:19:23 +02:00
biuklijaandHosted Weblate 7aa3c33348 Translated using Weblate (Croatian)
Currently translated at 99.7% (350 of 351 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/hr/
2025-05-04 23:19:23 +02:00
johes00andHosted Weblate 734328dd1f Translated using Weblate (German)
Currently translated at 100.0% (351 of 351 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/de/
2025-05-04 23:19:23 +02:00
kuci-JKandHosted Weblate 7d6c30e733 Translated using Weblate (Czech)
Currently translated at 100.0% (351 of 351 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/cs/
2025-05-04 23:19:23 +02:00
advplyr 3e682dfe50 Fix local cover images not showing in android auto #1565 2025-05-04 16:19:16 -05:00
advplyrandGitHub 50efda3858 Merge pull request #1561 from nichwall/android_vscode_subproject
Add: settings for gradle project in subfolder
2025-04-30 17:15:56 -05:00
advplyrandGitHub ca1b9e2bdb Merge pull request #1562 from nichwall/workflow_updates
Template updates
2025-04-28 09:17:12 -05:00
Nicholas Wallace aa3a91b9d9 Add pull request template 2025-04-27 16:58:50 -07:00
Nicholas Wallace 7117608445 Update app versions in issue template 2025-04-27 16:56:02 -07:00
Nicholas Wallace 6de640f53f Add: settings for gradle project in subfolder 2025-04-27 16:47:44 -07:00
advplyr 75627ea40f iOS version bump 0.9.81 & pod update 2025-04-26 10:03:57 -05:00
advplyr 0fa04d7d9a Version bump v0.9.81-beta 2025-04-25 16:56:34 -05:00
advplyrandGitHub 1e76ebe075 Merge pull request #1556 from Zibbp/master
fix(InternalDownloadManager): add accept-encoding identity to requests
2025-04-25 16:25:07 -05:00
advplyrandGitHub 965da2fee3 Merge pull request #1547 from weblate/weblate-audiobookshelf-abs-mobile-app
Translations update from Hosted Weblate
2025-04-25 16:24:51 -05:00
Zibbp b03f59ace3 fix(InternalDownloadManager): set accept-encoding header on just the download task 2025-04-25 15:51:46 -05:00
SunSpringandHosted Weblate 79c7244b24 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (349 of 349 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/zh_Hans/
2025-04-25 00:29:31 +02:00
PimandHosted Weblate 0843be3f4e Translated using Weblate (Dutch)
Currently translated at 97.9% (342 of 349 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/nl/
2025-04-25 00:29:31 +02:00
Kabika82andHosted Weblate 6911f368e5 Translated using Weblate (Hungarian)
Currently translated at 100.0% (349 of 349 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/hu/
2025-04-25 00:29:31 +02:00
cebo29andHosted Weblate 42167ea738 Translated using Weblate (German)
Currently translated at 100.0% (349 of 349 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/de/
2025-04-25 00:29:31 +02:00
thehijackerandHosted Weblate 1c57d3506a Translated using Weblate (Slovenian)
Currently translated at 100.0% (349 of 349 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/sl/
2025-04-25 00:29:31 +02:00
Максим ГорпинічandHosted Weblate 8adc6fae4c Translated using Weblate (Ukrainian)
Currently translated at 100.0% (349 of 349 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/uk/
2025-04-25 00:29:31 +02:00
NickSkierandHosted Weblate e9746577ba Translated using Weblate (Russian)
Currently translated at 100.0% (349 of 349 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/ru/
2025-04-25 00:29:31 +02:00
max grakovandHosted Weblate 9f68730622 Translated using Weblate (Russian)
Currently translated at 100.0% (349 of 349 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/ru/
2025-04-25 00:29:31 +02:00
biuklijaandHosted Weblate f019b67b0c Translated using Weblate (Croatian)
Currently translated at 99.7% (348 of 349 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/hr/
2025-04-25 00:29:31 +02:00
advplyrandGitHub 0f650c0572 Merge pull request #1555 from advplyr/sleep_timer_chime_android
Add:Android sleep timer setting to play a chime when almost finished
2025-04-24 17:29:25 -05:00
Zibbp dfc77ea0d0 fix(InternalDownloadManager): add accept-encoding identity to requests 2025-04-24 17:04:08 -05:00
149 changed files with 5747 additions and 1065 deletions
+4
View File
@@ -6,3 +6,7 @@ indent_size = 2
charset = utf-8
insert_final_newline = 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.*
multiple: true
options:
- 'Android App - 0.9.79'
- 'iOS App - 0.9.79'
- 'Android App - 0.9.78'
- 'iOS App - 0.9.78'
- 'Android App - 0.10.1'
- 'iOS App - 0.10.1'
- 'Android App - 0.10.0'
- 'iOS App - 0.10.0'
validations:
required: true
- 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.*
multiple: true
options:
- Android App - 0.9.79
- iOS App - 0.9.79
- Android App - 0.9.78
- iOS App - 0.9.78
- 'Android App - 0.10.1'
- 'iOS App - 0.10.1'
- 'Android App - 0.10.0'
- 'iOS App - 0.10.0'
validations:
required: true
- type: textarea
+41
View File
@@ -0,0 +1,41 @@
<!--
For Work In Progress Pull Requests, please use the Draft PR feature,
see https://github.blog/2019-02-14-introducing-draft-pull-requests/ for further details.
If you do not follow this template, the PR may be closed without review.
Please ensure all checks pass.
If you are a new contributor, the workflows will need to be manually approved before they run.
-->
## Brief summary
<!-- Please provide a brief summary of what your PR attempts to achieve. -->
## Which issue is fixed?
<!-- Which issue number does this PR fix? Ex: "Fixes #1234" -->
## Pull Request Type
<!--
Does this affect only Android, only iOS, or both?
Does this change the frontend or the backend of the apps?
-->
## In-depth Description
<!--
Describe your solution in more depth.
How does it work? Why is this the best solution?
Does it solve a problem that affects multiple users or is this an edge case for your setup?
-->
## How have you tested this?
<!-- Please describe in detail with reproducible steps how you tested your changes. -->
## Screenshots
<!-- If your PR includes any changes to the front-end, please include screenshots or a
short video from before and after your changes. -->
+6
View File
@@ -0,0 +1,6 @@
{
"java.project.root": "android",
"java.import.gradle.enabled": true,
"java.import.gradle.wrapper.enabled": true,
"java.import.gradle.user.home": "${workspaceFolder}/.gradle"
}
+2 -2
View File
@@ -36,8 +36,8 @@ android {
applicationId "com.audiobookshelf.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 111
versionName "0.9.80-beta"
versionCode 115
versionName "0.11.0-beta"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
manifestPlaceholders = [
"appAuthRedirectScheme": "com.audiobookshelf.app"
+4
View File
@@ -36,6 +36,10 @@
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/media_player_widget_info" />
<meta-data
android:name="com.samsung.android.appwidget.provider"
android:resource="@xml/samsung_cover_widget_info" />
</receiver>
<!-- Used by Android Auto -->
@@ -5,6 +5,10 @@
"plugins": {
"CapacitorHttp": {
"enabled": false
},
"StatusBar": {
"backgroundColor": "#232323",
"style": "DARK"
}
},
"server": {
@@ -6,10 +6,15 @@ import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.os.IBinder
import android.util.Log
import android.view.ViewGroup
import android.view.WindowInsets
import android.webkit.WebView
import androidx.core.app.ActivityCompat
import androidx.core.view.updateLayoutParams
import com.anggrayudi.storage.SimpleStorage
import com.anggrayudi.storage.SimpleStorageHelper
import com.audiobookshelf.app.managers.DbManager
@@ -40,18 +45,6 @@ class MainActivity : BridgeActivity() {
)
public override fun onCreate(savedInstanceState: Bundle?) {
// TODO: Optimize using strict mode logs
// StrictMode.setThreadPolicy(StrictMode.ThreadPolicy.Builder()
// .detectDiskReads()
// .detectDiskWrites().detectAll()
// .detectNetwork() // or .detectAll() for all detectable problems
// .penaltyLog()
// .build())
// StrictMode.setVmPolicy(VmPolicy.Builder()
// .detectLeakedSqlLiteObjects()
// .detectLeakedClosableObjects()
// .penaltyLog()
// .build())
DbManager.initialize(applicationContext)
registerPlugin(AbsAudioPlayer::class.java)
@@ -63,7 +56,47 @@ class MainActivity : BridgeActivity() {
super.onCreate(savedInstanceState)
Log.d(tag, "onCreate")
// Update the margins to handle edge-to-edge enforced in SDK 35
// See: https://developer.android.com/develop/ui/views/layout/edge-to-edge
val webView: WebView = findViewById(R.id.webview)
webView.setOnApplyWindowInsetsListener { v, insets ->
val (left, top, right, bottom) = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val sysInsets = insets.getInsets(WindowInsets.Type.systemBars())
Log.d(tag, "safe sysInsets: $sysInsets")
arrayOf(sysInsets.left, sysInsets.top, sysInsets.right, sysInsets.bottom)
} else {
arrayOf(
insets.systemWindowInsetLeft,
insets.systemWindowInsetTop,
insets.systemWindowInsetRight,
insets.systemWindowInsetBottom
)
}
// Inject as CSS variables
// NOTE: Possibly able to use in the future to support edge-to-edge better.
val js = """
document.documentElement.style.setProperty('--safe-area-inset-top', '${top}px');
document.documentElement.style.setProperty('--safe-area-inset-bottom', '${bottom}px');
document.documentElement.style.setProperty('--safe-area-inset-left', '${left}px');
document.documentElement.style.setProperty('--safe-area-inset-right', '${right}px');
""".trimIndent()
webView.evaluateJavascript(js, null)
// Set margins
v.updateLayoutParams<ViewGroup.MarginLayoutParams> {
leftMargin = left
bottomMargin = bottom
rightMargin = right
topMargin = top
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
WindowInsets.CONSUMED
} else {
insets
}
}
val permission = ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE)
if (permission != PackageManager.PERMISSION_GRANTED) {
@@ -14,6 +14,7 @@ data class AudioTrack(
var metadata: FileMetadata?,
var isLocal: Boolean,
var localFileId: String?,
// TODO: This should no longer be necessary
var serverIndex: Int? // Need to know if server track index is different
) {
@@ -32,11 +32,14 @@ enum class AndroidAutoBrowseSeriesSequenceOrderSetting {
ASC, DESC
}
@JsonIgnoreProperties(ignoreUnknown = true)
data class ServerConnectionConfig(
var id:String,
var index:Int,
var name:String,
var address:String,
// version added after 0.9.81-beta
var version:String?,
var userId:String,
var username:String,
var token:String,
@@ -5,8 +5,7 @@
package com.audiobookshelf.app.data
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.core.json.JsonReadFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import org.json.JSONObject
@@ -18,8 +17,7 @@ data class ItemInProgress(
val isLocal: Boolean
) {
companion object {
fun makeFromServerObject(serverItem: JSONObject):ItemInProgress {
val jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
fun makeFromServerObject(serverItem: JSONObject, jacksonMapper: ObjectMapper):ItemInProgress {
val libraryItem = jacksonMapper.readValue<LibraryItem>(serverItem.toString())
var episode:PodcastEpisode? = null
@@ -53,6 +53,11 @@ class LibraryItem(
return Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
}
// As of v2.17.0 token is not needed with cover image requests
if (DeviceManager.isServerVersionGreaterThanOrEqualTo("2.17.0")) {
return Uri.parse("${DeviceManager.serverAddress}/api/items/$id/cover")
}
return Uri.parse("${DeviceManager.serverAddress}/api/items/$id/cover?token=${DeviceManager.token}")
}
@@ -149,6 +149,16 @@ class PlaybackSession(
return total
}
@JsonIgnore
fun checkIsServerVersionGte(compareVersion: String): Boolean {
// Safety check this playback session is the same one currently connected (should always be)
if (DeviceManager.serverConnectionConfigId != serverConnectionConfigId) {
return false
}
return DeviceManager.isServerVersionGreaterThanOrEqualTo(compareVersion)
}
@JsonIgnore
fun getCoverUri(ctx: Context): Uri {
if (localLibraryItem?.coverContentUrl != null) {
@@ -168,12 +178,27 @@ class PlaybackSession(
if (coverPath == null)
return Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
// As of v2.17.0 token is not needed with cover image requests
if (checkIsServerVersionGte("2.17.0")) {
return Uri.parse("$serverAddress/api/items/$libraryItemId/cover")
}
return Uri.parse("$serverAddress/api/items/$libraryItemId/cover?token=${DeviceManager.token}")
}
@JsonIgnore
fun getContentUri(audioTrack: AudioTrack): Uri {
if (isLocal) return Uri.parse(audioTrack.contentUrl) // Local content url
// As of v2.22.0 tracks use a different endpoint
// See: https://github.com/advplyr/audiobookshelf/pull/4263
if (checkIsServerVersionGte("2.22.0")) {
return if (isDirectPlay) {
Uri.parse("$serverAddress/public/session/$id/track/${audioTrack.index}")
} else {
// Transcode uses HlsRouter on server
Uri.parse("$serverAddress${audioTrack.contentUrl}")
}
}
return Uri.parse("$serverAddress${audioTrack.contentUrl}?token=${DeviceManager.token}")
}
@@ -264,14 +289,16 @@ class PlaybackSession(
com.google.android.gms.cast.MediaMetadata.MEDIA_TYPE_AUDIOBOOK_CHAPTER
)
// As of v2.17.0 token is not needed with cover image requests
val coverUri = if (checkIsServerVersionGte("2.17.0")) {
Uri.parse("$serverAddress/api/items/$libraryItemId/cover")
} else {
Uri.parse("$serverAddress/api/items/$libraryItemId/cover?token=${DeviceManager.token}")
}
// Cast always uses server cover uri
coverPath?.let {
castMetadata.addImage(
WebImage(
Uri.parse(
"$serverAddress/api/items/$libraryItemId/cover?token=${DeviceManager.token}"
)
)
)
castMetadata.addImage(WebImage(coverUri))
}
castMetadata.putString(com.google.android.gms.cast.MediaMetadata.KEY_TITLE, displayTitle ?: "")
@@ -41,6 +41,7 @@ object DeviceManager {
get() = serverConnectionConfig?.userId ?: ""
val token
get() = serverConnectionConfig?.token ?: ""
val serverVersion get() = serverConnectionConfig?.version ?: ""
val isConnectedToServer
get() = serverConnectionConfig != null
@@ -111,6 +112,41 @@ object DeviceManager {
return id?.let { deviceData.serverConnectionConfigs.find { it.id == id } }
}
/**
* Check if the currently connected server version is >= compareVersion
* Abs server only uses major.minor.patch
* Note: Version is returned in Abs auth payloads starting v2.6.0
* Note: Version is saved with the server connection config starting after v0.9.81
*
* @example
* serverVersion=2.25.1
* isServerVersionGreaterThanOrEqualTo("2.26.0") = false
*
* serverVersion=2.26.1
* isServerVersionGreaterThanOrEqualTo("2.26.0") = true
*/
fun isServerVersionGreaterThanOrEqualTo(compareVersion:String):Boolean {
if (serverVersion == "") return false
if (compareVersion == "") return true
val serverVersionParts = serverVersion.split(".").map { it.toIntOrNull() ?: 0 }
val compareVersionParts = compareVersion.split(".").map { it.toIntOrNull() ?: 0 }
// Compare major, minor, and patch components
for (i in 0 until maxOf(serverVersionParts.size, compareVersionParts.size)) {
val serverVersionComponent = serverVersionParts.getOrElse(i) { 0 }
val compareVersionComponent = compareVersionParts.getOrElse(i) { 0 }
if (serverVersionComponent < compareVersionComponent) {
return false // Server version is less than compareVersion
} else if (serverVersionComponent > compareVersionComponent) {
return true // Server version is greater than compareVersion
}
}
return true // versions are equal in major, minor, and patch
}
/**
* Checks the network connectivity status.
* @param ctx The context to use for checking connectivity.
@@ -29,7 +29,7 @@ class InternalDownloadManager(
*/
@Throws(IOException::class)
fun download(url: String) {
val request: Request = Request.Builder().url(url).build()
val request: Request = Request.Builder().url(url).addHeader("Accept-Encoding", "identity").build()
client.newCall(request)
.enqueue(
object : Callback {
@@ -0,0 +1,124 @@
package com.audiobookshelf.app.managers
import android.content.Context
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import android.util.Log
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
class SecureStorage(private val context: Context) {
companion object {
private const val TAG = "SecureStorage"
private const val KEYSTORE_PROVIDER = "AndroidKeyStore"
private const val KEY_ALIAS = "AudiobookshelfRefreshTokens"
private const val TRANSFORMATION = "AES/GCM/NoPadding"
private const val IV_LENGTH = 12
private const val TAG_LENGTH = 128
}
private val keyStore = KeyStore.getInstance(KEYSTORE_PROVIDER).apply {
load(null)
}
/**
* Encrypts and stores a refresh token for a specific server connection
*/
fun storeRefreshToken(serverConnectionId: String, refreshToken: String): Boolean {
return try {
val key = getOrCreateKey()
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, key)
val encryptedBytes = cipher.doFinal(refreshToken.toByteArray(Charsets.UTF_8))
val combined = cipher.iv + encryptedBytes
val encoded = Base64.encodeToString(combined, Base64.DEFAULT)
val sharedPrefs = context.getSharedPreferences("SecureStorage", Context.MODE_PRIVATE)
sharedPrefs.edit().putString("refresh_token_$serverConnectionId", encoded).apply()
Log.d(TAG, "Successfully stored encrypted refresh token for server: $serverConnectionId")
true
} catch (e: Exception) {
Log.e(TAG, "Failed to store refresh token for server: $serverConnectionId", e)
false
}
}
/**
* Retrieves and decrypts a refresh token for a specific server connection
*/
fun getRefreshToken(serverConnectionId: String): String? {
return try {
val sharedPrefs = context.getSharedPreferences("SecureStorage", Context.MODE_PRIVATE)
val encoded = sharedPrefs.getString("refresh_token_$serverConnectionId", null) ?: return null
val combined = Base64.decode(encoded, Base64.DEFAULT)
val iv = combined.copyOfRange(0, IV_LENGTH)
val encryptedBytes = combined.copyOfRange(IV_LENGTH, combined.size)
val key = getOrCreateKey()
val cipher = Cipher.getInstance(TRANSFORMATION)
val spec = GCMParameterSpec(TAG_LENGTH, iv)
cipher.init(Cipher.DECRYPT_MODE, key, spec)
val decryptedBytes = cipher.doFinal(encryptedBytes)
String(decryptedBytes, Charsets.UTF_8)
} catch (e: Exception) {
Log.e(TAG, "Failed to retrieve refresh token for server: $serverConnectionId", e)
null
}
}
/**
* Removes a refresh token for a specific server connection
*/
fun removeRefreshToken(serverConnectionId: String): Boolean {
return try {
val sharedPrefs = context.getSharedPreferences("SecureStorage", Context.MODE_PRIVATE)
sharedPrefs.edit().remove("refresh_token_$serverConnectionId").apply()
Log.d(TAG, "Successfully removed refresh token for server: $serverConnectionId")
true
} catch (e: Exception) {
Log.e(TAG, "Failed to remove refresh token for server: $serverConnectionId", e)
false
}
}
/**
* Checks if a refresh token exists for a specific server connection
*/
fun hasRefreshToken(serverConnectionId: String): Boolean {
val sharedPrefs = context.getSharedPreferences("SecureStorage", Context.MODE_PRIVATE)
return sharedPrefs.contains("refresh_token_$serverConnectionId")
}
private fun getOrCreateKey(): SecretKey {
return if (keyStore.containsAlias(KEY_ALIAS)) {
keyStore.getKey(KEY_ALIAS, null) as SecretKey
} else {
createKey()
}
}
private fun createKey(): SecretKey {
val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE_PROVIDER)
val keyGenSpec = KeyGenParameterSpec.Builder(
KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setUserAuthenticationRequired(false)
.setRandomizedEncryptionRequired(true)
.build()
keyGenerator.init(keyGenSpec)
return keyGenerator.generateKey()
}
}
@@ -15,6 +15,7 @@ import org.json.JSONException
import org.json.JSONObject
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
import java.util.concurrent.atomic.AtomicInteger
class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
val tag = "MediaManager"
@@ -175,21 +176,20 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
* Load personalized shelves from server for all libraries.
* [cb] resolves when all libraries are processed
*/
fun populatePersonalizedDataForAllLibraries(cb: () -> Unit ) {
serverLibraries.forEach {
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--
fun populatePersonalizedDataForAllLibraries(cb: () -> Unit) {
val remaining = AtomicInteger(serverLibraries.size)
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")
allLibraryPersonalizationsDone = true
cb()
}
}
}
while (libraryPersonalizationsDone > 0) { }
Log.d(tag, "Finished loading all library personalization data")
allLibraryPersonalizationsDone = true
cb()
}
/**
@@ -14,7 +14,7 @@ import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.ui.PlayerNotificationManager
import kotlinx.coroutines.*
class AbMediaDescriptionAdapter constructor(private val controller: MediaControllerCompat, private val playerNotificationService: PlayerNotificationService) : PlayerNotificationManager.MediaDescriptionAdapter {
class AbMediaDescriptionAdapter (private val controller: MediaControllerCompat, private val playerNotificationService: PlayerNotificationService) : PlayerNotificationManager.MediaDescriptionAdapter {
private val tag = "MediaDescriptionAdapter"
private var currentIconUri: Uri? = null
@@ -35,6 +35,12 @@ class AbMediaDescriptionAdapter constructor(private val controller: MediaControl
callback: PlayerNotificationManager.BitmapCallback
): Bitmap? {
val albumArtUri = controller.metadata.description.iconUri
val albumBitmap = controller.metadata.description.iconBitmap
// For local cover images, bitmap is set in PlayerNotificationService TimelineQueueNavigator.getMediaDescription
if (albumBitmap != null) {
return albumBitmap
}
return if (currentIconUri != albumArtUri || currentBitmap == null) {
// Cache the bitmap for the current audiobook so that successive calls to
@@ -59,7 +65,6 @@ class AbMediaDescriptionAdapter constructor(private val controller: MediaControl
}
null
}
} else {
currentBitmap
}
@@ -76,7 +76,10 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
COMMAND_GET_MEDIA_ITEMS_METADATA,
COMMAND_SET_MEDIA_ITEMS_METADATA,
COMMAND_CHANGE_MEDIA_ITEMS,
COMMAND_GET_TRACKS)
COMMAND_GET_TRACKS,
COMMAND_GET_DEVICE_VOLUME,
COMMAND_SET_DEVICE_VOLUME,
COMMAND_ADJUST_DEVICE_VOLUME)
.build()
var currentPlaybackState = Player.STATE_IDLE
@@ -861,27 +864,48 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
}
override fun getDeviceVolume(): Int {
return 0
val session = castContext.sessionManager.currentCastSession
return if (session != null && !session.isMute) {
(session.volume * 100).toInt()
} else {
0
}
}
override fun isDeviceMuted(): Boolean {
return false
val session = castContext.sessionManager.currentCastSession
return session?.isMute ?: false
}
override fun setDeviceVolume(volume: Int) {
val session = castContext.sessionManager.currentCastSession
try {
val clamped = volume.coerceIn(0, 100)
session?.volume = clamped / 100.0
} catch (e: Exception) {
Log.e(tag, "Failed to set cast device volume", e)
}
}
override fun increaseDeviceVolume() {
val currentVolume = getDeviceVolume()
val newVolume = (currentVolume + 1).coerceAtMost(100)
setDeviceVolume(newVolume)
}
override fun decreaseDeviceVolume() {
val currentVolume = getDeviceVolume()
val newVolume = (currentVolume - 1).coerceAtLeast(0)
setDeviceVolume(newVolume)
}
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 {
@@ -4,17 +4,22 @@ import android.annotation.SuppressLint
import android.app.*
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Color
import android.graphics.ImageDecoder
import android.hardware.Sensor
import android.hardware.SensorManager
import android.net.*
import android.os.*
import android.provider.MediaStore
import android.provider.Settings
import android.support.v4.media.MediaBrowserCompat
import android.support.v4.media.MediaDescriptionCompat
import android.support.v4.media.MediaMetadataCompat
import android.support.v4.media.session.MediaControllerCompat
import android.support.v4.media.session.MediaSessionCompat
import androidx.media.VolumeProviderCompat
import android.media.AudioManager
import android.support.v4.media.session.PlaybackStateCompat
import android.util.Log
import androidx.annotation.RequiresApi
@@ -91,6 +96,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
private lateinit var mediaSessionConnector: MediaSessionConnector
private lateinit var playerNotificationManager: PlayerNotificationManager
lateinit var mediaSession: MediaSessionCompat
private var remoteVolumeProvider: VolumeProviderCompat? = null
private lateinit var transportControls: MediaControllerCompat.TransportControls
lateinit var mediaManager: MediaManager
@@ -308,6 +314,22 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
val coverUri = currentPlaybackSession!!.getCoverUri(ctx)
var bitmap: Bitmap? = null
// Local covers get bitmap
// Note: In Android Auto for local cover images, setting the icon uri to a local path does not work (cover is blank)
// so we create and set the bitmap here instead of AbMediaDescriptionAdapter
if (currentPlaybackSession!!.localLibraryItem?.coverContentUrl != null) {
bitmap =
if (Build.VERSION.SDK_INT < 28) {
MediaStore.Images.Media.getBitmap(ctx.contentResolver, coverUri)
} else {
val source: ImageDecoder.Source =
ImageDecoder.createSource(ctx.contentResolver, coverUri)
ImageDecoder.decodeBitmap(source)
}
}
// Fix for local images crashing on Android 11 for specific devices
// https://stackoverflow.com/questions/64186578/android-11-mediastyle-notification-crash/64232958#64232958
try {
@@ -331,7 +353,8 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
.setExtras(extra)
.setTitle(currentPlaybackSession!!.displayTitle)
mediaDescriptionBuilder.setIconUri(coverUri)
bitmap?.let { mediaDescriptionBuilder.setIconBitmap(it) }
?: mediaDescriptionBuilder.setIconUri(coverUri)
return mediaDescriptionBuilder.build()
}
@@ -685,11 +708,13 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
Log.d(tag, "switchToPlayer: Using Cast Player " + castPlayer?.deviceInfo)
mediaSessionConnector.setPlayer(castPlayer)
playerNotificationManager.setPlayer(castPlayer)
setMediaSessionToCastVolume()
castPlayer as CastPlayer
} else {
Log.d(tag, "switchToPlayer: Using ExoPlayer")
mediaSessionConnector.setPlayer(mPlayer)
playerNotificationManager.setPlayer(mPlayer)
setMediaSessionToLocalVolume()
mPlayer
}
@@ -706,6 +731,44 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
}
}
private fun setMediaSessionToCastVolume() {
val currentVol = try { castPlayer?.getDeviceVolume() ?: 0 } catch (e: Exception) { 0 }
val provider = object : VolumeProviderCompat(VolumeProviderCompat.VOLUME_CONTROL_ABSOLUTE, 100, currentVol) {
override fun onSetVolumeTo(volume: Int) {
val clamped = volume.coerceIn(0, 100)
try {
castPlayer?.setDeviceVolume(clamped)
val actual = castPlayer?.getDeviceVolume() ?: clamped
setCurrentVolume(actual)
} catch (_: Exception) {}
}
override fun onAdjustVolume(direction: Int) {
val current = try { castPlayer?.getDeviceVolume() ?: currentVolume } catch (_: Exception) { currentVolume }
val step = if (direction > 0) 1 else if (direction < 0) -1 else 0
if (step == 0) return
var target = (current + step).coerceIn(0, 100)
try {
castPlayer?.setDeviceVolume(target)
var actual = castPlayer?.getDeviceVolume() ?: current
// If device ignored tiny step, try nudging up to 3 steps total.
if (actual == current) {
target = (current + step * 3).coerceIn(0, 100)
castPlayer?.setDeviceVolume(target)
actual = castPlayer?.getDeviceVolume() ?: current
}
setCurrentVolume(actual)
} catch (_: Exception) {}
}
}
remoteVolumeProvider = provider
mediaSession.setPlaybackToRemote(provider)
}
private fun setMediaSessionToLocalVolume() {
mediaSession.setPlaybackToLocal(AudioManager.STREAM_MUSIC)
remoteVolumeProvider = null
}
fun getCurrentTrackStartOffsetMs(): Long {
return if (currentPlayer.mediaItemCount > 1) {
val windowIndex = currentPlayer.currentMediaItemIndex
@@ -1067,6 +1130,26 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
private val DOWNLOADS_ROOT = "__DOWNLOADS__"
private val CONTINUE_ROOT = "__CONTINUE__"
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
// normal loading of audiobooks is handled in webview (not natively)
@@ -1237,6 +1320,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
mediaManager.serverLibraries,
mediaManager.allLibraryPersonalizationsDone
)
onBrowseTreeInitialized()
val children =
browseTree[parentMediaId]?.map { item ->
Log.d(tag, "Found top menu item: ${item.description.title}")
@@ -1271,6 +1355,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
mediaManager.serverLibraries,
mediaManager.allLibraryPersonalizationsDone
)
onBrowseTreeInitialized()
val children =
browseTree[parentMediaId]?.map { item ->
Log.d(tag, "Found top menu item: ${item.description.title}")
@@ -1283,22 +1368,40 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
AbsLogger.info(tag, "onLoadChildren: Android auto data loaded")
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")
if (!firstLoadDone) {
if (!firstLoadDone)
{
result.sendResult(null)
return
}
// Wait until top-menu is initialized
while (!this::browseTree.isInitialized) {}
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
)
}
if (!this::browseTree.isInitialized)
{
// ✅ 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}")
MediaBrowserCompat.MediaItem(
item.description,
MediaBrowserCompat.MediaItem.FLAG_BROWSABLE
)
}
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
} else if (mediaManager.getIsLibrary(parentMediaId)) { // Load library items for library
Log.d(tag, "Loading items for library $parentMediaId")
@@ -8,6 +8,7 @@ import com.audiobookshelf.app.data.*
import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.media.MediaEventManager
import com.audiobookshelf.app.server.ApiHandler
import com.audiobookshelf.app.managers.SecureStorage
import com.fasterxml.jackson.core.json.JsonReadFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
@@ -22,17 +23,21 @@ class AbsDatabase : Plugin() {
val tag = "AbsDatabase"
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
lateinit var mainActivity: MainActivity
lateinit var apiHandler: ApiHandler
private lateinit var mainActivity: MainActivity
private lateinit var apiHandler: ApiHandler
private lateinit var secureStorage: SecureStorage
data class LocalMediaProgressPayload(val value:List<LocalMediaProgress>)
data class LocalLibraryItemsPayload(val value:List<LocalLibraryItem>)
data class LocalFoldersPayload(val value:List<LocalFolder>)
data class ServerConnConfigPayload(val id:String?, val index:Int, val name:String?, val userId:String, val username:String, val token:String, val address:String?, val customHeaders:Map<String,String>?)
data class ServerConnConfigPayload(val id:String?, val index:Int, val name:String?, val userId:String, val username:String, var version:String, val token:String, val refreshToken:String?, val address:String?, val customHeaders:Map<String,String>?)
override fun load() {
mainActivity = (activity as MainActivity)
apiHandler = ApiHandler(mainActivity)
ApiHandler.absDatabaseNotifyListeners = ::notifyListeners
secureStorage = SecureStorage(mainActivity)
DeviceManager.dbManager.cleanLocalMediaProgress()
DeviceManager.dbManager.cleanLocalLibraryItems()
@@ -120,7 +125,9 @@ class AbsDatabase : Plugin() {
val userId = serverConfigPayload.userId
val username = serverConfigPayload.username
val token = serverConfigPayload.token
val serverVersion = serverConfigPayload.version
val accessToken = serverConfigPayload.token
val refreshToken = serverConfigPayload.refreshToken // Refresh only sent after login or refresh
GlobalScope.launch(Dispatchers.IO) {
if (serverConnectionConfig == null) { // New Server Connection
@@ -129,7 +136,16 @@ class AbsDatabase : Plugin() {
// Create new server connection config
val sscId = DeviceManager.getBase64Id("$serverAddress@$username")
val sscIndex = DeviceManager.deviceData.serverConnectionConfigs.size
serverConnectionConfig = ServerConnectionConfig(sscId, sscIndex, "$serverAddress ($username)", serverAddress, userId, username, token, serverConfigPayload.customHeaders)
// Store refresh token securely if provided
val hasRefreshToken = if (!refreshToken.isNullOrEmpty()) {
secureStorage.storeRefreshToken(sscId, refreshToken)
} else {
false
}
Log.d(tag, "Refresh token secured = $hasRefreshToken")
serverConnectionConfig = ServerConnectionConfig(sscId, sscIndex, "$serverAddress ($username)", serverAddress, serverVersion, userId, username, accessToken, serverConfigPayload.customHeaders)
// Add and save
DeviceManager.deviceData.serverConnectionConfigs.add(serverConnectionConfig!!)
@@ -137,14 +153,21 @@ class AbsDatabase : Plugin() {
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
} else {
var shouldSave = false
if (serverConnectionConfig?.username != username || serverConnectionConfig?.token != token) {
if (serverConnectionConfig?.username != username || serverConnectionConfig?.token != accessToken || serverConnectionConfig?.version != serverVersion) {
serverConnectionConfig?.userId = userId
serverConnectionConfig?.username = username
serverConnectionConfig?.name = "${serverConnectionConfig?.address} (${serverConnectionConfig?.username})"
serverConnectionConfig?.token = token
serverConnectionConfig?.version = serverVersion
serverConnectionConfig?.token = accessToken
shouldSave = true
}
// Update refresh token if provided
if (!refreshToken.isNullOrEmpty()) {
val stored = secureStorage.storeRefreshToken(serverConnectionConfig!!.id, refreshToken)
Log.d(tag, "Refresh token secured = $stored")
}
// Set last connection config
if (DeviceManager.deviceData.lastServerConnectionConfigId != serverConfigPayload.id) {
DeviceManager.deviceData.lastServerConnectionConfigId = serverConfigPayload.id
@@ -163,6 +186,10 @@ class AbsDatabase : Plugin() {
fun removeServerConnectionConfig(call:PluginCall) {
GlobalScope.launch(Dispatchers.IO) {
val serverConnectionConfigId = call.getString("serverConnectionConfigId", "").toString()
// Remove refresh token if it exists
secureStorage.removeRefreshToken(serverConnectionConfigId)
DeviceManager.deviceData.serverConnectionConfigs = DeviceManager.deviceData.serverConnectionConfigs.filter { it.id != serverConnectionConfigId } as MutableList<ServerConnectionConfig>
if (DeviceManager.deviceData.lastServerConnectionConfigId == serverConnectionConfigId) {
DeviceManager.deviceData.lastServerConnectionConfigId = null
@@ -175,6 +202,42 @@ class AbsDatabase : Plugin() {
}
}
@PluginMethod
fun getRefreshToken(call:PluginCall) {
val serverConnectionConfigId = call.getString("serverConnectionConfigId", "").toString()
GlobalScope.launch(Dispatchers.IO) {
val refreshToken = secureStorage.getRefreshToken(serverConnectionConfigId)
if (refreshToken != null) {
val result = JSObject()
result.put("refreshToken", refreshToken)
call.resolve(result)
} else {
call.resolve()
}
}
}
@PluginMethod
fun clearRefreshToken(call:PluginCall) {
val serverConnectionConfigId = call.getString("serverConnectionConfigId", "").toString()
val refreshToken = secureStorage.removeRefreshToken(serverConnectionConfigId)
val result = JSObject()
result.put("success", refreshToken)
call.resolve(result)
}
@PluginMethod
fun getAccessToken(call:PluginCall) {
val serverConnectionConfigId = call.getString("serverConnectionConfigId", "").toString()
val serverConnectionConfig = DeviceManager.deviceData.serverConnectionConfigs.find { it.id == serverConnectionConfigId }
val token = serverConnectionConfig?.token ?: ""
val ret = JSObject()
ret.put("token", token)
call.resolve(ret)
}
@PluginMethod
fun logout(call:PluginCall) {
GlobalScope.launch(Dispatchers.IO) {
@@ -14,6 +14,7 @@ import com.audiobookshelf.app.media.SyncResult
import com.audiobookshelf.app.models.User
import com.audiobookshelf.app.BuildConfig
import com.audiobookshelf.app.plugins.AbsLogger
import com.audiobookshelf.app.managers.SecureStorage
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.core.json.JsonReadFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
@@ -33,9 +34,19 @@ import java.util.concurrent.TimeUnit
class ApiHandler(var ctx:Context) {
val tag = "ApiHandler"
companion object {
// For sending data back to the Webview frontend
lateinit var absDatabaseNotifyListeners:(String, JSObject) -> Unit
fun checkAbsDatabaseNotifyListenersInitted():Boolean {
return ::absDatabaseNotifyListeners.isInitialized
}
}
private var defaultClient = OkHttpClient()
private var pingClient = OkHttpClient.Builder().callTimeout(3, TimeUnit.SECONDS).build()
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
private var secureStorage = SecureStorage(ctx)
data class LocalSessionsSyncRequestPayload(val sessions:List<PlaybackSession>, val deviceInfo:DeviceInfo)
@JsonIgnoreProperties(ignoreUnknown = true)
@@ -110,6 +121,13 @@ class ApiHandler(var ctx:Context) {
override fun onResponse(call: Call, response: Response) {
response.use {
if (it.code == 401) {
// Handle 401 Unauthorized by attempting token refresh
AbsLogger.info(tag, "makeRequest: 401 Unauthorized for request to \"${request.url}\" - attempt token refresh")
handleTokenRefresh(request, httpClient, cb)
return
}
if (!it.isSuccessful) {
val jsobj = JSObject()
jsobj.put("error", "Unexpected code $response")
@@ -142,6 +160,269 @@ class ApiHandler(var ctx:Context) {
})
}
/**
* Handles token refresh when a 401 Unauthorized response is received
* This function will:
* 1. Get the refresh token from secure storage for the current server connection
* 2. Make a request to /auth/refresh endpoint with the refresh token
* 3. Update the stored tokens with the new access token
* 4. Retry the original request with the new access token
* 5. If refresh fails, handle logout
*
* @param originalRequest The original request that failed with 401
* @param httpClient The HTTP client to use for the request
* @param callback The callback to return the response
*/
private fun handleTokenRefresh(originalRequest: Request, httpClient: OkHttpClient?, callback: (JSObject) -> Unit) {
try {
AbsLogger.info(tag, "handleTokenRefresh: Attempting to refresh auth tokens for server ${DeviceManager.serverConnectionConfigString}")
// Get current server connection config ID
val serverConnectionConfigId = DeviceManager.serverConnectionConfigId
if (serverConnectionConfigId.isEmpty()) {
AbsLogger.error(tag, "handleTokenRefresh: Unable to refresh auth tokens. No server connection config ID")
val errorObj = JSObject()
errorObj.put("error", "No server connection available")
callback(errorObj)
return
}
// Get refresh token from secure storage
val refreshToken = secureStorage.getRefreshToken(serverConnectionConfigId)
if (refreshToken.isNullOrEmpty()) {
AbsLogger.error(tag, "handleTokenRefresh: Unable to refresh auth tokens. No refresh token available for server ${DeviceManager.serverConnectionConfigString}")
val errorObj = JSObject()
errorObj.put("error", "No refresh token available")
callback(errorObj)
return
}
Log.d(tag, "handleTokenRefresh: Retrieved refresh token, attempting to refresh access token")
// Create refresh token request
val refreshEndpoint = "${DeviceManager.serverAddress}/auth/refresh"
val refreshRequest = Request.Builder()
.url(refreshEndpoint)
.addHeader("x-refresh-token", refreshToken)
.addHeader("Content-Type", "application/json")
.post(EMPTY_REQUEST)
.build()
// Make the refresh request
val client = httpClient ?: defaultClient
client.newCall(refreshRequest).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
Log.e(tag, "handleTokenRefresh: Failed to connect to refresh endpoint", e)
AbsLogger.error(tag, "handleTokenRefresh: Failed to connect to refresh endpoint for server ${DeviceManager.serverConnectionConfigString} (error: ${e.message})")
handleRefreshFailure(callback)
}
override fun onResponse(call: Call, response: Response) {
response.use {
if (!it.isSuccessful) {
AbsLogger.error(tag, "handleTokenRefresh: Refresh request failed with status ${it.code} for server ${DeviceManager.serverConnectionConfigString}")
handleRefreshFailure(callback)
return
}
val bodyString = it.body!!.string()
try {
val responseJson = JSONObject(bodyString)
val userObj = responseJson.optJSONObject("user")
if (userObj == null) {
AbsLogger.error(tag, "handleTokenRefresh: No user object in refresh response for server ${DeviceManager.serverConnectionConfigString}")
handleRefreshFailure(callback)
return
}
val newAccessToken = userObj.optString("accessToken")
val newRefreshToken = userObj.optString("refreshToken")
if (newAccessToken.isEmpty()) {
AbsLogger.error(tag, "handleTokenRefresh: No access token in refresh response for server ${DeviceManager.serverConnectionConfigString}")
handleRefreshFailure(callback)
return
}
Log.d(tag, "handleTokenRefresh: Successfully obtained new access token")
// Update tokens in secure storage and device manager
updateTokens(newAccessToken, newRefreshToken.ifEmpty { refreshToken }, serverConnectionConfigId)
// Retry the original request with the new access token
Log.d(tag, "handleTokenRefresh: Retrying original request with new token")
retryOriginalRequest(originalRequest, newAccessToken, httpClient, callback)
} catch (e: Exception) {
Log.e(tag, "handleTokenRefresh: Failed to parse refresh response", e)
AbsLogger.error(tag, "handleTokenRefresh: Failed to parse refresh response for server ${DeviceManager.serverConnectionConfigString} (error: ${e.message})")
handleRefreshFailure(callback)
}
}
}
})
} catch (e: Exception) {
Log.e(tag, "handleTokenRefresh: Unexpected error during token refresh", e)
handleRefreshFailure(callback)
}
}
/**
* Updates the stored tokens with new access and refresh tokens
*
* @param newAccessToken The new access token
* @param newRefreshToken The new refresh token (or existing one if not provided)
*/
private fun updateTokens(newAccessToken: String, newRefreshToken: String, serverConnectionConfigId: String) {
try {
// Update the refresh token in secure storage if it's new
if (newRefreshToken != secureStorage.getRefreshToken(serverConnectionConfigId)) {
secureStorage.storeRefreshToken(serverConnectionConfigId, newRefreshToken)
Log.d(tag, "updateTokens: Updated refresh token in secure storage")
}
// Update the access token in the current server connection config
DeviceManager.serverConnectionConfig?.let { config ->
config.token = newAccessToken
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
Log.d(tag, "updateTokens: Updated access token in server connection config")
}
// Send access token to Webview frontend
if (checkAbsDatabaseNotifyListenersInitted()) {
val tokenJsObject = JSObject()
tokenJsObject.put("accessToken", newAccessToken)
absDatabaseNotifyListeners("onTokenRefresh", tokenJsObject)
} else {
// Can happen if Webview is never run
Log.i(tag, "AbsDatabaseNotifyListeners is not initialized so cannot send new access token")
}
AbsLogger.info(tag, "updateTokens: Successfully refreshed auth tokens for server ${DeviceManager.serverConnectionConfigString}")
} catch (e: Exception) {
Log.e(tag, "updateTokens: Failed to update tokens", e)
AbsLogger.error(tag, "updateTokens: Failed to refresh auth tokens for server ${DeviceManager.serverConnectionConfigString} (error: ${e.message})")
}
}
/**
* Retries the original request with the new access token
*
* @param originalRequest The original request to retry
* @param newAccessToken The new access token to use
* @param httpClient The HTTP client to use
* @param callback The callback to return the response
*/
private fun retryOriginalRequest(originalRequest: Request, newAccessToken: String, httpClient: OkHttpClient?, callback: (JSObject) -> Unit) {
try {
// Create a new request with the updated authorization header
val newRequest = originalRequest.newBuilder()
.removeHeader("Authorization")
.addHeader("Authorization", "Bearer $newAccessToken")
.build()
Log.d(tag, "retryOriginalRequest: Retrying request to ${newRequest.url}")
// Make the retry request
val client = httpClient ?: defaultClient
client.newCall(newRequest).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
Log.e(tag, "retryOriginalRequest: Failed to retry request", e)
AbsLogger.error(tag, "retryOriginalRequest: Failed to retry request after token refresh for server ${DeviceManager.serverConnectionConfigString} (error: ${e.message})")
val errorObj = JSObject()
errorObj.put("error", "Failed to retry request after token refresh")
callback(errorObj)
}
override fun onResponse(call: Call, response: Response) {
response.use {
if (!it.isSuccessful) {
Log.e(tag, "retryOriginalRequest: Retry request failed with status ${it.code}")
AbsLogger.error(tag, "retryOriginalRequest: Retry request failed with status ${it.code} for server ${DeviceManager.serverConnectionConfigString}")
val errorObj = JSObject()
errorObj.put("error", "Retry request failed with status ${it.code}")
callback(errorObj)
return
}
val bodyString = it.body!!.string()
if (bodyString == "OK") {
callback(JSObject())
} else {
try {
var jsonObj = JSObject()
if (bodyString.startsWith("[")) {
val array = JSArray(bodyString)
jsonObj.put("value", array)
} else {
jsonObj = JSObject(bodyString)
}
callback(jsonObj)
} catch(je:JSONException) {
Log.e(tag, "retryOriginalRequest: Invalid JSON response ${je.localizedMessage} from body $bodyString")
val errorObj = JSObject()
errorObj.put("error", "Invalid response body")
callback(errorObj)
}
}
}
}
})
} catch (e: Exception) {
Log.e(tag, "retryOriginalRequest: Unexpected error during retry", e)
AbsLogger.error(tag, "retryOriginalRequest: Unexpected error during retry for server ${DeviceManager.serverConnectionConfigString}")
val errorObj = JSObject()
errorObj.put("error", "Failed to retry request")
callback(errorObj)
}
}
/**
* Handles the case when token refresh fails
* This will clear the current session and notify the callback
*
* @param callback The callback to return the error
*/
private fun handleRefreshFailure(callback: (JSObject) -> Unit) {
try {
Log.d(tag, "handleRefreshFailure: Token refresh failed, clearing session")
// Clear the current server connection
DeviceManager.serverConnectionConfig = null
DeviceManager.deviceData.lastServerConnectionConfigId = null
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
// Remove refresh token from secure storage
val serverConnectionConfigId = DeviceManager.serverConnectionConfigId
if (serverConnectionConfigId.isNotEmpty()) {
secureStorage.removeRefreshToken(serverConnectionConfigId)
}
val errorObj = JSObject()
errorObj.put("error", "Authentication failed - please login again")
callback(errorObj)
if (checkAbsDatabaseNotifyListenersInitted()) {
val tokenJsObject = JSObject()
tokenJsObject.put("error", "Token refresh failed")
if (serverConnectionConfigId.isNotEmpty()) {
tokenJsObject.put("serverConnectionConfigId", serverConnectionConfigId)
}
absDatabaseNotifyListeners("onTokenRefreshFailure", tokenJsObject)
} else {
// Can happen if Webview is never run
Log.i(tag, "AbsDatabaseNotifyListeners is not initialized so cannot send token refresh failure notification")
}
} catch (e: Exception) {
Log.e(tag, "handleRefreshFailure: Error during failure handling", e)
val errorObj = JSObject()
errorObj.put("error", "Authentication failed")
callback(errorObj)
}
}
fun getCurrentUser(cb: (User?) -> Unit) {
getRequest("/api/me", null, null) {
if (it.has("error")) {
@@ -334,8 +615,7 @@ class ApiHandler(var ctx:Context) {
val array = it.getJSONArray("libraryItems")
for (i in 0 until array.length()) {
val jsobj = array.get(i) as JSONObject
val itemInProgress = ItemInProgress.makeFromServerObject(jsobj)
val itemInProgress = ItemInProgress.makeFromServerObject(jsobj, jacksonMapper)
items.add(itemInProgress)
}
}
@@ -7,6 +7,7 @@
tools:context=".MainActivity">
<WebView
android:id="@+id/webview"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
@@ -10,7 +10,7 @@
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.NoActionBar">
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
<item name="android:background">@null</item>
<item name="android:background">@color/background_dark</item>
<item name="android:statusBarColor">@color/background_dark</item>
<item name="android:navigationBarColor">@color/background_dark</item>
</style>
+2 -2
View File
@@ -3,5 +3,5 @@
<color name="light_blue_200">#FF81D4FA</color>
<color name="light_blue_600">#FF039BE5</color>
<color name="light_blue_900">#FF01579B</color>
<color name="background_dark">#262626</color>
</resources>
<color name="background_dark">#232323</color>
</resources>
+1 -1
View File
@@ -12,7 +12,7 @@
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
<item name="android:background">@null</item>
<item name="android:background">@color/background_dark</item>
<item name="android:statusBarColor">@color/background_dark</item>
<item name="android:navigationBarColor">@color/background_dark</item>
</style>
@@ -11,4 +11,4 @@
android:targetCellWidth="4"
android:targetCellHeight="1"
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 -3
View File
@@ -1,5 +1,5 @@
@import "./tailwind.css";
@import "./fonts.css";
@import './tailwind.css';
@import './fonts.css';
@import './defaultStyles.css';
@import './absicons.css';
@import './transitions.css';
@@ -61,6 +61,10 @@ textarea {
box-shadow: 4px 1px 8px #11111166, -4px 1px 8px #11111166, 1px -4px 8px #11111166;
}
.box-shadow-progressbar {
box-shadow: 0px -1px 4px rgb(62, 50, 2, 0.5);
}
.shadow-height {
height: calc(100% - 4px);
}
@@ -164,4 +168,4 @@ Bookshelf Label
.tracksTable th {
padding: 4px 8px;
font-size: 0.75rem;
}
}
+19
View File
@@ -22,6 +22,25 @@
--gradient-minimized-audio-player: linear-gradient(145deg, rgba(38, 38, 38, 0.5) 0%, rgba(38, 38, 38, 0.9) 20%, rgb(38, 38, 38) 60%);
}
html[data-theme='black'] {
color: white;
--color-bg: 0 0 0;
--color-bg-hover: 0 0 0;
--color-fg: 230 237 243;
--color-fg-muted: 120 126 132;
--color-primary: 0 0 0;
--color-secondary: 0 0 0;
--color-border: 55 62 65;
--color-bg-toggle: 0 0 0;
--color-bg-toggle-selected: 35 35 35;
--color-track-cursor: 229 231 235;
--color-track: 107 114 128;
--color-track-buffered: 75 85 99;
--gradient-item-page: rgb(0, 0, 0);
--gradient-audio-player: rgb(0, 0, 0);
--gradient-minimized-audio-player: rgb(0, 0, 0);
}
html[data-theme='light'] {
color: black;
--color-bg: 255 255 255;
+4
View File
@@ -5,6 +5,10 @@
"plugins": {
"CapacitorHttp": {
"enabled": false
},
"StatusBar": {
"backgroundColor": "#232323",
"style": "DARK"
}
},
"server": {
+12 -12
View File
@@ -4,14 +4,14 @@
<nuxt-link v-show="!showBack" to="/" class="mr-3">
<img src="/Logo.png" class="h-10 w-10" />
</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>
</a>
<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" />
<p class="text-sm leading-4 ml-2 mt-0.5 max-w-24 truncate">{{ currentLibraryName }}</p>
</div>
</button>
</div>
<widgets-connection-indicator />
@@ -21,19 +21,19 @@
<widgets-download-progress-indicator />
<!-- Must be connected to a server to cast, only supports media items on server -->
<div v-show="isCastAvailable && user" class="mx-2 cursor-pointer 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">
{{ isCasting ? 'cast_connected' : 'cast' }}
</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>
</nuxt-link>
<div class="h-7 mx-1.5">
<span class="material-symbols" style="font-size: 1.75rem" @click="clickShowSideDrawer">menu</span>
</div>
<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">menu</span>
</button>
</div>
</div>
</template>
@@ -100,14 +100,14 @@ export default {
this.isCastAvailable = data && data.value
}
},
mounted() {
async mounted() {
AbsAudioPlayer.getIsCastAvailable().then((data) => {
this.isCastAvailable = data && data.value
})
this.onCastAvailableUpdateListener = AbsAudioPlayer.addListener('onCastAvailableUpdate', this.onCastAvailableUpdate)
this.onCastAvailableUpdateListener = await AbsAudioPlayer.addListener('onCastAvailableUpdate', this.onCastAvailableUpdate)
},
beforeDestroy() {
if (this.onCastAvailableUpdateListener) this.onCastAvailableUpdateListener.remove()
this.onCastAvailableUpdateListener?.remove()
}
}
</script>
+24 -29
View File
@@ -4,15 +4,15 @@
<div class="w-full h-full absolute top-0 left-0 pointer-events-none" style="background: var(--gradient-audio-player)" />
<div class="top-4 left-4 absolute cursor-pointer">
<span class="material-symbols text-5xl" :class="{ 'text-black text-opacity-75': coverBgIsLight }" @click="collapseFullscreen">keyboard_arrow_down</span>
<span class="material-symbols text-5xl" :class="{ 'text-black text-opacity-75': coverBgIsLight && theme !== 'black' }" @click="collapseFullscreen">keyboard_arrow_down</span>
</div>
<div v-show="showCastBtn" class="top-6 right-16 absolute cursor-pointer">
<span class="material-symbols text-3xl" :class="coverBgIsLight ? 'text-black' : ''" @click="castClick">{{ isCasting ? 'cast_connected' : 'cast' }}</span>
<span class="material-symbols text-3xl" :class="coverBgIsLight && theme !== 'black' ? 'text-black' : ''" @click="castClick">{{ isCasting ? 'cast_connected' : 'cast' }}</span>
</div>
<div class="top-6 right-4 absolute cursor-pointer">
<span class="material-symbols text-3xl" :class="{ 'text-black text-opacity-75': coverBgIsLight }" @click="showMoreMenuDialog = true">more_vert</span>
<span class="material-symbols text-3xl" :class="{ 'text-black text-opacity-75': coverBgIsLight && theme !== 'black' }" @click="showMoreMenuDialog = true">more_vert</span>
</div>
<p class="top-4 absolute left-0 right-0 mx-auto text-center uppercase tracking-widest text-opacity-75" :class="{ 'text-black text-opacity-75': coverBgIsLight }" style="font-size: 10px">{{ isDirectPlayMethod ? $strings.LabelPlaybackDirect : isLocalPlayMethod ? $strings.LabelPlaybackLocal : $strings.LabelPlaybackTranscode }}</p>
<p class="top-4 absolute left-0 right-0 mx-auto text-center uppercase tracking-widest text-opacity-75" :class="{ 'text-black text-opacity-75': coverBgIsLight && theme !== 'black' }" style="font-size: 10px">{{ isDirectPlayMethod ? $strings.LabelPlaybackDirect : isLocalPlayMethod ? $strings.LabelPlaybackLocal : $strings.LabelPlaybackTranscode }}</p>
</div>
<div v-if="playerSettings.useChapterTrack && playerSettings.useTotalTrack && showFullscreen" class="absolute total-track w-full z-30 px-6">
@@ -140,13 +140,6 @@ export default {
readyTrackWidth: 0,
seekedTime: 0,
seekLoading: false,
onPlaybackSessionListener: null,
onPlaybackClosedListener: null,
onPlayingUpdateListener: null,
onMetadataListener: null,
onProgressSyncFailing: null,
onProgressSyncSuccess: null,
onPlaybackSpeedChangedListener: null,
touchStartY: 0,
touchStartTime: 0,
playerSettings: {
@@ -182,6 +175,9 @@ export default {
}
},
computed: {
theme() {
return document.documentElement.dataset.theme || 'dark'
},
menuItems() {
const items = []
// TODO: Implement on iOS
@@ -585,8 +581,12 @@ export default {
}
const ptWidth = Math.round(percentDone * this.trackWidth)
this.$refs.playedTrack.style.width = ptWidth + 'px'
this.$refs.bufferedTrack.style.width = Math.round(bufferedPercent * this.trackWidth) + 'px'
if (this.$refs.playedTrack) {
this.$refs.playedTrack.style.width = ptWidth + 'px'
}
if (this.$refs.bufferedTrack) {
this.$refs.bufferedTrack.style.width = Math.round(bufferedPercent * this.trackWidth) + 'px'
}
if (this.$refs.trackCursor) {
this.$refs.trackCursor.style.left = ptWidth - 14 + 'px'
@@ -880,14 +880,14 @@ export default {
async init() {
await this.loadPlayerSettings()
this.onPlaybackSessionListener = AbsAudioPlayer.addListener('onPlaybackSession', this.onPlaybackSession)
this.onPlaybackClosedListener = AbsAudioPlayer.addListener('onPlaybackClosed', this.onPlaybackClosed)
this.onPlaybackFailedListener = AbsAudioPlayer.addListener('onPlaybackFailed', this.onPlaybackFailed)
this.onPlayingUpdateListener = AbsAudioPlayer.addListener('onPlayingUpdate', this.onPlayingUpdate)
this.onMetadataListener = AbsAudioPlayer.addListener('onMetadata', this.onMetadata)
this.onProgressSyncFailing = AbsAudioPlayer.addListener('onProgressSyncFailing', this.showProgressSyncIsFailing)
this.onProgressSyncSuccess = AbsAudioPlayer.addListener('onProgressSyncSuccess', this.showProgressSyncSuccess)
this.onPlaybackSpeedChangedListener = AbsAudioPlayer.addListener('onPlaybackSpeedChanged', this.onPlaybackSpeedChanged)
AbsAudioPlayer.addListener('onPlaybackSession', this.onPlaybackSession)
AbsAudioPlayer.addListener('onPlaybackClosed', this.onPlaybackClosed)
AbsAudioPlayer.addListener('onPlaybackFailed', this.onPlaybackFailed)
AbsAudioPlayer.addListener('onPlayingUpdate', this.onPlayingUpdate)
AbsAudioPlayer.addListener('onMetadata', this.onMetadata)
AbsAudioPlayer.addListener('onProgressSyncFailing', this.showProgressSyncIsFailing)
AbsAudioPlayer.addListener('onProgressSyncSuccess', this.showProgressSyncSuccess)
AbsAudioPlayer.addListener('onPlaybackSpeedChanged', this.onPlaybackSpeedChanged)
},
async screenOrientationChange() {
if (this.isRefreshingUI) return
@@ -981,14 +981,9 @@ export default {
document.body.removeEventListener('touchend', this.touchend)
document.body.removeEventListener('touchmove', this.touchmove)
if (this.onPlayingUpdateListener) this.onPlayingUpdateListener.remove()
if (this.onMetadataListener) this.onMetadataListener.remove()
if (this.onPlaybackSessionListener) this.onPlaybackSessionListener.remove()
if (this.onPlaybackClosedListener) this.onPlaybackClosedListener.remove()
if (this.onPlaybackFailedListener) this.onPlaybackFailedListener.remove()
if (this.onProgressSyncFailing) this.onProgressSyncFailing.remove()
if (this.onProgressSyncSuccess) this.onProgressSyncSuccess.remove()
if (this.onPlaybackSpeedChangedListener) this.onPlaybackSpeedChangedListener.remove()
if (AbsAudioPlayer.removeAllListeners) {
AbsAudioPlayer.removeAllListeners()
}
clearInterval(this.playInterval)
}
}
+9 -9
View File
@@ -351,11 +351,11 @@ export default {
}
}
},
mounted() {
this.onLocalMediaProgressUpdateListener = AbsAudioPlayer.addListener('onLocalMediaProgressUpdate', this.onLocalMediaProgressUpdate)
this.onSleepTimerEndedListener = AbsAudioPlayer.addListener('onSleepTimerEnded', this.onSleepTimerEnded)
this.onSleepTimerSetListener = AbsAudioPlayer.addListener('onSleepTimerSet', this.onSleepTimerSet)
this.onMediaPlayerChangedListener = AbsAudioPlayer.addListener('onMediaPlayerChanged', this.onMediaPlayerChanged)
async mounted() {
this.onLocalMediaProgressUpdateListener = await AbsAudioPlayer.addListener('onLocalMediaProgressUpdate', this.onLocalMediaProgressUpdate)
this.onSleepTimerEndedListener = await AbsAudioPlayer.addListener('onSleepTimerEnded', this.onSleepTimerEnded)
this.onSleepTimerSetListener = await AbsAudioPlayer.addListener('onSleepTimerSet', this.onSleepTimerSet)
this.onMediaPlayerChangedListener = await AbsAudioPlayer.addListener('onMediaPlayerChanged', this.onMediaPlayerChanged)
this.playbackSpeed = this.$store.getters['user/getUserSetting']('playbackRate')
console.log(`[AudioPlayerContainer] Init Playback Speed: ${this.playbackSpeed}`)
@@ -370,10 +370,10 @@ export default {
this.$eventBus.$on('device-focus-update', this.deviceFocused)
},
beforeDestroy() {
if (this.onLocalMediaProgressUpdateListener) this.onLocalMediaProgressUpdateListener.remove()
if (this.onSleepTimerEndedListener) this.onSleepTimerEndedListener.remove()
if (this.onSleepTimerSetListener) this.onSleepTimerSetListener.remove()
if (this.onMediaPlayerChangedListener) this.onMediaPlayerChangedListener.remove()
this.onLocalMediaProgressUpdateListener?.remove()
this.onSleepTimerEndedListener?.remove()
this.onSleepTimerSetListener?.remove()
this.onMediaPlayerChangedListener?.remove()
this.$eventBus.$off('abs-ui-ready', this.onReady)
this.$eventBus.$off('play-item', this.playLibraryItem)
+18 -27
View File
@@ -11,11 +11,11 @@
<div class="w-full overflow-y-auto">
<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>
<p class="pl-4">{{ item.text }}</p>
</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>
<p class="pl-4">{{ item.text }}</p>
</nuxt-link>
@@ -134,14 +134,12 @@ export default {
to: '/settings'
})
if (this.$platform !== 'ios') {
items.push({
icon: 'bug_report',
iconOutlined: true,
text: this.$strings.ButtonLogs,
to: '/logs'
})
}
items.push({
icon: 'bug_report',
iconOutlined: true,
text: this.$strings.ButtonLogs,
to: '/logs'
})
if (this.serverConnectionConfig) {
items.push({
@@ -179,31 +177,24 @@ export default {
this.show = false
},
async logout() {
if (this.user) {
if (this.$store.getters['getIsPlayerOpen']) {
this.$eventBus.$emit('close-stream')
}
await this.$nativeHttp.post('/logout').catch((error) => {
console.error('Failed to logout', error)
})
}
this.$socket.logout()
await this.$db.logout()
this.$localStore.removeLastLibraryId()
this.$store.commit('user/logout')
this.$store.commit('libraries/setCurrentLibrary', null)
await this.$store.dispatch('user/logout')
},
async disconnect() {
await this.$hapticsImpact()
await this.logout()
// Redirect to home page
if (this.$route.name !== '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) {
this.touchEvent = new TouchEvent(e)
+2 -2
View File
@@ -1,5 +1,5 @@
<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 -->
<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" />
@@ -59,7 +59,7 @@
</div>
<!-- No progress shown for collapsed series in library -->
<div v-if="!collapsedSeries && (!isPodcast || recentEpisode)" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div>
<div v-if="!collapsedSeries && (!isPodcast || recentEpisode)" class="absolute bottom-0 left-0 h-1 max-w-full z-10 rounded-b box-shadow-progressbar" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div>
<!-- Downloaded icon -->
<div v-if="showHasLocalDownload" class="absolute right-0 top-0 z-20" :style="{ top: (isPodcast || (seriesSequence && showSequence) ? 1.75 : 0.375) * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
+60 -6
View File
@@ -14,7 +14,7 @@
<!-- No progress shown for collapsed series or podcasts in library -->
<div v-if="!isPodcast && !collapsedSeries" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: coverWidth * userProgressPercent + 'px' }"></div>
</div>
<div class="flex-grow px-2">
<div class="flex-grow pl-2" :class="showPlayButton ? 'pr-12' : 'pr-2'">
<p class="whitespace-normal line-clamp-2" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">
<span v-if="seriesSequence">#{{ seriesSequence }}&nbsp;</span>{{ displayTitle }}
</p>
@@ -29,6 +29,16 @@
{{ $getString('LabelNumEpisodes', [numEpisodes]) }}
</p>
</div>
<div v-if="showPlayButton" class="absolute top-0 bottom-0 right-0 h-full flex items-center justify-center z-20 pr-1">
<button type="button" class="relative rounded-full bg-fg-muted/50" :class="{ 'p-2': !playerIsStartingForThisMedia }" @click.stop.prevent="play">
<span v-if="!playerIsStartingForThisMedia" class="material-symbols text-2xl fill text-white">{{ playerIsPlaying ? 'pause' : 'play_arrow' }}</span>
<div v-else class="p-2 text-fg w-10 h-10 flex items-center justify-center bg-fg-muted/80 rounded-full overflow-hidden">
<svg class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24">
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
</svg>
</div>
</button>
</div>
<div v-if="localLibraryItem || isLocal" class="absolute top-0 right-0 z-20" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
<span class="material-symbols text-2xl text-success">download_done</span>
@@ -129,6 +139,9 @@ export default {
libraryItemId() {
return this._libraryItem.id
},
localLibraryItemId() {
return this.localLibraryItem?.id
},
series() {
return this.mediaMetadata.series
},
@@ -217,13 +230,31 @@ export default {
return this.isMissing || this.isInvalid
},
isStreaming() {
return this.store.getters['getlibraryItemIdStreaming'] === this.libraryItemId
return this.isPlaying && !this.store.getters['getIsCurrentSessionLocal']
},
isPlaying() {
if (this.localLibraryItemId && this.store.getters['getIsMediaStreaming'](this.localLibraryItemId)) return true
return this.store.getters['getIsMediaStreaming'](this.libraryItemId)
},
playerIsPlaying() {
return this.store.state.playerIsPlaying && (this.isStreaming || this.isPlaying)
},
playerIsStartingPlayback() {
// Play has been pressed and waiting for native play response
return this.store.state.playerIsStartingPlayback
},
playerIsStartingForThisMedia() {
const mediaId = this.store.state.playerStartingPlaybackMediaId
return mediaId === this.libraryItemId
},
isCasting() {
return this.store.state.isCasting
},
showReadButton() {
return !this.isSelectionMode && !this.showPlayButton && this.hasEbook
},
showPlayButton() {
return !this.isSelectionMode && !this.isMissing && !this.isInvalid && this.numTracks && !this.isStreaming
return !this.isSelectionMode && !this.isMissing && !this.isInvalid && this.numTracks && !this.isPodcast
},
showSmallEBookIcon() {
return !this.isSelectionMode && this.hasEbook
@@ -293,9 +324,32 @@ export default {
this.selected = !this.selected
this.$emit('select', this.libraryItem)
},
play() {
var eventBus = this.$eventBus || this.$nuxt.$eventBus
eventBus.$emit('play-item', { libraryItemId: this.libraryItemId })
async play() {
if (this.playerIsStartingPlayback) return
const hapticsImpact = this.$hapticsImpact || this.$nuxt.$hapticsImpact
if (hapticsImpact) {
await hapticsImpact()
}
const eventBus = this.$eventBus || this.$nuxt.$eventBus
if (this.playerIsPlaying) {
eventBus.$emit('pause-item')
} else {
// Audiobook
let libraryItemId = this.libraryItemId
// When casting use server library item
if (this.localLibraryItem && !this.isCasting) {
libraryItemId = this.localLibraryItem.id
} else if (this.hasLocal) {
libraryItemId = this.localLibraryItem.id
}
this.store.commit('setPlayerIsStartingPlayback', this.libraryItemId)
eventBus.$emit('play-item', { libraryItemId, serverLibraryItemId: this.libraryItemId })
}
},
destroy() {
// destroy the vue listeners, etc
+1 -1
View File
@@ -5,7 +5,7 @@
<covers-group-cover v-if="series" ref="cover" :id="seriesId" :name="title" :book-items="books" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</div>
<div v-if="seriesPercentInProgress > 0" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b w-full" :class="isSeriesFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: seriesPercentInProgress * 100 + '%' }" />
<div v-if="seriesPercentInProgress > 0" class="absolute bottom-0 left-0 h-1 max-w-full z-10 rounded-b w-full box-shadow-progressbar" :class="isSeriesFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: seriesPercentInProgress * 100 + '%' }" />
<div v-if="isAltViewEnabled && isCategorized" class="absolute z-30 left-0 right-0 mx-auto -bottom-8 h-8 py-1 rounded-md text-center">
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
+143 -63
View File
@@ -4,19 +4,27 @@
<!-- list of server connection configs -->
<template v-if="!showForm">
<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)">
<span class="material-symbols text-xl text-fg-muted">dns</span>
<p class="pl-3 pr-6 text-base text-fg">{{ config.name }}</p>
<div class="absolute top-0 right-0 h-full px-4 flex items-center" @click.stop="editServerConfig(config)">
<div class="flex items-center my-1 relative space-x-2" @click="connectToServer(config)">
<div class="grow inline-flex items-center overflow-hidden">
<p class="text-base text-fg truncate">{{ config.name }}</p>
</div>
<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>
</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>
<!-- 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">
<p class="text-xs text-warning">{{ $strings.MessageOldServerConnectionWarning }}</p>
<ui-btn class="text-xs whitespace-nowrap" :padding-x="2" :padding-y="1" @click="showOldUserIdWarningDialog">{{ $strings.LabelMoreInfo }}</ui-btn>
</div>
<!-- warning message if server connection config is using an old auth method -->
<div v-if="config.version && checkIsUsingOldAuth(config)" class="flex flex-nowrap justify-between items-center space-x-4 pt-4">
<p class="text-xs text-warning">{{ $strings.MessageOldServerAuthWarning }}</p>
<ui-btn class="text-xs whitespace-nowrap" :padding-x="2" :padding-y="1" @click="showOldAuthWarningDialog">{{ $strings.LabelMoreInfo }}</ui-btn>
</div>
</div>
<div class="my-1 py-4 w-full">
<ui-btn class="w-full" @click="newServerConfigClick">{{ $strings.ButtonAddNewServer }}</ui-btn>
@@ -25,7 +33,7 @@
<!-- form to add a new server connection config -->
<div v-else class="w-full">
<!-- 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">
<span class="material-symbols text-fg-muted">arrow_back</span>
</div>
@@ -52,7 +60,7 @@
<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">
<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" />
<ui-btn :disabled="processing || !networkConnected" type="submit" class="mt-1 h-10">{{ networkConnected ? $strings.ButtonSubmit : $strings.MessageNoNetworkConnection }}</ui-btn>
</div>
@@ -71,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>
<div class="absolute top-0 left-0 w-full p-6 flex items-center flex-col justify-center z-0 short:hidden">
<img src="/Logo.png" class="h-20 w-20 mb-2" />
</div>
<svg class="animate-spin w-16 h-16" viewBox="0 0 24 24">
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
</svg>
@@ -155,9 +160,23 @@ export default {
cancelText: this.$strings.ButtonOk
})
},
async showOldAuthWarningDialog() {
const confirmResult = await Dialog.confirm({
title: 'Old Server Auth Warning',
message: this.$strings.MessageOldServerAuthWarningHelp,
cancelButtonTitle: this.$strings.ButtonReadMore
})
if (!confirmResult.value) {
window.open('https://github.com/advplyr/audiobookshelf/discussions/4460', '_blank')
}
},
checkIdUuid(userId) {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(userId)
},
checkIsUsingOldAuth(config) {
if (!config.version) return true
return !this.$isValidVersion(config.version, '2.26.0')
},
/**
* Initiates the login process using OpenID via OAuth2.0.
* 1. Verifying the server's address
@@ -374,26 +393,36 @@ export default {
} catch (error) {} // No Error handling needed
try {
// Returns the same user response payload as /login
const response = await CapacitorHttp.get({
url: backendEndpoint
})
if (!response.data || !response.data.user || !response.data.user.token) {
throw new Error('Token data is missing in the response.')
// v2.26.0+ returns accessToken and refreshToken on user object
if (!response.data?.user?.token && !response.data?.user?.accessToken) {
throw new Error('Token is missing in response.')
}
this.serverConfig.token = response.data.user.token
const user = response.data.user
this.serverConfig.token = user.accessToken || user.token
// TODO: Is it necessary to authenticate again?
const payload = await this.authenticateToken()
if (!payload) {
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) {
throw new Error('Config already exists for this address and username.')
}
// For v2.26.0+ re-attach accessToken and refreshToken to user object because /authorize does not return them
if (user.accessToken) {
payload.user.accessToken = user.accessToken
payload.user.refreshToken = user.refreshToken
}
this.setUserAndConnection(payload)
} catch (error) {
console.error('[SSO] Error in exchangeCodeForToken: ', error)
@@ -436,16 +465,21 @@ export default {
}
this.error = null
var payload = await this.authenticateToken()
const payload = await this.authenticateToken()
if (payload) {
// Will NOT include access token and refresh token
this.setUserAndConnection(payload)
} else {
this.showAuth = true
let error = this.error
if (await this.submit(true)) {
this.showForm = true
this.error = error
}
}
},
async removeServerConfigClick() {
if (!this.serverConfig.id) return
async removeServerConfigClick(serverConfig) {
if (!serverConfig.id) return
await this.$hapticsImpact()
const { value } = await Dialog.confirm({
@@ -454,9 +488,9 @@ export default {
})
if (value) {
this.processing = true
await this.$db.removeServerConnectionConfig(this.serverConfig.id)
await this.$db.removeServerConnectionConfig(serverConfig.id)
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.serverConfig = {
@@ -468,14 +502,17 @@ export default {
this.processing = false
this.showAuth = false
this.showForm = !this.serverConnectionConfigs.length
this.error = null
}
},
editServerConfig(serverConfig) {
async editServerConfig(serverConfig) {
this.serverConfig = {
...serverConfig
}
this.showForm = true
this.showAuth = true
if (await this.submit(true)) {
this.showForm = true
}
},
async newServerConfigClick() {
await this.$hapticsImpact()
@@ -504,7 +541,7 @@ export default {
try {
var urlObject = new URL(url)
if (protocolOverride) urlObject.protocol = protocolOverride
return urlObject.href
return urlObject.href.replace(/\/$/, '') // Remove trailing slash
} catch (error) {
console.error('Invalid URL', error)
return null
@@ -597,7 +634,12 @@ export default {
})
},
requestServerLogin() {
return this.postRequest(`${this.serverConfig.address}/login`, { username: this.serverConfig.username, password: this.password || '' }, this.serverConfig.customHeaders, 20000)
const headers = {
// Tells the Abs server to return the refresh token
'x-return-tokens': 'true',
...(this.serverConfig.customHeaders || {})
}
return this.postRequest(`${this.serverConfig.address}/login`, { username: this.serverConfig.username, password: this.password || '' }, headers, 20000)
.then((data) => {
if (!data.user) {
console.error(data.error)
@@ -616,9 +658,8 @@ export default {
return false
})
},
async submit() {
if (!this.networkConnected) return
if (!this.serverConfig.address) return
async submit(preventAutoLogin = false) {
if (!this.networkConnected || !this.serverConfig.address) return false
const initialAddress = this.serverConfig.address
// Did the user specify a protocol?
@@ -631,6 +672,7 @@ export default {
this.authMethods = []
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
const statusData = await this.tryServerUrl(this.serverConfig.address, !protocolProvided)
if (this.validateLoginFormResponse(statusData, this.serverConfig.address, protocolProvided)) {
@@ -639,12 +681,17 @@ export default {
this.oauth.buttonText = statusData.data.authFormData?.authOpenIDButtonText || 'Login with OpenID'
this.serverConfig.version = statusData.data.serverVersion
if (statusData.data.authFormData?.authOpenIDAutoLaunch) {
if (statusData.data.authFormData?.authOpenIDAutoLaunch && !preventAutoLogin) {
this.clickLoginWithOpenId()
}
return true
} else {
console.log('[ServerConnectForm] submit validateLoginFormResponse failed: ' + this.serverConfig.address)
return false
}
} catch (error) {
this.handleLoginFormError(error)
return false
} finally {
this.processing = false
}
@@ -770,26 +817,6 @@ export default {
prependProtocolIfNeeded(address) {
return address.startsWith('http://') || address.startsWith('https://') ? address : `https://${address}`
},
/**
* Compares two semantic versioning strings to determine if the current version meets
* or exceeds the minimum version requirement.
*
* @param {string} currentVersion - The current version string to compare, e.g., "1.2.3".
* @param {string} minVersion - The minimum version string required, e.g., "1.0.0".
* @returns {boolean} - Returns true if the current version is greater than or equal
* to the minimum version, false otherwise.
*/
isValidVersion(currentVersion, minVersion) {
const currentParts = currentVersion.split('.').map(Number)
const minParts = minVersion.split('.').map(Number)
for (let i = 0; i < minParts.length; i++) {
if (currentParts[i] > minParts[i]) return true
if (currentParts[i] < minParts[i]) return false
}
return true
},
async submitAuth() {
if (!this.networkConnected) return
if (!this.serverConfig.username) {
@@ -806,9 +833,10 @@ export default {
this.error = null
this.processing = true
var payload = await this.requestServerLogin()
const payload = await this.requestServerLogin()
this.processing = false
if (payload) {
// Will include access token and refresh token
this.setUserAndConnection(payload)
}
},
@@ -821,18 +849,30 @@ export default {
this.$store.commit('libraries/setEReaderDevices', ereaderDevices)
this.$setServerLanguageCode(serverSettings.language)
// Set library - Use last library if set and available fallback to default user library
var lastLibraryId = await this.$localStore.getLastLibraryId()
if (lastLibraryId && (!user.librariesAccessible.length || user.librariesAccessible.includes(lastLibraryId))) {
this.$store.commit('libraries/setCurrentLibrary', lastLibraryId)
} else if (userDefaultLibraryId) {
this.$store.commit('libraries/setCurrentLibrary', userDefaultLibraryId)
this.serverConfig.userId = user.id
this.serverConfig.username = user.username
if (this.$isValidVersion(serverSettings.version, '2.26.0')) {
// Tokens only returned from /login endpoint
if (user.accessToken) {
this.serverConfig.token = user.accessToken
this.serverConfig.refreshToken = user.refreshToken
} else {
// Detect if the connection config is using the old token. If so, force re-login
if (this.serverConfig.token === user.token || user.isOldToken) {
this.setForceRelogin('oldAuthToken')
return
}
// If the token was updated during a refresh (in nativeHttp.js) it gets updated in the store, so refetch
this.serverConfig.token = this.$store.getters['user/getToken'] || this.serverConfig.token
}
} else {
// Server version before new JWT auth, use old user.token
this.serverConfig.token = user.token
}
this.serverConfig.userId = user.id
this.serverConfig.token = user.token
this.serverConfig.username = user.username
delete this.serverConfig.version
this.serverConfig.version = serverSettings.version
var serverConnectionConfig = await this.$db.setServerConnectionConfig(this.serverConfig)
@@ -849,7 +889,16 @@ export default {
}
}
// Set library - Use last library if set and available fallback to default user library
const lastLibraryId = await this.$localStore.getLastLibraryId()
if (lastLibraryId && (!user.librariesAccessible.length || user.librariesAccessible.includes(lastLibraryId))) {
this.$store.commit('libraries/setCurrentLibrary', lastLibraryId)
} else if (userDefaultLibraryId) {
this.$store.commit('libraries/setCurrentLibrary', userDefaultLibraryId)
}
this.$store.commit('user/setUser', user)
this.$store.commit('user/setAccessToken', serverConnectionConfig.token)
this.$store.commit('user/setServerConnectionConfig', serverConnectionConfig)
this.$socket.connect(this.serverConfig.address, this.serverConfig.token)
@@ -865,7 +914,13 @@ export default {
this.error = null
this.processing = true
const authRes = await this.postRequest(`${this.serverConfig.address}/api/authorize`, null, { Authorization: `Bearer ${this.serverConfig.token}` }).catch((error) => {
const nativeHttpOptions = {
headers: {
Authorization: `Bearer ${this.serverConfig.token}`
},
serverConnectionConfig: this.serverConfig
}
const authRes = await this.$nativeHttp.post(`${this.serverConfig.address}/api/authorize`, null, nativeHttpOptions).catch((error) => {
console.error('[ServerConnectForm] Server auth failed', error)
const errorMsg = error.message || error
this.error = 'Failed to authorize'
@@ -874,14 +929,39 @@ export default {
}
return false
})
console.log('[ServerConnectForm] authRes=', authRes)
this.processing = false
return authRes
},
async setForceRelogin(error) {
console.log('[ServerConnectForm] setForceRelogin', error, this.serverConfig.address)
// This calls /status on the server and sets the auth methods
const result = await this.submit(true)
if (result) {
this.showForm = true
if (error === 'oldAuthToken') {
this.error = this.$strings.MessageOldServerAuthReLoginRequired
} else if (error === 'refreshTokenFailed') {
this.error = this.$strings.MessageFailedToRefreshToken
}
}
},
init() {
if (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)
if (this.serverConfig) {
this.setForceRelogin(this.$route.query.error)
return
} else {
console.error('[ServerConnectForm] init with serverConnectionConfigId but no serverConfig found', this.$route.query.serverConnectionConfigId)
}
}
if (this.lastServerConnectionConfig) {
console.log('[ServerConnectForm] init with lastServerConnectionConfig', this.lastServerConnectionConfig)
this.connectToServer(this.lastServerConnectionConfig)
} else {
this.showForm = !this.serverConnectionConfigs.length
+6 -12
View File
@@ -17,7 +17,7 @@
<p class="text-xl font-mono">{{ this.$secondsToTimestamp(currentTime / _playbackRate) }}</p>
</div>
<ui-text-input-with-label v-model="newBookmarkTitle" ref="noteInput" label="Note" />
<ui-text-input-with-label v-model="newBookmarkTitle" :placeholder="bookmarkPlaceholder()" :autofocus="false" ref="noteInput" label="Note" />
<div class="flex justify-end mt-6">
<ui-btn color="success" class="w-full" @click.stop="submitBookmark">{{ selectedBookmark ? 'Update' : 'Create' }}</ui-btn>
</div>
@@ -93,6 +93,10 @@ export default {
}
},
methods: {
bookmarkPlaceholder() {
// using a method prevents caching the date
return this.$formatDate(Date.now(), 'MMM dd, yyyy HH:mm')
},
editBookmark(bm) {
this.selectedBookmark = bm
this.newBookmarkTitle = bm.title
@@ -157,18 +161,8 @@ export default {
},
createBookmark() {
this.selectedBookmark = null
this.newBookmarkTitle = this.$formatDate(Date.now(), 'MMM dd, yyyy HH:mm')
this.newBookmarkTitle = ''
this.showBookmarkTitleInput = true
// Auto focus the input and select the text
this.$nextTick(() => {
if (this.$refs.noteInput?.$refs.input?.$refs.input) {
this.$refs.noteInput.$refs.input.$refs.input.focus()
setTimeout(() => {
this.$refs.noteInput?.$refs.input?.$refs.input?.select()
}, 10)
}
})
},
async submitBookmark() {
await this.$hapticsImpact()
+44 -13
View File
@@ -1,5 +1,5 @@
<template>
<modals-modal v-model="show" :width="400" height="100%">
<modals-modal v-model="show" :width="400" max-width="95%" height="100%">
<template #outer>
<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>
@@ -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 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">
<template v-for="chapter in chapters">
<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="relative flex items-center pl-3 pr-20">
<p class="font-normal block truncate text-sm text-fg/80">{{ chapter.title }}</p>
<div class="absolute top-0 right-3 -mt-0.5">
<span class="font-mono text-fg-muted leading-3 text-sm" style="letter-spacing: -0.5px">{{ $secondsToTimestamp(chapter.start / _playbackRate) }}</span>
</div>
<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)">
<div class="grid grid-cols-[1fr_auto_auto] gap-2 px-3 py-3 items-start">
<p class="font-normal line-clamp-2 text-sm text-fg/80">{{ chapter.title }}</p>
<div class="text-right" style="min-width: 60px">
<span class="font-mono text-fg-muted 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 v-show="chapter.id === currentChapterId" class="w-0.5 h-full absolute top-0 left-0 bg-yellow-400" />
</li>
</template>
<div v-show="chapter.id === currentChapterId" class="w-0.5 h-full absolute top-0 left-0 bg-yellow-400" />
</li>
</ul>
</div>
</div>
@@ -42,7 +54,9 @@ export default {
playbackRate: Number
},
data() {
return {}
return {
isScrolled: false
}
},
watch: {
value(newVal) {
@@ -87,8 +101,25 @@ export default {
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>
+66 -29
View File
@@ -54,8 +54,39 @@ export default {
},
data() {
return {
sublist: null,
bookItems: [
sublist: null
}
},
watch: {
show(newVal) {
if (!newVal) {
if (this.sublist && !this.selectedItemSublist) this.sublist = null
if (!this.sublist && this.selectedItemSublist) this.sublist = this.selectedItemSublist
}
}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
selected: {
get() {
return this.filterBy
},
set(val) {
this.$emit('update:filterBy', val)
}
},
userCanAccessExplicitContent() {
return this.$store.getters['user/getUserCanAccessExplicitContent']
},
bookItems() {
const items = [
{
text: this.$strings.LabelAll,
value: 'all'
@@ -104,9 +135,26 @@ export default {
text: this.$strings.ButtonIssues,
value: 'issues',
sublist: false
},
{
text: this.$strings.LabelRSSFeedOpen,
value: 'feed-open',
sublist: false
}
],
podcastItems: [
]
if (this.userCanAccessExplicitContent) {
items.push({
text: this.$strings.LabelExplicit,
value: 'explicit',
sublist: false
})
}
return items
},
podcastItems() {
const items = [
{
text: this.$strings.LabelAll,
value: 'all'
@@ -120,34 +168,23 @@ export default {
text: this.$strings.LabelTag,
value: 'tags',
sublist: true
},
{
text: this.$strings.LabelRSSFeedOpen,
value: 'feed-open',
sublist: false
}
]
}
},
watch: {
show(newVal) {
if (!newVal) {
if (this.sublist && !this.selectedItemSublist) this.sublist = null
if (!this.sublist && this.selectedItemSublist) this.sublist = this.selectedItemSublist
}
}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
selected: {
get() {
return this.filterBy
},
set(val) {
this.$emit('update:filterBy', val)
if (this.userCanAccessExplicitContent) {
items.push({
text: this.$strings.LabelExplicit,
value: 'explicit',
sublist: false
})
}
return items
},
isPodcast() {
return this.$store.getters['libraries/getCurrentLibraryMediaType'] === 'podcast'
+2 -2
View File
@@ -1,5 +1,5 @@
<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">
<slot />
</div>
@@ -74,4 +74,4 @@ export default {
this.$eventBus.$off('close-modal', this.closeModalEvt)
}
}
</script>
</script>
+6 -2
View File
@@ -12,9 +12,9 @@
<div class="w-full relative">
<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 v-if="currentFeed.meta" class="mt-5">
@@ -119,6 +119,10 @@ export default {
demoFeedUrl() {
return `${this.serverAddress}/feed/${this.newFeedSlug}`
},
fullFeedUrl() {
if (!this.currentFeed) return ''
return `${this.serverAddress}${this.currentFeed.feedUrl}`
},
isHttp() {
return !!this.serverAddress?.startsWith('http://')
}
+2 -7
View File
@@ -77,9 +77,6 @@ export default {
}
},
computed: {
userToken() {
return this.$store.getters['user/getToken']
},
libraryItemId() {
return this.libraryItem?.id
},
@@ -245,11 +242,9 @@ export default {
async extract() {
this.loading = true
// TODO: Handle JWT auth refresh
const buff = await this.$axios.$get(this.url, {
responseType: 'blob',
headers: {
Authorization: `Bearer ${this.userToken}`
}
responseType: 'blob'
})
const archive = await Archive.open(buff)
+16 -6
View File
@@ -37,6 +37,7 @@ export default {
isRefreshingUI: false,
ereaderSettings: {
theme: 'dark',
font: 'serif',
fontScale: 100,
lineSpacing: 115,
textStroke: 0
@@ -49,9 +50,6 @@ export default {
}
},
computed: {
userToken() {
return this.$store.getters['user/getToken']
},
/** @returns {string} */
libraryItemId() {
return this.libraryItem?.id
@@ -137,6 +135,7 @@ export default {
const fontScale = settings.fontScale || 100
this.rendition.themes.fontSize(`${fontScale}%`)
this.rendition.themes.font(settings.font)
this.rendition.spread(settings.spread || 'auto')
},
goToChapter(href) {
@@ -298,15 +297,26 @@ export default {
/** @type {EpubReader} */
const reader = this
// Use axios to make request because we have token refresh logic in interceptor
const customRequest = async (url) => {
try {
return this.$axios.$get(url, {
responseType: 'arraybuffer'
})
} catch (error) {
console.error('EpubReader.initEpub customRequest failed:', error)
throw error
}
}
console.log('[EpubReader] initEpub', reader.url)
/** @type {ePub.Book} */
reader.book = new ePub(reader.url, {
width: window.innerWidth,
height: window.innerHeight - this.readerHeightOffset,
openAs: 'epub',
requestHeaders: {
Authorization: `Bearer ${this.userToken}`
}
requestMethod: this.isLocal ? null : customRequest
})
/** @type {ePub.Rendition} */
+4 -10
View File
@@ -22,11 +22,7 @@ export default {
data() {
return {}
},
computed: {
userToken() {
return this.$store.getters['user/getToken']
}
},
computed: {},
methods: {
addHtmlCss() {
let iframe = document.getElementsByTagName('iframe')[0]
@@ -84,11 +80,9 @@ export default {
},
async initMobi() {
// Fetch mobi file as blob
// TODO: Handle JWT auth refresh
var buff = await this.$axios.$get(this.url, {
responseType: 'blob',
headers: {
Authorization: `Bearer ${this.userToken}`
}
responseType: 'blob'
})
var reader = new FileReader()
reader.onload = async (event) => {
@@ -133,4 +127,4 @@ export default {
overflow-x: hidden;
overflow-y: auto;
}
</style>
</style>
+57 -3
View File
@@ -49,7 +49,8 @@ export default {
numPages: 0,
windowWidth: 0,
windowHeight: 0,
pdfDocInitParams: null
pdfDocInitParams: null,
isRefreshing: false
}
},
computed: {
@@ -109,6 +110,10 @@ export default {
},
isPlayerOpen() {
return this.$store.getters['getIsPlayerOpen']
},
ebookUrl() {
const serverAddress = this.$store.getters['user/getServerAddress']
return this.isLocal ? this.url : `${serverAddress}${this.url}`
}
},
methods: {
@@ -164,7 +169,56 @@ export default {
this.page++
this.updateProgress()
},
error(err) {
async handleRefreshFailure() {
try {
console.log('[PdfReader] Handling refresh failure - logging out user')
const serverConnectionConfigId = this.$store.getters['user/getServerConnectionConfigId']
// Clear store
await this.$store.dispatch('user/logout')
if (serverConnectionConfigId) {
// Clear refresh token for server connection config
await this.$db.clearRefreshToken(serverConnectionConfigId)
}
if (window.location.pathname !== '/connect') {
window.location.href = '/connect?error=refreshTokenFailed&serverConnectionConfigId=' + serverConnectionConfigId
}
} catch (error) {
console.error('[PdfReader] Failed to handle refresh failure:', error)
}
},
async refreshToken() {
if (this.isRefreshing) return
this.isRefreshing = true
// Cannot use axios with this pdf reader so we need to handle the refresh separately
// Should work on migrating to a different pdf reader in the future
const newAccessToken = await this.$store.dispatch('user/refreshToken').catch((error) => {
console.error('Failed to refresh token', error)
return null
})
if (!newAccessToken) {
this.handleRefreshFailure()
return
}
// Force Vue to re-render the PDF component by creating a new object
this.pdfDocInitParams = {
url: this.ebookUrl,
httpHeaders: {
Authorization: `Bearer ${newAccessToken}`
}
}
this.isRefreshing = false
},
async error(err) {
if (err && err.status === 401) {
console.log('Received 401 error, refreshing token')
await this.refreshToken()
return
}
console.error(err)
},
screenOrientationChange() {
@@ -173,7 +227,7 @@ export default {
},
init() {
this.pdfDocInitParams = {
url: this.url,
url: this.ebookUrl,
httpHeaders: {
Authorization: `Bearer ${this.userToken}`
}
+29 -6
View File
@@ -1,7 +1,7 @@
<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 }">
<!-- 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">
<button type="button" class="inline-flex mx-2" @click.stop="show = false">
<span class="material-symbols text-3xl text-fg">chevron_left</span>
@@ -14,7 +14,7 @@
<span class="material-symbols text-2xl text-fg">settings</span>
</button>
<button v-if="comicHasMetadata" type="button" class="inline-flex mx-2" @click.stop="clickMetadataBtn">
<span class="material-symbols text-2xl text-fg">more-vert</span>
<span class="material-symbols text-2xl text-fg">more</span>
</button>
</div>
@@ -62,7 +62,7 @@
<span class="material-symbols">close</span>
</button>
</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="flex items-center mb-6">
<div class="w-32">
@@ -70,6 +70,12 @@
</div>
<ui-toggle-btns v-model="ereaderSettings.theme" name="theme" :items="themeItems" @input="settingsUpdated" />
</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="w-32">
<p class="text-sm">{{ $strings.LabelFontScale }}</p>
@@ -141,6 +147,7 @@ export default {
isInittingWatchVolume: false,
ereaderSettings: {
theme: 'dark',
font: 'serif',
fontScale: 100,
lineSpacing: 115,
spread: 'auto',
@@ -180,6 +187,9 @@ export default {
this.$store.commit('setShowReader', val)
}
},
isIos() {
return this.$platform === 'ios'
},
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() {
if (this.ebookType === 'epub') return 'readers-epub-reader'
else if (this.ebookType === 'mobi') return 'readers-mobi-reader'
@@ -260,6 +282,7 @@ export default {
return null
},
ebookFile() {
if (!this.media) return null
// ebook file id is passed when reading a supplementary ebook
if (this.ebookFileId) {
return this.selectedLibraryItem.libraryFiles.find((lf) => lf.ino === this.ebookFileId)
@@ -304,11 +327,11 @@ export default {
if (this.localContentUrl) {
return Capacitor.convertFileSrc(this.localContentUrl)
}
const serverAddress = this.$store.getters['user/getServerAddress']
if (this.ebookFileId) {
return `${serverAddress}/api/items/${this.selectedLibraryItem.id}/ebook/${this.ebookFileId}`
return `/api/items/${this.selectedLibraryItem.id}/ebook/${this.ebookFileId}`
}
return `${serverAddress}/api/items/${this.selectedLibraryItem.id}/ebook`
return `/api/items/${this.selectedLibraryItem.id}/ebook`
},
isPlayerOpen() {
return this.$store.getters['getIsPlayerOpen']
+8 -2
View File
@@ -183,10 +183,16 @@ export default {
daysInARow() {
var count = 0
while (true) {
var _date = this.$addDaysToToday(count * -1)
var datestr = this.$formatJsDate(_date, 'yyyy-MM-dd')
const _date = this.$addDaysToToday(count * -1 - 1)
const datestr = this.$formatJsDate(_date, 'yyyy-MM-dd')
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
}
count++
+4
View File
@@ -15,6 +15,7 @@
<tr>
<th class="text-left">{{ $strings.LabelTitle }}</th>
<th class="text-center w-16">{{ $strings.LabelStart }}</th>
<th class="text-center w-16">{{ $strings.LabelDuration }}</th>
</tr>
<tr v-for="chapter in chapters" :key="chapter.id">
<td>
@@ -23,6 +24,9 @@
<td class="font-mono text-center underline w-16" @click.stop="goToTimestamp(chapter.start)">
{{ $secondsToTimestamp(chapter.start) }}
</td>
<td class="font-mono text-center">
{{ $secondsToTimestamp(Math.max(0, chapter.end - chapter.start)) }}
</td>
</tr>
</table>
</transition>
+5 -1
View File
@@ -1,6 +1,6 @@
<template>
<div class="relative">
<input v-model="input" ref="input" autofocus :type="type" :disabled="disabled" :readonly="readonly" autocorrect="off" autocapitalize="none" autocomplete="off" :placeholder="placeholder" class="py-2 w-full outline-none bg-primary disabled:text-fg-muted" :class="inputClass" @keyup="keyup" />
<input v-model="input" ref="input" :autofocus="autofocus" :type="type" :disabled="disabled" :readonly="readonly" autocorrect="off" autocapitalize="none" autocomplete="off" :placeholder="placeholder" class="py-2 w-full outline-none bg-primary disabled:text-fg-muted" :class="inputClass" @keyup="keyup" />
<div v-if="prependIcon" class="absolute top-0 left-0 h-full px-2 flex items-center justify-center">
<span class="material-symbols text-lg">{{ prependIcon }}</span>
</div>
@@ -22,6 +22,10 @@ export default {
disabled: Boolean,
readonly: Boolean,
borderless: Boolean,
autofocus: {
type: Boolean,
default: true
},
bg: {
type: String,
default: 'bg'
+7 -2
View File
@@ -1,7 +1,7 @@
<template>
<div class="w-full">
<p class="pb-0.5 text-sm font-semibold">{{ label }}</p>
<ui-text-input v-model="inputValue" ref="input" :disabled="disabled" :type="type" text-size="base" class="w-full" />
<ui-text-input v-model="inputValue" ref="input" :disabled="disabled" :type="type" :placeholder="placeholder" :autofocus="autofocus" text-size="base" class="w-full" />
</div>
</template>
@@ -14,7 +14,12 @@ export default {
type: String,
default: 'text'
},
disabled: Boolean
disabled: Boolean,
placeholder: String,
autofocus: {
type: Boolean,
default: true
}
},
data() {
return {}
+68
View File
@@ -0,0 +1,68 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="12px" height="12px" viewBox="0 0 512 512" class="ml-1">
<path
fill="white"
d="M 89.00,40.12
C 89.00,40.12 127.00,40.12 127.00,40.12
127.00,40.12 198.00,40.12 198.00,40.12
198.00,40.12 416.00,40.12 416.00,40.12
446.58,40.05 472.95,66.42 473.00,97.00
473.00,97.00 473.00,303.00 473.00,303.00
473.00,303.00 473.00,418.00 473.00,418.00
472.65,447.55 445.06,472.95 416.00,473.00
416.00,473.00 210.00,473.00 210.00,473.00
210.00,473.00 95.00,473.00 95.00,473.00
65.45,472.65 40.05,445.06 40.00,416.00
40.00,416.00 40.00,136.00 40.00,136.00
40.00,136.00 40.00,109.00 40.00,109.00
40.00,109.00 40.00,96.00 40.00,96.00
40.07,81.58 46.89,67.14 57.01,57.01
61.17,52.86 64.86,50.13 70.00,47.31
77.25,43.33 81.02,42.18 89.00,40.12 Z
M 372.00,392.00
C 372.00,392.00 364.02,364.00 364.02,364.00
364.02,364.00 350.72,319.00 350.72,319.00
350.72,319.00 310.42,183.00 310.42,183.00
310.42,183.00 296.86,137.00 296.86,137.00
296.86,137.00 291.30,121.99 291.30,121.99
291.30,121.99 284.00,121.00 284.00,121.00
284.00,121.00 230.00,121.00 230.00,121.00
230.00,121.00 222.51,122.02 222.51,122.02
222.51,122.02 216.86,137.00 216.86,137.00
216.86,137.00 203.28,183.00 203.28,183.00
203.28,183.00 163.28,318.00 163.28,318.00
163.28,318.00 148.71,367.00 148.71,367.00
148.71,367.00 142.00,392.00 142.00,392.00
142.00,392.00 183.00,392.00 183.00,392.00
183.00,392.00 190.86,390.43 190.86,390.43
190.86,390.43 195.86,375.00 195.86,375.00
195.86,375.00 206.00,338.00 206.00,338.00
206.00,338.00 293.00,338.00 293.00,338.00
295.64,338.01 299.26,337.65 301.30,339.60
303.23,341.43 304.80,348.22 305.58,351.00
305.58,351.00 313.00,378.00 313.00,378.00
316.91,391.63 315.20,391.98 325.00,392.00
325.00,392.00 372.00,392.00 372.00,392.00 Z
M 254.00,170.00
C 254.00,170.00 256.00,170.00 256.00,170.00
256.00,170.00 263.12,197.00 263.12,197.00
263.12,197.00 282.88,268.00 282.88,268.00
282.88,268.00 290.00,296.00 290.00,296.00
290.00,296.00 219.00,296.00 219.00,296.00
219.00,296.00 230.58,253.00 230.58,253.00
230.58,253.00 254.00,170.00 254.00,170.00 Z"
/>
</svg>
</template>
<script>
export default {
props: {},
data() {
return {}
},
computed: {},
methods: {},
mounted() {}
}
</script>
@@ -78,15 +78,15 @@ export default {
this.$store.commit('globals/updateDownloadItemPart', itemPart)
}
},
mounted() {
this.downloadItemListener = AbsDownloader.addListener('onDownloadItem', (data) => this.onDownloadItem(data))
this.itemPartUpdateListener = AbsDownloader.addListener('onDownloadItemPartUpdate', (data) => this.onDownloadItemPartUpdate(data))
this.completeListener = AbsDownloader.addListener('onItemDownloadComplete', (data) => this.onItemDownloadComplete(data))
async mounted() {
this.downloadItemListener = await AbsDownloader.addListener('onDownloadItem', (data) => this.onDownloadItem(data))
this.itemPartUpdateListener = await AbsDownloader.addListener('onDownloadItemPartUpdate', (data) => this.onDownloadItemPartUpdate(data))
this.completeListener = await AbsDownloader.addListener('onItemDownloadComplete', (data) => this.onItemDownloadComplete(data))
},
beforeDestroy() {
if (this.downloadItemListener) this.downloadItemListener.remove()
if (this.completeListener) this.completeListener.remove()
if (this.itemPartUpdateListener) this.itemPartUpdateListener.remove()
this.downloadItemListener?.remove()
this.completeListener?.remove()
this.itemPartUpdateListener?.remove()
}
}
</script>
@@ -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
+12 -4
View File
@@ -25,6 +25,7 @@
4D66B958282EEA14008272D4 /* AbsFileSystem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D66B957282EEA14008272D4 /* AbsFileSystem.swift */; };
4D91EEC62A40F28D004807ED /* EBookFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D91EEC52A40F28D004807ED /* EBookFile.swift */; };
4DABC04F2B0139CA000F6264 /* User.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DABC04E2B0139CA000F6264 /* User.swift */; };
4DB441E12E19B8BF0056C8F1 /* SecureStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DB441E02E19B8BF0056C8F1 /* SecureStorage.swift */; };
4DF6C7172DB58ABF004059F1 /* AbsLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DF6C7162DB58ABF004059F1 /* AbsLogger.swift */; };
4DF74912287105C600AC7814 /* DeviceSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DF74911287105C600AC7814 /* DeviceSettings.swift */; };
4DFE2DA32D345C390000B204 /* MyViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DFE2DA22D345C390000B204 /* MyViewController.swift */; };
@@ -35,6 +36,7 @@
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
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 */; };
E9D5504828AC1A7A00C746DD /* MediaType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5504728AC1A7A00C746DD /* MediaType.swift */; };
E9D5504A28AC1AA600C746DD /* Metadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5504928AC1AA600C746DD /* Metadata.swift */; };
@@ -98,6 +100,7 @@
4D8D412C26E187E400BA5F0D /* App-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "App-Bridging-Header.h"; sourceTree = "<group>"; };
4D91EEC52A40F28D004807ED /* EBookFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EBookFile.swift; sourceTree = "<group>"; };
4DABC04E2B0139CA000F6264 /* User.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = User.swift; sourceTree = "<group>"; };
4DB441E02E19B8BF0056C8F1 /* SecureStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureStorage.swift; sourceTree = "<group>"; };
4DF6C7162DB58ABF004059F1 /* AbsLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AbsLogger.swift; sourceTree = "<group>"; };
4DF74911287105C600AC7814 /* DeviceSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceSettings.swift; sourceTree = "<group>"; };
4DFE2DA22D345C390000B204 /* MyViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyViewController.swift; sourceTree = "<group>"; };
@@ -109,6 +112,7 @@
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>"; };
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; };
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>"; };
@@ -229,6 +233,7 @@
E9D5506128AC1CC900C746DD /* PlayerState.swift */,
4DF74911287105C600AC7814 /* DeviceSettings.swift */,
E9D5507428AEF93100C746DD /* PlayerSettings.swift */,
6F2516262E68DA3000F40541 /* LogEntry.swift */,
E9D5506328AC1D3F00C746DD /* server */,
E9D5506428AC1D5800C746DD /* local */,
E9D5506D28AC1E7400C746DD /* download */,
@@ -239,6 +244,7 @@
3AFCB5E627EA23F700ECCC05 /* util */ = {
isa = PBXGroup;
children = (
4DB441E02E19B8BF0056C8F1 /* SecureStorage.swift */,
E9FA07E228C82848005520B0 /* Logger.swift */,
3AFCB5E727EA240D00ECCC05 /* NowPlayingInfo.swift */,
3AD4FCEA280443DD006DB301 /* Database.swift */,
@@ -530,6 +536,7 @@
3ABF580928059BAE005DFBE5 /* PlaybackSession.swift in Sources */,
E9D5506628AC1D7300C746DD /* LocalLibraryItem.swift in Sources */,
E9D5504628AC1A3900C746DD /* LibraryItem.swift in Sources */,
6F2516272E68DA3000F40541 /* LogEntry.swift in Sources */,
3ABF618F2804325C0070250E /* PlayerHandler.swift in Sources */,
3AD4FCED28044E6C006DB301 /* Store.swift in Sources */,
4D66B958282EEA14008272D4 /* AbsFileSystem.swift in Sources */,
@@ -550,6 +557,7 @@
E9D5505828AC1C1A00C746DD /* Library.swift in Sources */,
3AD4FCEB280443DD006DB301 /* Database.swift in Sources */,
3AD4FCE528043E50006DB301 /* AbsDatabase.swift in Sources */,
4DB441E12E19B8BF0056C8F1 /* SecureStorage.swift in Sources */,
4DABC04F2B0139CA000F6264 /* User.swift in Sources */,
E9D5506828AC1DC300C746DD /* LocalPodcastEpisode.swift in Sources */,
EACB38162BCCA1500060DA4A /* DefaultedAudioPlayerRateManager.swift in Sources */,
@@ -732,12 +740,12 @@
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 37;
CURRENT_PROJECT_VERSION = 41;
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 0.9.80;
MARKETING_VERSION = 0.11.0;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app.dev;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -756,12 +764,12 @@
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 37;
CURRENT_PROJECT_VERSION = 41;
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 0.9.80;
MARKETING_VERSION = 0.11.0;
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
+21 -18
View File
@@ -5,8 +5,6 @@ import RealmSwift
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
private let logger = AppLogger(category: "AppDelegate")
lazy var window: UIWindow? = UIWindow(frame: UIScreen.main.bounds)
var backgroundCompletionHandler: (() -> Void)?
@@ -14,16 +12,16 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
// Override point for customization after application launch.
let configuration = Realm.Configuration(
schemaVersion: 19,
schemaVersion: 20,
migrationBlock: { [weak self] migration, oldSchemaVersion in
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
newObject?["enableAltView"] = false
}
}
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
migration.enumerateObjects(ofType: ServerConnectionConfig.className()) { oldObject, newObject in
newObject?["index"] = indexCounter
@@ -31,43 +29,48 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
}
}
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
newObject?["lockOrientation"] = "NONE"
}
}
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
newObject?["hapticFeedback"] = "LIGHT"
}
}
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
newObject?["languageCode"] = "en-us"
}
}
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
newObject?["chapterTrack"] = false
}
}
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
newObject?["downloadUsingCellular"] = "ALWAYS"
newObject?["streamingUsingCellular"] = "ALWAYS"
}
}
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
newObject?["disableSleepTimerFadeOut"] = false
}
}
}
}
if (oldSchemaVersion < 20) {
AbsLogger.info(message: "Realm schema version was \(oldSchemaVersion)... Adding version to ServerConnectionConfigs")
migration.enumerateObjects(ofType: ServerConnectionConfig.className()) { oldObject, newObject in
newObject?["version"] = ""
}
}
}
)
Realm.Configuration.defaultConfiguration = configuration
@@ -83,22 +86,22 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
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.
// 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) {
// 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) {
// 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) {
// 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 {
+4
View File
@@ -5,6 +5,10 @@
"plugins": {
"CapacitorHttp": {
"enabled": false
},
"StatusBar": {
"backgroundColor": "#232323",
"style": "DARK"
}
},
"server": {
+13 -12
View File
@@ -33,8 +33,6 @@ public class AbsAudioPlayer: CAPPlugin, CAPBridgedPlugin {
CAPPluginMethod(name: "getSleepTimerTime", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "setSleepTimer", returnType: CAPPluginReturnPromise)
]
private let logger = AppLogger(category: "AbsAudioPlayer")
private var initialPlayWhenReady = false
private var monitor: NWPathMonitor?
@@ -82,7 +80,7 @@ public class AbsAudioPlayer: CAPPlugin, CAPBridgedPlugin {
let startTimeOverride = call.getDouble("startTime")
if libraryItemId == nil {
logger.error("provide library item id")
AbsLogger.error(message: "No id provided")
return call.resolve()
}
@@ -93,7 +91,7 @@ public class AbsAudioPlayer: CAPPlugin, CAPBridgedPlugin {
let item = Database.shared.getLocalLibraryItem(localLibraryItemId: libraryItemId!)
let episode = item?.getPodcastEpisode(episodeId: episodeId)
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([:])
}
@@ -105,7 +103,7 @@ public class AbsAudioPlayer: CAPPlugin, CAPBridgedPlugin {
try self.startPlaybackSession(playbackSession, playWhenReady: playWhenReady, playbackRate: playbackRate)
call.resolve(try playbackSession.asDictionary())
} catch(let exception) {
logger.error("Failed to start session")
AbsLogger.error(message: "Failed to start session for local item: \(exception)")
debugPrint(exception)
call.resolve([:])
}
@@ -119,7 +117,7 @@ public class AbsAudioPlayer: CAPPlugin, CAPBridgedPlugin {
try self?.startPlaybackSession(session, playWhenReady: playWhenReady, playbackRate: playbackRate)
call.resolve(try session.asDictionary())
} catch(let exception) {
self?.logger.error("Failed to start session")
AbsLogger.error(message: "Failed to start streaming session")
debugPrint(exception)
call.resolve([:])
}
@@ -127,19 +125,22 @@ public class AbsAudioPlayer: CAPPlugin, CAPBridgedPlugin {
}
}
/// Stops playback and closes session.
@objc func closePlayback(_ call: CAPPluginCall) {
logger.log("Close playback")
AbsLogger.info(message: "Close playback")
PlayerHandler.stopPlayback()
call.resolve()
}
@objc func getCurrentTime(_ call: CAPPluginCall) {
call.resolve([
"value": PlayerHandler.getCurrentTime() ?? 0,
"bufferedTime": PlayerHandler.getCurrentTime() ?? 0,
])
}
@objc func setPlaybackSpeed(_ call: CAPPluginCall) {
let playbackRate = call.getFloat("value", 1.0)
let settings = PlayerSettings.main()
@@ -152,7 +153,7 @@ public class AbsAudioPlayer: CAPPlugin, CAPBridgedPlugin {
@objc func setChapterTrack(_ call: CAPPluginCall) {
let chapterTrack = call.getBool("enabled", true)
logger.log(String(chapterTrack))
AbsLogger.info(message: String(chapterTrack))
let settings = PlayerSettings.main()
try? settings.update {
settings.chapterTrack = chapterTrack
@@ -225,7 +226,7 @@ public class AbsAudioPlayer: CAPPlugin, CAPBridgedPlugin {
let seconds = time / 1000
logger.log("chapter time: \(isChapterTime)")
AbsLogger.info(message: "chapter time: \(isChapterTime)")
if isChapterTime {
PlayerHandler.setChapterSleepTime(stopAt: seconds)
return call.resolve([ "success": true ])
@@ -262,7 +263,7 @@ public class AbsAudioPlayer: CAPPlugin, CAPBridgedPlugin {
guard let localMediaProgressId = PlayerHandler.getPlaybackSession()?.localMediaProgressId else { return }
guard let localMediaProgress = Database.shared.getLocalMediaProgress(localMediaProgressId: localMediaProgressId) 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)
}
@@ -272,7 +273,7 @@ public class AbsAudioPlayer: CAPPlugin, CAPBridgedPlugin {
let playWhenReady = PlayerHandler.getPlayWhenReady()
let libraryItemId = session?.libraryItemId ?? ""
let episodeId = session?.episodeId ?? nil
logger.log("Forcing Transcode")
AbsLogger.info(message: "Forcing Transcode")
// If direct playing then fallback to transcode
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.sendMetadata()
} catch(let exception) {
self?.logger.error("Failed to start transcoded session")
AbsLogger.error(message: "Failed to start transcoded session")
debugPrint(exception)
}
}
+58 -15
View File
@@ -33,6 +33,8 @@ public class AbsDatabase: CAPPlugin, CAPBridgedPlugin {
public let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "setCurrentServerConnectionConfig", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "removeServerConnectionConfig", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "getRefreshToken", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "clearRefreshToken", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "logout", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "getDeviceData", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "getLocalLibraryItems", returnType: CAPPluginReturnPromise),
@@ -47,27 +49,45 @@ public class AbsDatabase: CAPPlugin, CAPBridgedPlugin {
CAPPluginMethod(name: "updateDeviceSettings", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "updateLocalEbookProgress", returnType: CAPPluginReturnPromise)
]
private let logger = AppLogger(category: "AbsDatabase")
private let secureStorage = SecureStorage()
// Used to notify the webview frontend that the token has been refreshed
static var tokenRefreshCallback: ((String, [String: Any]) -> Void)?
override public func load() {
AbsDatabase.tokenRefreshCallback = { [weak self] eventName, data in
self?.notifyListeners(eventName, data: data)
}
}
@objc func setCurrentServerConnectionConfig(_ call: CAPPluginCall) {
var id = call.getString("id")
let address = call.getString("address", "")
let version = call.getString("version", "")
let userId = call.getString("userId", "")
let username = call.getString("username", "")
let token = call.getString("token", "")
let refreshToken = call.getString("refreshToken", "") // Refresh only sent after login or refresh
let name = "\(address) (\(username))"
if id == nil {
id = "\(address)@\(username)".toBase64()
}
if (refreshToken != "") {
// Store refresh token securely if provided
let hasRefreshToken = secureStorage.storeRefreshToken(serverConnectionConfigId: id ?? "", refreshToken: refreshToken)
AbsLogger.info(message: "Refresh token secured = \(hasRefreshToken)")
}
let config = ServerConnectionConfig()
config.id = id ?? ""
config.index = 0
config.name = name
config.address = address
config.version = version
config.userId = userId
config.username = username
config.token = token
@@ -76,12 +96,35 @@ public class AbsDatabase: CAPPlugin, CAPBridgedPlugin {
let savedConfig = Store.serverConfig // Fetch the latest value
call.resolve(convertServerConnectionConfigToJSON(config: savedConfig!))
}
@objc func removeServerConnectionConfig(_ call: CAPPluginCall) {
let id = call.getString("serverConnectionConfigId", "")
// Remove refresh token if it exists
_ = secureStorage.removeRefreshToken(serverConnectionConfigId: id)
Database.shared.deleteServerConnectionConfig(id: id)
call.resolve()
}
@objc func getRefreshToken(_ call: CAPPluginCall) {
let serverConnectionConfigId = call.getString("serverConnectionConfigId", "")
let refreshToken = secureStorage.getRefreshToken(serverConnectionConfigId: serverConnectionConfigId)
if let refreshToken = refreshToken {
call.resolve(["refreshToken": refreshToken])
} else {
call.resolve()
}
}
@objc func clearRefreshToken(_ call: CAPPluginCall) {
let serverConnectionConfigId = call.getString("serverConnectionConfigId", "")
let success = secureStorage.removeRefreshToken(serverConnectionConfigId: serverConnectionConfigId)
call.resolve(["success": success])
}
@objc func logout(_ call: CAPPluginCall) {
Store.serverConfig = nil
call.resolve()
@@ -104,15 +147,16 @@ public class AbsDatabase: CAPPlugin, CAPBridgedPlugin {
let items = Database.shared.getLocalLibraryItems()
call.resolve([ "value": try items.asDictionaryArray()])
} catch(let exception) {
logger.error("error while readling local library items")
AbsLogger.error(message: "error reading local library items \(exception)")
debugPrint(exception)
call.resolve()
}
}
@objc func getLocalLibraryItem(_ call: CAPPluginCall) {
let id = call.getString("id") ?? ""
do {
let item = Database.shared.getLocalLibraryItem(localLibraryItemId: call.getString("id") ?? "")
let item = Database.shared.getLocalLibraryItem(localLibraryItemId: id)
switch item {
case .some(let foundItem):
call.resolve(try foundItem.asDictionary())
@@ -120,7 +164,7 @@ public class AbsDatabase: CAPPlugin, CAPBridgedPlugin {
call.resolve()
}
} catch(let exception) {
logger.error("error while readling local library items")
AbsLogger.error(message: "error reading local library item[\(id)] \(exception)")
debugPrint(exception)
call.resolve()
}
@@ -136,8 +180,7 @@ public class AbsDatabase: CAPPlugin, CAPBridgedPlugin {
call.resolve()
}
} catch(let exception) {
logger.error("error while readling local library items")
debugPrint(exception)
AbsLogger.error(message: "error while readling local library items: \(exception)", error: exception)
call.resolve()
}
}
@@ -150,8 +193,7 @@ public class AbsDatabase: CAPPlugin, CAPBridgedPlugin {
do {
call.resolve([ "value": try Database.shared.getAllLocalMediaProgress().asDictionaryArray() ])
} catch {
logger.error("Error while loading local media progress")
debugPrint(error)
AbsLogger.error(message: "Error while loading local media progress", error: error)
call.resolve(["value": []])
}
}
@@ -168,7 +210,7 @@ public class AbsDatabase: CAPPlugin, CAPBridgedPlugin {
@objc func syncLocalSessionsWithServer(_ call: CAPPluginCall) {
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 {
call.reject("syncLocalSessionsWithServer not connected to server")
return call.resolve()
@@ -200,12 +242,13 @@ public class AbsDatabase: CAPPlugin, CAPBridgedPlugin {
return
}
logger.log("syncServerMediaProgressWithLocalMediaProgress: Saving local media progress")
AbsLogger.info(message: "Saving local media progress \(serverMediaProgress)")
try localMediaProgress.updateFromServerMediaProgress(serverMediaProgress)
call.resolve(try localMediaProgress.asDictionary())
} catch {
call.reject("Failed to sync media progress")
AbsLogger.error(message: "Failed to sync: \(error)")
debugPrint(error)
}
}
@@ -220,7 +263,7 @@ public class AbsDatabase: CAPPlugin, CAPBridgedPlugin {
localMediaProgressId += "-\(localEpisodeId ?? "")"
}
logger.log("updateLocalMediaProgressFinished \(localMediaProgressId) | Is Finished: \(isFinished)")
AbsLogger.info(message: "\(localMediaProgressId): isFinished=\(isFinished)")
do {
let localMediaProgress = try LocalMediaProgress.fetchOrCreateLocalMediaProgress(localMediaProgressId: localMediaProgressId, localLibraryItemId: localLibraryItemId, localEpisodeId: localEpisodeId)
@@ -293,7 +336,7 @@ public class AbsDatabase: CAPPlugin, CAPBridgedPlugin {
let ebookLocation = call.getString("ebookLocation", "")
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 {
let localMediaProgress = try LocalMediaProgress.fetchOrCreateLocalMediaProgress(localMediaProgressId: localLibraryItemId, localLibraryItemId: localLibraryItemId, localEpisodeId: nil)
+13 -15
View File
@@ -19,8 +19,6 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
static private let downloadsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
private let logger = AppLogger(category: "AbsDownloader")
private lazy var session: URLSession = {
let config = URLSessionConfiguration.background(withIdentifier: "AbsDownloader")
let queue = OperationQueue()
@@ -104,7 +102,7 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
private func handleDownloadTaskUpdate(downloadTask: URLSessionTask, progressHandler: DownloadProgressHandler) {
do {
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
let downloadItem = Database.shared.getDownloadItem(downloadItemPartId: downloadItemPartId)
@@ -119,7 +117,7 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
try progressHandler(downloadItem, part)
try? self.notifyListeners("onDownloadItemPartUpdate", data: part.asDictionary())
} catch {
logger.error("Error while processing progress")
AbsLogger.error(message: "Error while processing progress")
debugPrint(error)
}
@@ -130,7 +128,7 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
}
self.notifyDownloadProgress()
} catch {
logger.error("DownloadItemError")
AbsLogger.error(message: "DownloadItemError")
debugPrint(error)
}
}
@@ -138,18 +136,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
private func notifyDownloadProgress() {
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 {
DispatchQueue.runOnMainQueue {
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
func fetchActiveDownloads() -> [String: DownloadItem]? {
self.progressStatusQueue.sync {
let activeDownloads = self.downloadItemProgress
if activeDownloads.isEmpty {
logger.log("Finishing monitoring download progress...")
AbsLogger.info(message: "Finishing monitoring download progress...")
t.invalidate()
}
return activeDownloads
@@ -184,7 +182,7 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
if ( downloadItem.didDownloadSuccessfully() ) {
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
var coverFile: String?
@@ -231,12 +229,12 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
var episodeId = call.getString("episodeId")
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"]) }
ApiClient.getLibraryItemWithProgress(libraryItemId: libraryItemId, episodeId: episodeId) { [weak self] libraryItem in
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 {
if let episodeId = episodeId {
// Download a podcast episode
@@ -317,7 +315,7 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
}
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
guard let filename = track.metadata?.filename else {
@@ -393,10 +391,10 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
private func createLibraryItemFileDirectory(item: LibraryItem) throws -> String {
let itemDirectory = item.id
logger.log("ITEM DIR \(itemDirectory)")
AbsLogger.info(message: "ITEM DIR \(itemDirectory)")
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
}
@@ -418,7 +416,7 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
return itemFolder
} catch {
AppLogger().error("Failed to CREATE LI DIRECTORY \(error)")
AbsLogger.error(message: "Failed to CREATE LI DIRECTORY \(error)", error: error)
return nil
}
}
+11 -13
View File
@@ -23,12 +23,10 @@ public class AbsFileSystem: CAPPlugin, CAPBridgedPlugin {
CAPPluginMethod(name: "deleteTrackFromItem", returnType: CAPPluginReturnPromise)
]
private let logger = AppLogger(category: "AbsFileSystem")
@objc func selectFolder(_ call: CAPPluginCall) {
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")
}
@@ -36,7 +34,7 @@ public class AbsFileSystem: CAPPlugin, CAPBridgedPlugin {
@objc func checkFolderPermission(_ call: CAPPluginCall) {
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")
}
@@ -45,7 +43,7 @@ public class AbsFileSystem: CAPPlugin, CAPBridgedPlugin {
let folderId = call.getString("folderId")
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")
}
@@ -53,7 +51,7 @@ public class AbsFileSystem: CAPPlugin, CAPBridgedPlugin {
@objc func removeFolder(_ call: CAPPluginCall) {
let folderId = call.getString("folderId")
logger.log("removeFolder \(folderId ?? "UNSET")")
AbsLogger.info(message: "removeFolder \(folderId ?? "UNSET")")
call.unavailable("Not available on iOS")
}
@@ -61,7 +59,7 @@ public class AbsFileSystem: CAPPlugin, CAPBridgedPlugin {
@objc func removeLocalLibraryItem(_ call: CAPPluginCall) {
let localLibraryItemId = call.getString("localLibraryItemId")
logger.log("removeLocalLibraryItem \(localLibraryItemId ?? "UNSET")")
AbsLogger.info(message: "removeLocalLibraryItem \(localLibraryItemId ?? "UNSET")")
call.unavailable("Not available on iOS")
}
@@ -70,7 +68,7 @@ public class AbsFileSystem: CAPPlugin, CAPBridgedPlugin {
let localLibraryItemId = call.getString("localLibraryItemId")
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")
}
@@ -79,7 +77,7 @@ public class AbsFileSystem: CAPPlugin, CAPBridgedPlugin {
let localLibraryItemId = call.getString("id")
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
do {
@@ -89,7 +87,7 @@ public class AbsFileSystem: CAPPlugin, CAPBridgedPlugin {
success = true
}
} catch {
logger.error("Failed to delete \(error)")
AbsLogger.error(message: "Failed to delete \(error)")
success = false
}
@@ -100,7 +98,7 @@ public class AbsFileSystem: CAPPlugin, CAPBridgedPlugin {
let localLibraryItemId = call.getString("id")
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
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
}
} catch {
logger.error("Failed to delete \(error)")
AbsLogger.error(message: "Failed to delete \(error)")
success = false
}
}
} catch {
logger.error("Failed to delete \(error)")
AbsLogger.error(message: "Failed to delete \(error)")
success = false
}
}
+86 -9
View File
@@ -8,6 +8,16 @@
import Foundation
import Capacitor
enum AbsLogLevel {
case info
case warn
case error
}
func fileName(file: String = #file) -> String {
return (file as NSString).lastPathComponent
}
@objc(AbsLogger)
public class AbsLogger: CAPPlugin, CAPBridgedPlugin {
public var identifier = "AbsLoggerPlugin"
@@ -19,29 +29,96 @@ public class AbsLogger: CAPPlugin, CAPBridgedPlugin {
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) {
let message = call.getString("message") ?? ""
let tag = call.getString("tag") ?? ""
logger.log("[\(tag)] \(message)")
call.resolve()
do {
try info(tag: tag, message: message)
call.resolve()
} catch {
call.reject("Failed to log \(message)", "101", error)
}
}
@objc func error(_ call: CAPPluginCall) {
let message = call.getString("message") ?? ""
let tag = call.getString("tag") ?? ""
logger.error("[\(tag)] \(message)")
call.resolve()
do {
try error(tag: tag, message: message)
call.resolve()
} catch {
call.reject("Failed to log \(message)", "101", error)
}
}
@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) {
call.unimplemented("Not implemented on iOS")
do {
try Database.shared.clearLogs()
call.resolve()
} catch {
call.reject("Failed to clear logs", "100", error)
}
}
}
+10 -10
View File
@@ -1,5 +1,5 @@
PODS:
- Alamofire (5.8.1)
- Alamofire (5.10.2)
- Capacitor (7.2.0):
- CapacitorCordova
- CapacitorApp (7.0.1):
@@ -25,11 +25,11 @@ PODS:
- Capacitor
- CordovaPlugins (6.2.1):
- CapacitorCordova
- Realm (10.47.0):
- Realm/Headers (= 10.47.0)
- Realm/Headers (10.47.0)
- RealmSwift (10.47.0):
- Realm (= 10.47.0)
- Realm (10.54.4):
- Realm/Headers (= 10.54.4)
- Realm/Headers (10.54.4)
- RealmSwift (10.54.4):
- Realm (= 10.54.4)
- WebnativellcCapacitorFilesharer (7.0.4):
- Capacitor
@@ -88,7 +88,7 @@ EXTERNAL SOURCES:
:path: "../../node_modules/@webnativellc/capacitor-filesharer"
SPEC CHECKSUMS:
Alamofire: 3ca42e259043ee0dc5c0cdd76c4bc568b8e42af7
Alamofire: 7193b3b92c74a07f85569e1a6c4f4237291e7496
Capacitor: 106e7a4205f4618d582b886a975657c61179138d
CapacitorApp: d63334c052278caf5d81585d80b21905c6f93f39
CapacitorBrowser: 081852cf532acf77b9d2953f3a88fe5b9711fb06
@@ -102,10 +102,10 @@ SPEC CHECKSUMS:
CapacitorPreferences: cbf154e5e5519b7f5ab33817a334dda1e98387f9
CapacitorStatusBar: 275cbf2f4dfc00388f519ef80c7ec22edda342c9
CordovaPlugins: 5a72a85b45469e68556bb172409f1b6d57b27236
Realm: e43fb540ae947497e3ea8a662443256920602060
RealmSwift: 8b06ed06b5d16749ae0c4d91c0cba414a9e28189
Realm: 8b5cda39a41f17a1734da2f39c6004eb8745587a
RealmSwift: 0b4f808fed6898f1f6c26f501f740efd80dff0b4
WebnativellcCapacitorFilesharer: 10b111373d4dc49608935600dcbcc14605258c73
PODFILE CHECKSUM: 498821c0cfa2508609567fa95d7244c01cbef538
COCOAPODS: 1.12.1
COCOAPODS: 1.16.2
+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
}
@@ -13,6 +13,7 @@ class ServerConnectionConfig: Object {
@Persisted(indexed: true) var index: Int = 1
@Persisted var name: String = ""
@Persisted var address: String = ""
@Persisted var version: String = ""
@Persisted var userId: String = ""
@Persisted var username: String = ""
@Persisted var token: String = ""
@@ -29,6 +30,7 @@ func convertServerConnectionConfigToJSON(config: ServerConnectionConfig) -> Dict
"name": config.name,
"index": config.index,
"address": config.address,
"version": config.version,
"userId": config.userId,
"username": config.username,
"token": config.token,
+37 -31
View File
@@ -26,7 +26,6 @@ enum PlayerStatus: Int {
class AudioPlayer: NSObject {
internal let queue = DispatchQueue(label: "ABSAudioPlayerQueue")
internal let logger = AppLogger(category: "AudioPlayer")
private var status: PlayerStatus
internal var rateManager: AudioPlayerRateManager
@@ -81,7 +80,7 @@ class AudioPlayer: NSObject {
let playbackSession = self.getPlaybackSession()
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)
return
}
@@ -115,10 +114,10 @@ class AudioPlayer: NSObject {
}
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]
logger.log("Setting player items \(playerItems.count)")
AbsLogger.info(message:"Setting player items \(playerItems.count)")
for item in Array(playerItems) {
self.audioPlayer.insert(item, after:self.audioPlayer.items().last)
@@ -128,7 +127,7 @@ class AudioPlayer: NSObject {
setupQueueObserver()
setupQueueItemStatusObserver()
logger.log("Audioplayer ready")
AbsLogger.info(message:"Audioplayer ready")
}
deinit {
@@ -146,8 +145,7 @@ class AudioPlayer: NSObject {
do {
try AVAudioSession.sharedInstance().setActive(false)
} catch {
logger.error("Failed to set AVAudioSession inactive")
logger.error(error)
AbsLogger.error(message: "Failed to set AVAudioSession inactive", error: error)
}
self.removeAudioSessionNotifications()
@@ -253,14 +251,14 @@ class AudioPlayer: NSObject {
self.audioPlayer.currentItem.map { item in
self.currentTrackIndex = self.allPlayerItems.firstIndex(of:item) ?? 0
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() {
logger.log("queueStatusObserver: Setting up")
AbsLogger.info(message:"queueStatusObserver: Setting up")
// Listen for player item updates
self.queueItemStatusObserver?.invalidate()
@@ -275,13 +273,13 @@ class AudioPlayer: NSObject {
}
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 {
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.failed.rawValue), object: nil)
return
}
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
let firstReady = self.status == .uninitialized
@@ -300,7 +298,7 @@ class AudioPlayer: NSObject {
self.status = .paused
}
} 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)
}
}
@@ -332,7 +330,7 @@ class AudioPlayer: NSObject {
}
private func resumePlayback() {
logger.log("PLAY: Resuming playback")
AbsLogger.info(message:"PLAY: Resuming playback")
self.markAudioSessionAs(active: true)
DispatchQueue.runOnMainQueue {
@@ -351,7 +349,7 @@ class AudioPlayer: NSObject {
public func pause() {
guard self.isInitialized() else { return }
logger.log("PAUSE: Pausing playback")
AbsLogger.info(message:"PAUSE: Pausing playback")
DispatchQueue.runOnMainQueue {
self.audioPlayer.pause()
}
@@ -371,7 +369,7 @@ class AudioPlayer: NSObject {
public func startFadeOut() {
guard self.isInitialized() 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.
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.
self.audioPlayer.volume = targetVolume
t.invalidate()
self.logger.log("Fadeout: Fade complete, pausing playback")
AbsLogger.info(message:"Fadeout: Fade complete, pausing playback")
self.pause()
self.audioPlayer.volume = initialVolume
self.seek(currentTime, from: "fadeOut")
@@ -419,12 +417,12 @@ class AudioPlayer: NSObject {
}
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 }
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 {
self.currentTrackIndex = indexOfSeek
@@ -488,16 +486,16 @@ class AudioPlayer: NSObject {
let currentTrack = playbackSession.audioTracks[self.currentTrackIndex]
let ctso = currentTrack.startOffset ?? 0.0
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 seekTime = boundedTime - ctso
DispatchQueue.runOnMainQueue {
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 {
self?.logger.log("SEEK: WARNING: seeking not completed (to \(seekTime)")
AbsLogger.info(message:"SEEK: WARNING: seeking not completed (to \(seekTime)")
return
}
guard let self = self else { return }
@@ -561,13 +559,20 @@ class AudioPlayer: NSObject {
guard let playbackSession = self.getPlaybackSession() else { return nil }
if (playbackSession.playMethod == PlayMethod.directplay.rawValue) {
let urlstr = "\(Store.serverConfig!.address)/api/items/\(itemId)/file/\(ino)?token=\(Store.serverConfig!.token)"
let url = URL(string: urlstr)!
// As of v2.22.0 tracks use a different endpoint
// See: https://github.com/advplyr/audiobookshelf/pull/4263
let contentUrl: String
if Store.isServerVersionGreaterThanOrEqualTo("2.22.0") {
contentUrl = "\(Store.serverConfig!.address)/public/session/\(playbackSession.id)/track/\(track.index ?? 1)"
} else {
contentUrl = "\(Store.serverConfig!.address)/api/items/\(itemId)/file/\(ino)?token=\(Store.serverConfig!.token)"
}
let url = URL(string: contentUrl)!
return AVURLAsset(url: url)
} else if (playbackSession.playMethod == PlayMethod.local.rawValue) {
guard let localFile = track.getLocalFile() else {
// 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 url = URL(string: urlstr)!
return AVURLAsset(url: url)
@@ -577,7 +582,9 @@ class AudioPlayer: NSObject {
let headers: [String: String] = [
"Authorization": "Bearer \(Store.serverConfig!.token)"
]
return AVURLAsset(url: URL(string: "\(Store.serverConfig!.address)\(track.contentUrl ?? "")")!, options: ["AVURLAssetHTTPHeaderFieldsKey": headers])
let contentUrl = "\(Store.serverConfig!.address)\(track.contentUrl ?? "")"
return AVURLAsset(url: URL(string: contentUrl)!, options: ["AVURLAssetHTTPHeaderFieldsKey": headers])
}
}
@@ -585,8 +592,7 @@ class AudioPlayer: NSObject {
do {
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio)
} catch {
logger.error("Failed to set AVAudioSession category")
logger.error(error)
AbsLogger.error(message: "Failed to set AVAudioSession category", error: error)
}
}
@@ -594,7 +600,7 @@ class AudioPlayer: NSObject {
do {
try AVAudioSession.sharedInstance().setActive(active)
} catch {
logger.error("Failed to set audio session as active=\(active)")
AbsLogger.error(message:"Failed to set audio session as active=\(active)")
}
}
@@ -611,7 +617,7 @@ class AudioPlayer: NSObject {
let reasonValue = userInfo[AVAudioSessionInterruptionReasonKey] as? UInt ?? 0
let reason = AVAudioSession.InterruptionReason(rawValue: reasonValue)
if (reason == .appWasSuspended) {
logger.log("AVAudioSession was suspended")
AbsLogger.info(message:"AVAudioSession was suspended")
return
}
}
@@ -803,14 +809,14 @@ class AudioPlayer: NSObject {
if context == &playerContext {
if keyPath == #keyPath(AVPlayer.currentItem) {
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 the queue is rebuilding, we expect the current item may be nil
if self.isRebuildingQueue {
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)
}
}
@@ -31,7 +31,7 @@ extension AudioPlayer {
}
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.sleepTimeRemaining = secondsUntilSleep
@@ -53,7 +53,7 @@ extension AudioPlayer {
guard let currentTime = self.getCurrentTime() 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
self.sleepTimeChapterStopAt = stopAt
@@ -138,7 +138,7 @@ extension AudioPlayer {
}
private func handleSleepEnd() {
logger.log("SLEEP TIMER: Pausing audio")
AbsLogger.info(message: "SLEEP TIMER: Pausing audio")
self.pause()
self.removeSleepTimer()
}
@@ -10,8 +10,7 @@ import AVFoundation
@available(iOS 16.0, *)
class DefaultedAudioPlayerRateManager: NSObject, AudioPlayerRateManager {
internal let logger = AppLogger(category: "DefaultedAudioPlayerRateManager")
internal var audioPlayer: AVPlayer
// MARK: - AudioPlayerRateManager
@@ -9,7 +9,6 @@ import Foundation
import AVFoundation
class LegacyAudioPlayerRateManager: NSObject, AudioPlayerRateManager {
internal let logger = AppLogger(category: "AudioPlayer")
internal var audioPlayer: AVPlayer
@@ -54,7 +53,7 @@ class LegacyAudioPlayerRateManager: NSObject, AudioPlayerRateManager {
let playbackSpeedChanged = rate > 0.0 && rate != self.defaultRate && !(observed && rate == 1)
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 {
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?) {
if context == &managerContext {
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)
}
} else {
+10 -13
View File
@@ -14,8 +14,6 @@ class PlayerProgress {
private static var TIME_BETWEEN_SESSION_SYNC_IN_SECONDS = 15.0
private let logger = AppLogger(category: "PlayerProgress")
private init() {}
@@ -30,8 +28,7 @@ class PlayerProgress {
try await updateServerSessionFromLocalSession(session, rateLimitSync: !isStopping)
}
} catch {
logger.error("Failed to syncFromPlayer")
logger.error(error)
AbsLogger.error(message: "Failed to syncFromPlayer \(error)", error: error)
}
await UIApplication.shared.endBackgroundTask(backgroundToken)
}
@@ -76,7 +73,7 @@ class PlayerProgress {
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
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.localProgress.rawValue), object: nil)
@@ -126,7 +123,7 @@ class PlayerProgress {
session = session.freeze()
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
if session.isLocal {
@@ -155,25 +152,25 @@ class PlayerProgress {
// TODO: Unused for now
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: {
$0.isActiveSession == true && $0.serverConnectionConfigId == Store.serverConfig?.id
})?.freeze() else {
logger.log("updateLocalSessionFromServerMediaProgress: Failed to get session")
AbsLogger.info(message:"updateLocalSessionFromServerMediaProgress: Failed to get session")
return
}
// Fetch the current progress
let progress = await ApiClient.getMediaProgress(libraryItemId: session.libraryItemId!, episodeId: session.episodeId)
guard let progress = progress else {
logger.log("updateLocalSessionFromServerMediaProgress: No progress object")
AbsLogger.info(message:"updateLocalSessionFromServerMediaProgress: No progress object")
return
}
// Determine which session is newer
let serverLastUpdate = progress.lastUpdate
guard let localLastUpdate = session.updatedAt else {
logger.log("updateLocalSessionFromServerMediaProgress: No local session updatedAt")
AbsLogger.info(message:"updateLocalSessionFromServerMediaProgress: No local session updatedAt")
return
}
let serverCurrentTime = progress.currentTime
@@ -184,16 +181,16 @@ class PlayerProgress {
// Update the session, if needed
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 }
try session.update {
session.currentTime = serverCurrentTime
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)
} 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")
}
}
+411 -43
View File
@@ -9,7 +9,7 @@ import Foundation
import Alamofire
class ApiClient {
private static let logger = AppLogger(category: "ApiClient")
private static let secureStorage = SecureStorage()
public static func getData(from url: URL, completion: @escaping (UIImage?) -> Void) {
URLSession.shared.dataTask(with: url, completionHandler: {(data, response, error) in
@@ -21,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)?) {
if (Store.serverConfig == nil) {
logger.error("Server config not set")
AbsLogger.error(message: "Server config not set")
return
}
@@ -34,7 +34,7 @@ class ApiClient {
case .success(let obj):
callback?(obj)
case .failure(let error):
logger.error("api request to \(endpoint) failed")
AbsLogger.error(message: "api request to \(endpoint) failed")
print(error)
}
}
@@ -42,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)?) {
if (Store.serverConfig == nil) {
logger.error("Server config not set")
AbsLogger.error(message: "Server config not set")
return
}
@@ -55,7 +55,7 @@ class ApiClient {
case .success(let obj):
callback?(obj)
case .failure(let error):
logger.error("api request to \(endpoint) failed")
AbsLogger.error(message: "api request to \(endpoint) failed")
print(error)
}
}
@@ -71,7 +71,7 @@ class ApiClient {
public static func postResource<T:Encodable>(endpoint: String, parameters: T, callback: ((_ success: Bool) -> Void)?) {
if (Store.serverConfig == nil) {
logger.error("Server config not set")
AbsLogger.error(message: "Server config not set")
callback?(false)
return
}
@@ -85,7 +85,7 @@ class ApiClient {
case .success(_):
callback?(true)
case .failure(let error):
logger.error("api request to \(endpoint) failed")
AbsLogger.error(message: "api request to \(endpoint) failed")
print(error)
callback?(false)
@@ -95,7 +95,7 @@ class ApiClient {
public static func patchResource<T: Encodable>(endpoint: String, parameters: T, callback: ((_ success: Bool) -> Void)?) {
if (Store.serverConfig == nil) {
logger.error("Server config not set")
AbsLogger.error(message: "Server config not set")
callback?(false)
return
}
@@ -109,7 +109,7 @@ class ApiClient {
case .success(_):
callback?(true)
case .failure(let error):
logger.error("api request to \(endpoint) failed")
AbsLogger.error(message: "api request to \(endpoint) failed")
print(error)
callback?(false)
}
@@ -126,7 +126,7 @@ class ApiClient {
public static func getResource<T: Decodable>(endpoint: String, decodable: T.Type = T.self, callback: ((_ param: T?) -> Void)?) {
if (Store.serverConfig == nil) {
logger.error("Server config not set")
AbsLogger.error(message: "Server config not set")
callback?(nil)
return
}
@@ -140,12 +140,324 @@ class ApiClient {
case .success(let obj):
callback?(obj)
case .failure(let error):
logger.error("api request to \(endpoint) failed")
AbsLogger.error(message: "api request to \(endpoint) failed")
print(error)
}
}
}
// MARK: - Token Refresh Handling
/**
* Handles token refresh when a 401 Unauthorized response is received
* This function will:
* 1. Get the refresh token from secure storage for the current server connection
* 2. Make a request to /auth/refresh endpoint with the refresh token
* 3. Update the connection config with the new accessToken and put the refreshToken in secure storage
* 4. Retry the original request with the new access token
* 5. If refresh fails, handle logout
*/
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 {
AbsLogger.error(message: "handleTokenRefresh: No server config available")
callback?(nil)
return
}
AbsLogger.info(message: "handleTokenRefresh: Attempting to refresh auth tokens for server \(serverConfig.name)")
// Get refresh token from secure storage
guard let refreshToken = secureStorage.getRefreshToken(serverConnectionConfigId: serverConfig.id) else {
AbsLogger.error(message: "handleTokenRefresh: No refresh token available for server \(serverConfig.name)")
handleRefreshFailure()
callback?(nil)
return
}
AbsLogger.info(message: "handleTokenRefresh: Retrieved refresh token, attempting to refresh access token")
// Create refresh token request
let refreshHeaders: HTTPHeaders = [
"x-refresh-token": refreshToken,
"Content-Type": "application/json"
]
let refreshRequest = AF.request("\(serverConfig.address)/auth/refresh", method: .post, headers: refreshHeaders)
refreshRequest.responseDecodable(of: RefreshResponse.self) { response in
switch response.result {
case .success(let refreshResponse):
guard let user = refreshResponse.user,
!user.accessToken.isEmpty else {
AbsLogger.error(message: "handleTokenRefresh: No access token in refresh response for server \(serverConfig.name)")
handleRefreshFailure()
callback?(nil)
return
}
AbsLogger.info(message: "handleTokenRefresh: Successfully obtained new access token")
// Update tokens in secure storage and store
updateTokens(newAccessToken: user.accessToken, newRefreshToken: user.refreshToken ?? refreshToken, serverConnectionConfigId: serverConfig.id)
// Retry the original request with the new access 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)
case .failure(let error):
AbsLogger.error(message: "handleTokenRefresh: Refresh request failed for server \(serverConfig.name): \(error)")
handleRefreshFailure()
callback?(nil)
}
}
}
/**
* Updates the stored tokens with new access and refresh tokens
*/
private static func updateTokens(newAccessToken: String, newRefreshToken: String, serverConnectionConfigId: String) {
// Update the refresh token in secure storage if it's new
if newRefreshToken != secureStorage.getRefreshToken(serverConnectionConfigId: serverConnectionConfigId) {
let hasStored = secureStorage.storeRefreshToken(serverConnectionConfigId: serverConnectionConfigId, refreshToken: newRefreshToken)
AbsLogger.info(message: "updateTokens: Updated refresh token in secure storage. Stored=\(hasStored)")
}
// Update access token on server connection config
Database.shared.updateServerConnectionConfigToken(newToken: newAccessToken)
AbsLogger.info(message: "updateTokens: Updated access token in server connection config")
AbsLogger.info(message: "updateTokens: Successfully refreshed auth tokens for server \(Store.serverConfig?.name ?? "unknown")")
// Notify webview frontend about token refresh
if let callback = AbsDatabase.tokenRefreshCallback {
let tokenData: [String: Any] = ["accessToken": newAccessToken]
callback("onTokenRefresh", tokenData)
}
}
/**
* Retries the original request with the new access token
*/
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 {
AbsLogger.error(message: "retryOriginalRequest: No server config available")
callback?(nil)
return
}
let headers: HTTPHeaders = [
"Authorization": "Bearer \(newAccessToken)"
]
let retryRequest: DataRequest
switch method {
case .get:
retryRequest = AF.request("\(serverConfig.address)/\(endpoint)", method: .get, headers: headers)
case .post:
if let parameters = parameters as? [String: Any] {
retryRequest = AF.request("\(serverConfig.address)/\(endpoint)", method: .post, parameters: parameters, encoding: JSONEncoding.default, headers: headers)
} else if let encodableParams = parameters as? Encodable {
retryRequest = AF.request("\(serverConfig.address)/\(endpoint)", method: .post, parameters: encodableParams, encoder: JSONParameterEncoder.default, headers: headers)
} else {
retryRequest = AF.request("\(serverConfig.address)/\(endpoint)", method: .post, headers: headers)
}
case .patch:
if let encodableParams = parameters as? Encodable {
retryRequest = AF.request("\(serverConfig.address)/\(endpoint)", method: .patch, parameters: encodableParams, encoder: JSONParameterEncoder.default, headers: headers)
} else {
retryRequest = AF.request("\(serverConfig.address)/\(endpoint)", method: .patch, headers: headers)
}
default:
AbsLogger.error(message: "retryOriginalRequest: Unsupported method \(method)")
callback?(nil)
return
}
// Handle the response
retryRequest.response { response in
if let statusCode = response.response?.statusCode, (200...299).contains(statusCode) {
// Check if response has data
if let data = response.data, !data.isEmpty {
// If it is a string return nil (e.g. express returns OK for 200 status codes)
if let responseString = String(data: data, encoding: .utf8) {
AbsLogger.info(message: "retryOriginalRequest: Got string response '\(responseString)'")
callback?(nil)
return
}
// If not a string, try JSON
do {
let decodedObject = try JSONDecoder().decode(decodable, from: data)
callback?(decodedObject)
} catch {
AbsLogger.error(message: "retryOriginalRequest: JSON decode failed: \(error)", error: error)
callback?(nil)
}
} else {
// Empty response
AbsLogger.info(message: "retryOriginalRequest: Empty response with success status \(statusCode)")
callback?(nil)
}
} else {
AbsLogger.error(message: "retryOriginalRequest: Request failed with status \(response.response?.statusCode ?? 0)")
callback?(nil)
}
}
}
/**
* Handles the case when token refresh fails
* This will clear the current server connection and notify webview
*/
private static func handleRefreshFailure() {
AbsLogger.info(message: "handleRefreshFailure: Token refresh failed, clearing session")
// Clear the current server connection
Store.serverConfig = nil
// Remove refresh token from secure storage
if let serverConfig = Store.serverConfig {
_ = secureStorage.removeRefreshToken(serverConnectionConfigId: serverConfig.id)
}
// Notify webview frontend about token refresh failure
if let callback = AbsDatabase.tokenRefreshCallback {
callback("onTokenRefreshFailure", ["error": "Token refresh failed"])
}
}
// MARK: - Enhanced API Methods with Token Refresh
public static func getResourceWithTokenRefresh<T: Decodable>(endpoint: String, decodable: T.Type = T.self, callback: ((_ param: T?) -> Void)?) {
if (Store.serverConfig == nil) {
AbsLogger.error(message: "Server config not set")
callback?(nil)
return
}
let headers: HTTPHeaders = [
"Authorization": "Bearer \(Store.serverConfig!.token)"
]
let request = AF.request("\(Store.serverConfig!.address)/\(endpoint)", method: .get, headers: headers)
request.responseDecodable(of: decodable) { response in
if let statusCode = response.response?.statusCode, statusCode == 401 {
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)
} else {
switch response.result {
case .success(let obj):
callback?(obj)
case .failure(let error):
AbsLogger.error(message: "api request to \(endpoint) failed")
print(error)
callback?(nil)
}
}
}
}
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) {
AbsLogger.error(message: "Server config not set")
callback?(nil)
return
}
let headers: HTTPHeaders = [
"Authorization": "Bearer \(Store.serverConfig!.token)"
]
let request = AF.request("\(Store.serverConfig!.address)/\(endpoint)", method: .post, parameters: parameters, encoder: JSONParameterEncoder.default, headers: headers)
request.responseDecodable(of: decodable) { response in
if let statusCode = response.response?.statusCode, statusCode == 401 {
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)
} else {
switch response.result {
case .success(let obj):
callback?(obj)
case .failure(let error):
AbsLogger.error(message: "api request to \(endpoint) failed")
print(error)
callback?(nil)
}
}
}
}
/**
* POST request for endpoints that only return success/failure
*/
public static func postResourceWithTokenRefresh<T: Encodable>(endpoint: String, parameters: T, callback: ((_ success: Bool) -> Void)?) {
if (Store.serverConfig == nil) {
AbsLogger.error(message: "Server config not set")
callback?(false)
return
}
let headers: HTTPHeaders = [
"Authorization": "Bearer \(Store.serverConfig!.token)"
]
let request = AF.request("\(Store.serverConfig!.address)/\(endpoint)", method: .post, parameters: parameters, encoder: JSONParameterEncoder.default, headers: headers)
request.response { response in
if let statusCode = response.response?.statusCode, statusCode == 401 {
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
callback?(result != nil)
}
} else {
switch response.result {
case .success(_):
callback?(true)
case .failure(let error):
AbsLogger.error(message: "api request to \(endpoint) failed")
print(error)
callback?(false)
}
}
}
}
public static func patchResourceWithTokenRefresh<T: Encodable>(endpoint: String, parameters: T, callback: ((_ success: Bool) -> Void)?) {
if (Store.serverConfig == nil) {
AbsLogger.error(message: "Server config not set")
callback?(false)
return
}
let headers: HTTPHeaders = [
"Authorization": "Bearer \(Store.serverConfig!.token)"
]
let request = AF.request("\(Store.serverConfig!.address)/\(endpoint)", method: .patch, parameters: parameters, encoder: JSONParameterEncoder.default, headers: headers)
request.response { response in
if let statusCode = response.response?.statusCode, statusCode == 401 {
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
callback?(result != nil)
}
} else {
switch response.result {
case .success(_):
callback?(true)
case .failure(let error):
AbsLogger.error(message: "api request to \(endpoint) failed")
print(error)
callback?(false)
}
}
}
}
// MARK: - API Functions
public static func startPlaybackSession(libraryItemId: String, episodeId: String?, forceTranscode:Bool, callback: @escaping (_ param: PlaybackSession) -> Void) {
var endpoint = "api/items/\(libraryItemId)/play"
if episodeId != nil {
@@ -160,20 +472,28 @@ class ApiClient {
}
}
let parameters: [String: Any] = [
"forceDirectPlay": !forceTranscode ? "1" : "",
"forceTranscode": forceTranscode ? "1" : "",
"mediaPlayer": "AVPlayer",
"deviceInfo": [
"deviceId": UIDevice.current.identifierForVendor?.uuidString,
"manufacturer": "Apple",
"model": modelCode,
"clientVersion": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
]
]
ApiClient.postResource(endpoint: endpoint, parameters: parameters, decodable: PlaybackSession.self) { obj in
let session = obj
// Create an Encodable struct for the parameters
let parameters = PlaybackSessionRequest(
forceDirectPlay: !forceTranscode ? "1" : "",
forceTranscode: forceTranscode ? "1" : "",
mediaPlayer: "AVPlayer",
deviceInfo: DeviceInfo(
deviceId: UIDevice.current.identifierForVendor?.uuidString,
manufacturer: "Apple",
model: modelCode,
clientVersion: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
)
)
// Use the new token refresh-enabled method
postResourceWithTokenRefresh(endpoint: endpoint, parameters: parameters, decodable: PlaybackSession.self) { session in
guard let session = session else {
AbsLogger.error(message: "startPlaybackSession: Failed to create playback session")
callback(PlaybackSession()) // Return empty session on failure
return
}
// Set server connection info on the session
session.serverConnectionConfigId = Store.serverConfig!.id
session.serverAddress = Store.serverConfig!.address
@@ -182,15 +502,28 @@ class ApiClient {
}
public static func reportPlaybackProgress(report: PlaybackReport, sessionId: String) async -> Bool {
return await postResource(endpoint: "api/session/\(sessionId)/sync", parameters: report)
return await withCheckedContinuation { continuation in
postResourceWithTokenRefresh(endpoint: "api/session/\(sessionId)/sync", parameters: report) { success in
continuation.resume(returning: success)
}
}
}
public static func reportLocalPlaybackProgress(_ session: PlaybackSession) async -> Bool {
return await postResource(endpoint: "api/session/local", parameters: session)
return await withCheckedContinuation { continuation in
postResourceWithTokenRefresh(endpoint: "api/session/local", parameters: session) { success in
continuation.resume(returning: success)
}
}
}
public static func reportAllLocalPlaybackSessions(_ sessions: [PlaybackSession]) async -> Bool {
return await postResource(endpoint: "api/session/local-all", parameters: LocalPlaybackSessionSyncAllPayload(sessions: sessions, deviceInfo: sessions.first?.deviceInfo))
return await withCheckedContinuation { continuation in
let payload = LocalPlaybackSessionSyncAllPayload(sessions: sessions, deviceInfo: sessions.first?.deviceInfo)
postResourceWithTokenRefresh(endpoint: "api/session/local-all", parameters: payload) { success in
continuation.resume(returning: success)
}
}
}
public static func syncLocalSessionsWithServer(isFirstSync: Bool) async {
@@ -199,14 +532,14 @@ class ApiClient {
let localMediaProgressList = Database.shared.getAllLocalMediaProgress().filter {
$0.serverConnectionConfigId == Store.serverConfig?.id
}.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) {
logger.log("syncLocalSessionsWithServer: No local progress to sync")
AbsLogger.info(message: "syncLocalSessionsWithServer: No local progress to sync")
} else {
let currentUser = await ApiClient.getCurrentUser()
guard let currentUser = currentUser else {
logger.log("syncLocalSessionsWithServer: No User")
AbsLogger.info(message: "syncLocalSessionsWithServer: No User")
return
}
try currentUser.mediaProgress.forEach { mediaProgress in
@@ -218,12 +551,12 @@ class ApiClient {
}
}
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() {
try localMediaProgress.updateFromServerMediaProgress(mediaProgress)
}
} 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")
}
}
}
@@ -232,13 +565,13 @@ class ApiClient {
let playbackSessions = Database.shared.getAllPlaybackSessions().filter {
$0.serverConnectionConfigId == Store.serverConfig?.id
}.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) {
let success = await ApiClient.reportAllLocalPlaybackSessions(playbackSessions)
if (success) {
// Remove sessions from db
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
if (!session.isActiveSession || isFirstSync) {
if let session = session.thaw() {
@@ -255,31 +588,39 @@ class ApiClient {
}
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 ?? "")"
patchResource(endpoint: endpoint, parameters: payload) { success in
patchResourceWithTokenRefresh(endpoint: endpoint, parameters: payload) { _ in
callback()
}
}
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 ?? "")"
return await getResource(endpoint: endpoint, decodable: MediaProgress.self)
return await withCheckedContinuation { continuation in
getResourceWithTokenRefresh(endpoint: endpoint, decodable: MediaProgress.self) { result in
continuation.resume(returning: result)
}
}
}
public static func getCurrentUser() async -> User? {
logger.log("getCurrentUser")
return await getResource(endpoint: "api/me", decodable: User.self)
AbsLogger.info(message: "getCurrentUser")
return await withCheckedContinuation { continuation in
getResourceWithTokenRefresh(endpoint: "api/me", decodable: User.self) { result in
continuation.resume(returning: result)
}
}
}
public static func getLibraryItemWithProgress(libraryItemId:String, episodeId:String?, callback: @escaping (_ param: LibraryItem?) -> Void) {
public static func getLibraryItemWithProgress(libraryItemId: String, episodeId: String?, callback: @escaping (_ param: LibraryItem?) -> Void) {
var endpoint = "api/items/\(libraryItemId)?expanded=1&include=progress"
if episodeId != nil {
endpoint += "&episodeId=\(episodeId!)"
}
ApiClient.getResource(endpoint: endpoint, decodable: LibraryItem.self) { obj in
getResourceWithTokenRefresh(endpoint: endpoint, decodable: LibraryItem.self) { obj in
callback(obj)
}
}
@@ -338,3 +679,30 @@ struct Connectivity {
return self.sharedInstance.isReachable
}
}
// MARK: - Response Models
struct RefreshResponse: Decodable {
let user: RefreshUser?
}
struct RefreshUser: Decodable {
let accessToken: String
let refreshToken: String?
}
struct EmptyResponse: Decodable {}
struct PlaybackSessionRequest: Encodable {
let forceDirectPlay: String
let forceTranscode: String
let mediaPlayer: String
let deviceInfo: DeviceInfo
}
struct DeviceInfo: Encodable {
let deviceId: String?
let manufacturer: String
let model: String?
let clientVersion: String?
}
+76 -8
View File
@@ -12,10 +12,14 @@ class Database {
public static var shared = {
return Database()
}()
private let logger = AppLogger(category: "Database")
private init() {}
private init() {
do {
try cleanExpiredLogs()
} catch {
debugPrint(error)
}
}
public func setServerConnectionConfig(config: ServerConnectionConfig) {
let config = config
@@ -27,12 +31,13 @@ class Database {
try existing.update {
existing.name = config.name
existing.address = config.address
existing.version = config.version
existing.userId = config.userId
existing.username = config.username
existing.token = config.token
}
} catch {
logger.error("failed to update server config")
AbsLogger.error("setServerConn", message: "failed to update server config")
debugPrint(error)
}
@@ -53,13 +58,26 @@ class Database {
realm.add(config)
}
} catch(let exception) {
logger.error("failed to save server config")
AbsLogger.error(message: "failed to save server config")
debugPrint(exception)
}
setLastActiveConfigIndex(index: config.index)
}
}
public func updateServerConnectionConfigToken(newToken: String) {
do {
let realm = try Realm()
if let config = realm.objects(ServerConnectionConfig.self).first(where: { $0.index == getLastActiveConfigIndex() }) {
try realm.write {
config.token = newToken
}
}
} catch {
debugPrint("Failed to update server connection config token: \(error)")
}
}
public func deleteServerConnectionConfig(id: String) {
let realm = try! Realm()
@@ -72,7 +90,7 @@ class Database {
}
}
} catch(let exception) {
logger.error("failed to delete server config")
AbsLogger.error(message: "failed to delete server config")
debugPrint(exception)
}
}
@@ -103,7 +121,7 @@ class Database {
}
}
} catch(let exception) {
logger.error("failed to save server config active index")
AbsLogger.error(message: "failed to save server config active index")
debugPrint(exception)
}
}
@@ -123,7 +141,7 @@ class Database {
realm.add(deviceSettings)
}
} catch {
logger.error("failed to save device settings")
AbsLogger.error(message: "failed to save device settings")
}
}
@@ -261,4 +279,54 @@ class Database {
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)
return try JSONDecoder().decode(type, from: json)
} catch {
AppLogger().error("Failed to get json for \(key)")
debugPrint(error)
AbsLogger.error(message: "Failed to get json for \(key)", error: error)
return nil
}
}
@@ -106,3 +105,80 @@ extension URL {
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())
}
}
+10 -2
View File
@@ -22,8 +22,16 @@ struct NowPlayingMetadata {
return item.coverUrl
} else {
guard let config = Store.serverConfig else { return nil }
guard let url = URL(string: "\(config.address)/api/items/\(itemId)/cover?token=\(config.token)") else { return nil }
return url
// As of v2.17.0 token is not needed with cover image requests
let coverUrlString: String
if Store.isServerVersionGreaterThanOrEqualTo("2.17.0") {
coverUrlString = "\(config.address)/api/items/\(itemId)/cover"
} else {
coverUrlString = "\(config.address)/api/items/\(itemId)/cover?token=\(config.token)"
}
return URL(string: coverUrlString)
}
}
}
+116
View File
@@ -0,0 +1,116 @@
//
// SecureStorage.swift
// App
//
// Created by advplyr on 2025-07-05
//
import Foundation
import Security
class SecureStorage {
private static let tag = "SecureStorage"
private static let serviceName = "AudiobookshelfRefreshTokens"
/**
* Encrypts and stores a refresh token for a specific server connection
*/
func storeRefreshToken(serverConnectionConfigId: String, refreshToken: String) -> Bool {
let key = "refresh_token_\(serverConnectionConfigId)"
guard let data = refreshToken.data(using: .utf8) else {
print("\(Self.tag): Failed to convert refresh token to data")
return false
}
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: Self.serviceName,
kSecAttrAccount as String: key,
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock
]
// First, try to delete any existing item
SecItemDelete(query as CFDictionary)
// Then add the new item
let status = SecItemAdd(query as CFDictionary, nil)
if status == errSecSuccess {
print("\(Self.tag): Successfully stored encrypted refresh token for server: \(serverConnectionConfigId)")
return true
} else {
print("\(Self.tag): Failed to store refresh token for server: \(serverConnectionConfigId), status: \(status)")
return false
}
}
/**
* Retrieves and decrypts a refresh token for a specific server connection
*/
func getRefreshToken(serverConnectionConfigId: String) -> String? {
let key = "refresh_token_\(serverConnectionConfigId)"
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: Self.serviceName,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecSuccess,
let data = result as? Data,
let refreshToken = String(data: data, encoding: .utf8) {
return refreshToken
} else {
print("\(Self.tag): Failed to retrieve refresh token for server: \(serverConnectionConfigId), status: \(status)")
return nil
}
}
/**
* Removes a refresh token for a specific server connection
*/
func removeRefreshToken(serverConnectionConfigId: String) -> Bool {
let key = "refresh_token_\(serverConnectionConfigId)"
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: Self.serviceName,
kSecAttrAccount as String: key
]
let status = SecItemDelete(query as CFDictionary)
if status == errSecSuccess || status == errSecItemNotFound {
print("\(Self.tag): Successfully removed refresh token for server: \(serverConnectionConfigId)")
return true
} else {
print("\(Self.tag): Failed to remove refresh token for server: \(serverConnectionConfigId), status: \(status)")
return false
}
}
/**
* Checks if a refresh token exists for a specific server connection
*/
func hasRefreshToken(serverConnectionConfigId: String) -> Bool {
let key = "refresh_token_\(serverConnectionConfigId)"
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: Self.serviceName,
kSecAttrAccount as String: key,
kSecReturnData as String: false,
kSecMatchLimit as String: kSecMatchLimitOne
]
let status = SecItemCopyMatching(query as CFDictionary, nil)
return status == errSecSuccess
}
}
+42
View File
@@ -29,4 +29,46 @@ class Store {
}
}
}
/**
* Check if the currently connected server version is >= compareVersion
* Abs server only uses major.minor.patch
* Note: Version is returned in Abs auth payloads starting v2.6.0
* Note: Version is saved with the server connection config starting after v0.9.81
*
* @example
* serverVersion=2.25.1
* isServerVersionGreaterThanOrEqualTo("2.26.0") = false
*
* serverVersion=2.26.1
* isServerVersionGreaterThanOrEqualTo("2.26.0") = true
*/
public static func isServerVersionGreaterThanOrEqualTo(_ compareVersion: String) -> Bool {
guard let serverConfig = serverConfig, !serverConfig.version.isEmpty else {
return false
}
if compareVersion.isEmpty {
return true
}
let serverVersionParts = serverConfig.version.split(separator: ".").compactMap { Int($0) }
let compareVersionParts = compareVersion.split(separator: ".").compactMap { Int($0) }
// Compare major, minor, and patch components
let maxLength = max(serverVersionParts.count, compareVersionParts.count)
for i in 0..<maxLength {
let serverVersionComponent = i < serverVersionParts.count ? serverVersionParts[i] : 0
let compareVersionComponent = i < compareVersionParts.count ? compareVersionParts[i] : 0
if serverVersionComponent < compareVersionComponent {
return false // Server version is less than compareVersion
} else if serverVersionComponent > compareVersionComponent {
return true // Server version is greater than compareVersion
}
}
return true // versions are equal in major, minor, and patch
}
}
+27 -16
View File
@@ -99,21 +99,6 @@ export default {
await this.$store.dispatch('user/loadUserSettings')
},
async postRequest(url, data, headers, connectTimeout = 30000) {
const options = {
url,
headers,
data,
connectTimeout
}
const response = await CapacitorHttp.post(options)
console.log('[default] POST request response', response)
if (response.status >= 400) {
throw new Error(response.data)
} else {
return response.data
}
},
async attemptConnection() {
console.warn('[default] attemptConnection')
if (!this.networkConnected) {
@@ -145,10 +130,18 @@ export default {
AbsLogger.info({ tag: 'default', message: `attemptConnection: Got server config, attempt authorize (${serverConfig.name})` })
const authRes = await this.postRequest(`${serverConfig.address}/api/authorize`, null, { Authorization: `Bearer ${serverConfig.token}` }, 6000).catch((error) => {
const nativeHttpOptions = {
headers: {
Authorization: `Bearer ${serverConfig.token}`
},
connectTimeout: 6000,
serverConnectionConfig: serverConfig
}
const authRes = await this.$nativeHttp.post(`${serverConfig.address}/api/authorize`, null, nativeHttpOptions).catch((error) => {
AbsLogger.error({ tag: 'default', message: `attemptConnection: Server auth failed (${serverConfig.name})` })
return false
})
if (!authRes) {
this.attemptingConnection = false
return
@@ -158,6 +151,22 @@ export default {
this.$store.commit('setServerSettings', serverSettings)
this.$store.commit('libraries/setEReaderDevices', ereaderDevices)
if (this.$isValidVersion(serverSettings.version, '2.26.0')) {
// Check if the server is using the new JWT auth and is still using an old token in the server config
// If so, redirect to /connect and request to re-login
if (serverConfig.token === user.token || user.isOldToken) {
this.attemptingConnection = false
AbsLogger.info({ tag: 'default', message: `attemptConnection: Server is using new JWT auth but config is still using an old token (server version: ${serverSettings.version}) (${serverConfig.name})` })
// Clear last server config
await this.$store.dispatch('user/logout')
this.$router.push(`/connect?error=oldAuthToken&serverConnectionConfigId=${serverConfig.id}`)
return
}
// Token may have been refreshed during the authorize call so refetch from store
serverConfig.token = this.$store.getters['user/getToken'] || serverConfig.token
}
// Set library - Use last library if set and available fallback to default user library
const lastLibraryId = await this.$localStore.getLastLibraryId()
if (lastLibraryId && (!user.librariesAccessible.length || user.librariesAccessible.includes(lastLibraryId))) {
@@ -165,9 +174,11 @@ export default {
} else if (userDefaultLibraryId) {
this.$store.commit('libraries/setCurrentLibrary', userDefaultLibraryId)
}
serverConfig.version = serverSettings.version
const serverConnectionConfig = await this.$db.setServerConnectionConfig(serverConfig)
this.$store.commit('user/setUser', user)
this.$store.commit('user/setAccessToken', serverConnectionConfig.token)
this.$store.commit('user/setServerConnectionConfig', serverConnectionConfig)
this.$socket.connect(serverConnectionConfig.address, serverConnectionConfig.token)
+1 -1
View File
@@ -30,7 +30,7 @@ export default {
css: ['@/assets/tailwind.css', '@/assets/app.css'],
plugins: ['@/plugins/server.js', '@/plugins/db.js', '@/plugins/localStore.js', '@/plugins/init.client.js', '@/plugins/axios.js', '@/plugins/nativeHttp.js', '@/plugins/capacitor/index.js', '@/plugins/capacitor/AbsAudioPlayer.js', '@/plugins/toast.js', '@/plugins/constants.js', '@/plugins/haptics.js', '@/plugins/i18n.js'],
plugins: ['@/plugins/server.js', '@/plugins/db.js', '@/plugins/localStore.js', '@/plugins/init.client.js', '@/plugins/axios.js', '@/plugins/capacitor/index.js', '@/plugins/capacitor/AbsAudioPlayer.js', '@/plugins/nativeHttp.js', '@/plugins/toast.js', '@/plugins/constants.js', '@/plugins/haptics.js', '@/plugins/i18n.js'],
components: true,
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "audiobookshelf-app",
"version": "0.9.80-beta",
"version": "0.11.0-beta",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "audiobookshelf-app",
"version": "0.9.80-beta",
"version": "0.11.0-beta",
"dependencies": {
"@capacitor-community/keep-awake": "^7.0.0",
"@capacitor-community/volume-buttons": "^7.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "audiobookshelf-app",
"version": "0.9.80-beta",
"version": "0.11.0-beta",
"author": "advplyr",
"scripts": {
"dev": "nuxt --hostname 0.0.0.0 --port 1337",
+9 -11
View File
@@ -4,6 +4,10 @@
<ui-text-input-with-label :value="username" :label="$strings.LabelUsername" disabled class="my-2" />
<div v-if="serverVersion" class="text-sm text-fg">
<p>Server version: v{{ serverVersion }}</p>
</div>
<ui-btn color="primary flex items-center justify-between gap-2 ml-auto text-base mt-8" @click="logout">{{ $strings.ButtonSwitchServerUser }}<span class="material-symbols" style="font-size: 1.1rem">logout</span></ui-btn>
<div class="flex justify-center items-center my-4 left-0 right-0 bottom-0 absolute">
@@ -43,22 +47,16 @@ export default {
},
serverAddress() {
return this.serverConnectionConfig.address
},
serverVersion() {
// Saved in server connection config after 0.9.81
return this.serverConnectionConfig.version
}
},
methods: {
async logout() {
await this.$hapticsImpact()
if (this.user) {
await this.$nativeHttp.post('/logout').catch((error) => {
console.error(error)
})
}
this.$socket.logout()
await this.$db.logout()
this.$localStore.removeLastLibraryId()
this.$store.commit('user/logout')
this.$store.commit('libraries/setCurrentLibrary', null)
await this.$store.dispatch('user/logout')
this.$router.push('/connect')
}
},
+6
View File
@@ -74,6 +74,12 @@ export default {
console.log(`[categories] networkConnected false so fetching categories`)
this.fetchCategories()
}
},
user(newVal, oldVal) {
if ((newVal && !oldVal) || (!newVal && oldVal)) {
console.log(`[categories] user changed so fetching categories`)
this.fetchCategories()
}
}
},
computed: {
+1 -1
View File
@@ -62,7 +62,7 @@ export default {
async loadRecentEpisodes(page = 0) {
this.loadedLibraryId = this.currentLibraryId
this.processing = true
const episodePayload = await this.$nativeHttp.get(`/api/libraries/${this.currentLibraryId}/recent-episodes?limit=25&page=${page}`).catch((error) => {
const episodePayload = await this.$nativeHttp.get(`/api/libraries/${this.currentLibraryId}/recent-episodes?limit=50&page=${page}`).catch((error) => {
console.error('Failed to get recent episodes', error)
this.$toast.error('Failed to get recent episodes')
return null
+1 -1
View File
@@ -37,9 +37,9 @@ export default {
computed: {},
methods: {
async init() {
await this.$store.dispatch('setupNetworkListener')
this.deviceData = await this.$db.getDeviceData()
this.$store.commit('setDeviceData', this.deviceData)
await this.$store.dispatch('setupNetworkListener')
}
},
mounted() {

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