Compare commits

..
Author SHA1 Message Date
advplyr 2ac4de4d58 Bump iOS build number 2026-09-11 16:06:49 -05:00
advplyr 68931febf6 Fix crash on cleanExpiredLogs for iOS 2026-09-11 15:57:59 -05:00
advplyr 8561ada1d2 Merge pull request #2019 from nichwall/ios-workflow
Ios debug build workflow
2026-09-11 15:00:22 -05:00
Nicholas Wallace 06f4ba2e82 Delete iOS release workflow for separate PR 2026-09-11 06:43:42 -07:00
Nicholas Wallace 353e2f02df Reapply "Fix package-lock for macOS runner"
This reverts commit 94db1f72a7.
2026-09-11 06:38:58 -07:00
advplyr 2927ab92ae Bump iOS build number 2026-09-11 06:22:42 -05:00
Nicholas Wallace 94db1f72a7 Revert "Fix package-lock for macOS runner"
This reverts commit 607e5fc670.
2026-09-10 17:18:10 -07:00
Nicholas Wallace bf92d18b48 Change workflows to exclude optional dependencies 2026-09-10 17:16:19 -07:00
Nicholas Wallace c4aed2587f Initial IPA and Testflight push workflow 2026-09-10 16:36:23 -07:00
Nicholas Wallace 7a5e4f559c Add quiet flag to reduce amount of logs printed 2026-09-10 16:31:20 -07:00
Nicholas Wallace fe14ec0f45 Delete temporary workflow 2026-09-10 16:15:51 -07:00
Nicholas Wallace 607e5fc670 Fix package-lock for macOS runner 2026-09-10 16:15:22 -07:00
Nicholas Wallace a34a1bb2b9 Dummy workflow to get macOS npm dependencies 2026-09-10 16:13:23 -07:00
Nicholas Wallace e48f2f0da2 Initial iOS build workflow 2026-09-10 16:02:27 -07:00
Nicholas Wallace 32a8212653 Add inverted iOS icons for debug builds 2026-09-10 16:01:50 -07:00
advplyr 9a2f0c3e4a Add one time wipe of logs for iOS 2026-09-10 17:03:20 -05:00
advplyr 7292e367d2 iOS version bump 0.14.1 2026-09-08 17:35:53 -05:00
advplyr fe8f5de891 Update android target sdk to 36 2026-09-08 17:35:36 -05:00
advplyr c6d0f0e8b5 Version bump v0.14.1-beta 2026-09-08 17:22:02 -05:00
advplyr 14885467a4 Add padding on report bugs message on account page 2026-09-08 17:13:49 -05:00
advplyr 87f1c83cbf Merge branch 'master' of https://github.com/advplyr/audiobookshelf-app 2026-09-08 17:11:14 -05:00
advplyr b706337852 Fix iOS expired logs never clearing 2026-09-08 17:10:30 -05:00
advplyr 34f2165ef6 Merge pull request #1956 from weblate/weblate-audiobookshelf-abs-mobile-app
Translations update from Hosted Weblate
2026-09-08 16:36:14 -05:00
Pavel Miniutka 88ab456ce5 Translated using Weblate (Belarusian)
Currently translated at 100.0% (381 of 381 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/be/
2026-09-08 23:28:15 +02:00
J. Lavoie 022a9c5875 Translated using Weblate (Greek)
Currently translated at 71.9% (274 of 381 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/el/
2026-09-08 23:28:15 +02:00
J. Lavoie 4cb4183a12 Translated using Weblate (Finnish)
Currently translated at 98.9% (377 of 381 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/fi/
2026-09-08 23:28:15 +02:00
J. Lavoie 62e80cb469 Translated using Weblate (Italian)
Currently translated at 99.7% (380 of 381 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/it/
2026-09-08 23:28:15 +02:00
J. Lavoie 37b19e9a7b Translated using Weblate (French)
Currently translated at 100.0% (381 of 381 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/fr/
2026-09-08 23:28:15 +02:00
J. Lavoie 961e66b863 Translated using Weblate (German)
Currently translated at 100.0% (381 of 381 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/de/
2026-09-08 23:28:15 +02:00
samia shanta 6c56003c26 Translated using Weblate (Bengali)
Currently translated at 87.6% (334 of 381 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/bn/
2026-09-08 23:28:15 +02:00
Martynas d44dbd770a Translated using Weblate (Lithuanian)
Currently translated at 55.9% (213 of 381 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/lt/
2026-09-08 23:28:15 +02:00
kuci-JK ca04bb7a3e Translated using Weblate (Czech)
Currently translated at 99.2% (378 of 381 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/cs/
2026-09-08 23:28:15 +02:00
ugyes c832c86ca4 Translated using Weblate (Hungarian)
Currently translated at 100.0% (381 of 381 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/hu/
2026-09-08 23:28:15 +02:00
Jan Paulzen 64cbb41b0b Translated using Weblate (German)
Currently translated at 100.0% (381 of 381 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/de/
2026-09-08 23:28:15 +02:00
advplyr d11d9860b6 iOS update refresh token failure to only logout when request returns 401/403 2026-09-08 16:28:01 -05:00
advplyr c1a0b96ae4 iOS version bump 0.14.0 2026-09-08 16:27:27 -05:00
epipenRX a09326b5b2 Pause instead of close playback on KEYCODE_MEDIA_STOP (#1913)
Car head units send KEYCODE_MEDIA_STOP when powering off, which was
calling closePlayback() and destroying the session entirely. This removed
the media notification from the shade and required manually reopening the
app to resume listening.

Change to pause() so the session and notification stay alive, matching
the behavior of YouTube, Plex, and Jellyfin. When the car powers back on
and sends KEYCODE_MEDIA_PLAY, playback resumes in the same session.
2026-09-07 17:41:37 -05:00
advplyr 1a290b4d07 Merge pull request #2000 from JorenVerbaandert/fix/playback-session-bug
Fix playback session bug
2026-09-07 17:32:42 -05:00
advplyr 8d8147ffea Merge pull request #2003 from ljlongwing/fix-1711-1712-library-switch
Leave playlist/collection detail pages when the library changes (#1711, #1712)
2026-09-07 17:20:56 -05:00
advplyr 17fcec4401 Merge pull request #1972 from Michael-128/fix/ipados-resize
Fix Stage Manager resize issue on iPadOS (#1563)
2026-09-07 17:18:05 -05:00
advplyr 79a925088f Merge pull request #2008 from huggenknubbel/cast-sleep-timer-volume
Let the sleep timer fade out on a cast device too
2026-09-07 17:13:49 -05:00
advplyr 2fc8735643 Merge pull request #2009 from huggenknubbel/cast-availability
Report receiver availability from the framework as well
2026-09-07 17:13:21 -05:00
advplyr 0b1dfa4c34 Merge pull request #2002 from nichwall/android-download-followup
Android download followup
2026-09-06 18:22:08 -05:00
advplyr 30c4fa7e16 Add try catch around scan download item 2026-09-06 18:11:49 -05:00
advplyr 71bd354bc7 Refactor token refresh to better handle types of failures and retries during download 2026-09-06 17:53:46 -05:00
advplyr e06ce4a868 Only clear refresh token and logout when refresh request explicitly rejects, prevents transient request errors from causing a logout 2026-09-06 17:16:36 -05:00
advplyr 90f8d99287 Merge pull request #2001 from JorenVerbaandert/docs/update-bug-report-template
Add 0.14 to bug report template, remove 0.12
2026-09-06 16:46:10 -05:00
huggenknubbel 1cdf0a59b1 cast: report receiver availability from the framework as well
ChromecastListener has always implemented CastStateListener, but nothing
ever registered it. Whether a receiver was reported as available hung
entirely on the media router callback, which does not always fire - and
then the cast button never appears even though devices are there.

The framework's own signal is registered now and the current state is
reported straight away. It is not taken at face value in one direction:
the framework drops back to NO_DEVICES_AVAILABLE when a session ends,
because it stops discovering at that point, which would hide the button
right after someone stops casting. The devices the media router still
knows decide that case.

The listener is dropped again when the activity goes.
2026-09-03 08:15:20 +02:00
Nicholas Wallace 6560038495 Use actual file size from server it size does not match metadata 2026-09-02 13:30:04 -07:00
Nicholas Wallace 4b8abe7deb Refresh auth token on download 401 2026-09-02 10:39:15 -07:00
Nicholas Wallace d230b16d5e Restore fullname check 2026-09-02 10:37:59 -07:00
huggenknubbel 0e71eb0308 cast: let the sleep timer fade out on a cast device too
The sleep timer fades playback down to 10% over its last minute by setting the
player's volume. CastPlayer.setVolume was an empty stub and getVolume returned
zero, so while casting the fade did nothing at all.

Both now go through the media stream volume the Cast SDK offers. That is the
receiver's volume for this stream only: the device volume stays where the user
put it, and on a cast group the balance between its members is untouched,
because the receiver applies the stream volume to the group's output.
2026-08-30 23:38:27 +02:00
LJ 4a1cf0ea4a Leave playlist/collection detail pages when the library changes (#1711, #1712)
The item detail page already listens for the app-wide `library-changed`
event and navigates away; the playlist and collection detail pages did
not, so switching libraries left them showing stale content with no way
back to the list.

- playlist/_id.vue: route to /bookshelf/playlists on library change
- collection/_id.vue: route to /bookshelf/collections unless the new
  library is the collection's own library
2026-08-28 13:03:59 -06:00
Nicholas Wallace d2196ce035 Add download complete to logs 2026-08-28 07:36:53 -07:00
Nicholas Wallace a100226a85 Add AbsLogger to download paths 2026-08-27 20:38:48 -07:00
Nicholas Wallace ba66869502 Remove unnecessary changes 2026-08-27 15:31:32 -07:00
Nicholas Wallace 02e3f3541f Simplify and revert Capacitor changes 2026-08-27 15:12:32 -07:00
Nicholas Wallace f25ff6344e Inline download resume policy 2026-08-27 15:12:04 -07:00
Nicholas Wallace c800ed727a Fix concurrent podcast download cover issue 2026-08-27 14:52:14 -07:00
Joren Verbaandert 4eb848ae5f Update the feature request template 2026-08-27 22:53:48 +02:00
Joren Verbaandert 9dfa68ea8b Add 0.14 to bug report template, remove 0.12 2026-08-27 22:35:29 +02:00
Nicholas Wallace 7989607313 Retry from beginning of file on failure and restore 2026-08-27 13:26:51 -07:00
Nicholas Wallace 61c314a531 Initial download service startup fixes and range request fix 2026-08-27 13:08:46 -07:00
Joren Verbaandert a709e994a0 Fix playback session bug 2026-08-27 21:26:12 +02:00
Michael-128 96fc9adbbf Fix Stage Manager resize issue on iPadOS (#1563) 2026-08-24 18:12:06 +02:00
advplyr 12025ab59a Version bump 0.14.0-beta 2026-08-04 17:26:09 -05:00
advplyr f66840e8e4 Merge pull request #1948 from weblate/weblate-audiobookshelf-abs-mobile-app
Translations update from Hosted Weblate
2026-08-04 17:17:03 -05:00
violawang0401-cloud 4c3e098785 Translated using Weblate (Chinese (Traditional Han script))
Currently translated at 100.0% (381 of 381 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/zh_Hant/
2026-08-04 23:59:01 +02:00
violawang0401-cloud 441086e773 Translated using Weblate (Chinese (Traditional Han script))
Currently translated at 94.7% (361 of 381 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/zh_Hant/
2026-08-04 23:59:00 +02:00
FiendFEARing 1408a4c3ff Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (381 of 381 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/zh_Hans/
2026-08-04 23:59:00 +02:00
advplyrandadvplyr fe67a221ac Render a higher resolution cover in the Android notification background (#1917)
* Render a higher resolution bitmap in the notification background art

Previously only METADATA_KEY_ALBUM_ART_URI/ART_URI were set, leaving notification/lock-screen/Android Auto consumers to resolve the cover art themselves. Likely due to internal scaling, a lower quality bitmap was loaded.

This commit adds a shared resolveUriAsBitmap() helper (extracted from AbMediaDescriptionAdapter.kt) and uses it to:
  - Resolve and cache a bitmap on PlaybackSession once per
  session (synchronously for local covers, asynchronously for server covers), then set it on METADATA_KEY_ALBUM_ART/ART once available, falling back to the URI keys until it resolves.
  - Reuse the same helper in AbMediaDescriptionAdapter so the notification icon is resolved consistently.

  PlayerNotificationService tracks the resolution Job and cancels/replaces it on each new playback session, invalidating the media session metadata once the bitmap is ready.

* Remove the size override in Glide

The override was not needed. It wasn't in the original code, so I'll remove it for consistency.

* Update notification icon to reuse cover bitmap if already loaded on session

---------

Co-authored-by: advplyr <advplyr@protonmail.com>
2026-08-04 16:58:47 -05:00
advplyr f087cbed6f Update notification icon to reuse cover bitmap if already loaded on session 2026-08-04 16:57:00 -05:00
databoy2kanddataboy2k 9820db0ef6 fix(android-auto): run server connection check off the main thread (#1920)
Android Auto browsing could freeze and crash the app (phone app "closes",
Android Auto shows a generic connection error) whenever the Capacitor
webview had not yet established the server connection.

Root cause: onLoadChildren runs on the media browser service main thread.
It calls loadAndroidAutoItems -> checkSetValidServerConnectionConfig, which
used runBlocking to ping every saved server config and authorize the user.
That blocked the main thread on network I/O. Android Auto (plus Assistant)
re-request the browse root every ~2s, and every onGetRoot flags a reload
while disconnected, so the blocking work was triggered repeatedly and
re-entrantly, producing an ANR.

Changes:
- checkSetValidServerConnectionConfig now runs on a dedicated
  Dispatchers.IO scope instead of runBlocking, so pings/authorize never
  block the caller. The callback contract is unchanged; downstream Android
  Auto callbacks already ran off the main thread.
- loadAndroidAutoItems coalesces overlapping calls into a single in-flight
  load and fires all queued callbacks on completion, so the browse-root
  polling storm can no longer spawn concurrent loads racing shared state.
- Fix latent bug in checkResetServerItems: server config id was compared
  with !== (reference identity) instead of != (value), causing spurious
  cache resets that fed the reload storm.

Co-authored-by: databoy2k <18686442+databoy2k@users.noreply.github.com>
2026-08-04 16:26:09 -05:00
advplyr 9664f79b1f Merge pull request #1947 from hypnotoad08/fix/finished-book-restarts
Restart finished local media from the beginning
2026-08-03 17:36:59 -05:00
advplyr 533396b855 Android clear current time on local media progress when marking as finished 2026-08-03 17:32:19 -05:00
advplyr 005a051cd0 Merge pull request #1906 from jucor/fix/audiotrack-nullable-mimetype
Make AudioTrack.mimeType nullable to prevent Android playback stall (fixes #1905)
2026-08-03 17:06:18 -05:00
hypnotoad08 f06168fdc2 Restart finished local media from the beginning 2026-08-02 20:15:20 -04:00
advplyr 26c6b9149b Merge pull request #1938 from cosandr/fix-304
Remember sort & filter options for podcast episodes
2026-08-02 17:49:02 -05:00
advplyr 8b70d02332 Fix getUserSetting incorrectly returning null for all falsy values 2026-08-02 17:43:45 -05:00
advplyr 4370b19e12 Merge pull request #1879 from OguzIronCode/fix/search-result-limit
Remove hardcoded search result limit of 5
2026-08-02 17:28:34 -05:00
advplyr f708e4dc07 Merge pull request #1862 from weblate/weblate-audiobookshelf-abs-mobile-app
Translations update from Hosted Weblate
2026-08-02 07:28:09 -05:00
Vladimir Makeev 4bb809c1b9 Translated using Weblate (Russian)
Currently translated at 100.0% (381 of 381 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/ru/
2026-08-01 22:54:17 +00:00
Igor Dobrača ccf57bb016 Translated using Weblate (Croatian)
Currently translated at 100.0% (381 of 381 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/hr/
2026-08-01 22:54:11 +00:00
Pavel Miniutka 65b2a1a9a4 Translated using Weblate (Belarusian)
Currently translated at 100.0% (374 of 374 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/be/
2026-08-01 22:54:06 +00:00
Zarakkas 8e6ef8e718 Translated using Weblate (German)
Currently translated at 100.0% (374 of 374 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/de/
2026-08-01 22:54:04 +00:00
Mona Lisa cbea6229b5 Translated using Weblate (Swedish)
Currently translated at 100.0% (374 of 374 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/sv/
2026-08-01 22:54:02 +00:00
Axel Björnsson 05205eb646 Translated using Weblate (Icelandic)
Currently translated at 2.6% (10 of 374 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/is/
2026-08-01 22:54:00 +00:00
Mona Lisa 32fc866d8a Translated using Weblate (Swedish)
Currently translated at 100.0% (374 of 374 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/sv/
2026-08-01 22:53:57 +00:00
Mona Lisa da089338dc Translated using Weblate (Swedish)
Currently translated at 100.0% (374 of 374 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/sv/
2026-08-01 22:53:55 +00:00
FiendFEARing e19f47b291 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (374 of 374 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/zh_Hans/
2026-08-01 22:53:53 +00:00
FiendFEARing e7ed54439b Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (374 of 374 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/zh_Hans/
2026-08-01 22:53:51 +00:00
FiendFEARing 3db3bbd756 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (374 of 374 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/zh_Hans/
2026-08-01 22:53:48 +00:00
FiendFEARing 24ab13e27e Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (374 of 374 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/zh_Hans/
2026-08-01 22:53:46 +00:00
FiendFEARing 195d1d1ef4 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (374 of 374 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/zh_Hans/
2026-08-01 22:53:45 +00:00
burghy86 5383e91249 Translated using Weblate (Italian)
Currently translated at 100.0% (374 of 374 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/it/
2026-08-01 22:53:44 +00:00
தமிழ்நேரம் c02e45b703 Translated using Weblate (Tamil)
Currently translated at 100.0% (374 of 374 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/ta/
2026-08-01 22:53:41 +00:00
தமிழ்நேரம் 991dd997ee Translated using Weblate (Tamil)
Currently translated at 100.0% (374 of 374 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/ta/
2026-08-01 22:53:37 +00:00
Gabriel daddd5c3c2 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (374 of 374 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/pt_BR/
2026-08-01 22:53:34 +00:00
Gabriel 8b77fae668 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (374 of 374 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/pt_BR/
2026-08-01 22:53:33 +00:00
advplyr bba15dacf8 Added translation using Weblate (Tamil) 2026-08-01 22:53:32 +00:00
Hans Henrik Juhl bfaeb6ea42 Translated using Weblate (Danish)
Currently translated at 100.0% (374 of 374 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/da/
2026-08-01 22:53:31 +00:00
Hans Henrik Juhl 5f66f65d6f Translated using Weblate (Danish)
Currently translated at 100.0% (374 of 374 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/da/
2026-08-01 22:53:30 +00:00
Mads Kvist Møller 8b9098c877 Translated using Weblate (Danish)
Currently translated at 99.4% (372 of 374 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/da/
2026-08-01 22:53:27 +00:00
no1ta 7044925791 Translated using Weblate (Greek)
Currently translated at 72.1% (270 of 374 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/el/
2026-08-01 22:53:23 +00:00
advplyr bcc292b33a Added translation using Weblate (Uzbek) 2026-08-01 22:53:22 +00:00
Pavel Miniutka ae08206c3e Translated using Weblate (Belarusian)
Currently translated at 100.0% (374 of 374 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/be/
2026-08-01 22:53:20 +00:00
EteranlK 91315dd9b5 Translated using Weblate (Arabic)
Currently translated at 99.4% (372 of 374 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/ar/
2026-08-01 22:53:18 +00:00
advplyr 8a253e7679 Added translation using Weblate (Latvian) 2026-08-01 22:53:17 +00:00
advplyr 04e9c8a329 Merge pull request #1869 from AndrolGenhald/reduce-data-usage
Send less data to server with progress update.
2026-08-01 17:53:00 -05:00
advplyr 1d163df773 Update iOS to send partial playback session on local syncs 2026-08-01 17:47:35 -05:00
advplyr 4cfa22004c Merge pull request #1861 from lkiesow/landscape-player
Player view in landscape mode
2026-07-31 18:06:31 -04:00
advplyr e0747e3f19 Fix chapter track overflow and switch to percentage width 2026-07-31 17:00:35 -05:00
advplyr b14e7b7357 Merge branch 'master' into landscape-player 2026-07-31 16:42:19 -05:00
advplyr 0123df99d2 Improve logging around progress sync 2026-07-31 16:38:15 -05:00
advplyr 4a48cb0a9f Merge pull request #1848 from Kyrluckechuck/fix/seek-to-exact-time-rather-than-floored-second
Seek to millisecond rather than flooring to second
2026-07-31 17:18:55 -04:00
advplyr 425ef4c3b6 Merge pull request #1937 from nichwall/download-overhaul-again
Android download rewrite, try 2
2026-07-30 18:36:32 -04:00
advplyr e5ef38295d Update download notification cancel to clear download queue in UI 2026-07-30 17:31:23 -05:00
advplyr b084e54dcc Update download notification id to 11 2026-07-30 17:08:24 -05:00
advplyr d3e55cabf4 Version guard download notification channel API 26+ 2026-07-30 16:51:48 -05:00
advplyr 2cc9ff5a41 Add POST_NOTIFICATION to manifest and start foreground service with type 2026-07-29 17:07:27 -05:00
Andrei Costescu 9562e8f252 Fix ascending bug 2026-07-21 21:05:27 +02:00
Andrei Costescu 7726192008 Align with codebase 2026-07-21 20:53:58 +02:00
Nicholas Wallace f8424c03fd Increase download chunk size to 512 KB 2026-07-20 20:05:18 -07:00
Nicholas Wallace 58fbfca645 Add translation strings for download dialogs and notification 2026-07-20 20:01:02 -07:00
Nicholas Wallace 6282fd1f95 Remove deleted migration call 2026-07-20 19:54:47 -07:00
Nicholas Wallace 245df5d934 Remove unnecessary migration, autoformat 2026-07-20 19:06:35 -07:00
Nicholas Wallace dc6184f41d Remove old and slow simplePath from SAF, also autoformatted 2026-07-20 18:59:19 -07:00
Nicholas Wallace b3c3950c55 Check if completed file exists in shared storage before downloading again 2026-07-19 22:31:32 -07:00
Nicholas Wallace 034a134319 Remove retry stubs 2026-07-19 17:54:46 -07:00
Nicholas Wallace 8142d626c0 Ensure token is not part of download URL 2026-07-19 10:24:12 -07:00
Nicholas Wallace 8b20730b6f Cleaning up retry logic and unused return types 2026-07-19 10:24:01 -07:00
Nicholas Wallace d27f431f6f Comment cleanup 2026-07-19 09:23:31 -07:00
Nicholas Wallace 0df0a206e7 Delete partial files after 24 hours 2026-07-19 09:16:35 -07:00
Nicholas Wallace 6aeb31b591 Add support for background download and resumption 2026-07-18 22:56:12 -07:00
Nicholas Wallace eb2483d039 Initial rewrite of 1587 by codex 2026-07-18 17:32:37 -07:00
Andrei Costescu 67fbeeeb6c Remember sort & filter options for podcast episodes 2026-07-18 18:07:14 +02:00
photown 5099e39f7b Remove the size override in Glide
The override was not needed. It wasn't in the original code, so I'll remove it for consistency.
2026-07-05 23:32:28 -04:00
photown 34710dbddc Render a higher resolution bitmap in the notification background art
Previously only METADATA_KEY_ALBUM_ART_URI/ART_URI were set, leaving notification/lock-screen/Android Auto consumers to resolve the cover art themselves. Likely due to internal scaling, a lower quality bitmap was loaded.

This commit adds a shared resolveUriAsBitmap() helper (extracted from AbMediaDescriptionAdapter.kt) and uses it to:
  - Resolve and cache a bitmap on PlaybackSession once per
  session (synchronously for local covers, asynchronously for server covers), then set it on METADATA_KEY_ALBUM_ART/ART once available, falling back to the URI keys until it resolves.
  - Reuse the same helper in AbMediaDescriptionAdapter so the notification icon is resolved consistently.

  PlayerNotificationService tracks the resolution Job and cancels/replaces it on each new playback session, invalidating the media session metadata once the bitmap is ready.
2026-07-05 23:32:28 -04:00
Julien Cornebise 4798644bea Make AudioTrack.mimeType nullable to prevent Android playback stall
The POST /api/items/<id>/play/<episodeId> response inlines every episode
of a podcast with an audioTrack per episode. Server-side, audioTrack.mimeType
is derived from audioFile.mimeType, which is null when the row was created
without a successful audio probe (direct DB inserts, import scripts,
partial probe failures).

The Jackson deserializer hits one null mimeType among the inlined
episodes, throws MissingKotlinParameterException, and the entire
play-session response fails to deserialize. ExoPlayer is never called,
the UI silently stalls.

Make mimeType nullable on the data class to tolerate this. ExoPlayer's
MediaItem.Builder.setMimeType already accepts nullable. Defensively
coerce null to empty string for Cast's MediaInfo.Builder.setContentType,
whose Java signature is non-null.

Fixes #1905
2026-06-23 15:58:18 +01:00
OguzHAN bd0cbf8bf0 Remove hardcoded search result limit of 5
The search API call had a hardcoded limit=5 parameter which restricted
search results to only 5 items regardless of how many matches exist.
Removing this limit allows the server's default pagination to apply,
showing all relevant search results to the user.

Fixes #1872
2026-05-25 21:12:38 +03:00
AndrolGenhald f562a1a5dc Send less data to server with progress update. 2026-05-13 21:18:05 -05:00
advplyr 185cba16eb Merge pull request #1868 from pavel-miniutka/master
Belarusian language option
2026-05-13 16:32:34 -05:00
pavel-miniutka 547d086228 Belarusian language option 2026-05-13 09:11:27 +03:00
advplyr 3a2d5647d4 Merge branch 'master' of https://github.com/advplyr/audiobookshelf-app 2026-05-12 08:22:20 -05:00
advplyr 0a67c9b802 Update gh issue templates for 0.13.0 2026-05-12 08:22:15 -05:00
advplyr 0254afd5d8 iOS version bump 0.13.0 2026-05-10 15:18:30 -05:00
advplyr 853546f3d2 Fix cover image sampling not working when webview strips origin header, instead fetch image from server with CapacitorHttp 2026-05-10 15:15:13 -05:00
advplyr b8ac073247 Version bump 0.13.0-beta 2026-05-10 14:29:52 -05:00
advplyr 56cb4df31a Cleanup unnecessary logs 2026-05-10 14:23:17 -05:00
advplyr 7285b0bf26 iOS update RealmSwift to 10.54.6 2026-05-10 14:12:58 -05:00
advplyr 3fdc2e6dcf Merge pull request #1864 from advplyr/fix_open_session_progress_sync
Fix external progress sync for open sessions
2026-05-10 11:24:38 -05:00
advplyr ed2f9d2e45 Prevent focus and socket reconnect from both fetching server media progress 2026-05-10 11:12:17 -05:00
advplyr 68dafbba75 Sync local media open in player when socket reconnects after 30s+ 2026-05-10 11:05:17 -05:00
Lars Kiesow 7d17367c1e Adjust title and author size in landscape mode 2026-05-10 00:55:53 +02:00
advplyr 8be08702c1 Fix regain focus check server progress for local media items open in player & sync more recent 2026-05-09 16:48:55 -05:00
Lars Kiesow 91bc56e1e1 Player view in landscape mode
Rotating your phone to landscape mode and opening the player, the view
wasn't using the available space very efficiently. Especially the cover
image is not very helpful being pushed to a minimal size.

This patch redesigned the view in landscape mode, pushing the cover
image to the left half of the screen while the controls are moved to the
right half of the screen.
2026-05-07 02:05:04 +02:00
advplyr 2c2e77f6cd Merge pull request #1824 from weblate/weblate-audiobookshelf-abs-mobile-app
Translations update from Hosted Weblate
2026-05-05 15:55:32 -05:00
LvanAlphen 12d02645fd Translated using Weblate (Dutch)
Currently translated at 100.0% (374 of 374 strings)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/sv/
2026-02-16 06:11:21 +01:00
127 changed files with 5059 additions and 2323 deletions

No files matched your search

+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.11.0'
- 'iOS App - 0.11.0'
- 'Android App - 0.10.1'
- 'iOS App - 0.10.1'
- 'Android App - 0.14.0'
- 'iOS App - 0.14.0'
- 'Android App - 0.13.0'
- 'iOS App - 0.13.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.11.0'
- 'iOS App - 0.11.0'
- 'Android App - 0.10.1'
- 'iOS App - 0.10.1'
- 'Android App - 0.14.0'
- 'iOS App - 0.14.0'
- 'Android App - 0.13.0'
- 'iOS App - 0.13.0'
validations:
required: true
- type: textarea
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
java-version: 21
- name: install dependencies
run: npm ci
run: npm ci --omit=optional
- name: build Nuxt project
run: npm run generate
+68
View File
@@ -0,0 +1,68 @@
name: Build iOS
on:
push:
paths-ignore:
- 'android/**'
- '.github/workflows/build-apk.yml'
- '.github/workflows/deploy-apk.yml'
- 'readme.md'
pull_request:
paths-ignore:
- 'android/**'
- '.github/workflows/build-apk.yml'
- '.github/workflows/deploy-apk.yml'
- 'readme.md'
permissions:
contents: read
jobs:
build:
name: Build iOS simulator app
runs-on: macos-latest
steps:
- name: Check out sources
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
- name: Install JavaScript dependencies
run: npm ci --omit=optional
- name: Build shared web app
run: npm run generate
- name: Sync Capacitor iOS project
run: npx cap sync ios
- name: Install CocoaPods dependencies
working-directory: ios/App
run: pod install --deployment
- name: Build iOS simulator app
working-directory: ios/App
run: |
xcodebuild -quiet \
-workspace App.xcworkspace \
-scheme App \
-configuration Debug \
-sdk iphonesimulator \
-destination 'generic/platform=iOS Simulator' \
-derivedDataPath build \
ASSETCATALOG_COMPILER_APPICON_NAME=IconsDebug \
CODE_SIGNING_ALLOWED=NO \
build
echo "iOS simulator build succeeded."
- name: Upload iOS simulator app
uses: actions/upload-artifact@v4
with:
name: audiobookshelf-ios-simulator
path: ios/App/build/Build/Products/Debug-iphonesimulator/Audiobookshelf.app
if-no-files-found: error
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
java-version: 21
- name: install dependencies
run: npm ci
run: npm ci --omit=optional
- name: build Nuxt project
run: npm run generate
+3 -2
View File
@@ -36,8 +36,8 @@ android {
applicationId "com.audiobookshelf.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 116
versionName "0.12.0-beta"
versionCode 120
versionName "0.14.1-beta"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
manifestPlaceholders = [
"appAuthRedirectScheme": "com.audiobookshelf.app"
@@ -92,6 +92,7 @@ dependencies {
implementation project(':capacitor-cordova-android-plugins')
implementation "androidx.core:core-ktx:$androidx_core_ktx_version"
implementation "androidx.work:work-runtime-ktx:2.9.1"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlin_coroutines_version"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version"
+8
View File
@@ -6,12 +6,14 @@
<!-- Permissions -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:allowBackup="true"
@@ -98,6 +100,12 @@
</intent-filter>
</service>
<service
android:name=".services.DownloadService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="dataSync" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
@@ -40,9 +40,6 @@ class MainActivity : BridgeActivity() {
val storage = SimpleStorage(this)
val REQUEST_PERMISSIONS = 1
var PERMISSIONS_ALL = arrayOf(
Manifest.permission.READ_EXTERNAL_STORAGE
)
public override fun onCreate(savedInstanceState: Bundle?) {
DbManager.initialize(applicationContext)
@@ -98,11 +95,20 @@ class MainActivity : BridgeActivity() {
}
}
val permission = ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE)
if (permission != PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(this,
PERMISSIONS_ALL,
REQUEST_PERMISSIONS)
requestNeededPermissions()
}
private fun requestNeededPermissions() {
val needed = mutableListOf<String>()
if (ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
needed.add(Manifest.permission.READ_EXTERNAL_STORAGE)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
ActivityCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
needed.add(Manifest.permission.POST_NOTIFICATIONS)
}
if (needed.isNotEmpty()) {
ActivityCompat.requestPermissions(this, needed.toTypedArray(), REQUEST_PERMISSIONS)
}
}
@@ -10,7 +10,7 @@ data class AudioTrack(
var duration: Double,
var title: String,
var contentUrl: String,
var mimeType: String,
var mimeType: String?,
var metadata: FileMetadata?,
var isLocal: Boolean,
var localFileId: String?,
@@ -1,74 +1,102 @@
package com.audiobookshelf.app.data
import android.content.Context
import android.net.Uri
import android.support.v4.media.MediaDescriptionCompat
import android.util.Log
import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonSubTypes
import com.fasterxml.jackson.annotation.JsonTypeInfo
import java.io.File
enum class LockOrientationSetting {
NONE, PORTRAIT, LANDSCAPE
NONE,
PORTRAIT,
LANDSCAPE
}
enum class HapticFeedbackSetting {
OFF, LIGHT, MEDIUM, HEAVY
OFF,
LIGHT,
MEDIUM,
HEAVY
}
enum class ShakeSensitivitySetting {
VERY_LOW, LOW, MEDIUM, HIGH, VERY_HIGH
VERY_LOW,
LOW,
MEDIUM,
HIGH,
VERY_HIGH
}
enum class DownloadUsingCellularSetting {
ASK, ALWAYS, NEVER
ASK,
ALWAYS,
NEVER
}
enum class StreamingUsingCellularSetting {
ASK, ALWAYS, NEVER
ASK,
ALWAYS,
NEVER
}
enum class AndroidAutoBrowseSeriesSequenceOrderSetting {
ASC, DESC
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,
var customHeaders:Map<String, String>?
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,
var customHeaders: Map<String, String>?
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class LocalFile(
var id:String,
var filename:String?,
var contentUrl:String,
var basePath:String,
var absolutePath:String,
var simplePath:String,
var mimeType:String?,
var size:Long
var id: String,
var filename: String?,
var contentUrl: String,
var basePath: String,
var absolutePath: String,
var mimeType: String?,
var size: Long
) {
@JsonIgnore
fun isAudioFile():Boolean {
fun exists(ctx: Context): Boolean {
if (contentUrl.startsWith("content:")) {
return try {
ctx.contentResolver.openFileDescriptor(Uri.parse(contentUrl), "r")?.use { true } ?: false
} catch (e: Exception) {
Log.w("LocalFile", "Cannot access SAF file $contentUrl", e)
false
}
}
return File(absolutePath).exists()
}
@JsonIgnore
fun isAudioFile(): Boolean {
if (mimeType == "application/octet-stream") return true
if (mimeType == "video/mp4") return true
return mimeType?.startsWith("audio") == true
}
@JsonIgnore
fun isEBookFile():Boolean {
fun isEBookFile(): Boolean {
return getEBookFormat() != null
}
@JsonIgnore
fun getEBookFormat():String? {
fun getEBookFormat(): String? {
if (mimeType == "application/epub+zip") return "epub"
if (mimeType == "application/pdf") return "pdf"
if (mimeType == "application/x-mobipocket-ebook") return "mobi"
@@ -81,118 +109,124 @@ data class LocalFile(
@JsonIgnoreProperties(ignoreUnknown = true)
data class LocalFolder(
var id:String,
var name:String,
var contentUrl:String,
var basePath:String,
var absolutePath:String,
var simplePath:String,
var storageType:String,
var mediaType:String
var id: String,
var name: String,
var contentUrl: String,
var basePath: String,
var absolutePath: String,
var storageType: String,
var mediaType: String
)
@JsonTypeInfo(use= JsonTypeInfo.Id.DEDUCTION)
@JsonSubTypes(
JsonSubTypes.Type(LibraryItem::class),
JsonSubTypes.Type(LocalLibraryItem::class)
)
open class LibraryItemWrapper(var id:String) {
@JsonTypeInfo(use = JsonTypeInfo.Id.DEDUCTION)
@JsonSubTypes(JsonSubTypes.Type(LibraryItem::class), JsonSubTypes.Type(LocalLibraryItem::class))
open class LibraryItemWrapper(var id: String) {
@JsonIgnore
open fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context): MediaDescriptionCompat { return MediaDescriptionCompat.Builder().build() }
open fun getMediaDescription(
progress: MediaProgressWrapper?,
ctx: Context
): MediaDescriptionCompat {
return MediaDescriptionCompat.Builder().build()
}
}
@JsonIgnoreProperties(ignoreUnknown = true)
data class DeviceInfo(
var deviceId:String,
var manufacturer:String,
var model:String,
var sdkVersion:Int,
var clientVersion: String
var deviceId: String,
var manufacturer: String,
var model: String,
var sdkVersion: Int,
var clientVersion: String
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class PlayItemRequestPayload(
var mediaPlayer:String,
var forceDirectPlay:Boolean,
var forceTranscode:Boolean,
var deviceInfo:DeviceInfo
var mediaPlayer: String,
var forceDirectPlay: Boolean,
var forceTranscode: Boolean,
var deviceInfo: DeviceInfo
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class DeviceSettings(
var disableAutoRewind:Boolean,
var enableAltView:Boolean,
var allowSeekingOnMediaControls:Boolean,
var jumpBackwardsTime:Int,
var jumpForwardTime:Int,
var enableMp3IndexSeeking:Boolean,
var disableShakeToResetSleepTimer:Boolean,
var shakeSensitivity: ShakeSensitivitySetting,
var lockOrientation: LockOrientationSetting,
var hapticFeedback: HapticFeedbackSetting,
var autoSleepTimer: Boolean,
var autoSleepTimerStartTime: String,
var autoSleepTimerEndTime: String,
var autoSleepTimerAutoRewind: Boolean,
var autoSleepTimerAutoRewindTime: Long, //Time in milliseconds
var sleepTimerLength: Long, // Time in milliseconds
var disableSleepTimerFadeOut: Boolean,
var disableSleepTimerResetFeedback: Boolean,
var enableSleepTimerAlmostDoneChime: Boolean,
var languageCode: String,
var downloadUsingCellular: DownloadUsingCellularSetting,
var streamingUsingCellular: StreamingUsingCellularSetting,
var androidAutoBrowseLimitForGrouping: Int,
var androidAutoBrowseSeriesSequenceOrder: AndroidAutoBrowseSeriesSequenceOrderSetting
var disableAutoRewind: Boolean,
var enableAltView: Boolean,
var allowSeekingOnMediaControls: Boolean,
var jumpBackwardsTime: Int,
var jumpForwardTime: Int,
var enableMp3IndexSeeking: Boolean,
var disableShakeToResetSleepTimer: Boolean,
var shakeSensitivity: ShakeSensitivitySetting,
var lockOrientation: LockOrientationSetting,
var hapticFeedback: HapticFeedbackSetting,
var autoSleepTimer: Boolean,
var autoSleepTimerStartTime: String,
var autoSleepTimerEndTime: String,
var autoSleepTimerAutoRewind: Boolean,
var autoSleepTimerAutoRewindTime: Long, // Time in milliseconds
var sleepTimerLength: Long, // Time in milliseconds
var disableSleepTimerFadeOut: Boolean,
var disableSleepTimerResetFeedback: Boolean,
var enableSleepTimerAlmostDoneChime: Boolean,
var languageCode: String,
var downloadUsingCellular: DownloadUsingCellularSetting,
var streamingUsingCellular: StreamingUsingCellularSetting,
var androidAutoBrowseLimitForGrouping: Int,
var androidAutoBrowseSeriesSequenceOrder: AndroidAutoBrowseSeriesSequenceOrderSetting
) {
companion object {
// Static method to get default device settings
fun default():DeviceSettings {
fun default(): DeviceSettings {
return DeviceSettings(
disableAutoRewind = false,
enableAltView = true,
allowSeekingOnMediaControls = false,
jumpBackwardsTime = 10,
jumpForwardTime = 10,
enableMp3IndexSeeking = false,
disableShakeToResetSleepTimer = false,
shakeSensitivity = ShakeSensitivitySetting.MEDIUM,
lockOrientation = LockOrientationSetting.NONE,
hapticFeedback = HapticFeedbackSetting.LIGHT,
autoSleepTimer = false,
autoSleepTimerStartTime = "22:00",
autoSleepTimerEndTime = "06:00",
sleepTimerLength = 900000L, // 15 minutes
autoSleepTimerAutoRewind = false,
autoSleepTimerAutoRewindTime = 300000L, // 5 minutes
disableSleepTimerFadeOut = false,
disableSleepTimerResetFeedback = false,
enableSleepTimerAlmostDoneChime = false,
languageCode = "en-us",
downloadUsingCellular = DownloadUsingCellularSetting.ALWAYS,
streamingUsingCellular = StreamingUsingCellularSetting.ALWAYS,
androidAutoBrowseLimitForGrouping = 100,
androidAutoBrowseSeriesSequenceOrder = AndroidAutoBrowseSeriesSequenceOrderSetting.ASC
disableAutoRewind = false,
enableAltView = true,
allowSeekingOnMediaControls = false,
jumpBackwardsTime = 10,
jumpForwardTime = 10,
enableMp3IndexSeeking = false,
disableShakeToResetSleepTimer = false,
shakeSensitivity = ShakeSensitivitySetting.MEDIUM,
lockOrientation = LockOrientationSetting.NONE,
hapticFeedback = HapticFeedbackSetting.LIGHT,
autoSleepTimer = false,
autoSleepTimerStartTime = "22:00",
autoSleepTimerEndTime = "06:00",
sleepTimerLength = 900000L, // 15 minutes
autoSleepTimerAutoRewind = false,
autoSleepTimerAutoRewindTime = 300000L, // 5 minutes
disableSleepTimerFadeOut = false,
disableSleepTimerResetFeedback = false,
enableSleepTimerAlmostDoneChime = false,
languageCode = "en-us",
downloadUsingCellular = DownloadUsingCellularSetting.ALWAYS,
streamingUsingCellular = StreamingUsingCellularSetting.ALWAYS,
androidAutoBrowseLimitForGrouping = 100,
androidAutoBrowseSeriesSequenceOrder = AndroidAutoBrowseSeriesSequenceOrderSetting.ASC
)
}
}
@get:JsonIgnore
val jumpBackwardsTimeMs get() = jumpBackwardsTime * 1000L
val jumpBackwardsTimeMs
get() = jumpBackwardsTime * 1000L
@get:JsonIgnore
val jumpForwardTimeMs get() = jumpForwardTime * 1000L
val jumpForwardTimeMs
get() = jumpForwardTime * 1000L
@get:JsonIgnore
val autoSleepTimerStartHour get() = autoSleepTimerStartTime.split(":")[0].toInt()
val autoSleepTimerStartHour
get() = autoSleepTimerStartTime.split(":")[0].toInt()
@get:JsonIgnore
val autoSleepTimerStartMinute get() = autoSleepTimerStartTime.split(":")[1].toInt()
val autoSleepTimerStartMinute
get() = autoSleepTimerStartTime.split(":")[1].toInt()
@get:JsonIgnore
val autoSleepTimerEndHour get() = autoSleepTimerEndTime.split(":")[0].toInt()
val autoSleepTimerEndHour
get() = autoSleepTimerEndTime.split(":")[0].toInt()
@get:JsonIgnore
val autoSleepTimerEndMinute get() = autoSleepTimerEndTime.split(":")[1].toInt()
val autoSleepTimerEndMinute
get() = autoSleepTimerEndTime.split(":")[1].toInt()
@JsonIgnore
fun getShakeThresholdGravity() : Float { // Used in ShakeDetector
fun getShakeThresholdGravity(): Float { // Used in ShakeDetector
return if (shakeSensitivity == ShakeSensitivitySetting.VERY_HIGH) 1.1f
else if (shakeSensitivity == ShakeSensitivitySetting.HIGH) 1.3f
else if (shakeSensitivity == ShakeSensitivitySetting.MEDIUM) 1.5f
@@ -206,10 +240,10 @@ data class DeviceSettings(
}
data class DeviceData(
var serverConnectionConfigs:MutableList<ServerConnectionConfig>,
var lastServerConnectionConfigId:String?,
var deviceSettings: DeviceSettings?,
var lastPlaybackSession: PlaybackSession?
var serverConnectionConfigs: MutableList<ServerConnectionConfig>,
var lastServerConnectionConfigId: String?,
var deviceSettings: DeviceSettings?,
var lastPlaybackSession: PlaybackSession?
) {
@JsonIgnore
fun getLastServerConnectionConfig(): ServerConnectionConfig? {
@@ -218,4 +252,3 @@ data class DeviceData(
}
}
}
@@ -1,15 +0,0 @@
package com.audiobookshelf.app.data
data class FolderScanResult(
var itemsAdded:Int,
var itemsUpdated:Int,
var itemsRemoved:Int,
var itemsUpToDate:Int,
val localFolder:LocalFolder,
val localLibraryItems:List<LocalLibraryItem>,
)
data class LocalLibraryItemScanResult(
val updated:Boolean,
val localLibraryItem:LocalLibraryItem,
)
@@ -80,7 +80,7 @@ class LocalLibraryItem(
}
@JsonIgnore
fun hasTracks(episode:PodcastEpisode?): Boolean {
fun hasTracks(ctx: Context, episode:PodcastEpisode?): Boolean {
var audioTracks = media.getAudioTracks() as MutableList<AudioTrack>
if (episode != null) { // Get podcast episode audio track
episode.audioTrack?.let { at -> mutableListOf(at) }?.let { tracks -> audioTracks = tracks }
@@ -91,15 +91,18 @@ class LocalLibraryItem(
if (it.metadata === null) {
return false
}
// Check that file exists
val file = File(it.metadata!!.path)
if (!file.exists()) {
if (!trackExists(ctx, it.contentUrl, it.metadata!!.path)) {
return false
}
}
return true
}
@JsonIgnore
private fun trackExists(ctx: Context, contentUrl: String?, path: String): Boolean {
return LocalFile("", null, contentUrl ?: "", "", path, null, 0).exists(ctx)
}
@JsonIgnore
fun getPlaybackSession(episode:PodcastEpisode?, deviceInfo:DeviceInfo):PlaybackSession {
val localEpisodeId = episode?.id
@@ -108,7 +111,7 @@ class LocalLibraryItem(
// Get current progress for local media
val mediaProgressId = if (localEpisodeId.isNullOrEmpty()) id else "$id-$localEpisodeId"
val mediaProgress = DeviceManager.dbManager.getLocalMediaProgress(mediaProgressId)
val currentTime = mediaProgress?.currentTime ?: 0.0
val currentTime = if (mediaProgress?.isFinished == true) 0.0 else mediaProgress?.currentTime ?: 0.0
val mediaMetadata = media.metadata
@@ -14,7 +14,6 @@ data class LocalMediaItem(
var mediaType: String,
var folderId: String,
var contentUrl: String,
var simplePath: String,
var basePath: String,
var absolutePath: String,
var audioTracks: MutableList<AudioTrack>,
@@ -46,6 +46,10 @@ class LocalMediaProgress(
progress = if (finished) 1.0 else 0.0
}
if (finished) {
currentTime = 0.0
}
isFinished = finished
lastUpdate = System.currentTimeMillis()
finishedAt = if (isFinished) lastUpdate else null
@@ -1,6 +1,7 @@
package com.audiobookshelf.app.data
import android.content.Context
import android.graphics.Bitmap
import android.graphics.ImageDecoder
import android.net.Uri
import android.os.Build
@@ -20,6 +21,9 @@ import com.google.android.exoplayer2.MediaMetadata
import com.google.android.gms.cast.MediaInfo
import com.google.android.gms.cast.MediaQueueItem
import com.google.android.gms.common.images.WebImage
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
@JsonIgnoreProperties(ignoreUnknown = true)
class PlaybackSession(
@@ -202,6 +206,13 @@ class PlaybackSession(
return Uri.parse("$serverAddress${audioTrack.contentUrl}?token=${DeviceManager.token}")
}
/** Bitmap for the cover art, once resolved */
@JsonIgnore
private var resolvedCoverBitmap: Bitmap? = null
/**
* Builds the current session metadata, including the cover art bitmap if it has already been resolved.
*/
@JsonIgnore
fun getMediaMetadataCompat(ctx: Context): MediaMetadataCompat {
val coverUri = getCoverUri(ctx)
@@ -217,16 +228,42 @@ class PlaybackSession(
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST, displayAuthor)
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION, displayAuthor)
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, coverUri.toString())
.putString(MediaMetadataCompat.METADATA_KEY_ART_URI, coverUri.toString())
.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, totalDurationMs)
.putString(
MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI,
coverUri.toString()
)
// Local covers get bitmap
if (resolvedCoverBitmap != null) {
metadataBuilder
.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, resolvedCoverBitmap)
.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, resolvedCoverBitmap)
} else {
metadataBuilder
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, coverUri.toString())
.putString(MediaMetadataCompat.METADATA_KEY_ART_URI, coverUri.toString())
}
return metadataBuilder.build()
}
/**
* Resolves the cover art bitmap (local covers are decoded synchronously, server-side covers
* are fetched asynchronously) and calls `onArtResolved` once it's available
*
* Returns the Job for the async fetch, or `null` if the bitmap was resolved synchronously.
*/
@JsonIgnore
fun resolveCoverBitmapAsync(
ctx: Context,
coroutineScope: CoroutineScope,
onArtResolved: () -> Unit
): Job? {
val coverUri = getCoverUri(ctx)
// Local covers get bitmap synchronously, no async fetch needed
if (localLibraryItem?.coverContentUrl != null) {
val bitmap =
resolvedCoverBitmap =
if (Build.VERSION.SDK_INT < 28) {
MediaStore.Images.Media.getBitmap(ctx.contentResolver, coverUri)
} else {
@@ -234,11 +271,18 @@ class PlaybackSession(
ImageDecoder.createSource(ctx.contentResolver, coverUri)
ImageDecoder.decodeBitmap(source)
}
metadataBuilder.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, bitmap)
metadataBuilder.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, bitmap)
onArtResolved()
return null
}
return metadataBuilder.build()
// Server-side cover: resolve the art bitmap async
return coroutineScope.launch {
val bitmap = resolveUriAsBitmap(ctx, coverUri)
bitmap?.let {
resolvedCoverBitmap = it
onArtResolved()
}
}
}
@JsonIgnore
@@ -332,7 +376,7 @@ class PlaybackSession(
MediaInfo.Builder(mediaUri.toString())
.apply {
setContentUrl(mediaUri.toString())
setContentType(audioTrack.mimeType)
setContentType(audioTrack.mimeType ?: "")
setMetadata(castMetadata)
setStreamType(MediaInfo.STREAM_TYPE_BUFFERED)
}
@@ -7,570 +7,306 @@ import androidx.documentfile.provider.DocumentFile
import com.anggrayudi.storage.file.*
import com.audiobookshelf.app.data.*
import com.audiobookshelf.app.models.DownloadItem
import com.fasterxml.jackson.core.json.JsonReadFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.audiobookshelf.app.models.DownloadItemPart
import java.io.File
class FolderScanner(var ctx: Context) {
/** Creates local-library records from a completed download manifest. */
class FolderScanner(private val ctx: Context) {
private val tag = "FolderScanner"
private var jacksonMapper =
jacksonObjectMapper()
.enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
data class DownloadItemScanResult(
val localLibraryItem: LocalLibraryItem,
var localMediaProgress: LocalMediaProgress?
)
private fun getLocalLibraryItemId(mediaItemId: String): String {
return "local_" + DeviceManager.getBase64Id(mediaItemId)
}
private fun localLibraryItemId(mediaItemId: String) =
"local_${DeviceManager.getBase64Id(mediaItemId)}"
private fun scanInternalDownloadItem(
downloadItem: DownloadItem,
cb: (DownloadItemScanResult?) -> Unit
) {
val localLibraryItemId = "local_${downloadItem.libraryItemId}"
var localEpisodeId: String? = null
var localLibraryItem: LocalLibraryItem?
if (downloadItem.mediaType == "book") {
localLibraryItem =
LocalLibraryItem(
localLibraryItemId,
downloadItem.localFolder.id,
downloadItem.itemFolderPath,
downloadItem.itemFolderPath,
"",
false,
downloadItem.mediaType,
downloadItem.media.getLocalCopy(),
mutableListOf(),
null,
null,
true,
downloadItem.serverConnectionConfigId,
downloadItem.serverAddress,
downloadItem.serverUserId,
downloadItem.libraryItemId
)
} else {
// Lookup or create podcast local library item
localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
if (localLibraryItem == null) {
Log.d(
tag,
"[FolderScanner] Podcast local library item not created yet for ${downloadItem.media.metadata.title}"
)
localLibraryItem =
LocalLibraryItem(
localLibraryItemId,
downloadItem.localFolder.id,
downloadItem.itemFolderPath,
downloadItem.itemFolderPath,
"",
false,
downloadItem.mediaType,
downloadItem.media.getLocalCopy(),
mutableListOf(),
null,
null,
true,
downloadItem.serverConnectionConfigId,
downloadItem.serverAddress,
downloadItem.serverUserId,
downloadItem.libraryItemId
)
}
private fun createLocalFile(
part: DownloadItemPart,
externalFile: DocumentFile? = null
): LocalFile? {
if (part.isInternalStorage) {
val file = File(part.finalDestinationPath)
if (!file.exists()) return null
return LocalFile(
DeviceManager.getBase64Id(file.name),
file.name,
Uri.fromFile(file).toString(),
file.getBasePath(ctx),
file.absolutePath,
file.mimeType,
file.length()
)
}
val audioTracks: MutableList<AudioTrack> = mutableListOf()
var foundEBookFile = false
downloadItem.downloadItemParts.forEach { downloadItemPart ->
Log.d(
tag,
"Scan internal storage item with finalDestinationUri=${downloadItemPart.finalDestinationUri}"
part.completedDestinationUri?.let { contentUrl ->
val uri = Uri.parse(contentUrl)
val size =
try {
ctx.contentResolver.openFileDescriptor(uri, "r")?.use { descriptor ->
descriptor.statSize.coerceAtLeast(0L)
}
?: 0L
} catch (e: Exception) {
Log.e(tag, "Could not open completed SAF file: $contentUrl", e)
return null
}
// Android 10 DownloadsProvider may not reconstruct a DocumentFile for an audio URI even
// though the URI remains readable. Keep the URI as the authoritative local-file location.
return LocalFile(
DeviceManager.getBase64Id(contentUrl),
part.filename,
contentUrl,
part.localFolderName,
part.finalDestinationPath,
mimeTypeFor(part),
size
)
}
val file = File(downloadItemPart.finalDestinationPath)
Log.d(tag, "Scan internal storage item created file ${file.name}")
// Do not reconstruct a DocumentFile from an absolute path: on Android 10 that becomes a
// file:// URI, which DocumentsContract rejects. The caller resolves this from the persisted
// SAF tree grant instead.
val document = externalFile
if (document == null || !document.exists()) {
Log.e(tag, "Could not resolve downloaded SAF file: ${part.finalDestinationPath}")
return null
}
return LocalFile(
DeviceManager.getBase64Id(document.id),
document.name,
document.uri.toString(),
document.getBasePath(ctx),
document.getAbsolutePath(ctx),
document.mimeType,
document.length()
)
}
if (file == null) {
Log.e(
tag,
"scanInternalDownloadItem: Null docFile for path ${downloadItemPart.finalDestinationPath}"
)
} else {
if (downloadItemPart.audioTrack != null) {
val audioTrackFromServer = downloadItemPart.audioTrack
Log.d(
tag,
"scanInternalDownloadItem: Audio Track from Server index = ${audioTrackFromServer.index}"
private fun newLocalLibraryItem(
id: String,
downloadItem: DownloadItem,
basePath: String,
absolutePath: String,
contentUrl: String
) =
LocalLibraryItem(
id,
downloadItem.localFolder.id,
basePath,
absolutePath,
contentUrl,
false,
downloadItem.mediaType,
downloadItem.media.getLocalCopy(),
mutableListOf(),
null,
null,
true,
downloadItem.serverConnectionConfigId,
downloadItem.serverAddress,
downloadItem.serverUserId,
downloadItem.libraryItemId
)
val localFileId = DeviceManager.getBase64Id(file.name)
Log.d(tag, "Scan internal file localFileId=$localFileId")
val localFile =
LocalFile(
localFileId,
file.name,
downloadItemPart.finalDestinationUri.toString(),
file.getBasePath(ctx),
file.absolutePath,
file.getSimplePath(ctx),
file.mimeType,
file.length()
)
localLibraryItem.localFiles.add(localFile)
private fun scanParts(
item: DownloadItem,
localItem: LocalLibraryItem,
externalFolder: DocumentFile? = null,
callback: (DownloadItemScanResult?) -> Unit
) {
val tracks = mutableListOf<AudioTrack>()
var foundEbook = false
var localEpisodeId: String? = null
val trackFileMetadata =
item.downloadItemParts.forEach { part ->
val externalFile =
if (part.isInternalStorage) {
null
} else {
part.completedDestinationUri?.let { DocumentFileCompat.fromUri(ctx, Uri.parse(it)) }
?: resolveExternalFile(externalFolder, part)
}
Log.d(tag, "Resolve part ${part.filename}: externalFile=${externalFile?.uri}")
val localFile = createLocalFile(part, externalFile) ?: return@forEach
when {
part.audioTrack != null -> {
val serverTrack = part.audioTrack
localItem.localFiles.removeAll { it.id == localFile.id }
localItem.localFiles.add(localFile)
val metadata =
FileMetadata(
file.name,
file.extension,
file.absolutePath,
file.getBasePath(ctx),
file.length()
localFile.filename ?: "",
File(localFile.filename ?: "").extension,
localFile.absolutePath,
localFile.basePath,
localFile.size
)
// Create new audio track
val track =
AudioTrack(
audioTrackFromServer.index,
audioTrackFromServer.startOffset,
audioTrackFromServer.duration,
serverTrack.index,
serverTrack.startOffset,
serverTrack.duration,
localFile.filename ?: "",
localFile.contentUrl,
localFile.mimeType ?: "",
trackFileMetadata,
metadata,
true,
localFileId,
audioTrackFromServer.index
localFile.id,
serverTrack.index
)
audioTracks.add(track)
Log.d(
tag,
"scanInternalDownloadItem: Created Audio Track with index ${track.index} from local file ${localFile.absolutePath}"
)
// Add podcast episodes to library
downloadItemPart.episode?.let { podcastEpisode ->
val podcast = localLibraryItem.media as Podcast
val newEpisode = podcast.addEpisode(track, podcastEpisode)
localEpisodeId = newEpisode.id
Log.d(
tag,
"scanInternalDownloadItem: Added episode to podcast ${podcastEpisode.title} ${track.title} | Track index: ${podcastEpisode.audioTrack?.index}"
)
tracks.add(track)
Log.d(tag, "Added local audio track ${track.contentUrl} (${track.metadata?.path})")
part.episode?.let { episode ->
val podcast = localItem.media as Podcast
localEpisodeId = podcast.addEpisode(track, episode).id
}
} else if (downloadItemPart.ebookFile != null) {
foundEBookFile = true
Log.d(tag, "scanInternalDownloadItem: Ebook file found with mimetype=${file.mimeType}")
val localFileId = DeviceManager.getBase64Id(file.name)
val localFile =
LocalFile(
localFileId,
file.name,
Uri.fromFile(file).toString(),
file.getBasePath(ctx),
file.absolutePath,
file.getSimplePath(ctx),
file.mimeType,
file.length()
)
localLibraryItem.localFiles.add(localFile)
val ebookFile =
}
part.ebookFile != null -> {
foundEbook = true
localItem.localFiles.removeAll { it.id == localFile.id }
localItem.localFiles.add(localFile)
(localItem.media as Book).ebookFile =
EBookFile(
downloadItemPart.ebookFile.ino,
downloadItemPart.ebookFile.metadata,
downloadItemPart.ebookFile.ebookFormat,
part.ebookFile.ino,
part.ebookFile.metadata,
part.ebookFile.ebookFormat,
true,
localFileId,
localFile.id,
localFile.contentUrl
)
(localLibraryItem.media as Book).ebookFile = ebookFile
Log.d(tag, "scanInternalDownloadItem: Ebook file added to lli ${localFile.contentUrl}")
} else {
val localFileId = DeviceManager.getBase64Id(file.name)
val localFile =
LocalFile(
localFileId,
file.name,
Uri.fromFile(file).toString(),
file.getBasePath(ctx),
file.absolutePath,
file.getSimplePath(ctx),
file.mimeType,
file.length()
)
localLibraryItem.coverAbsolutePath = localFile.absolutePath
localLibraryItem.coverContentUrl = localFile.contentUrl
localLibraryItem.localFiles.add(localFile)
}
else -> {
localItem.coverAbsolutePath = localFile.absolutePath
localItem.coverContentUrl = localFile.contentUrl
localItem.localFiles.removeAll { it.id == localFile.id }
localItem.localFiles.add(localFile)
}
}
}
if (audioTracks.isEmpty() && !foundEBookFile) {
Log.d(
tag,
"scanDownloadItem did not find any audio tracks or ebook file in folder for ${downloadItem.itemFolderPath}"
)
return cb(null)
if (tracks.isEmpty() && !foundEbook) {
callback(null)
return
}
// For books sort audio tracks then set
if (downloadItem.mediaType == "book") {
audioTracks.sortBy { it.index }
var indexCheck = 1
var startOffset = 0.0
audioTracks.forEach { audioTrack ->
if (audioTrack.index != indexCheck || audioTrack.startOffset != startOffset) {
audioTrack.index = indexCheck
audioTrack.startOffset = startOffset
}
indexCheck++
startOffset += audioTrack.duration
if (item.mediaType == "book") {
tracks.sortBy { it.index }
var expectedIndex = 1
var offset = 0.0
tracks.forEach { track ->
track.index = expectedIndex++
track.startOffset = offset
offset += track.duration
}
localLibraryItem.media.setAudioTracks(audioTracks)
localItem.media.setAudioTracks(tracks)
}
val downloadItemScanResult = DownloadItemScanResult(localLibraryItem, null)
// If library item had media progress then make local media progress and save
downloadItem.userMediaProgress?.let { mediaProgress ->
val localMediaProgressId =
if (downloadItem.episodeId.isNullOrEmpty()) localLibraryItemId
else "$localLibraryItemId-$localEpisodeId"
val newLocalMediaProgress =
val result = DownloadItemScanResult(localItem, null)
item.userMediaProgress?.let { progress ->
val progressId =
if (item.episodeId.isNullOrEmpty()) localItem.id
else "${localItem.id}-$localEpisodeId"
result.localMediaProgress =
LocalMediaProgress(
id = localMediaProgressId,
localLibraryItemId = localLibraryItemId,
localEpisodeId = localEpisodeId,
duration = mediaProgress.duration,
progress = mediaProgress.progress,
currentTime = mediaProgress.currentTime,
isFinished = mediaProgress.isFinished,
ebookLocation = mediaProgress.ebookLocation,
ebookProgress = mediaProgress.ebookProgress,
lastUpdate = mediaProgress.lastUpdate,
startedAt = mediaProgress.startedAt,
finishedAt = mediaProgress.finishedAt,
serverConnectionConfigId = downloadItem.serverConnectionConfigId,
serverAddress = downloadItem.serverAddress,
serverUserId = downloadItem.serverUserId,
libraryItemId = downloadItem.libraryItemId,
episodeId = downloadItem.episodeId
progressId,
localItem.id,
localEpisodeId,
progress.duration,
progress.progress,
progress.currentTime,
progress.isFinished,
progress.ebookLocation,
progress.ebookProgress,
progress.lastUpdate,
progress.startedAt,
progress.finishedAt,
item.serverConnectionConfigId,
item.serverAddress,
item.serverUserId,
item.libraryItemId,
item.episodeId
)
Log.d(
tag,
"scanLibraryItemFolder: Saving local media progress ${newLocalMediaProgress.id} at progress ${newLocalMediaProgress.progress}"
)
DeviceManager.dbManager.saveLocalMediaProgress(newLocalMediaProgress)
downloadItemScanResult.localMediaProgress = newLocalMediaProgress
DeviceManager.dbManager.saveLocalMediaProgress(result.localMediaProgress!!)
}
DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem)
cb(downloadItemScanResult)
DeviceManager.dbManager.saveLocalLibraryItem(localItem)
callback(result)
}
// Scan item after download and create local library item
fun scanDownloadItem(downloadItem: DownloadItem, cb: (DownloadItemScanResult?) -> Unit) {
// If downloading to internal storage handle separately
if (downloadItem.isInternalStorage) {
scanInternalDownloadItem(downloadItem, cb)
private fun findFolderByPath(root: DocumentFile, subPath: String): DocumentFile? {
if (subPath.isBlank()) return root
var current = root
subPath.split('/').filter { it.isNotBlank() }.forEach { segment ->
if (segment == "." || segment == "..") return null
current = current.findFile(segment) ?: return null
}
return current
}
/**
* DownloadsProvider on Android 10 may expose an audio document without its extension through
* DocumentFile.findFile(). Match the manifest first, then match the provider-normalized base
* filename. MIME type and server-reported size are unreliable for Opus on this platform.
*/
private fun resolveExternalFile(folder: DocumentFile?, part: DownloadItemPart): DocumentFile? {
if (folder == null) return null
folder.findFile(part.filename)?.let {
return it
}
val expectedBaseName = part.filename.substringBeforeLast('.')
return folder.listFiles().firstOrNull { document ->
document.name == part.filename ||
document.fullName == part.filename ||
(part.audioTrack != null &&
document.isFile &&
(document.name ?: "").substringBeforeLast('.') == expectedBaseName) ||
(part.audioTrack != null &&
document.isFile &&
document.fullName.substringBeforeLast('.') == expectedBaseName)
}
}
private fun mimeTypeFor(part: DownloadItemPart): String? {
return part.audioTrack?.mimeType
?: when (part.ebookFile?.ebookFormat?.lowercase()) {
"epub" -> "application/epub+zip"
"pdf" -> "application/pdf"
else -> "image/jpeg"
}
}
fun scanDownloadItem(item: DownloadItem, callback: (DownloadItemScanResult?) -> Unit) {
if (item.isInternalStorage) {
val id = "local_${item.libraryItemId}"
val localItem =
DeviceManager.dbManager.getLocalLibraryItem(id)
?: newLocalLibraryItem(id, item, item.itemFolderPath, item.itemFolderPath, "")
scanParts(item, localItem, callback = callback)
return
}
val folderDf = DocumentFileCompat.fromUri(ctx, Uri.parse(downloadItem.localFolder.contentUrl))
val foldersFound = folderDf?.search(true, DocumentFileType.FOLDER) ?: mutableListOf()
var itemFolderId = ""
var itemFolderUrl = ""
var itemFolderBasePath = ""
var itemFolderAbsolutePath = ""
foldersFound.forEach {
// e.g. absolute path is "storage/emulated/0/Audiobooks/Orson Scott Card/Enders Game"
// and itemSubfolder is "Orson Scott Card/Enders Game"
if (it.getAbsolutePath(ctx).endsWith(downloadItem.itemSubfolder)) {
itemFolderId = it.id
itemFolderUrl = it.uri.toString()
itemFolderBasePath = it.getBasePath(ctx)
itemFolderAbsolutePath = it.getAbsolutePath(ctx)
}
val root = DocumentFileCompat.fromUri(ctx, Uri.parse(item.localFolder.contentUrl))
if (root == null) {
Log.e(tag, "Invalid SAF root: ${item.localFolder.contentUrl}")
callback(null)
return
}
if (itemFolderUrl == "") {
Log.d(tag, "scanDownloadItem failed to find media folder")
return cb(null)
val itemFolder = findFolderByPath(root, item.itemSubfolder)
if (itemFolder == null) {
Log.e(tag, "SAF item folder not found: ${item.itemSubfolder}")
callback(null)
return
}
val df: DocumentFile? = DocumentFileCompat.fromUri(ctx, Uri.parse(itemFolderUrl))
if (df == null) {
Log.e(tag, "Folder Doc File Invalid ${downloadItem.itemFolderPath}")
return cb(null)
}
val localLibraryItemId = getLocalLibraryItemId(itemFolderId)
Log.d(
tag,
"scanDownloadItem starting for ${downloadItem.itemFolderPath} | ${df.uri} | Item Folder Id:$itemFolderId | LLI Id:$localLibraryItemId"
)
// Search for files in media item folder
// m4b files showing as mimeType application/octet-stream on Android 10 and earlier see #154
val filesFound =
df.search(
false,
DocumentFileType.FILE,
arrayOf("audio/*", "image/*", "video/mp4", "application/*")
)
Log.d(tag, "scanDownloadItem ${filesFound.size} files found in ${downloadItem.itemFolderPath}")
var localEpisodeId: String? = null
var localLibraryItem: LocalLibraryItem?
if (downloadItem.mediaType == "book") {
localLibraryItem =
LocalLibraryItem(
localLibraryItemId,
downloadItem.localFolder.id,
itemFolderBasePath,
itemFolderAbsolutePath,
itemFolderUrl,
false,
downloadItem.mediaType,
downloadItem.media.getLocalCopy(),
mutableListOf(),
null,
null,
true,
downloadItem.serverConnectionConfigId,
downloadItem.serverAddress,
downloadItem.serverUserId,
downloadItem.libraryItemId
)
} else {
// Lookup or create podcast local library item
localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
if (localLibraryItem == null) {
Log.d(
tag,
"[FolderScanner] Podcast local library item not created yet for ${downloadItem.media.metadata.title}"
)
localLibraryItem =
LocalLibraryItem(
localLibraryItemId,
downloadItem.localFolder.id,
itemFolderBasePath,
itemFolderAbsolutePath,
itemFolderUrl,
false,
downloadItem.mediaType,
downloadItem.media.getLocalCopy(),
mutableListOf(),
null,
null,
true,
downloadItem.serverConnectionConfigId,
downloadItem.serverAddress,
downloadItem.serverUserId,
downloadItem.libraryItemId
)
}
}
val audioTracks: MutableList<AudioTrack> = mutableListOf()
var foundEBookFile = false
filesFound.forEach { docFile ->
val itemPart =
downloadItem.downloadItemParts.find { itemPart -> itemPart.filename == docFile.name }
if (itemPart == null) {
if (downloadItem.mediaType == "book"
) { // for books every download item should be a file found
Log.e(
tag,
"scanDownloadItem: Item part not found for doc file ${docFile.name} | ${docFile.getAbsolutePath(ctx)} | ${docFile.uri}"
)
}
} else if (itemPart.audioTrack != null) { // Is audio track
val audioTrackFromServer = itemPart.audioTrack
Log.d(
tag,
"scanDownloadItem: Audio Track from Server index = ${audioTrackFromServer.index}"
)
val localFileId = DeviceManager.getBase64Id(docFile.id)
val localFile =
LocalFile(
localFileId,
docFile.name,
docFile.uri.toString(),
docFile.getBasePath(ctx),
docFile.getAbsolutePath(ctx),
docFile.getSimplePath(ctx),
docFile.mimeType,
docFile.length()
)
localLibraryItem.localFiles.add(localFile)
// Create new audio track
val trackFileMetadata =
FileMetadata(
docFile.name ?: "",
docFile.extension ?: "",
docFile.getAbsolutePath(ctx),
docFile.getBasePath(ctx),
docFile.length()
)
val track =
AudioTrack(
audioTrackFromServer.index,
audioTrackFromServer.startOffset,
audioTrackFromServer.duration,
localFile.filename ?: "",
localFile.contentUrl,
localFile.mimeType ?: "",
trackFileMetadata,
true,
localFileId,
audioTrackFromServer.index
)
audioTracks.add(track)
Log.d(
tag,
"scanDownloadItem: Created Audio Track with index ${track.index} from local file ${localFile.absolutePath}"
)
// Add podcast episodes to library
itemPart.episode?.let { podcastEpisode ->
val podcast = localLibraryItem.media as Podcast
val newEpisode = podcast.addEpisode(track, podcastEpisode)
localEpisodeId = newEpisode.id
Log.d(
tag,
"scanDownloadItem: Added episode to podcast ${podcastEpisode.title} ${track.title} | Track index: ${podcastEpisode.audioTrack?.index}"
)
}
} else if (itemPart.ebookFile != null) { // Ebook
foundEBookFile = true
Log.d(tag, "scanDownloadItem: Ebook file found with mimetype=${docFile.mimeType}")
val localFileId = DeviceManager.getBase64Id(docFile.id)
val localFile =
LocalFile(
localFileId,
docFile.name,
docFile.uri.toString(),
docFile.getBasePath(ctx),
docFile.getAbsolutePath(ctx),
docFile.getSimplePath(ctx),
docFile.mimeType,
docFile.length()
)
localLibraryItem.localFiles.add(localFile)
val ebookFile =
EBookFile(
itemPart.ebookFile.ino,
itemPart.ebookFile.metadata,
itemPart.ebookFile.ebookFormat,
true,
localFileId,
localFile.contentUrl
)
(localLibraryItem.media as Book).ebookFile = ebookFile
Log.d(tag, "scanDownloadItem: Ebook file added to lli ${localFile.contentUrl}")
} else { // Cover image
val localFileId = DeviceManager.getBase64Id(docFile.id)
val localFile =
LocalFile(
localFileId,
docFile.name,
docFile.uri.toString(),
docFile.getBasePath(ctx),
docFile.getAbsolutePath(ctx),
docFile.getSimplePath(ctx),
docFile.mimeType,
docFile.length()
)
localLibraryItem.coverAbsolutePath = localFile.absolutePath
localLibraryItem.coverContentUrl = localFile.contentUrl
localLibraryItem.localFiles.add(localFile)
}
}
if (audioTracks.isEmpty() && !foundEBookFile) {
Log.d(
tag,
"scanDownloadItem did not find any audio tracks or ebook file in folder for ${downloadItem.itemFolderPath}"
)
return cb(null)
}
// For books sort audio tracks then set
if (downloadItem.mediaType == "book") {
audioTracks.sortBy { it.index }
var indexCheck = 1
var startOffset = 0.0
audioTracks.forEach { audioTrack ->
if (audioTrack.index != indexCheck || audioTrack.startOffset != startOffset) {
audioTrack.index = indexCheck
audioTrack.startOffset = startOffset
}
indexCheck++
startOffset += audioTrack.duration
}
localLibraryItem.media.setAudioTracks(audioTracks)
}
val downloadItemScanResult = DownloadItemScanResult(localLibraryItem, null)
// If library item had media progress then make local media progress and save
downloadItem.userMediaProgress?.let { mediaProgress ->
val localMediaProgressId =
if (downloadItem.episodeId.isNullOrEmpty()) localLibraryItemId
else "$localLibraryItemId-$localEpisodeId"
val newLocalMediaProgress =
LocalMediaProgress(
id = localMediaProgressId,
localLibraryItemId = localLibraryItemId,
localEpisodeId = localEpisodeId,
duration = mediaProgress.duration,
progress = mediaProgress.progress,
currentTime = mediaProgress.currentTime,
isFinished = mediaProgress.isFinished,
ebookLocation = mediaProgress.ebookLocation,
ebookProgress = mediaProgress.ebookProgress,
lastUpdate = mediaProgress.lastUpdate,
startedAt = mediaProgress.startedAt,
finishedAt = mediaProgress.finishedAt,
serverConnectionConfigId = downloadItem.serverConnectionConfigId,
serverAddress = downloadItem.serverAddress,
serverUserId = downloadItem.serverUserId,
libraryItemId = downloadItem.libraryItemId,
episodeId = downloadItem.episodeId
)
Log.d(
tag,
"scanLibraryItemFolder: Saving local media progress ${newLocalMediaProgress.id} at progress ${newLocalMediaProgress.progress}"
)
DeviceManager.dbManager.saveLocalMediaProgress(newLocalMediaProgress)
downloadItemScanResult.localMediaProgress = newLocalMediaProgress
}
DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem)
cb(downloadItemScanResult)
val id = localLibraryItemId(itemFolder.id)
val localItem =
DeviceManager.dbManager.getLocalLibraryItem(id)
?: newLocalLibraryItem(
id,
item,
itemFolder.getBasePath(ctx),
itemFolder.getAbsolutePath(ctx),
itemFolder.uri.toString()
)
scanParts(item, localItem, itemFolder, callback)
}
}
@@ -7,7 +7,6 @@ import com.audiobookshelf.app.models.DownloadItem
import com.audiobookshelf.app.plugins.AbsLog
import com.audiobookshelf.app.plugins.AbsLogger
import io.paperdb.Paper
import java.io.File
class DbManager {
val tag = "DbManager"
@@ -148,7 +147,7 @@ class DbManager {
}
// Make sure all local file ids still exist
fun cleanLocalLibraryItems() {
fun cleanLocalLibraryItems(context: Context) {
val localLibraryItems = getLocalLibraryItems()
localLibraryItems.forEach { lli ->
@@ -157,15 +156,15 @@ class DbManager {
// Check local files
lli.localFiles =
lli.localFiles.filter { localFile ->
val file = File(localFile.absolutePath)
if (!file.exists()) {
val exists = localFile.exists(context)
if (!exists) {
Log.d(
tag,
"cleanLocalLibraryItems: Local file ${localFile.absolutePath} was removed from library item ${lli.media.metadata.title}"
)
hasUpdates = true
}
file.exists()
exists
} as
MutableList<LocalFile>
@@ -203,9 +202,11 @@ class DbManager {
// Check cover still there
lli.coverAbsolutePath?.let {
val coverFile = File(it)
if (!coverFile.exists()) {
val coverExists =
lli.localFiles.any { localFile ->
localFile.absolutePath == it && localFile.exists(context)
}
if (!coverExists) {
Log.d(
tag,
"cleanLocalLibraryItems: Cover $it was removed from library item ${lli.media.metadata.title}"
@@ -290,15 +291,13 @@ class DbManager {
return sessions
}
fun saveLog(log:AbsLog) {
fun saveLog(log: AbsLog) {
Paper.book("log").write(log.id, log)
}
fun getAllLogs() : List<AbsLog> {
val logs:MutableList<AbsLog> = mutableListOf()
fun getAllLogs(): List<AbsLog> {
val logs: MutableList<AbsLog> = mutableListOf()
Paper.book("log").allKeys.forEach { logId ->
Paper.book("log").read<AbsLog>(logId)?.let {
logs.add(it)
}
Paper.book("log").read<AbsLog>(logId)?.let { logs.add(it) }
}
return logs.sortedBy { it.timestamp }
}
@@ -317,7 +316,10 @@ class DbManager {
}
}
if (logsRemoved > 0) {
AbsLogger.info("DbManager", "cleanLogs: Removed $logsRemoved logs older than $numberOfHoursToKeep hours")
AbsLogger.info(
"DbManager",
"cleanLogs: Removed $logsRemoved logs older than $numberOfHoursToKeep hours"
)
}
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,89 @@
package com.audiobookshelf.app.managers
import android.content.Context
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.Worker
import androidx.work.WorkerParameters
import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.models.DownloadItem
import com.audiobookshelf.app.plugins.AbsLogger
import java.io.File
import java.util.concurrent.TimeUnit
/** Removes only staging data for terminally failed downloads after their retention window. */
object IncompleteDownloadCleanup {
private const val tag = "IncompleteDownloadCleanup"
private const val RETENTION_MS = 24L * 60L * 60L * 1000L
private const val WORK_PREFIX = "incomplete-download-"
fun schedule(context: Context, item: DownloadItem) {
val failedAt = item.terminalFailureAt ?: return
if (item.stagingCleanupAt != null) return
val delay = (failedAt + RETENTION_MS - System.currentTimeMillis()).coerceAtLeast(0L)
val request = OneTimeWorkRequestBuilder<IncompleteDownloadCleanupWorker>()
.setInitialDelay(delay, TimeUnit.MILLISECONDS)
.build()
WorkManager.getInstance(context).enqueueUniqueWork(
WORK_PREFIX + item.id, ExistingWorkPolicy.REPLACE, request)
}
fun cancel(context: Context, itemId: String) {
WorkManager.getInstance(context).cancelUniqueWork(WORK_PREFIX + itemId)
}
/** Cleans staging data retained longer than 24 hours when scheduled work did not run. */
@Synchronized
fun cleanupExpired(context: Context) {
val now = System.currentTimeMillis()
DeviceManager.dbManager.getDownloadItems()
.filter { item -> isEligible(item, now) }
.forEach { item ->
deleteItem(context, item)
}
}
private fun isEligible(item: DownloadItem, now: Long): Boolean {
val failedAt = item.terminalFailureAt ?: return false
if (item.stagingCleanupAt != null) return false
if (now - failedAt < RETENTION_MS) return false
return item.downloadItemParts.all { part ->
part.moved || (part.failed && !part.isMoving)
}
}
private fun deleteItem(context: Context, item: DownloadItem) {
item.downloadItemParts.forEach { part ->
deleteAppOwnedFile(context, File(part.destinationPath))
if (!part.moved) {
part.bytesDownloaded = 0L
part.completed = false
}
}
item.stagingCleanupAt = System.currentTimeMillis()
DeviceManager.dbManager.saveDownloadItem(item)
cancel(context, item.id)
AbsLogger.info(tag, "Deleted staging files for terminally failed download item ${item.id}")
}
private fun deleteAppOwnedFile(context: Context, file: File) {
val path = file.absolutePath
val internal = context.filesDir.absolutePath
val external = context.getExternalFilesDir(null)?.absolutePath
if (path.startsWith(internal) || (external != null && path.startsWith(external))) {
if (file.exists() && !file.delete()) AbsLogger.error(tag, "Could not delete expired staging file $path")
file.parentFile?.takeIf { it.isDirectory && it.list()?.isEmpty() == true }?.delete()
} else {
AbsLogger.error(tag, "Refusing to delete non-app-owned path $path")
}
}
}
class IncompleteDownloadCleanupWorker(context: Context, params: WorkerParameters) : Worker(context, params) {
override fun doWork(): Result {
DbManager.initialize(applicationContext)
IncompleteDownloadCleanup.cleanupExpired(applicationContext)
return Result.success()
}
}
@@ -1,114 +1,203 @@
package com.audiobookshelf.app.managers
import android.util.Log
import java.io.*
import com.audiobookshelf.app.plugins.AbsLogger
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
import java.util.concurrent.TimeUnit
import okhttp3.*
import okhttp3.Call
import okhttp3.Callback
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
/**
* Manages the internal download process.
*
* @property outputStream The output stream to write the downloaded data.
* @property progressCallback The callback to report download progress.
*/
/** Streams a download into an app-owned staging file. */
class InternalDownloadManager(
private val outputStream: FileOutputStream,
private val progressCallback: DownloadItemManager.InternalProgressCallback
) : AutoCloseable {
private val destinationFile: File,
private val expectedSize: Long,
private val progressCallback: DownloadItemManager.InternalProgressCallback,
private val hasAvailableSpace: () -> Boolean
) {
private val tag = "InternalDownloadManager"
private val client: OkHttpClient =
OkHttpClient.Builder().connectTimeout(30, TimeUnit.SECONDS).build()
private val writer = BinaryFileWriter(outputStream, progressCallback)
/**
* Downloads a file from the given URL.
*
* @param url The URL to download the file from.
* @throws IOException If an I/O error occurs.
*/
@Throws(IOException::class)
fun download(url: String) {
val request: Request = Request.Builder().url(url).addHeader("Accept-Encoding", "identity").build()
client.newCall(request)
.enqueue(
object : Callback {
override fun onFailure(call: Call, e: IOException) {
Log.e(tag, "Download URL $url FAILED", e)
progressCallback.onComplete(true)
}
override fun onResponse(call: Call, response: Response) {
response.body?.let { responseBody ->
val length: Long = response.header("Content-Length")?.toLongOrNull() ?: 0L
writer.write(responseBody.byteStream(), length)
}
?: run {
Log.e(tag, "Response doesn't contain a file")
progressCallback.onComplete(true)
}
}
}
)
interface DownloadHandle {
fun cancel()
}
/**
* Closes the download manager and releases resources.
*
* @throws Exception If an error occurs during closing.
*/
@Throws(Exception::class)
override fun close() {
writer.close()
}
}
private class ActiveDownloadHandle : DownloadHandle {
private val cancelled = AtomicBoolean(false)
private val activeCall = AtomicReference<Call?>()
/**
* Writes binary data to an output stream.
*
* @property outputStream The output stream to write the data to.
* @property progressCallback The callback to report write progress.
*/
class BinaryFileWriter(
private val outputStream: OutputStream,
private val progressCallback: DownloadItemManager.InternalProgressCallback
) : AutoCloseable {
fun setCall(call: Call) {
activeCall.set(call)
if (cancelled.get()) call.cancel()
}
/**
* Writes data from the input stream to the output stream.
*
* @param inputStream The input stream to read the data from.
* @param length The total length of the data to be written.
* @return The total number of bytes written.
* @throws IOException If an I/O error occurs.
*/
@Throws(IOException::class)
fun write(inputStream: InputStream, length: Long): Long {
BufferedInputStream(inputStream).use { input ->
val dataBuffer = ByteArray(CHUNK_SIZE)
var totalBytes: Long = 0
var readBytes: Int
while (input.read(dataBuffer).also { readBytes = it } != -1) {
totalBytes += readBytes
outputStream.write(dataBuffer, 0, readBytes)
progressCallback.onProgress(totalBytes, (totalBytes * 100L) / length)
}
progressCallback.onComplete(false)
return totalBytes
override fun cancel() {
cancelled.set(true)
activeCall.get()?.cancel()
}
}
/**
* Closes the writer and releases resources.
* Starts or resumes a download.
*
* @throws IOException If an error occurs during closing.
* @param url download URL
* @param token access token sent in the Authorization header
* @return logical handle used to cancel the active request, including a restarted request
*/
@Throws(IOException::class)
override fun close() {
outputStream.close()
fun download(url: String, token: String): DownloadHandle {
destinationFile.parentFile?.mkdirs()
val handle = ActiveDownloadHandle()
startRequest(url, token, handle, allowRestart = true)
return handle
}
companion object {
private const val CHUNK_SIZE = 8192 // Increased chunk size for better performance
private fun startRequest(
url: String,
token: String,
handle: ActiveDownloadHandle,
allowRestart: Boolean
) {
var existingBytes = destinationFile.takeIf { it.exists() }?.length() ?: 0L
AbsLogger.info(
tag,
"Starting ${if (existingBytes > 0L) "resumed" else "new"} download for ${destinationFile.name} at byte $existingBytes")
val request =
Request.Builder()
.url(url)
.addHeader("Accept-Encoding", "identity")
.addHeader("Authorization", "Bearer $token")
.apply { if (existingBytes > 0L) header("Range", "bytes=$existingBytes-") }
.build()
val call = client.newCall(request)
handle.setCall(call)
call.enqueue(
object : Callback {
override fun onFailure(call: Call, e: IOException) {
AbsLogger.error(tag, "Download request failed for ${destinationFile.name}: ${e.message}")
progressCallback.onComplete(true)
}
override fun onResponse(call: Call, response: Response) {
response.use {
try {
if (response.code == 401) {
AbsLogger.error(tag, "Download unauthorized (401) for ${destinationFile.name}")
progressCallback.onAuthError()
return
}
if (response.code == 416) {
val serverSize =
response.header("Content-Range")
?.removePrefix("bytes */")
?.toLongOrNull()
if (serverSize != null) progressCallback.onSizeResolved(serverSize)
if (serverSize != null && existingBytes == serverSize) {
progressCallback.onProgress(existingBytes, 100L)
AbsLogger.info(tag, "Download completed for ${destinationFile.name} ($existingBytes bytes)")
progressCallback.onComplete(false)
} else if (allowRestart && destinationFile.delete()) {
AbsLogger.info(tag, "Restarting stale range from byte zero for ${destinationFile.name}")
startRequest(url, token, handle, allowRestart = false)
} else {
AbsLogger.error(tag, "Could not recover invalid range for ${destinationFile.name} at byte $existingBytes")
progressCallback.onComplete(true)
}
return
}
val append =
existingBytes > 0L &&
response.code == 206 &&
hasExpectedRange(response, existingBytes)
if (existingBytes > 0L && !append && response.code != 200) {
AbsLogger.error(
tag,
"Invalid resume response ${response.code} for ${destinationFile.name} at byte $existingBytes"
)
progressCallback.onComplete(true)
return
}
if (!response.isSuccessful || response.body == null) {
AbsLogger.error(tag, "Download HTTP failure ${response.code} for ${destinationFile.name}")
progressCallback.onComplete(true)
return
}
val startingBytes = if (append) existingBytes else 0L
val responseLength = response.body!!.contentLength()
val serverSize =
if (append) contentRangeTotal(response)
else responseLength.takeIf { it >= 0L }
if (serverSize != null) progressCallback.onSizeResolved(serverSize)
val totalLength =
serverSize ?: if (expectedSize > 0L) expectedSize else 0L
FileOutputStream(destinationFile, append).use { output ->
response.body!!.byteStream().use { input ->
val buffer = ByteArray(CHUNK_SIZE)
var totalBytes = startingBytes
while (true) {
val read = input.read(buffer)
if (read < 0) break
if (!hasAvailableSpace())
throw IOException("Download paused to preserve free storage")
output.write(buffer, 0, read)
totalBytes += read
val progress =
if (totalLength > 0L) (totalBytes * 100L) / totalLength else 0L
progressCallback.onProgress(totalBytes, progress.coerceAtMost(100L))
}
}
}
val downloadedSize = destinationFile.length()
if (serverSize != null && downloadedSize != serverSize) {
AbsLogger.error(
tag,
"Downloaded size for ${destinationFile.name} was $downloadedSize, expected server size $serverSize"
)
progressCallback.onComplete(true)
} else {
AbsLogger.info(
tag,
"Download completed for ${destinationFile.name} ($downloadedSize bytes)"
)
progressCallback.onComplete(false)
}
} catch (e: IOException) {
AbsLogger.error(tag, "Could not write staging file ${destinationFile.name}: ${e.message}")
progressCallback.onComplete(true)
}
}
}
}
)
}
private fun hasExpectedRange(response: Response, offset: Long): Boolean {
val range = response.header("Content-Range") ?: return false
val match = CONTENT_RANGE.matchEntire(range) ?: return false
return match.groupValues[1].toLongOrNull() == offset &&
match.groupValues[2].toLongOrNull()?.let { it >= offset } == true
}
private fun contentRangeTotal(response: Response): Long? =
CONTENT_RANGE.matchEntire(response.header("Content-Range") ?: "")
?.groupValues
?.get(3)
?.takeUnless { it == "*" }
?.toLongOrNull()
private companion object {
const val CHUNK_SIZE = 512 * 1024 // 512 KB
val CONTENT_RANGE = Regex("bytes (\\d+)-(\\d+)/(\\d+|\\*)")
val client =
OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.writeTimeout(60, TimeUnit.SECONDS)
.build()
}
}
@@ -9,8 +9,11 @@ import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.server.ApiHandler
import com.getcapacitor.JSObject
import java.util.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.launch
import org.json.JSONException
import org.json.JSONObject
import kotlin.coroutines.resume
@@ -46,6 +49,16 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
var userSettingsPlaybackRate:Float? = null
// Android Auto browses on the media browser service main thread, so the server pings
// and authorize call must not run there or they block it and trigger an ANR
private val androidAutoIOScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
// Android Auto re-requests the browse root every ~2s and multiple clients do so at once,
// so overlapping loads are coalesced into one to avoid racing on the shared server state
private var isLoadingAndroidAutoItems = false
private val pendingAndroidAutoLoadCallbacks = mutableListOf<() -> Unit>()
private val androidAutoLoadLock = Any()
fun getIsLibrary(id:String) : Boolean {
return serverLibraries.find { it.id == id } != null
}
@@ -136,7 +149,7 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
// and reset any server data already set
val serverConnConfig = if (DeviceManager.isConnectedToServer) DeviceManager.serverConnectionConfig else DeviceManager.deviceData.getLastServerConnectionConfig()
if (!DeviceManager.isConnectedToServer || !DeviceManager.checkConnectivity(ctx) || serverConnConfig == null || serverConnConfig.id !== serverConfigIdUsed) {
if (!DeviceManager.isConnectedToServer || !DeviceManager.checkConnectivity(ctx) || serverConnConfig == null || serverConnConfig.id != serverConfigIdUsed) {
podcastEpisodeLibraryItemMap = mutableMapOf()
serverLibraries = listOf()
serverLibraryItems = mutableListOf()
@@ -730,7 +743,10 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
return mediaProgress
}
private fun checkSetValidServerConnectionConfig(cb: (Boolean) -> Unit) = runBlocking {
// Runs on [androidAutoIOScope] rather than blocking the caller: this is reached from
// onLoadChildren on the media browser service main thread, and the pings/authorize below
// are network calls, so runBlocking here caused ANRs while browsing in Android Auto
private fun checkSetValidServerConnectionConfig(cb: (Boolean) -> Unit) = androidAutoIOScope.launch {
Log.d(tag, "checkSetValidServerConnectionConfig | serverConfigIdUsed=$serverConfigIdUsed | lastServerConnectionConfigId=${DeviceManager.deviceData.lastServerConnectionConfigId}")
coroutineScope {
@@ -835,6 +851,26 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
fun loadAndroidAutoItems(cb: () -> Unit) {
Log.d(tag, "Load android auto items")
// Coalesce overlapping calls into a single load, every caller still gets its callback
synchronized(androidAutoLoadLock) {
pendingAndroidAutoLoadCallbacks.add(cb)
if (isLoadingAndroidAutoItems) {
Log.d(tag, "loadAndroidAutoItems: Load already in progress, queued callback")
return
}
isLoadingAndroidAutoItems = true
}
val onLoadFinished = {
val callbacks: List<() -> Unit>
synchronized(androidAutoLoadLock) {
isLoadingAndroidAutoItems = false
callbacks = pendingAndroidAutoLoadCallbacks.toList()
pendingAndroidAutoLoadCallbacks.clear()
}
callbacks.forEach { it() }
}
// Check if any valid server connection if not use locally downloaded books
checkSetValidServerConnectionConfig { isConnected ->
if (isConnected) {
@@ -844,14 +880,14 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
loadLibraries { libraries ->
if (libraries.isEmpty()) {
Log.w(tag, "No libraries returned from server request")
cb()
onLoadFinished()
} else {
cb() // Fully loaded
onLoadFinished() // Fully loaded
}
}
} else { // Not connected to server
Log.d(tag, "loadAndroidAutoItems: Not connected to server")
cb()
onLoadFinished()
}
}
}
@@ -6,34 +6,40 @@ import com.audiobookshelf.app.data.MediaType
import com.fasterxml.jackson.annotation.JsonIgnore
data class DownloadItem(
val id: String,
val libraryItemId:String,
val episodeId:String?,
val userMediaProgress: MediaProgress?,
val serverConnectionConfigId:String,
val serverAddress:String,
val serverUserId:String,
val mediaType: String,
val itemFolderPath:String,
val localFolder: LocalFolder,
val itemTitle: String,
val itemSubfolder: String,
val media: MediaType,
val downloadItemParts: MutableList<DownloadItemPart>
val id: String,
val libraryItemId: String,
val episodeId: String?,
val userMediaProgress: MediaProgress?,
val serverConnectionConfigId: String,
val serverAddress: String,
val serverUserId: String,
val mediaType: String,
val itemFolderPath: String,
val localFolder: LocalFolder,
val itemTitle: String,
val itemSubfolder: String,
val media: MediaType,
val downloadItemParts: MutableList<DownloadItemPart>,
@JsonIgnore var terminalFailureAt: Long? = null,
@JsonIgnore var stagingCleanupAt: Long? = null
) {
@get:JsonIgnore
val isInternalStorage get() = localFolder.id.startsWith("internal-")
val isInternalStorage
get() = localFolder.id.startsWith("internal-")
@get:JsonIgnore
val isDownloadFinished get() = !downloadItemParts.any { !it.completed || it.isMoving }
val isDownloadFinished
get() = downloadItemParts.isNotEmpty() && downloadItemParts.all {
it.completed && it.moved && !it.isMoving && !it.failed
}
@JsonIgnore
fun getNextDownloadItemParts(limit:Int): MutableList<DownloadItemPart> {
fun getNextDownloadItemParts(limit: Int): MutableList<DownloadItemPart> {
val itemParts = mutableListOf<DownloadItemPart>()
if (limit == 0) return itemParts
for (it in downloadItemParts) {
if (!it.completed && it.downloadId == null) {
if (!it.completed && !it.failed && it.downloadId == null) {
itemParts.add(it)
if (itemParts.size >= limit) break
}
@@ -1,6 +1,5 @@
package com.audiobookshelf.app.models
import android.app.DownloadManager
import android.net.Uri
import android.util.Log
import com.audiobookshelf.app.data.AudioTrack
@@ -15,7 +14,8 @@ data class DownloadItemPart(
val id: String,
val downloadItemId: String,
val filename: String,
val fileSize: Long,
var fileSize: Long,
@JsonIgnore val destinationPath: String,
val finalDestinationPath:String,
val serverPath: String,
val localFolderName: String,
@@ -31,28 +31,31 @@ data class DownloadItemPart(
@JsonIgnore val uri: Uri,
@JsonIgnore val destinationUri: Uri,
@JsonIgnore val finalDestinationUri: Uri,
@JsonIgnore var completedDestinationUri: String?,
val finalDestinationSubfolder: String,
var downloadId: Long?,
@JsonIgnore var lastUpdateTime: Long?,
var progress: Long,
var bytesDownloaded: Long
var bytesDownloaded: Long,
@JsonIgnore var retryCount: Int = 0,
@JsonIgnore var authRetryCount: Int = 0,
@JsonIgnore var waitingForSpace: Boolean = false,
@JsonIgnore var reusedExistingFile: Boolean = false
) {
companion object {
fun make(downloadItemId:String, filename:String, fileSize: Long, destinationFile: File, finalDestinationFile: File, subfolder:String, serverPath:String, localFolder: LocalFolder, ebookFile: EBookFile?, audioTrack: AudioTrack?, episode: PodcastEpisode?) :DownloadItemPart {
val destinationUri = Uri.fromFile(destinationFile)
val finalDestinationUri = Uri.fromFile(finalDestinationFile)
val rawCover = if (serverPath.endsWith("/cover")) "?raw=1" else ""
val downloadUri = Uri.parse("${DeviceManager.serverAddress}${serverPath}$rawCover")
var downloadUrl = "${DeviceManager.serverAddress}${serverPath}?token=${DeviceManager.token}"
if (serverPath.endsWith("/cover")) {
downloadUrl += "&raw=1" // Download raw cover image
}
val downloadUri = Uri.parse(downloadUrl)
Log.d("DownloadItemPart", "Audio File Destination Uri: $destinationUri | Final Destination Uri: $finalDestinationUri | Download URI $downloadUri")
Log.d("DownloadItemPart", "Audio File Destination Uri: $destinationUri | Final Destination Uri: $finalDestinationUri | Server Path $serverPath")
return DownloadItemPart(
id = DeviceManager.getBase64Id(finalDestinationFile.absolutePath),
downloadItemId,
filename = filename,
fileSize = fileSize,
destinationPath = destinationFile.absolutePath,
finalDestinationPath = finalDestinationFile.absolutePath,
serverPath = serverPath,
localFolderName = localFolder.name,
@@ -68,10 +71,13 @@ data class DownloadItemPart(
uri = downloadUri,
destinationUri = destinationUri,
finalDestinationUri = finalDestinationUri,
completedDestinationUri = null,
finalDestinationSubfolder = subfolder,
downloadId = null,
lastUpdateTime = null,
progress = 0,
bytesDownloaded = 0
bytesDownloaded = 0,
reusedExistingFile = false
)
}
}
@@ -79,16 +85,4 @@ data class DownloadItemPart(
@get:JsonIgnore
val isInternalStorage get() = localFolderId.startsWith("internal-")
@get:JsonIgnore
val serverUrl get() = uri.toString()
@JsonIgnore
fun getDownloadRequest(): DownloadManager.Request {
val dlRequest = DownloadManager.Request(uri)
dlRequest.setTitle(filename)
dlRequest.setDescription("Downloading to $localFolderName with filename $filename")
dlRequest.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE)
dlRequest.setDestinationUri(destinationUri)
return dlRequest
}
}
@@ -6,10 +6,8 @@ import android.graphics.ImageDecoder
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import android.support.v4.media.MediaMetadataCompat
import android.support.v4.media.session.MediaControllerCompat
import com.audiobookshelf.app.BuildConfig
import com.audiobookshelf.app.R
import com.bumptech.glide.Glide
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.ui.PlayerNotificationManager
import kotlinx.coroutines.*
@@ -36,7 +34,9 @@ class AbMediaDescriptionAdapter (private val controller: MediaControllerCompat,
): Bitmap? {
val albumArtUri = controller.metadata.description.iconUri
val albumBitmap = controller.metadata.description.iconBitmap
?: controller.metadata.getBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART)
// Reuse bitmap from queue navigator (local) or PlaybackSession.resolveCoverBitmapAsync (streaming)
// For local cover images, bitmap is set in PlayerNotificationService TimelineQueueNavigator.getMediaDescription
if (albumBitmap != null) {
return albumBitmap
@@ -59,7 +59,7 @@ class AbMediaDescriptionAdapter (private val controller: MediaControllerCompat,
} else {
serviceScope.launch {
currentBitmap = albumArtUri?.let {
resolveUriAsBitmap(it)
resolveUriAsBitmap(playerNotificationService, it)
}
currentBitmap?.let { callback.onBitmap(it) }
}
@@ -69,26 +69,4 @@ class AbMediaDescriptionAdapter (private val controller: MediaControllerCompat,
currentBitmap
}
}
private suspend fun resolveUriAsBitmap(uri: Uri): Bitmap? {
return withContext(Dispatchers.IO) {
try {
Glide.with(playerNotificationService)
.asBitmap()
.load(uri)
.placeholder(R.drawable.icon)
.error(R.drawable.icon)
.submit()
.get()
} catch (e: Exception) {
e.printStackTrace()
Glide.with(playerNotificationService)
.asBitmap()
.load(Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon))
.submit()
.get()
}
}
}
}
@@ -21,6 +21,31 @@ class CastManager constructor(val mainActivity:Activity) {
private var playerNotificationService:PlayerNotificationService? = null
private var newConnectionListener: SessionListener? = null
private var castStateListener: CastStateListener? = null
/**
* The framework reports NO_DEVICES_AVAILABLE when a session ends, because it stops discovering -
* taken at face value that hides the cast button. The media router is the second opinion.
*/
private fun isReceiverAvailable(castState: Int): Boolean {
if (castState != CastState.NO_DEVICES_AVAILABLE) return true
// The framework's own selector, which carries the receiver id this app is configured with -
// building one by hand here would ask about a different receiver than the one being cast to
val selector = getContext().mergedSelector ?: return false
return getMediaRouter()?.isRouteAvailable(
selector,
MediaRouter.AVAILABILITY_FLAG_IGNORE_DEFAULT_ROUTE or
MediaRouter.AVAILABILITY_FLAG_REQUIRE_MATCH
) == true
}
/** Drops what this manager registered for the activity, which does not outlive it. */
fun detach() {
castStateListener?.let { getContext().removeCastStateListener(it) }
castStateListener = null
}
private fun switchToPlayer(useCastPlayer:Boolean) {
Handler(Looper.getMainLooper()).post() {
@@ -123,6 +148,13 @@ class CastManager constructor(val mainActivity:Activity) {
}
fun startRouteScan(connListener:ChromecastListener) {
// ChromecastListener has always implemented CastStateListener, but nothing registered it, so
// availability hung entirely on the media router callback below - which does not always fire
val stateListener = CastStateListener { state -> connListener.onReceiverAvailableUpdate(isReceiverAvailable(state)) }
castStateListener = stateListener
getContext().addCastStateListener(stateListener)
connListener.onReceiverAvailableUpdate(isReceiverAvailable(getContext().castState))
val callback = object : ScanCallback() {
override fun onRouteUpdate(routes: List<MediaRouter.RouteInfo>?) {
Log.d(tag, "CAST On ROUTE UPDATED ${routes?.size} | ${getContext().castState}")
@@ -815,12 +815,21 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
return AudioAttributes.DEFAULT
}
/**
* The volume of the media stream, which is what the sleep timer fades - not the volume of the
* receiver. The device volume stays where the user put it, and on a cast group the balance
* between its members is left alone, because the receiver applies this to the group's output.
*/
override fun setVolume(audioVolume: Float) {
try {
remoteMediaClient?.setStreamVolume(audioVolume.toDouble().coerceIn(0.0, 1.0))
} catch (e: Exception) {
Log.e(tag, "Failed to set the cast stream volume", e)
}
}
override fun getVolume(): Float {
return 0F
return remoteMediaClient?.mediaStatus?.streamVolume?.toFloat() ?: 1F
}
override fun clearVideoSurface() {
@@ -0,0 +1,36 @@
package com.audiobookshelf.app.player
import android.content.Context
import android.graphics.Bitmap
import android.net.Uri
import android.util.Log
import com.audiobookshelf.app.BuildConfig
import com.audiobookshelf.app.R
import com.bumptech.glide.Glide
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
private const val TAG = "CoverImageLoader"
/** Loads [uri] as a bitmap via Glide, falling back to the app icon if the load fails. */
suspend fun resolveUriAsBitmap(context: Context, uri: Uri): Bitmap? {
return withContext(Dispatchers.IO) {
try {
Glide.with(context)
.asBitmap()
.load(uri)
.placeholder(R.drawable.icon)
.error(R.drawable.icon)
.submit()
.get()
} catch (e: Exception) {
Log.e(TAG, "Failed to load cover bitmap for uri: $uri", e)
Glide.with(context)
.asBitmap()
.load(Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon))
.submit()
.get()
}
}
}
@@ -234,7 +234,7 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
playerNotificationService.jumpBackward()
}
KeyEvent.KEYCODE_MEDIA_STOP -> {
playerNotificationService.closePlayback()
playerNotificationService.pause()
}
else -> {
Log.d(tag, "KeyCode:${keyEvent.keyCode}")
@@ -54,6 +54,11 @@ import com.google.android.exoplayer2.ui.PlayerNotificationManager
import com.google.android.exoplayer2.upstream.*
import java.util.*
import kotlin.concurrent.schedule
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
const val SLEEP_TIMER_WAKE_UP_EXPIRATION = 120000L // 2m
@@ -116,6 +121,9 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
var currentPlaybackSession: PlaybackSession? = null
private var initialPlaybackRate: Float? = null
private val metadataScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
private var metadataArtJob: Job? = null
private var isAndroidAuto = false
// The following are used for the shake detection
@@ -198,6 +206,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
castPlayer?.release()
mediaSession.release()
mediaProgressSyncer.reset()
metadataScope.cancel()
super.onDestroy()
}
@@ -295,6 +304,12 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
transportControls = mediaController.transportControls
mediaSessionConnector = MediaSessionConnector(mediaSession)
// Without this, the connector's default metadata provider rebuilds metadata from the
// player's own state on media item transitions/timeline changes, dropping the cover art
// bitmap that PlaybackSession.resolveCoverBitmapAsync resolves separately.
mediaSessionConnector.setMediaMetadataProvider { _ ->
currentPlaybackSession?.getMediaMetadataCompat(ctx) ?: MediaMetadataCompat.Builder().build()
}
val queueNavigator: TimelineQueueNavigator =
object : TimelineQueueNavigator(mediaSession) {
override fun getSupportedQueueNavigatorActions(player: Player): Long {
@@ -430,8 +445,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
isClosed = false
val metadata = playbackSession.getMediaMetadataCompat(ctx)
mediaSession.setMetadata(metadata)
val mediaItems = playbackSession.getMediaItems(ctx)
val playbackRateToUse = playbackRate ?: initialPlaybackRate ?: 1f
initialPlaybackRate = playbackRate
@@ -459,6 +472,13 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
playbackSession
) // Save playback session to use when app is closed
metadataArtJob?.cancel()
metadataArtJob =
playbackSession.resolveCoverBitmapAsync(ctx, metadataScope) {
mediaSessionConnector.invalidateMediaSessionMetadata()
}
mediaSessionConnector.invalidateMediaSessionMetadata()
AbsLogger.info("PlayerNotificationService", "preparePlayer: Started playback session for item ${currentPlaybackSession?.mediaItemId}. MediaPlayer ${currentPlaybackSession?.mediaPlayer}")
// Notify client
clientEventEmitter?.onPlaybackSession(playbackSession)
@@ -127,6 +127,11 @@ class AbsAudioPlayer : Plugin() {
notifyListeners(evtName, ret)
}
override fun handleOnDestroy() {
castManager?.detach()
super.handleOnDestroy()
}
override fun handleOnPause() {
super.handleOnPause()
isInForeground = false
@@ -229,7 +234,7 @@ class AbsAudioPlayer : Plugin() {
return call.resolve(JSObject("{\"error\":\"Podcast episode not found\"}"))
}
}
if (!it.hasTracks(episode)) {
if (!it.hasTracks(mainActivity, episode)) {
return call.resolve(JSObject("{\"error\":\"No audio files found on device. Download book again to fix.\"}"))
}
@@ -325,10 +330,10 @@ class AbsAudioPlayer : Plugin() {
@PluginMethod
fun seek(call: PluginCall) {
val time:Int = call.getInt("value", 0) ?: 0 // Value in seconds
val time: Double = call.getDouble("value", 0.0) ?: 0.0 // Value in seconds, fractional
Log.d(tag, "seek action to $time")
Handler(Looper.getMainLooper()).post {
playerNotificationService.seekPlayer(time * 1000L) // convert to ms
playerNotificationService.seekPlayer((time * 1000L).toLong())
call.resolve()
}
}
@@ -40,7 +40,7 @@ class AbsDatabase : Plugin() {
secureStorage = SecureStorage(mainActivity)
DeviceManager.dbManager.cleanLocalMediaProgress()
DeviceManager.dbManager.cleanLocalLibraryItems()
DeviceManager.dbManager.cleanLocalLibraryItems(mainActivity)
DeviceManager.dbManager.cleanLogs()
}
@@ -119,7 +119,7 @@ class AbsDatabase : Plugin() {
@PluginMethod
fun setCurrentServerConnectionConfig(call:PluginCall) {
Log.d(tag, "setCurrentServerConnectionConfig ${call.data}")
Log.d(tag, "setCurrentServerConnectionConfig called")
val serverConfigPayload = jacksonMapper.readValue<ServerConnConfigPayload>(call.data.toString())
var serverConnectionConfig = DeviceManager.deviceData.serverConnectionConfigs.find { it.id == serverConfigPayload.id }
@@ -558,7 +558,7 @@ class AbsDatabase : Plugin() {
@PluginMethod
fun updateDeviceSettings(call:PluginCall) { // Returns device data
Log.d(tag, "updateDeviceSettings ${call.data}")
Log.d(tag, "updateDeviceSettings called")
val newDeviceSettings = jacksonMapper.readValue<DeviceSettings>(call.data.toString())
Handler(Looper.getMainLooper()).post {
@@ -576,7 +576,7 @@ class AbsDatabase : Plugin() {
@PluginMethod
fun getMediaItemHistory(call:PluginCall) { // Returns device data
Log.d(tag, "getMediaItemHistory ${call.data}")
Log.d(tag, "getMediaItemHistory called")
val mediaId = call.getString("mediaId") ?: ""
GlobalScope.launch(Dispatchers.IO) {
@@ -1,17 +1,14 @@
package com.audiobookshelf.app.plugins
import android.app.DownloadManager
import android.content.Context
import android.os.Environment
import android.util.Log
import com.audiobookshelf.app.MainActivity
import com.audiobookshelf.app.data.*
import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.device.FolderScanner
import com.audiobookshelf.app.models.DownloadItem
import com.audiobookshelf.app.models.DownloadItemPart
import com.audiobookshelf.app.server.ApiHandler
import com.audiobookshelf.app.managers.DownloadItemManager
import com.audiobookshelf.app.services.DownloadServiceHost
import com.fasterxml.jackson.core.json.JsonReadFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.getcapacitor.JSObject
@@ -27,9 +24,7 @@ class AbsDownloader : Plugin() {
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
lateinit var mainActivity: MainActivity
lateinit var downloadManager: DownloadManager
lateinit var apiHandler: ApiHandler
lateinit var folderScanner: FolderScanner
lateinit var downloadItemManager: DownloadItemManager
private val clientEventEmitter = (object : DownloadItemManager.DownloadEventEmitter {
@@ -42,14 +37,44 @@ class AbsDownloader : Plugin() {
override fun onDownloadItemComplete(jsobj:JSObject) {
notifyListeners("onItemDownloadComplete", jsobj)
}
override fun onQueueChanged(hasWork: Boolean) {
notifyListeners("onQueueChanged", JSObject().put("hasWork", hasWork))
}
})
override fun load() {
mainActivity = (activity as MainActivity)
downloadManager = activity.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
folderScanner = FolderScanner(mainActivity)
apiHandler = ApiHandler(mainActivity)
downloadItemManager = DownloadItemManager(downloadManager, folderScanner, mainActivity, clientEventEmitter)
downloadItemManager = DownloadServiceHost.ensure(mainActivity)
DownloadServiceHost.attachBridge(mainActivity, clientEventEmitter)
}
override fun handleOnDestroy() {
DownloadServiceHost.detachBridge()
super.handleOnDestroy()
}
@PluginMethod
fun setDownloadNotificationStrings(call: PluginCall) {
DownloadServiceHost.setNotificationStrings(
mainActivity,
call.getString("preparing") ?: "Preparing downloads",
call.getString("downloadingFile") ?: "Downloading {0}",
call.getString("waitingForStorage") ?: "Waiting for available storage",
call.getString("downloads") ?: "Downloads",
call.getString("cancel") ?: "Cancel")
call.resolve()
}
/** Replays restored queue items when the frontend subscribes to download events. */
@PluginMethod(returnType = PluginMethod.RETURN_NONE)
override fun addListener(call: PluginCall) {
super.addListener(call)
if (call.getString("eventName") == "onDownloadItem" && ::downloadItemManager.isInitialized) {
downloadItemManager.downloadItemQueue.forEach { item ->
notifyListeners("onDownloadItem", JSObject(jacksonMapper.writeValueAsString(item)))
}
}
}
@PluginMethod
@@ -58,57 +83,60 @@ class AbsDownloader : Plugin() {
var episodeId = call.data.getString("episodeId").toString()
if (episodeId == "null") episodeId = ""
var localFolderId = call.data.getString("localFolderId", "").toString()
Log.d(tag, "Download library item $libraryItemId to folder $localFolderId / episode: $episodeId")
AbsLogger.info(tag, "Requested download for item $libraryItemId${if (episodeId.isEmpty()) "" else " / episode $episodeId"}")
val downloadId = if (episodeId.isEmpty()) libraryItemId else "$libraryItemId-$episodeId"
if (downloadItemManager.downloadItemQueue.find { it.id == downloadId } != null) {
Log.d(tag, "Download already started for this media entity $downloadId")
return call.resolve(JSObject("{\"error\":\"Download already started for this media entity\"}"))
}
apiHandler.getLibraryItemWithProgress(libraryItemId, episodeId) { libraryItem ->
if (libraryItem == null) {
call.resolve(JSObject("{\"error\":\"Server request failed\"}"))
} else {
Log.d(tag, "Got library item from server ${libraryItem.id}")
if (localFolderId == "") {
localFolderId = "internal-${libraryItem.mediaType}"
DownloadServiceHost.retryExisting(mainActivity, downloadId) { result ->
when (result) {
DownloadServiceHost.ExistingDownloadResult.RETRIED -> call.resolve()
DownloadServiceHost.ExistingDownloadResult.ACTIVE -> {
AbsLogger.info(tag, "Download already active for $downloadId")
call.resolve(JSObject("{\"error\":\"Download already started for this media entity\"}"))
}
var localFolder = DeviceManager.dbManager.getLocalFolder(localFolderId)
if (localFolder == null && localFolderId.startsWith("internal-")) {
Log.d(tag, "Creating new App Storage internal LocalFolder $localFolderId")
localFolder = LocalFolder(localFolderId, "Internal App Storage", "", "", "", "", "internal", libraryItem.mediaType)
DeviceManager.dbManager.saveLocalFolder(localFolder)
}
if (localFolder != null) {
if (episodeId.isNotEmpty() && libraryItem.mediaType != "podcast") {
Log.e(tag, "Library item is not a podcast but episode was requested")
call.resolve(JSObject("{\"error\":\"Invalid library item not a podcast\"}"))
} else if (episodeId.isNotEmpty()) {
val podcast = libraryItem.media as Podcast
val episode = podcast.episodes?.find { podcastEpisode ->
podcastEpisode.id == episodeId
}
if (episode == null) {
call.resolve(JSObject("{\"error\":\"Invalid podcast episode not found\"}"))
DownloadServiceHost.ExistingDownloadResult.SERVICE_START_FAILED ->
call.resolve(JSObject("{\"error\":\"Unable to start the Android download service\"}"))
DownloadServiceHost.ExistingDownloadResult.NOT_FOUND -> {
apiHandler.getLibraryItemWithProgress(libraryItemId, episodeId) { libraryItem ->
if (libraryItem == null) {
call.resolve(JSObject("{\"error\":\"Server request failed\"}"))
} else {
startLibraryItemDownload(libraryItem, localFolder, episode)
call.resolve()
AbsLogger.info(tag, "Preparing download for ${libraryItem.id}")
if (localFolderId == "") localFolderId = "internal-${libraryItem.mediaType}"
var localFolder = DeviceManager.dbManager.getLocalFolder(localFolderId)
if (localFolder == null && localFolderId.startsWith("internal-")) {
AbsLogger.info(tag, "Creating internal download folder $localFolderId")
localFolder = LocalFolder(localFolderId, "Internal App Storage", "", "", "", "internal", libraryItem.mediaType)
DeviceManager.dbManager.saveLocalFolder(localFolder)
}
if (localFolder == null) {
call.resolve(JSObject("{\"error\":\"Local Folder Not Found\"}"))
} else if (episodeId.isNotEmpty() && libraryItem.mediaType != "podcast") {
call.resolve(JSObject("{\"error\":\"Invalid library item not a podcast\"}"))
} else if (episodeId.isNotEmpty()) {
val podcast = libraryItem.media as Podcast
val episode = podcast.episodes?.find { it.id == episodeId }
if (episode == null) {
call.resolve(JSObject("{\"error\":\"Invalid podcast episode not found\"}"))
} else {
startLibraryItemDownload(libraryItem, localFolder, episode) { error -> resolveDownloadCall(call, error) }
}
} else {
startLibraryItemDownload(libraryItem, localFolder, null) { error -> resolveDownloadCall(call, error) }
}
}
} else {
startLibraryItemDownload(libraryItem, localFolder, null)
call.resolve()
}
} else {
call.resolve(JSObject("{\"error\":\"Local Folder Not Found\"}"))
}
}
}
}
private fun resolveDownloadCall(call: PluginCall, error: String?) {
if (error == null) call.resolve()
else call.resolve(JSObject().put("error", error))
}
// Item filenames could be the same if they are in sub-folders, this will make them unique
private fun getFilenameFromRelPath(relPath: String): String {
var cleanedRelPath = relPath.replace("\\", "_").replace("/", "_")
@@ -129,21 +157,31 @@ class AbsDownloader : Plugin() {
return newTitle
}
private fun startLibraryItemDownload(libraryItem: LibraryItem, localFolder: LocalFolder, episode:PodcastEpisode?) {
private fun startLibraryItemDownload(
libraryItem: LibraryItem,
localFolder: LocalFolder,
episode: PodcastEpisode?,
callback: (String?) -> Unit
) {
val isInternal = localFolder.id.startsWith("internal-")
val tempFolderPath = if (isInternal) "${mainActivity.filesDir}/downloads/${libraryItem.id}" else mainActivity.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
val finalInternalFolderPath = "${mainActivity.filesDir}/downloads/${libraryItem.id}"
val tempFolderPath =
if (isInternal) {
"${mainActivity.filesDir}/download-staging/${libraryItem.id}"
} else {
"${mainActivity.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) ?: mainActivity.filesDir}/download-staging/${libraryItem.id}"
}
Log.d(tag, "downloadCacheDirectory=$tempFolderPath")
if (libraryItem.mediaType == "book") {
val bookTitle = cleanStringForFileSystem(libraryItem.media.metadata.title)
val bookAuthor = cleanStringForFileSystem(libraryItem.media.metadata.getAuthorDisplayName())
val tracks = libraryItem.media.getAudioTracks()
Log.d(tag, "Starting library item download with ${tracks.size} tracks")
AbsLogger.info(tag, "Queueing library item download with ${tracks.size} files")
val itemSubfolder = "$bookAuthor/$bookTitle"
val itemFolderPath = if (isInternal) "$tempFolderPath" else "${localFolder.absolutePath}/$itemSubfolder"
val itemFolderPath = if (isInternal) finalInternalFolderPath else "${localFolder.absolutePath}/$itemSubfolder"
val downloadItem = DownloadItem(libraryItem.id, libraryItem.id, null, libraryItem.userMediaProgress,DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, bookTitle, itemSubfolder, libraryItem.media, mutableListOf())
val book = libraryItem.media as Book
@@ -152,17 +190,7 @@ class AbsDownloader : Plugin() {
val serverPath = "/api/items/${libraryItem.id}/file/${ebookFile.ino}/download"
val destinationFilename = getFilenameFromRelPath(ebookFile.metadata?.relPath ?: "")
val finalDestinationFile = File("$itemFolderPath/$destinationFilename")
val destinationFile = File("$tempFolderPath/$destinationFilename")
if (destinationFile.exists()) {
Log.d(tag, "TEMP ebook file already exists, removing it from ${destinationFile.absolutePath}")
destinationFile.delete()
}
if (finalDestinationFile.exists()) {
Log.d(tag, "ebook file already exists, removing it from ${finalDestinationFile.absolutePath}")
finalDestinationFile.delete()
}
val destinationFile = File("$tempFolderPath/$destinationFilename.part")
val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, fileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,ebookFile,null,null)
downloadItem.downloadItemParts.add(downloadItemPart)
@@ -178,20 +206,9 @@ class AbsDownloader : Plugin() {
val serverPath = "/api/items/${libraryItem.id}/file/${audioFileIno}/download"
val destinationFilename = getFilenameFromRelPath(audioTrack.relPath)
Log.d(tag, "Audio File Server Path $serverPath | AF RelPath ${audioTrack.relPath} | LocalFolder Path ${localFolder.absolutePath} | DestName $destinationFilename")
val finalDestinationFile = File("$itemFolderPath/$destinationFilename")
val destinationFile = File("$tempFolderPath/$destinationFilename")
if (destinationFile.exists()) {
Log.d(tag, "TEMP Audio file already exists, removing it from ${destinationFile.absolutePath}")
destinationFile.delete()
}
if (finalDestinationFile.exists()) {
Log.d(tag, "Audio file already exists, removing it from ${finalDestinationFile.absolutePath}")
finalDestinationFile.delete()
}
val destinationFile = File("$tempFolderPath/$destinationFilename.part")
val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, fileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,null,audioTrack,null)
downloadItem.downloadItemParts.add(downloadItemPart)
@@ -205,25 +222,15 @@ class AbsDownloader : Plugin() {
val serverPath = "/api/items/${libraryItem.id}/cover"
val destinationFilename = "cover-${libraryItem.id}.jpg"
val destinationFile = File("$tempFolderPath/$destinationFilename")
val destinationFile = File("$tempFolderPath/$destinationFilename.part")
val finalDestinationFile = File("$itemFolderPath/$destinationFilename")
if (destinationFile.exists()) {
Log.d(tag, "TEMP Audio file already exists, removing it from ${destinationFile.absolutePath}")
destinationFile.delete()
}
if (finalDestinationFile.exists()) {
Log.d(tag, "Cover already exists, removing it from ${finalDestinationFile.absolutePath}")
finalDestinationFile.delete()
}
val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, coverFileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,null,null,null)
downloadItem.downloadItemParts.add(downloadItemPart)
}
downloadItemManager.addDownloadItem(downloadItem)
}
DownloadServiceHost.enqueue(mainActivity, downloadItem, callback)
} else callback("No downloadable files found")
} else {
// Podcast episode download
val podcastTitle = cleanStringForFileSystem(libraryItem.media.metadata.title)
@@ -232,22 +239,16 @@ class AbsDownloader : Plugin() {
val audioFileIno = episode?.audioFile?.ino
val fileSize = audioTrack?.metadata?.size ?: 0
Log.d(tag, "Starting podcast episode download")
val itemFolderPath = if (isInternal) "$tempFolderPath" else "${localFolder.absolutePath}/$podcastTitle"
AbsLogger.info(tag, "Queueing podcast episode download")
val itemFolderPath = if (isInternal) finalInternalFolderPath else "${localFolder.absolutePath}/$podcastTitle"
val downloadItemId = "${libraryItem.id}-${episode?.id}"
val downloadItem = DownloadItem(downloadItemId, libraryItem.id, episode?.id, libraryItem.userMediaProgress, DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, podcastTitle, podcastTitle, libraryItem.media, mutableListOf())
var serverPath = "/api/items/${libraryItem.id}/file/${audioFileIno}/download"
var destinationFilename = getFilenameFromRelPath(audioTrack?.relPath ?: "")
Log.d(tag, "Audio File Server Path $serverPath | AF RelPath ${audioTrack?.relPath} | LocalFolder Path ${localFolder.absolutePath} | DestName $destinationFilename")
var destinationFile = File("$tempFolderPath/$destinationFilename")
var destinationFile = File("$tempFolderPath/$destinationFilename.part")
var finalDestinationFile = File("$itemFolderPath/$destinationFilename")
if (finalDestinationFile.exists()) {
Log.d(tag, "Audio file already exists, removing it from ${finalDestinationFile.absolutePath}")
finalDestinationFile.delete()
}
var downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,fileSize, destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,null,audioTrack,episode)
downloadItem.downloadItemParts.add(downloadItemPart)
@@ -258,18 +259,14 @@ class AbsDownloader : Plugin() {
serverPath = "/api/items/${libraryItem.id}/cover"
destinationFilename = "cover.jpg"
destinationFile = File("$tempFolderPath/$destinationFilename")
destinationFile = File("$tempFolderPath/$destinationFilename.part")
finalDestinationFile = File("$itemFolderPath/$destinationFilename")
if (finalDestinationFile.exists()) {
Log.d(tag, "Podcast cover already exists - not downloading cover again")
} else {
downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,coverFileSize,destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,null,null,null)
downloadItem.downloadItemParts.add(downloadItemPart)
}
downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,coverFileSize,destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,null,null,null)
downloadItem.downloadItemParts.add(downloadItemPart)
}
downloadItemManager.addDownloadItem(downloadItem)
DownloadServiceHost.enqueue(mainActivity, downloadItem, callback)
}
}
}
@@ -1,6 +1,7 @@
package com.audiobookshelf.app.plugins
import android.app.AlertDialog
import android.content.Context
import android.net.Uri
import android.os.Build
import android.util.Log
@@ -23,40 +24,62 @@ import java.io.File
class AbsFileSystem : Plugin() {
private val TAG = "AbsFileSystem"
private val tag = "AbsFileSystem"
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
private var jacksonMapper =
jacksonObjectMapper()
.enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
lateinit var mainActivity: MainActivity
override fun load() {
mainActivity = (activity as MainActivity)
mainActivity.storage.storageAccessCallback = object : StorageAccessCallback {
override fun onRootPathNotSelected(
requestCode: Int,
rootPath: String,
uri: Uri,
selectedStorageType: StorageType,
expectedStorageType: StorageType
) {
Log.d(TAG, "STORAGE ACCESS CALLBACK")
}
mainActivity.storage.storageAccessCallback =
object : StorageAccessCallback {
override fun onRootPathNotSelected(
requestCode: Int,
rootPath: String,
uri: Uri,
selectedStorageType: StorageType,
expectedStorageType: StorageType
) {
Log.d(TAG, "STORAGE ACCESS CALLBACK")
}
override fun onCanceledByUser(requestCode: Int) {
Log.d(TAG, "STORAGE ACCESS CALLBACK")
}
override fun onCanceledByUser(requestCode: Int) {
Log.d(TAG, "STORAGE ACCESS CALLBACK")
}
override fun onExpectedStorageNotSelected(requestCode: Int, selectedFolder: DocumentFile, selectedStorageType: StorageType, expectedBasePath: String, expectedStorageType: StorageType) {
Log.d(TAG, "STORAGE ACCESS CALLBACK")
}
override fun onExpectedStorageNotSelected(
requestCode: Int,
selectedFolder: DocumentFile,
selectedStorageType: StorageType,
expectedBasePath: String,
expectedStorageType: StorageType
) {
Log.d(TAG, "STORAGE ACCESS CALLBACK")
}
override fun onStoragePermissionDenied(requestCode: Int) {
Log.d(TAG, "STORAGE ACCESS CALLBACK")
}
override fun onStoragePermissionDenied(requestCode: Int) {
Log.d(TAG, "STORAGE ACCESS CALLBACK")
}
override fun onRootPathPermissionGranted(requestCode: Int, root: DocumentFile) {
Log.d(TAG, "STORAGE ACCESS CALLBACK")
}
}
override fun onRootPathPermissionGranted(requestCode: Int, root: DocumentFile) {
Log.d(TAG, "STORAGE ACCESS CALLBACK")
}
}
}
@PluginMethod
fun setFolderPickerStrings(call: PluginCall) {
mainActivity.getSharedPreferences(FOLDER_PICKER_PREFERENCES, Context.MODE_PRIVATE)
.edit()
.putString(KEY_WRITE_ACCESS_REQUIRED, call.getString("writeAccessRequired"))
.putString(KEY_ALLOW, call.getString("allow"))
.putString(KEY_CANCEL, call.getString("cancel"))
.putString(KEY_ACCESS_DENIED, call.getString("accessDenied"))
.putString(KEY_PERMISSION_DENIED, call.getString("permissionDenied"))
.apply()
call.resolve()
}
@PluginMethod
@@ -65,60 +88,74 @@ class AbsFileSystem : Plugin() {
val REQUEST_CODE_SELECT_FOLDER = 6
val REQUEST_CODE_SDCARD_ACCESS = 7
mainActivity.storage.folderPickerCallback = object : FolderPickerCallback {
override fun onFolderSelected(requestCode: Int, folder: DocumentFile) {
Log.d(TAG, "ON FOLDER SELECTED ${folder.uri} ${folder.name}")
val absolutePath = folder.getAbsolutePath(activity)
val storageType = folder.getStorageType(activity)
val simplePath = folder.getSimplePath(activity)
val basePath = folder.getBasePath(activity)
val folderId = android.util.Base64.encodeToString(folder.id.toByteArray(), android.util.Base64.DEFAULT)
mainActivity.storage.folderPickerCallback =
object : FolderPickerCallback {
override fun onFolderSelected(requestCode: Int, folder: DocumentFile) {
Log.d(TAG, "ON FOLDER SELECTED ${folder.uri} ${folder.name}")
val absolutePath = folder.getAbsolutePath(activity)
val storageType = folder.getStorageType(activity)
val basePath = folder.getBasePath(activity)
val folderId =
android.util.Base64.encodeToString(
folder.id.toByteArray(),
android.util.Base64.DEFAULT
)
val localFolder = LocalFolder(folderId, folder.name ?: "", folder.uri.toString(),basePath,absolutePath, simplePath, storageType.toString(), mediaType)
val localFolder =
LocalFolder(
folderId,
folder.name ?: "",
folder.uri.toString(),
basePath,
absolutePath,
storageType.toString(),
mediaType
)
DeviceManager.dbManager.saveLocalFolder(localFolder)
call.resolve(JSObject(jacksonMapper.writeValueAsString(localFolder)))
}
DeviceManager.dbManager.saveLocalFolder(localFolder)
call.resolve(JSObject(jacksonMapper.writeValueAsString(localFolder)))
}
override fun onStorageAccessDenied(
requestCode: Int,
folder: DocumentFile?,
storageType: StorageType,
storageId: String
) {
Log.e(tag, "Storage Access Denied ${folder?.getAbsolutePath(mainActivity)}")
override fun onStorageAccessDenied(
requestCode: Int,
folder: DocumentFile?,
storageType: StorageType,
storageId: String
) {
Log.e(tag, "Storage Access Denied ${folder?.getAbsolutePath(mainActivity)}")
val jsobj = JSObject()
if (requestCode == REQUEST_CODE_SELECT_FOLDER) {
val jsobj = JSObject()
if (requestCode == REQUEST_CODE_SELECT_FOLDER) {
val builder: AlertDialog.Builder = AlertDialog.Builder(mainActivity)
builder.setMessage(
"You have no write access to this storage, thus selecting this folder is useless." +
"\nWould you like to grant access to this folder?")
builder.setNegativeButton("Dont Allow") { _, _ ->
run {
jsobj.put("error", "User Canceled, Access Denied")
call.resolve(jsobj)
val builder: AlertDialog.Builder = AlertDialog.Builder(mainActivity)
builder.setMessage(folderPickerString(KEY_WRITE_ACCESS_REQUIRED, DEFAULT_WRITE_ACCESS_REQUIRED))
builder.setNegativeButton(folderPickerString(KEY_CANCEL, DEFAULT_CANCEL)) { _, _ ->
run {
jsobj.put("error", folderPickerString(KEY_ACCESS_DENIED, DEFAULT_ACCESS_DENIED))
call.resolve(jsobj)
}
}
builder.setPositiveButton(folderPickerString(KEY_ALLOW, DEFAULT_ALLOW)) { _, _ ->
mainActivity.storageHelper.requestStorageAccess(
REQUEST_CODE_SDCARD_ACCESS,
initialPath = FileFullPath(mainActivity, storageId, "")
)
}
builder.show()
} else {
Log.d(TAG, "STORAGE ACCESS DENIED $requestCode")
jsobj.put("error", folderPickerString(KEY_ACCESS_DENIED, DEFAULT_ACCESS_DENIED))
call.resolve(jsobj)
}
}
override fun onStoragePermissionDenied(requestCode: Int) {
Log.d(TAG, "STORAGE PERMISSION DENIED $requestCode")
val jsobj = JSObject()
jsobj.put("error", folderPickerString(KEY_PERMISSION_DENIED, DEFAULT_PERMISSION_DENIED))
call.resolve(jsobj)
}
}
}
builder.setPositiveButton("Allow.") { _, _ -> mainActivity.storageHelper.requestStorageAccess(REQUEST_CODE_SDCARD_ACCESS, initialPath = FileFullPath(mainActivity, storageId, "")) }
builder.show()
} else {
Log.d(TAG, "STORAGE ACCESS DENIED $requestCode")
jsobj.put("error", "Access Denied")
call.resolve(jsobj)
}
}
override fun onStoragePermissionDenied(requestCode: Int) {
Log.d(TAG, "STORAGE PERMISSION DENIED $requestCode")
val jsobj = JSObject()
jsobj.put("error", "Permission Denied")
call.resolve(jsobj)
}
}
mainActivity.storage.openFolderPicker(REQUEST_CODE_SELECT_FOLDER)
}
@@ -152,7 +189,7 @@ class AbsFileSystem : Plugin() {
val folderUrl = call.data.getString("folderUrl", "").toString()
Log.d(TAG, "Check Folder Permissions for $folderUrl")
val hasAccess = SimpleStorage.hasStorageAccess(context,folderUrl,true)
val hasAccess = SimpleStorage.hasStorageAccess(context, folderUrl, true)
val jsobj = JSObject()
jsobj.put("value", hasAccess)
@@ -196,26 +233,29 @@ class AbsFileSystem : Plugin() {
if (localLibraryItem?.folderId?.startsWith("internal-") == true) {
Log.d(tag, "Deleting internal library item at absolutePath $absolutePath")
val file = File(absolutePath)
success = if (file.exists()) {
file.deleteRecursively()
} else {
true
}
success =
if (file.exists()) {
file.deleteRecursively()
} else {
true
}
} else {
var subfolderPathToDelete = ""
localLibraryItem?.folderId?.let { folderId ->
val folder = DeviceManager.dbManager.getLocalFolder(folderId)
folder?.absolutePath?.let { folderPath ->
val splitAbsolutePath = absolutePath.split("/")
val fullSubDir = splitAbsolutePath.subList(0, splitAbsolutePath.size - 1).joinToString("/")
val fullSubDir =
splitAbsolutePath.subList(0, splitAbsolutePath.size - 1).joinToString("/")
if (fullSubDir != folderPath) {
val subdirHasAnItem = DeviceManager.dbManager.getLocalLibraryItems().any { _localLibraryItem ->
if (_localLibraryItem.id == localLibraryItemId) {
false
} else {
_localLibraryItem.absolutePath.startsWith(fullSubDir)
}
}
val subdirHasAnItem =
DeviceManager.dbManager.getLocalLibraryItems().any { _localLibraryItem ->
if (_localLibraryItem.id == localLibraryItemId) {
false
} else {
_localLibraryItem.absolutePath.startsWith(fullSubDir)
}
}
subfolderPathToDelete = if (subdirHasAnItem) "" else fullSubDir
}
}
@@ -266,4 +306,23 @@ class AbsFileSystem : Plugin() {
call.resolve(JSObject("{\"success\":false}"))
}
}
private fun folderPickerString(key: String, defaultValue: String): String =
mainActivity.getSharedPreferences(FOLDER_PICKER_PREFERENCES, Context.MODE_PRIVATE)
.getString(key, defaultValue) ?: defaultValue
private companion object {
const val FOLDER_PICKER_PREFERENCES = "folder_picker"
const val KEY_WRITE_ACCESS_REQUIRED = "write_access_required"
const val KEY_ALLOW = "allow"
const val KEY_CANCEL = "cancel"
const val KEY_ACCESS_DENIED = "access_denied"
const val KEY_PERMISSION_DENIED = "permission_denied"
const val DEFAULT_WRITE_ACCESS_REQUIRED =
"You do not have write access to this folder. Would you like to grant access?"
const val DEFAULT_ALLOW = "Allow"
const val DEFAULT_CANCEL = "Cancel"
const val DEFAULT_ACCESS_DENIED = "Access denied"
const val DEFAULT_PERMISSION_DENIED = "Permission denied"
}
}
@@ -17,6 +17,7 @@ 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.databind.node.ObjectNode
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import com.getcapacitor.JSArray
@@ -48,7 +49,6 @@ class ApiHandler(var ctx:Context) {
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)
data class LocalSessionSyncResult(val id:String, val success:Boolean, val progressSynced:Boolean?, val error:String?)
data class LocalSessionsSyncResponsePayload(val results:List<LocalSessionSyncResult>)
@@ -167,105 +167,116 @@ class ApiHandler(var ctx:Context) {
* 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
* 5. If refresh fails, fail the request ([refreshAuthTokens] owns clearing the session)
*
* @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}")
val serverConnectionConfigId = DeviceManager.serverConnectionConfigId
refreshAuthTokens(serverConnectionConfigId, httpClient) { result ->
if (result is RefreshResult.Success) {
retryOriginalRequest(originalRequest, result.accessToken, httpClient, callback)
} else {
callback(JSObject().put("error", "Authentication failed - login again"))
}
}
}
// 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
sealed interface RefreshResult {
data class Success(val accessToken: String) : RefreshResult
/** The server rejected the refresh token, so the session has already been cleared. */
data object Rejected : RefreshResult
/** The refresh could not be completed. Credentials are untouched and the caller may retry. */
data object Transient : RefreshResult
}
/** Refreshes tokens for a specific saved server, including downloads queued while another server is active. */
fun refreshAuthTokens(
serverConnectionConfigId: String,
httpClient: OkHttpClient? = null,
onResult: (RefreshResult) -> Unit
) {
val config = DeviceManager.getServerConnectionConfig(serverConnectionConfigId)
val refreshToken = secureStorage.getRefreshToken(serverConnectionConfigId)
if (config == null || refreshToken.isNullOrEmpty()) {
AbsLogger.error(tag, "No refresh token or server configuration for $serverConnectionConfigId")
handleRefreshRejected(serverConnectionConfigId)
onResult(RefreshResult.Rejected)
return
}
val request = try {
Request.Builder()
.url("${config.address}/auth/refresh")
.addHeader("x-refresh-token", refreshToken)
.addHeader("Content-Type", "application/json")
.post(EMPTY_REQUEST)
.build()
} catch (e: Exception) {
AbsLogger.error(tag, "Could not create refresh request for ${config.name}: ${e.message}")
onResult(RefreshResult.Transient)
return
}
(httpClient ?: defaultClient).newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
AbsLogger.error(tag, "Token refresh failed for ${config.name}: ${e.message}")
onResult(RefreshResult.Transient)
}
// 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)
override fun onResponse(call: Call, response: Response) {
response.use {
if (!it.isSuccessful) {
AbsLogger.error(tag, "Token refresh returned ${it.code} for ${config.name}")
if (it.code != 401 && it.code != 403) {
onResult(RefreshResult.Transient)
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)
handleRefreshRejected(serverConnectionConfigId)
onResult(RefreshResult.Rejected)
return
}
try {
val user = JSONObject(it.body!!.string()).optJSONObject("user")
val accessToken = user?.optString("accessToken").orEmpty()
if (accessToken.isEmpty()) {
AbsLogger.error(tag, "Refresh response had no access token for ${config.name}")
onResult(RefreshResult.Transient)
return
}
updateTokens(accessToken, user?.optString("refreshToken").orEmpty().ifEmpty { refreshToken }, serverConnectionConfigId)
onResult(RefreshResult.Success(accessToken))
} catch (e: Exception) {
AbsLogger.error(tag, "Could not parse refresh response for ${config.name}: ${e.message}")
onResult(RefreshResult.Transient)
}
}
})
}
})
}
/**
* Clears only the server that rejected the refresh token; queued downloads can target a non-active server.
*
* Only call this when the server explicitly rejected the refresh token. Transient failures should not log the user out
*/
private fun handleRefreshRejected(serverConnectionConfigId: String) {
// Must not throw: callers still have to report the refresh result to an in-flight request or download.
try {
secureStorage.removeRefreshToken(serverConnectionConfigId)
if (DeviceManager.serverConnectionConfigId != serverConnectionConfigId) return
DeviceManager.serverConnectionConfig = null
DeviceManager.deviceData.lastServerConnectionConfigId = null
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
if (checkAbsDatabaseNotifyListenersInitted()) {
absDatabaseNotifyListeners(
"onTokenRefreshFailure",
JSObject().put("error", "Token refresh failed").put("serverConnectionConfigId", serverConnectionConfigId))
}
} catch (e: Exception) {
Log.e(tag, "handleTokenRefresh: Unexpected error during token refresh", e)
handleRefreshFailure(callback)
AbsLogger.error(tag, "Could not clear session for $serverConnectionConfigId: ${e.message}")
}
}
@@ -283,15 +294,15 @@ class ApiHandler(var ctx:Context) {
Log.d(tag, "updateTokens: Updated refresh token in secure storage")
}
// Update the access token in the current server connection config
DeviceManager.serverConnectionConfig?.let { config ->
// The refreshed connection may be queued in the downloader rather than currently active.
DeviceManager.getServerConnectionConfig(serverConnectionConfigId)?.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()) {
if (DeviceManager.serverConnectionConfigId == serverConnectionConfigId && checkAbsDatabaseNotifyListenersInitted()) {
val tokenJsObject = JSObject()
tokenJsObject.put("accessToken", newAccessToken)
absDatabaseNotifyListeners("onTokenRefresh", tokenJsObject)
@@ -379,50 +390,6 @@ class ApiHandler(var ctx:Context) {
}
}
/**
* 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")) {
@@ -652,10 +619,29 @@ class ApiHandler(var ctx:Context) {
}
}
fun sendLocalProgressSync(playbackSession:PlaybackSession, cb: (Boolean, String?) -> Unit) {
val payload = JSObject(jacksonMapper.writeValueAsString(playbackSession))
private fun createPartialPlaybackSession(playbackSession: PlaybackSession): ObjectNode {
val json = jacksonMapper.createObjectNode()
json.put("id", playbackSession.id)
json.put("userId", playbackSession.userId)
json.put("libraryItemId", playbackSession.libraryItemId)
json.put("episodeId", playbackSession.episodeId)
json.put("mediaType", playbackSession.mediaType)
json.put("displayTitle", playbackSession.displayTitle)
json.put("displayAuthor", playbackSession.displayAuthor)
json.put("duration", playbackSession.duration)
json.put("playMethod", playbackSession.playMethod)
json.put("startedAt", playbackSession.startedAt)
json.put("updatedAt", playbackSession.updatedAt)
json.put("timeListening", playbackSession.timeListening)
json.put("currentTime", playbackSession.currentTime)
json.put("mediaPlayer", playbackSession.mediaPlayer)
return json
}
postRequest("/api/session/local", payload, null) {
fun sendLocalProgressSync(playbackSession:PlaybackSession, cb: (Boolean, String?) -> Unit) {
val partialSession = createPartialPlaybackSession(playbackSession)
partialSession.set<ObjectNode>("deviceInfo", jacksonMapper.valueToTree(playbackSession.deviceInfo))
postRequest("/api/session/local", JSObject(partialSession.toString()), null) {
if (!it.getString("error").isNullOrEmpty()) {
cb(false, it.getString("error"))
} else {
@@ -748,10 +734,12 @@ class ApiHandler(var ctx:Context) {
val deviceId = Settings.Secure.getString(ctx.contentResolver, Settings.Secure.ANDROID_ID)
val deviceInfo = DeviceInfo(deviceId, Build.MANUFACTURER, Build.MODEL, Build.VERSION.SDK_INT, BuildConfig.VERSION_NAME)
val payload = JSObject(jacksonMapper.writeValueAsString(LocalSessionsSyncRequestPayload(playbackSessions, deviceInfo)))
val json = jacksonMapper.createObjectNode();
json.putArray("sessions").addAll(playbackSessions.map(::createPartialPlaybackSession))
json.set<ObjectNode>("deviceInfo", jacksonMapper.valueToTree(deviceInfo))
AbsLogger.info("ApiHandler", "sendSyncLocalSessions: Sending ${playbackSessions.size} saved local playback sessions to server (${DeviceManager.serverConnectionConfigName})")
postRequest("/api/session/local-all", payload, null) {
postRequest("/api/session/local-all", JSObject(json.toString()), null) {
if (!it.getString("error").isNullOrEmpty()) {
AbsLogger.error("ApiHandler", "sendSyncLocalSessions: Failed to sync local sessions. (${it.getString("error")})")
cb(false, it.getString("error"))
@@ -0,0 +1,102 @@
package com.audiobookshelf.app.services
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import com.audiobookshelf.app.R
import com.audiobookshelf.app.models.DownloadItemPart
/** Android-owned foreground lifecycle for transfers that must outlive the WebView and Activity. */
class DownloadService : Service() {
override fun onCreate() {
super.onCreate()
createChannel()
startForegroundWithType(DownloadServiceHost.notificationStrings(this).preparing)
DownloadServiceHost.attachService(this)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_CANCEL -> DownloadServiceHost.cancelAll(this)
else -> {
startForegroundWithType(DownloadServiceHost.notificationStrings(this).preparing)
DownloadServiceHost.startWork(this)
}
}
return START_STICKY
}
override fun onDestroy() {
DownloadServiceHost.detachService(this)
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? = null
fun onPartUpdate(part: DownloadItemPart) {
val strings = DownloadServiceHost.notificationStrings(this)
val text =
if (part.waitingForSpace) strings.waitingForStorage
else strings.downloadingFile.replace("{0}", part.filename)
val progress = part.progress.coerceIn(0L, 100L).toInt()
val notification = notification(text, progress, part.fileSize > 0L)
(getSystemService(NOTIFICATION_SERVICE) as NotificationManager).notify(NOTIFICATION_ID, notification)
}
fun onQueueChanged(hasWork: Boolean) {
if (!hasWork) {
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
}
private fun startForegroundWithType(text: String) {
val notification = notification(text)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
} else {
startForeground(NOTIFICATION_ID, notification)
}
}
private fun notification(text: String, progress: Int = 0, determinate: Boolean = false): Notification {
val cancelIntent = PendingIntent.getService(
this, 1, Intent(this, DownloadService::class.java).setAction(ACTION_CANCEL), pendingIntentFlags())
return NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.icon)
.setContentTitle(DownloadServiceHost.notificationStrings(this).downloads)
.setContentText(text)
.setOnlyAlertOnce(true)
.setOngoing(true)
.setProgress(100, progress, !determinate)
.addAction(0, DownloadServiceHost.notificationStrings(this).cancel, cancelIntent)
.build()
}
private fun createChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
manager.createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
DownloadServiceHost.notificationStrings(this).downloads,
NotificationManager.IMPORTANCE_LOW))
}
private fun pendingIntentFlags(): Int = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
companion object {
private const val CHANNEL_ID = "downloads"
private const val NOTIFICATION_ID = 11
private const val ACTION_CANCEL = "com.audiobookshelf.app.download.CANCEL"
fun intent(context: Context) = Intent(context, DownloadService::class.java)
}
}
@@ -0,0 +1,213 @@
package com.audiobookshelf.app.services
import android.content.Context
import androidx.core.content.ContextCompat
import com.audiobookshelf.app.device.FolderScanner
import com.audiobookshelf.app.managers.DbManager
import com.audiobookshelf.app.managers.DownloadItemManager
import com.audiobookshelf.app.managers.IncompleteDownloadCleanup
import com.audiobookshelf.app.models.DownloadItem
import com.audiobookshelf.app.plugins.AbsLogger
import com.getcapacitor.JSObject
import java.util.Collections
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
/** Shared process owner used by the foreground service and the Capacitor bridge. */
object DownloadServiceHost {
enum class ExistingDownloadResult { NOT_FOUND, ACTIVE, RETRIED, SERVICE_START_FAILED }
data class NotificationStrings(
val preparing: String,
val downloadingFile: String,
val waitingForStorage: String,
val downloads: String,
val cancel: String
)
private var manager: DownloadItemManager? = null
private var bridgeEmitter: DownloadItemManager.DownloadEventEmitter = NoopEmitter
@Volatile private var service: DownloadService? = null
@Volatile private var bridgeReady = false
private val deferredCompletions = Collections.synchronizedList(mutableListOf<JSObject>())
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var restoreJob: Job? = null
@Synchronized
fun ensure(context: Context): DownloadItemManager {
if (manager == null) {
val appContext = context.applicationContext
DbManager.initialize(appContext)
manager = DownloadItemManager(FolderScanner(appContext), appContext, ForwardingEmitter)
restoreJob = scope.launch {
IncompleteDownloadCleanup.cleanupExpired(appContext)
manager!!.restoreQueue()
onRestoreComplete(appContext)
}
}
return manager!!
}
/** Attaches the frontend after restored queue items have been emitted. */
@Synchronized
fun attachBridge(context: Context, emitter: DownloadItemManager.DownloadEventEmitter) {
bridgeReady = false
bridgeEmitter = emitter
ensure(context).setEventEmitter(ForwardingEmitter)
bridgeReady = true
val completions = synchronized(deferredCompletions) {
deferredCompletions.toList().also { deferredCompletions.clear() }
}
completions.forEach(bridgeEmitter::onDownloadItemComplete)
}
@Synchronized
fun detachBridge() {
bridgeReady = false
bridgeEmitter = NoopEmitter
}
fun enqueue(context: Context, item: DownloadItem, callback: (String?) -> Unit) {
val queue = ensure(context)
scope.launch {
restoreJob?.join()
queue.addDownloadItem(item)
if (startService(context)) callback(null)
else callback("Unable to start the Android download service")
}
}
fun retryExisting(
context: Context,
downloadItemId: String,
callback: (ExistingDownloadResult) -> Unit
) {
val queue = ensure(context)
scope.launch {
restoreJob?.join()
val existing = queue.downloadItemQueue.find { it.id == downloadItemId }
if (existing == null) {
callback(ExistingDownloadResult.NOT_FOUND)
} else if (!queue.retryDownloadItem(downloadItemId)) {
callback(ExistingDownloadResult.ACTIVE)
} else if (startService(context)) {
callback(ExistingDownloadResult.RETRIED)
} else {
callback(ExistingDownloadResult.SERVICE_START_FAILED)
}
}
}
fun cancelAll(context: Context) {
val queue = ensure(context)
scope.launch {
restoreJob?.join()
queue.cancelAll()
}
}
fun setNotificationStrings(
context: Context,
preparing: String,
downloadingFile: String,
waitingForStorage: String,
downloads: String,
cancel: String
) {
context.getSharedPreferences(NOTIFICATION_PREFERENCES, Context.MODE_PRIVATE)
.edit()
.putString(KEY_PREPARING, preparing)
.putString(KEY_DOWNLOADING_FILE, downloadingFile)
.putString(KEY_WAITING_FOR_STORAGE, waitingForStorage)
.putString(KEY_DOWNLOADS, downloads)
.putString(KEY_CANCEL, cancel)
.apply()
}
fun notificationStrings(context: Context): NotificationStrings {
val preferences = context.getSharedPreferences(NOTIFICATION_PREFERENCES, Context.MODE_PRIVATE)
return NotificationStrings(
preferences.getString(KEY_PREPARING, DEFAULT_PREPARING) ?: DEFAULT_PREPARING,
preferences.getString(KEY_DOWNLOADING_FILE, DEFAULT_DOWNLOADING_FILE)
?: DEFAULT_DOWNLOADING_FILE,
preferences.getString(KEY_WAITING_FOR_STORAGE, DEFAULT_WAITING_FOR_STORAGE)
?: DEFAULT_WAITING_FOR_STORAGE,
preferences.getString(KEY_DOWNLOADS, DEFAULT_DOWNLOADS) ?: DEFAULT_DOWNLOADS,
preferences.getString(KEY_CANCEL, DEFAULT_CANCEL) ?: DEFAULT_CANCEL)
}
fun attachService(downloadService: DownloadService) {
synchronized(this) { service = downloadService }
startWork(downloadService)
}
@Synchronized
fun detachService(downloadService: DownloadService) {
if (service === downloadService) service = null
}
fun startWork(context: Context) {
val queue = ensure(context)
scope.launch {
restoreJob?.join()
queue.resumeWork()
}
}
private fun onRestoreComplete(context: Context) {
val attachedService = synchronized(this) { service }
if (attachedService != null) {
manager?.resumeWork()
} else if (bridgeReady && manager?.hasWork() == true) {
startService(context)
}
}
private fun startService(context: Context): Boolean {
return try {
ContextCompat.startForegroundService(context, DownloadService.intent(context))
true
} catch (e: RuntimeException) {
AbsLogger.error(TAG, "Could not start download foreground service: ${e.message}")
false
}
}
private object ForwardingEmitter : DownloadItemManager.DownloadEventEmitter {
override fun onDownloadItem(downloadItem: DownloadItem) { bridgeEmitter.onDownloadItem(downloadItem) }
override fun onDownloadItemPartUpdate(downloadItemPart: com.audiobookshelf.app.models.DownloadItemPart) {
if (bridgeReady) bridgeEmitter.onDownloadItemPartUpdate(downloadItemPart)
service?.onPartUpdate(downloadItemPart)
}
override fun onDownloadItemComplete(jsobj: JSObject) {
if (bridgeReady) bridgeEmitter.onDownloadItemComplete(jsobj) else deferredCompletions.add(jsobj)
}
override fun onQueueChanged(hasWork: Boolean) {
bridgeEmitter.onQueueChanged(hasWork)
service?.onQueueChanged(hasWork)
}
}
private object NoopEmitter : DownloadItemManager.DownloadEventEmitter {
override fun onDownloadItem(downloadItem: DownloadItem) = Unit
override fun onDownloadItemPartUpdate(downloadItemPart: com.audiobookshelf.app.models.DownloadItemPart) = Unit
override fun onDownloadItemComplete(jsobj: JSObject) = Unit
override fun onQueueChanged(hasWork: Boolean) = Unit
}
private const val NOTIFICATION_PREFERENCES = "download_notifications"
private const val KEY_PREPARING = "preparing"
private const val KEY_DOWNLOADING_FILE = "downloading_file"
private const val KEY_WAITING_FOR_STORAGE = "waiting_for_storage"
private const val KEY_DOWNLOADS = "downloads"
private const val KEY_CANCEL = "cancel"
private const val DEFAULT_PREPARING = "Preparing downloads"
private const val DEFAULT_DOWNLOADING_FILE = "Downloading {0}"
private const val DEFAULT_WAITING_FOR_STORAGE = "Waiting for available storage"
private const val DEFAULT_DOWNLOADS = "Downloads"
private const val DEFAULT_CANCEL = "Cancel"
private const val TAG = "DownloadServiceHost"
}
+2 -2
View File
@@ -1,7 +1,7 @@
ext {
minSdkVersion = 24
compileSdkVersion = 35
targetSdkVersion = 35
compileSdkVersion = 36
targetSdkVersion = 36
androidxActivityVersion = '1.9.2'
androidxAppCompatVersion = '1.7.0'
androidxCoordinatorLayoutVersion = '1.3.0'
+139 -54
View File
@@ -1,20 +1,31 @@
<template>
<!-- Main container - only shows when playback session exists, applies fullscreen/platform classes -->
<div v-if="playbackSession" id="streamContainer" class="fixed top-0 left-0 layout-wrapper right-0 z-50 pointer-events-none" :class="{ fullscreen: showFullscreen, 'ios-player': $platform === 'ios', 'web-player': $platform === 'web' }">
<!-- Fullscreen overlays: colored background and menu overlays -->
<div v-if="showFullscreen" class="w-full h-full z-10 absolute top-0 left-0 pointer-events-auto" :style="{ backgroundColor: coverRgb }">
<!-- Background gradient for player -->
<div class="w-full h-full absolute top-0 left-0 pointer-events-none" style="background: var(--gradient-audio-player)" />
<!-- Collapse button - minimizes player -->
<div class="top-4 left-4 absolute cursor-pointer">
<span class="material-symbols text-5xl" :class="{ 'text-black text-opacity-75': coverBgIsLight && theme !== 'black' }" @click="collapseFullscreen">keyboard_arrow_down</span>
</div>
<!-- Cast button - Chromecast toggle -->
<div v-show="showCastBtn" class="top-6 right-16 absolute cursor-pointer">
<span class="material-symbols text-3xl" :class="coverBgIsLight && theme !== 'black' ? 'text-black' : ''" @click="castClick">{{ isCasting ? 'cast_connected' : 'cast' }}</span>
</div>
<!-- Player options menu button -->
<div class="top-6 right-4 absolute cursor-pointer">
<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 && theme !== 'black' }" style="font-size: 10px">{{ isDirectPlayMethod ? $strings.LabelPlaybackDirect : isLocalPlayMethod ? $strings.LabelPlaybackLocal : $strings.LabelPlaybackTranscode }}</p>
<!-- Playback method indicator (Direct/Local/Transcode) -->
<p class="top-4 absolute left-0 right-0 mx-auto text-center uppercase tracking-widest text-opacity-75 z-50" :class="{ 'text-black text-opacity-75': coverBgIsLight && theme !== 'black' }" style="font-size: 10px">{{ isDirectPlayMethod ? $strings.LabelPlaybackDirect : isLocalPlayMethod ? $strings.LabelPlaybackLocal : $strings.LabelPlaybackTranscode }}</p>
</div>
<!-- Overall book progress bar -->
<div v-if="playerSettings.useChapterTrack && playerSettings.useTotalTrack && showFullscreen" class="absolute total-track w-full z-30 px-6">
<div class="flex">
<p class="font-mono text-fg" style="font-size: 0.8rem">{{ currentTimePretty }}</p>
@@ -22,7 +33,7 @@
<p class="font-mono text-fg" style="font-size: 0.8rem">{{ totalTimeRemainingPretty }}</p>
</div>
<div class="w-full">
<div class="h-1 w-full bg-track/50 relative rounded-full">
<div class="h-1 w-full bg-track/50 relative rounded-full overflow-hidden">
<div ref="totalReadyTrack" class="h-full bg-track-buffered absolute top-0 left-0 pointer-events-none rounded-full" />
<div ref="totalBufferedTrack" class="h-full bg-track absolute top-0 left-0 pointer-events-none rounded-full" />
<div ref="totalPlayedTrack" class="h-full bg-track-cursor absolute top-0 left-0 pointer-events-none rounded-full" />
@@ -30,16 +41,19 @@
</div>
</div>
<!-- Book cover wrapper -->
<div class="cover-wrapper absolute z-30 pointer-events-auto" @click="clickContainer">
<div class="w-full h-full flex justify-center">
<covers-book-cover v-if="libraryItem || localLibraryItemCoverSrc" ref="cover" :library-item="libraryItem" :download-cover="localLibraryItemCoverSrc" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" raw @imageLoaded="coverImageLoaded" />
</div>
<!-- Sync failed indicator - shows error icon if progress sync failed -->
<div v-if="syncStatus === $constants.SyncStatus.FAILED" class="absolute top-0 left-0 w-full h-full flex items-center justify-center z-30" @click.stop="showSyncsFailedDialog">
<span class="material-symbols text-error text-3xl">error</span>
</div>
</div>
<!-- Title and author text -->
<div class="title-author-texts absolute z-30 left-0 right-0 overflow-hidden" @click="clickTitleAndAuthor">
<div ref="titlewrapper" class="overflow-hidden relative">
<p class="title-text whitespace-nowrap"></p>
@@ -48,6 +62,7 @@
</div>
<div id="playerContent" class="playerContainer w-full z-20 absolute bottom-0 left-0 right-0 p-2 pointer-events-auto transition-all" :style="{ backgroundColor: showFullscreen ? '' : coverRgb }" @click="clickContainer">
<!-- Top controls bar - fullscreen only: bookmarks, speed, sleep timer, chapters -->
<div v-if="showFullscreen" class="absolute bottom-4 left-0 right-0 w-full pb-4 pt-2 mx-auto px-6" style="max-width: 414px">
<div class="flex items-center justify-between pointer-events-auto">
<span v-if="!isPodcast && serverLibraryItemId && socketConnected" class="material-symbols text-3xl text-fg-muted cursor-pointer" :class="{ fill: bookmarks.length }" @click="$emit('showBookmarks')">bookmark</span>
@@ -67,34 +82,36 @@
</div>
<div v-else class="w-full h-full absolute top-0 left-0 pointer-events-none" style="background: var(--gradient-minimized-audio-player)" />
<!-- Playback controls - jump buttons, play/pause, chapter navigation -->
<div id="playerControls" class="absolute right-0 bottom-0 mx-auto" style="max-width: 414px">
<div class="flex items-center max-w-full" :class="playerSettings.lockUi ? 'justify-center' : 'justify-between'">
<span v-show="showFullscreen && !playerSettings.lockUi" class="material-symbols next-icon text-fg cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpChapterStart">first_page</span>
<div v-show="!playerSettings.lockUi" class="jump-icon text-fg cursor-pointer flex flex-col items-center" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpBackwards">
<span v-show="showFullscreen && !playerSettings.lockUi" class="material-symbols next-icon text-fg cursor-pointer" :class="showLoadingState ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpChapterStart">first_page</span>
<div v-show="!playerSettings.lockUi" class="jump-icon text-fg cursor-pointer flex flex-col items-center" :class="showLoadingState ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpBackwards">
<span class="material-symbols text-3xl leading-none">replay</span>
<span v-if="showFullscreen" class="jump-label text-[10px] font-semibold leading-tight">{{ jumpBackwardsLabel }}</span>
</div>
<div class="play-btn cursor-pointer shadow-sm flex items-center justify-center rounded-full text-primary mx-4 relative overflow-hidden" :style="{ backgroundColor: coverRgb }" :class="{ 'animate-spin': seekLoading }" @mousedown.prevent @mouseup.prevent @click.stop="playPauseClick">
<div v-if="!coverBgIsLight" class="absolute top-0 left-0 w-full h-full bg-white bg-opacity-20 pointer-events-none" />
<span v-if="!isLoading" class="material-symbols fill" :class="{ 'text-white': coverRgb && !coverBgIsLight }">{{ seekLoading ? 'autorenew' : !isPlaying ? 'play_arrow' : 'pause' }}</span>
<span v-if="!showLoadingState" class="material-symbols fill" :class="{ 'text-white': coverRgb && !coverBgIsLight }">{{ seekLoading ? 'autorenew' : !isPlaying ? 'play_arrow' : 'pause' }}</span>
<widgets-spinner-icon v-else class="h-8 w-8" />
</div>
<div v-show="!playerSettings.lockUi" class="jump-icon text-fg cursor-pointer flex flex-col items-center" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpForward">
<div v-show="!playerSettings.lockUi" class="jump-icon text-fg cursor-pointer flex flex-col items-center" :class="showLoadingState ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpForward">
<span class="material-symbols text-3xl leading-none">forward_media</span>
<span v-if="showFullscreen" class="jump-label text-[10px] font-semibold leading-tight">{{ jumpForwardLabel }}</span>
</div>
<span v-show="showFullscreen && !playerSettings.lockUi" class="material-symbols next-icon text-fg cursor-pointer" :class="nextChapter && !isLoading ? 'text-opacity-75' : 'text-opacity-10'" @click.stop="jumpNextChapter">last_page</span>
<span v-show="showFullscreen && !playerSettings.lockUi" class="material-symbols next-icon text-fg cursor-pointer" :class="nextChapter && !showLoadingState ? 'text-opacity-75' : 'text-opacity-10'" @click.stop="jumpNextChapter">last_page</span>
</div>
</div>
<!-- Chapter progress bar -->
<div id="playerTrack" class="absolute left-0 w-full px-6">
<div class="flex pointer-events-none">
<p class="font-mono text-fg" style="font-size: 0.8rem" ref="currentTimestamp">0:00</p>
<div class="flex-grow" />
<p class="font-mono text-fg" style="font-size: 0.8rem">{{ timeRemainingPretty }}</p>
</div>
<div ref="track" class="h-1.5 w-full bg-track/50 relative rounded-full" :class="{ 'animate-pulse': isLoading }" @click.stop>
<div ref="track" class="h-1.5 w-full bg-track/50 relative rounded-full overflow-hidden" :class="{ 'animate-pulse': showLoadingState }" @click.stop>
<div ref="readyTrack" class="h-full bg-track-buffered absolute top-0 left-0 rounded-full pointer-events-none" />
<div ref="bufferedTrack" class="h-full bg-track absolute top-0 left-0 rounded-full pointer-events-none" />
<div ref="playedTrack" class="h-full bg-track-cursor absolute top-0 left-0 rounded-full pointer-events-none" />
@@ -105,7 +122,9 @@
</div>
</div>
<!-- Chapters modal - lists chapters for navigation -->
<modals-chapters-modal v-model="showChapterModal" :current-chapter="currentChapter" :chapters="chapters" :playback-rate="currentPlaybackRate" @select="selectChapter" />
<!-- More menu dialog - player settings and options -->
<modals-dialog v-model="showMoreMenuDialog" :items="menuItems" width="80vw" @action="clickMenuAction" />
</div>
</template>
@@ -114,7 +133,7 @@
import { Capacitor } from '@capacitor/core'
import { AbsAudioPlayer } from '@/plugins/capacitor'
import { Dialog } from '@capacitor/dialog'
import { FastAverageColor } from 'fast-average-color'
import { getAverageColorFromCoverUrl } from '@/utils/coverAverageColor'
import WrappingMarquee from '@/assets/WrappingMarquee.js'
import jumpLabelMixin from '@/mixins/jumpLabel'
@@ -157,6 +176,7 @@ export default {
lockUi: false
},
isLoading: false,
isCheckingServerProgress: false,
isDraggingCursor: false,
draggingTouchStartX: 0,
draggingTouchStartTime: 0,
@@ -270,6 +290,9 @@ export default {
return 190 * heightScale
}
},
showLoadingState() {
return this.isLoading || this.isCheckingServerProgress
},
showCastBtn() {
return this.$store.state.isCastAvailable
},
@@ -408,17 +431,14 @@ export default {
},
async coverImageLoaded(fullCoverUrl) {
if (!fullCoverUrl) return
const fac = new FastAverageColor()
fac
.getColorAsync(fullCoverUrl)
.then((color) => {
this.coverRgb = color.rgba
this.coverBgIsLight = color.isLight
})
.catch((e) => {
console.log(e)
})
const avg = await getAverageColorFromCoverUrl(this, fullCoverUrl)
if (!avg) {
this.coverRgb = 'rgb(55, 56, 56)'
this.coverBgIsLight = false
} else {
this.coverRgb = avg.rgba
this.coverBgIsLight = avg.isLight
}
},
clickTitleAndAuthor() {
if (!this.showFullscreen) return
@@ -448,9 +468,8 @@ export default {
this.showFullscreen = true
if (this.titleMarquee) this.titleMarquee.reset()
// Update track for total time bar if useChapterTrack is set
this.$nextTick(() => {
this.updateTrack()
this.measureAndUpdateTrackWidth()
})
},
collapseFullscreen() {
@@ -461,13 +480,13 @@ export default {
},
async jumpNextChapter() {
await this.$hapticsImpact()
if (this.isLoading) return
if (this.showLoadingState) return
if (!this.nextChapter) return
this.seek(this.nextChapter.start)
},
async jumpChapterStart() {
await this.$hapticsImpact()
if (this.isLoading) return
if (this.showLoadingState) return
if (!this.currentChapter) {
return this.restart()
}
@@ -497,12 +516,12 @@ export default {
},
async jumpBackwards() {
await this.$hapticsImpact()
if (this.isLoading) return
if (this.showLoadingState) return
AbsAudioPlayer.seekBackward({ value: this.jumpBackwardsTime })
},
async jumpForward() {
await this.$hapticsImpact()
if (this.isLoading) return
if (this.showLoadingState) return
AbsAudioPlayer.seekForward({ value: this.jumpForwardTime })
},
setStreamReady() {
@@ -530,13 +549,17 @@ export default {
this.updateReadyTrack()
},
updateReadyTrack() {
if (!this.$refs.readyTrack) return
if (this.playerSettings.useChapterTrack) {
if (this.$refs.totalReadyTrack) {
this.$refs.totalReadyTrack.style.width = this.readyTrackWidth + 'px'
if (this.$refs.totalReadyTrack && this.trackWidth) {
const readyPercent = (this.readyTrackWidth / this.trackWidth) * 100
this.$refs.totalReadyTrack.style.width = readyPercent + '%'
}
this.$refs.readyTrack.style.width = this.trackWidth + 'px'
} else {
this.$refs.readyTrack.style.width = this.readyTrackWidth + 'px'
this.$refs.readyTrack.style.width = '100%'
} else if (this.trackWidth) {
const readyPercent = (this.readyTrackWidth / this.trackWidth) * 100
this.$refs.readyTrack.style.width = readyPercent + '%'
}
},
updateTimestamp() {
@@ -588,25 +611,27 @@ export default {
bufferedPercent = Math.max(0, Math.min(1, (this.bufferedTime - this.currentChapter.start) / this.currentChapterDuration))
}
const ptWidth = Math.round(percentDone * this.trackWidth)
const playedPercent = percentDone * 100
const bufferedPercentWidth = bufferedPercent * 100
if (this.$refs.playedTrack) {
this.$refs.playedTrack.style.width = ptWidth + 'px'
this.$refs.playedTrack.style.width = playedPercent + '%'
}
if (this.$refs.bufferedTrack) {
this.$refs.bufferedTrack.style.width = Math.round(bufferedPercent * this.trackWidth) + 'px'
this.$refs.bufferedTrack.style.width = bufferedPercentWidth + '%'
}
if (this.$refs.trackCursor) {
this.$refs.trackCursor.style.left = ptWidth - 14 + 'px'
if (this.$refs.trackCursor && this.$refs.track) {
const trackWidth = this.$refs.track.clientWidth
this.$refs.trackCursor.style.left = Math.round(percentDone * trackWidth) - 14 + 'px'
}
if (this.playerSettings.useChapterTrack) {
if (this.$refs.totalPlayedTrack) this.$refs.totalPlayedTrack.style.width = Math.round(totalPercentDone * this.trackWidth) + 'px'
if (this.$refs.totalBufferedTrack) this.$refs.totalBufferedTrack.style.width = Math.round(totalBufferedPercent * this.trackWidth) + 'px'
if (this.$refs.totalPlayedTrack) this.$refs.totalPlayedTrack.style.width = totalPercentDone * 100 + '%'
if (this.$refs.totalBufferedTrack) this.$refs.totalBufferedTrack.style.width = totalBufferedPercent * 100 + '%'
}
},
seek(time) {
if (this.isLoading) return
if (this.showLoadingState) return
if (this.seekLoading) {
console.error('Already seek loading', this.seekedTime)
return
@@ -615,12 +640,12 @@ export default {
this.seekedTime = time
this.seekLoading = true
AbsAudioPlayer.seek({ value: Math.floor(time) })
// Pass fractional seconds so seeks to non-integer chapter starts don't truncate
AbsAudioPlayer.seek({ value: time })
if (this.$refs.playedTrack) {
const perc = time / this.totalDuration
const ptWidth = Math.round(perc * this.trackWidth)
this.$refs.playedTrack.style.width = ptWidth + 'px'
this.$refs.playedTrack.style.width = perc * 100 + '%'
this.$refs.playedTrack.classList.remove('bg-gray-200')
this.$refs.playedTrack.classList.add('bg-yellow-300')
@@ -638,11 +663,14 @@ export default {
},
async playPauseClick() {
await this.$hapticsImpact()
if (this.isLoading) return
if (this.showLoadingState) return
this.isPlaying = !!((await AbsAudioPlayer.playPause()) || {}).playing
this.isEnded = false
},
setIsCheckingServerProgress(value) {
this.isCheckingServerProgress = !!value
},
play() {
AbsAudioPlayer.playPlayer()
this.startPlayInterval()
@@ -726,7 +754,8 @@ export default {
maxTime = minTime + duration
}
const timePerPixel = duration / this.trackWidth
const trackWidth = this.$refs.track?.clientWidth || this.trackWidth
const timePerPixel = duration / trackWidth
const newTime = this.draggingTouchStartTime + timePerPixel * distanceMoved
this.draggingCurrentTime = Math.min(maxTime, Math.max(minTime, newTime))
@@ -864,11 +893,7 @@ export default {
this.titleMarquee = new WrappingMarquee(this.$refs.titlewrapper)
this.titleMarquee.init(this.title)
if (this.$refs.track) {
this.trackWidth = this.$refs.track.clientWidth
} else {
console.error('Track not loaded', this.$refs)
}
this.measureAndUpdateTrackWidth()
})
},
onPlaybackClosed() {
@@ -914,15 +939,26 @@ export default {
}
}
// playerContainer width transition is 150ms, remeasure after layout settles
await new Promise((resolve) => setTimeout(resolve, 200))
this.refreshUI()
this.isRefreshingUI = false
},
measureAndUpdateTrackWidth() {
if (!this.$refs.track) {
console.error('Track not loaded', this.$refs)
return
}
this.trackWidth = this.$refs.track.clientWidth
this.updateTrack()
this.updateReadyTrack()
},
refreshUI() {
this.updateScreenSize()
if (this.$refs.track) {
this.trackWidth = this.$refs.track.clientWidth
this.updateTrack()
this.updateReadyTrack()
}
this.$nextTick(() => {
this.measureAndUpdateTrackWidth()
})
},
updateScreenSize() {
setTimeout(() => {
@@ -1013,6 +1049,12 @@ export default {
.fullscreen .playerContainer {
height: 200px;
}
@media (orientation: landscape) {
.fullscreen .playerContainer {
width: 50%;
left: 50%;
}
}
#playerContent {
box-shadow: 0px -8px 8px #11111155;
}
@@ -1024,6 +1066,7 @@ export default {
transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1);
transition-property: margin;
bottom: 35px;
max-width: 100%;
}
.fullscreen #playerTrack {
bottom: unset;
@@ -1040,13 +1083,45 @@ export default {
border-radius: 3px;
overflow: hidden;
}
@media (orientation: landscape) {
.fullscreen .cover-wrapper {
left: 25px !important;
top: 50px;
width: calc(50% - 25px) !important;
height: calc(100% - 85px) !important;
}
.fullscreen .cover-wrapper > div > div {
width: 100% !important;
height: 100% !important;
max-width: 100% !important;
max-height: 100% !important;
}
.fullscreen .cover-wrapper > div > div > div {
background-color: transparent;
}
.fullscreen .cover-wrapper img {
object-fit: contain;
}
}
.total-track {
bottom: 215px;
left: 0;
right: 0;
}
@media (orientation: landscape) {
.fullscreen .total-track {
left: 50%;
width: 50%;
}
}
@media (orientation: landscape) {
.fullscreen .title-author-texts {
left: 50% !important;
width: 50% !important;
}
}
.title-author-texts {
transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1);
transition-property: left, bottom, width, height;
@@ -1063,12 +1138,22 @@ export default {
font-size: 0.85rem;
line-height: 1.5;
}
@media (orientation: landscape) {
.fullscreen .title-author-texts .title-text {
font-size: larger !important;
}
}
.title-author-texts .author-text {
transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1);
transition-property: font-size;
font-size: 0.75rem;
line-height: 1.2;
}
@media (orientation: landscape) {
.fullscreen .title-author-texts .author-text {
font-size: inherit !important;
}
}
.fullscreen .title-author-texts {
bottom: calc(50% - var(--cover-image-height) / 2 + 50px);
+126 -40
View File
@@ -48,6 +48,9 @@ export default {
},
isIos() {
return this.$platform === 'ios'
},
currentPlaybackSession() {
return this.$store.state.currentPlaybackSession
}
},
methods: {
@@ -226,7 +229,7 @@ export default {
console.log('Already streaming item', startTime)
if (startTime !== undefined && startTime !== null) {
// seek to start time
AbsAudioPlayer.seek({ value: Math.floor(startTime) })
AbsAudioPlayer.seek({ value: startTime })
} else if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.play()
}
@@ -304,48 +307,129 @@ export default {
this.$refs.audioPlayer?.seek(currentTime)
},
/**
* When device gains focus then refresh the timestamps in the audio player
* Fetch the current user's media progress from the server for a given library item / episode.
* Returns the server media progress object, or null if the request fails, times out, or the
* response doesn't match the requested library item.
*
* The audio player's loading state is shown while the request is in flight so the user
* doesn't tap play before we have a chance to update the timestamps. The request timeout
* is 7 seconds so a slow/unresponsive server doesn't block the user for long.
*/
deviceFocused(hasFocus) {
if (!this.$store.state.currentPlaybackSession) return
async getServerMediaProgressForCurrentSession() {
if (!this.$store.state.user.user || !this.$store.state.networkConnected) return null
const libraryItemId = this.currentPlaybackSession?.libraryItemId
const episodeId = this.currentPlaybackSession?.episodeId
if (!libraryItemId) return null
if (hasFocus) {
if (this.$refs.audioPlayer?.isCheckingServerProgress) {
console.log('[AudioPlayerContainer] getServerMediaProgressForCurrentSession: already checking server progress')
return null
}
const url = episodeId ? `/api/me/progress/${libraryItemId}/${episodeId}` : `/api/me/progress/${libraryItemId}`
this.$refs.audioPlayer?.setIsCheckingServerProgress(true)
try {
const data = await this.$nativeHttp.get(url, { connectTimeout: 7000, readTimeout: 7000 })
if (!data || data.libraryItemId !== libraryItemId) return null
return data
} catch (error) {
console.error('[AudioPlayerContainer] Failed to get server media progress', error)
return null
} finally {
this.$refs.audioPlayer?.setIsCheckingServerProgress(false)
}
},
getLocalMediaProgressForCurrentSession() {
if (!this.currentPlaybackSession) return null
return this.$store.getters['globals/getLocalMediaProgressById'](this.currentPlaybackSession.localLibraryItem?.id, this.currentPlaybackSession.localEpisodeId)
},
/**
* Sync the server media progress with the local media progress
*/
async syncServerMediaProgressWithLocalMediaProgress(localMediaProgressId, serverMediaProgress) {
try {
const newLocalMediaProgress = await this.$db.syncServerMediaProgressWithLocalMediaProgress({
localMediaProgressId,
mediaProgress: serverMediaProgress
})
if (newLocalMediaProgress?.id) {
this.$store.commit('globals/updateLocalMediaProgress', newLocalMediaProgress)
}
} catch (error) {
console.error('[AudioPlayerContainer] Failed to sync server progress with local media progress', error)
}
},
/**
* Check if the server media progress is more recent than the local media progress and sync if so
*/
async checkSyncServerProgressWithLocalProgress(localMediaProgress) {
if (!localMediaProgress) return
console.log('[AudioPlayerContainer] checkSyncServerProgressWithLocalProgress: checking server media progress for local media item open in player')
const serverMediaProgress = await this.getServerMediaProgressForCurrentSession()
if (!serverMediaProgress?.lastUpdate || serverMediaProgress.lastUpdate <= localMediaProgress.lastUpdate) return
console.log('[AudioPlayerContainer] checkSyncServerProgressWithLocalProgress: server progress is more recent than local progress. Server current time:', serverMediaProgress.currentTime, 'vs local', localMediaProgress.currentTime, `(server lastUpdate=${serverMediaProgress.lastUpdate} > local lastUpdate=${localMediaProgress.lastUpdate})`)
if (!this.$refs.audioPlayer?.isPlaying && serverMediaProgress.currentTime !== localMediaProgress.currentTime) {
// Use seek() so the native audio player's current session is updated
this.$refs.audioPlayer.seek(serverMediaProgress.currentTime)
}
await this.syncServerMediaProgressWithLocalMediaProgress(localMediaProgress.id, serverMediaProgress)
},
/**
* When socket is reconnected after a delay, if a local media item is open in the player (paused)
* we fetch the server media progress and sync it if it is more recent than the local progress
*
* If there is no socket connection we may have missed external progress updates
*/
async socketReconnected() {
if (!this.currentPlaybackSession) return
// dont update timestamps if player is playing
if (this.$refs.audioPlayer?.isPlaying) return
if (this.$refs.audioPlayer.isLocalPlayMethod) {
const localMediaProgress = this.getLocalMediaProgressForCurrentSession()
if (!localMediaProgress) {
console.error('[AudioPlayerContainer] socket reconnected: Local media progress not found')
return
}
await this.checkSyncServerProgressWithLocalProgress(localMediaProgress)
}
},
/**
* When device re-gains focus then refresh the timestamps in the audio player
* if local item is open then fetch the server media progress and update if more recent
*/
async deviceFocused(hasFocus) {
if (!this.currentPlaybackSession || !hasFocus) return
// dont update timestamps if player is playing
if (this.$refs.audioPlayer?.isPlaying) return
if (this.$refs.audioPlayer.isLocalPlayMethod) {
const localMediaProgress = this.getLocalMediaProgressForCurrentSession()
if (!localMediaProgress) {
console.error('[AudioPlayerContainer] device visibility: Local media progress not found')
return
}
console.log('[AudioPlayerContainer] device visibility: found local media progress', localMediaProgress.currentTime, 'last time in player is', this.currentTime)
this.$refs.audioPlayer.currentTime = localMediaProgress.currentTime
this.$refs.audioPlayer.timeupdate()
await this.checkSyncServerProgressWithLocalProgress(localMediaProgress)
} else {
// server item so fetch server media progress and update player time
console.log('[AudioPlayerContainer] device visibility: checking server media progress for server media item open in player')
const data = await this.getServerMediaProgressForCurrentSession()
if (!data) return
if (!this.$refs.audioPlayer?.isPlaying) {
const playbackSession = this.$store.state.currentPlaybackSession
if (this.$refs.audioPlayer.isLocalPlayMethod) {
const localLibraryItemId = playbackSession.localLibraryItem?.id
const localEpisodeId = playbackSession.localEpisodeId
if (!localLibraryItemId) {
console.error('[AudioPlayerContainer] device visibility: no local library item for session', JSON.stringify(playbackSession))
return
}
const localMediaProgress = this.$store.state.globals.localMediaProgress.find((mp) => {
if (localEpisodeId) return mp.localEpisodeId === localEpisodeId
return mp.localLibraryItemId === localLibraryItemId
})
if (localMediaProgress) {
console.log('[AudioPlayerContainer] device visibility: found local media progress', localMediaProgress.currentTime, 'last time in player is', this.currentTime)
this.$refs.audioPlayer.currentTime = localMediaProgress.currentTime
this.$refs.audioPlayer.timeupdate()
} else {
console.error('[AudioPlayerContainer] device visibility: Local media progress not found')
}
} else {
const libraryItemId = playbackSession.libraryItemId
const episodeId = playbackSession.episodeId
const url = episodeId ? `/api/me/progress/${libraryItemId}/${episodeId}` : `/api/me/progress/${libraryItemId}`
this.$nativeHttp
.get(url)
.then((data) => {
if (!this.$refs.audioPlayer?.isPlaying && data.libraryItemId === libraryItemId) {
console.log('[AudioPlayerContainer] device visibility: got server media progress', data.currentTime, 'last time in player is', this.currentTime)
this.$refs.audioPlayer.currentTime = data.currentTime
this.$refs.audioPlayer.timeupdate()
}
})
.catch((error) => {
console.error('[AudioPlayerContainer] device visibility: Failed to get progress', error)
})
console.log('[AudioPlayerContainer] device visibility: got server media progress', data.currentTime, 'last time in player is', this.currentTime)
// Only seek if the difference is greater than 1 second
if (Math.abs(data.currentTime - this.currentTime) > 1) {
// Use seek() so the native audio player's current session is updated
this.$refs.audioPlayer.seek(data.currentTime)
}
}
}
@@ -368,6 +452,7 @@ export default {
this.$eventBus.$on('user-settings', this.settingsUpdated)
this.$eventBus.$on('playback-time-update', this.playbackTimeUpdate)
this.$eventBus.$on('device-focus-update', this.deviceFocused)
this.$eventBus.$on('socket-reconnected', this.socketReconnected)
},
beforeDestroy() {
this.onLocalMediaProgressUpdateListener?.remove()
@@ -383,6 +468,7 @@ export default {
this.$eventBus.$off('user-settings', this.settingsUpdated)
this.$eventBus.$off('playback-time-update', this.playbackTimeUpdate)
this.$eventBus.$off('device-focus-update', this.deviceFocused)
this.$eventBus.$off('socket-reconnected', this.socketReconnected)
}
}
</script>
+69
View File
@@ -0,0 +1,69 @@
<template>
<div class="flex h-full px-1 overflow-hidden">
<covers-book-cover :library-item="libraryItem" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<div class="grow px-2 audiobookSearchCardContent">
<p class="truncate text-sm">{{ title }}</p>
<p v-if="subtitle" class="truncate text-xs text-gray-300">{{ subtitle }}</p>
<p class="text-xs text-gray-200 truncate">{{ $getString('LabelByAuthor', [authorName]) }}</p>
</div>
</div>
</template>
<script>
export default {
props: {
episode: {
type: Object,
default: () => {}
},
libraryItem: {
type: Object,
default: () => {}
}
},
data() {
return {}
},
computed: {
bookCoverAspectRatio() {
return this.$store.getters['libraries/getBookCoverAspectRatio']
},
coverWidth() {
if (this.bookCoverAspectRatio === 1) return 50 * 1.2
return 50
},
media() {
return this.libraryItem ? this.libraryItem.media || {} : {}
},
mediaMetadata() {
return this.media.metadata || {}
},
mediaType() {
return this.libraryItem ? this.libraryItem.mediaType : null
},
isPodcast() {
return this.mediaType === 'podcast'
},
title() {
return this.episode?.title || 'No Title'
},
subtitle() {
return this.mediaMetadata.title
},
authorName() {
return this.mediaMetadata.author
}
},
methods: {},
mounted() {}
}
</script>
<style>
.audiobookSearchCardContent {
width: calc(100% - 80px);
display: flex;
flex-direction: column;
justify-content: center;
}
</style>
+2 -2
View File
@@ -484,7 +484,7 @@ export default {
const { value } = await Dialog.confirm({
title: this.$strings.HeaderConfirm,
message: this.$strings.MessageConfirmDeleteServerConfig,
message: this.$strings.MessageConfirmDeleteServerConfig
})
if (value) {
this.processing = true
@@ -843,7 +843,7 @@ export default {
async setUserAndConnection({ user, userDefaultLibraryId, serverSettings, ereaderDevices }) {
if (!user) return
console.log('Successfully logged in', JSON.stringify(user))
console.log('Successfully logged in: ' + user.username)
this.$store.commit('setServerSettings', serverSettings)
this.$store.commit('libraries/setEReaderDevices', ereaderDevices)
+23 -6
View File
@@ -79,9 +79,6 @@ export default {
episodesCopy: [],
showFiltersModal: false,
showSortModal: false,
sortKey: 'publishedAt',
sortDesc: true,
filterKey: 'incomplete',
fetchingRSSFeed: false,
podcastFeedEpisodes: [],
showPodcastEpisodeFeed: false,
@@ -223,6 +220,27 @@ export default {
if (!this.sortKey) return ''
const _sel = this.episodeSortItems.find((i) => i.value === this.sortKey)
return _sel?.text || ''
},
filterKey() {
return this.$store.getters['user/getUserSetting']('podcastEpisodesFilterBy') || 'incomplete'
},
sortKey: {
get() {
return this.$store.getters['user/getUserSetting']('podcastEpisodesOrderBy') || 'publishedAt'
},
set(val) {
this.$store.dispatch('user/updateUserSettings', { podcastEpisodesOrderBy: val })
}
},
sortDesc: {
get() {
const desc = this.$store.getters['user/getUserSetting']('podcastEpisodesOrderDesc')
if (desc == null) return this.mediaMetadata.type === 'episodic'
return desc
},
set(val) {
this.$store.dispatch('user/updateUserSettings', { podcastEpisodesOrderDesc: val })
}
}
},
methods: {
@@ -277,8 +295,8 @@ export default {
this.$store.commit('globals/setShowPlaylistsAddCreateModal', true)
},
setFilter(filter) {
this.filterKey = filter
this.showFiltersModal = false
this.$store.dispatch('user/updateUserSettings', { podcastEpisodesFilterBy: filter })
},
showFilters() {
this.showFiltersModal = true
@@ -291,7 +309,6 @@ export default {
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId, episode.id)
},
init() {
this.sortDesc = this.mediaMetadata.type === 'episodic'
this.episodesCopy = this.episodes.map((ep) => {
return { ...ep }
})
@@ -316,7 +333,7 @@ export default {
},
mounted() {
if (this.$route.query['episodefilter'] === 'downloaded') {
this.filterKey = 'downloaded'
this.$store.dispatch('user/updateUserSettings', { podcastEpisodesFilterBy: 'downloaded' })
}
this.$socket.$on('episode_download_queued', this.episodeDownloadQueued)
this.$socket.$on('episode_download_started', this.episodeDownloadStarted)
@@ -12,7 +12,8 @@ export default {
return {
downloadItemListener: null,
completeListener: null,
itemPartUpdateListener: null
itemPartUpdateListener: null,
queueChangedListener: null
}
},
computed: {
@@ -76,17 +77,22 @@ export default {
},
onDownloadItemPartUpdate(itemPart) {
this.$store.commit('globals/updateDownloadItemPart', itemPart)
},
onQueueChanged(data) {
if (!data.hasWork) this.$store.commit('globals/clearItemDownloads')
}
},
async mounted() {
this.downloadItemListener = await AbsDownloader.addListener('onDownloadItem', (data) => this.onDownloadItem(data))
this.itemPartUpdateListener = await AbsDownloader.addListener('onDownloadItemPartUpdate', (data) => this.onDownloadItemPartUpdate(data))
this.queueChangedListener = await AbsDownloader.addListener('onQueueChanged', (data) => this.onQueueChanged(data))
this.completeListener = await AbsDownloader.addListener('onItemDownloadComplete', (data) => this.onItemDownloadComplete(data))
},
beforeDestroy() {
this.downloadItemListener?.remove()
this.completeListener?.remove()
this.itemPartUpdateListener?.remove()
this.queueChangedListener?.remove()
}
}
</script>
+4 -4
View File
@@ -740,12 +740,12 @@
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 41;
CURRENT_PROJECT_VERSION = 47;
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 0.11.0;
MARKETING_VERSION = 0.14.1;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app.dev;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -764,12 +764,12 @@
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 41;
CURRENT_PROJECT_VERSION = 47;
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 0.11.0;
MARKETING_VERSION = 0.14.1;
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
+5 -1
View File
@@ -12,7 +12,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
// Override point for customization after application launch.
let configuration = Realm.Configuration(
schemaVersion: 20,
schemaVersion: 21,
migrationBlock: { [weak self] migration, oldSchemaVersion in
if (oldSchemaVersion < 1) {
AbsLogger.info(message: "Realm schema version was \(oldSchemaVersion)")
@@ -71,6 +71,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
newObject?["version"] = ""
}
}
if (oldSchemaVersion < 21) {
// One-time wipe of accumulated logs that were freezing the app on load/clear
migration.deleteData(forType: LogEntry.className())
}
}
)
Realm.Configuration.defaultConfiguration = configuration
@@ -0,0 +1,314 @@
{
"images" : [
{
"filename" : "icon-40.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "20x20"
},
{
"filename" : "icon-60.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "20x20"
},
{
"filename" : "icon-58.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "29x29"
},
{
"filename" : "icon-87.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "29x29"
},
{
"filename" : "icon-80.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "40x40"
},
{
"filename" : "icon-120.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "40x40"
},
{
"filename" : "icon-120.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "60x60"
},
{
"filename" : "icon-180.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "60x60"
},
{
"filename" : "icon-20.png",
"idiom" : "ipad",
"scale" : "1x",
"size" : "20x20"
},
{
"filename" : "icon-40.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "20x20"
},
{
"filename" : "icon-29.png",
"idiom" : "ipad",
"scale" : "1x",
"size" : "29x29"
},
{
"filename" : "icon-58.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "29x29"
},
{
"filename" : "icon-40.png",
"idiom" : "ipad",
"scale" : "1x",
"size" : "40x40"
},
{
"filename" : "icon-80.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "40x40"
},
{
"filename" : "icon-76.png",
"idiom" : "ipad",
"scale" : "1x",
"size" : "76x76"
},
{
"filename" : "icon-152.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "76x76"
},
{
"filename" : "icon-167.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "83.5x83.5"
},
{
"filename" : "icon-120.png",
"idiom" : "car",
"scale" : "2x",
"size" : "60x60"
},
{
"filename" : "icon-180.png",
"idiom" : "car",
"scale" : "3x",
"size" : "60x60"
},
{
"filename" : "icon-1024.png",
"idiom" : "ios-marketing",
"scale" : "1x",
"size" : "1024x1024"
},
{
"filename" : "icon-16.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "16x16"
},
{
"filename" : "icon-32.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "16x16"
},
{
"filename" : "icon-32.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "32x32"
},
{
"filename" : "icon-64.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "32x32"
},
{
"filename" : "icon-128.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "128x128"
},
{
"filename" : "icon-256.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "128x128"
},
{
"filename" : "icon-256.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "256x256"
},
{
"filename" : "icon-512.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "256x256"
},
{
"filename" : "icon-512.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "512x512"
},
{
"filename" : "icon-1024.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "512x512"
},
{
"filename" : "icon-48.png",
"idiom" : "watch",
"role" : "notificationCenter",
"scale" : "2x",
"size" : "24x24",
"subtype" : "38mm"
},
{
"filename" : "icon-55.png",
"idiom" : "watch",
"role" : "notificationCenter",
"scale" : "2x",
"size" : "27.5x27.5",
"subtype" : "42mm"
},
{
"filename" : "icon-58.png",
"idiom" : "watch",
"role" : "companionSettings",
"scale" : "2x",
"size" : "29x29"
},
{
"filename" : "icon-87.png",
"idiom" : "watch",
"role" : "companionSettings",
"scale" : "3x",
"size" : "29x29"
},
{
"idiom" : "watch",
"role" : "notificationCenter",
"scale" : "2x",
"size" : "33x33",
"subtype" : "45mm"
},
{
"filename" : "icon-80.png",
"idiom" : "watch",
"role" : "appLauncher",
"scale" : "2x",
"size" : "40x40",
"subtype" : "38mm"
},
{
"filename" : "icon-88.png",
"idiom" : "watch",
"role" : "appLauncher",
"scale" : "2x",
"size" : "44x44",
"subtype" : "40mm"
},
{
"idiom" : "watch",
"role" : "appLauncher",
"scale" : "2x",
"size" : "46x46",
"subtype" : "41mm"
},
{
"filename" : "icon-100.png",
"idiom" : "watch",
"role" : "appLauncher",
"scale" : "2x",
"size" : "50x50",
"subtype" : "44mm"
},
{
"idiom" : "watch",
"role" : "appLauncher",
"scale" : "2x",
"size" : "51x51",
"subtype" : "45mm"
},
{
"idiom" : "watch",
"role" : "appLauncher",
"scale" : "2x",
"size" : "54x54",
"subtype" : "49mm"
},
{
"filename" : "icon-172.png",
"idiom" : "watch",
"role" : "quickLook",
"scale" : "2x",
"size" : "86x86",
"subtype" : "38mm"
},
{
"filename" : "icon-196.png",
"idiom" : "watch",
"role" : "quickLook",
"scale" : "2x",
"size" : "98x98",
"subtype" : "42mm"
},
{
"filename" : "icon-216.png",
"idiom" : "watch",
"role" : "quickLook",
"scale" : "2x",
"size" : "108x108",
"subtype" : "44mm"
},
{
"idiom" : "watch",
"role" : "quickLook",
"scale" : "2x",
"size" : "117x117",
"subtype" : "45mm"
},
{
"idiom" : "watch",
"role" : "quickLook",
"scale" : "2x",
"size" : "129x129",
"subtype" : "49mm"
},
{
"filename" : "icon-1024.png",
"idiom" : "watch-marketing",
"scale" : "1x",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 399 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 672 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 845 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

+2
View File
@@ -78,6 +78,7 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
liveDownloadItemPart.progress = 100
liveDownloadItemPart.completed = true
}
AbsLogger.info(message: "Download completed for \(liveDownloadItemPart.filename ?? partId)")
do {
// Move the downloaded file into place
@@ -89,6 +90,7 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
try realm.write {
liveDownloadItemPart.moved = true
}
AbsLogger.info(message: "Move completed for \(liveDownloadItemPart.filename ?? partId) to \(destinationUrl.path)")
} catch {
try realm.write {
liveDownloadItemPart.failed = true
+21 -21
View File
@@ -21,15 +21,15 @@ PODS:
- Capacitor
- CapacitorPreferences (7.0.1):
- Capacitor
- CapacitorStatusBar (7.0.1):
- CapacitorStatusBar (7.0.6):
- Capacitor
- CordovaPlugins (6.2.1):
- CapacitorCordova
- Realm (10.54.4):
- Realm/Headers (= 10.54.4)
- Realm/Headers (10.54.4)
- RealmSwift (10.54.4):
- Realm (= 10.54.4)
- Realm (10.54.6):
- Realm/Headers (= 10.54.6)
- Realm/Headers (10.54.6)
- RealmSwift (10.54.6):
- Realm (= 10.54.6)
- WebnativellcCapacitorFilesharer (7.0.4):
- Capacitor
@@ -89,22 +89,22 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
Alamofire: 7193b3b92c74a07f85569e1a6c4f4237291e7496
Capacitor: 106e7a4205f4618d582b886a975657c61179138d
CapacitorApp: d63334c052278caf5d81585d80b21905c6f93f39
CapacitorBrowser: 081852cf532acf77b9d2953f3a88fe5b9711fb06
CapacitorClipboard: b98aead5dc7ec595547fc2c5d75bacd2ae3338bc
CapacitorCommunityKeepAwake: 00dfd8fa3cca0df003c9a3e2cd7bee678aeec68b
CapacitorCommunityVolumeButtons: 8a0443a202ed659688d85f4d44d66f42f62f2b56
Capacitor: 03bc7cbdde6a629a8b910a9d7d78c3cc7ed09ea7
CapacitorApp: febecbb9582cb353aed037e18ec765141f880fe9
CapacitorBrowser: 6299776d496e968505464884d565992faa20444a
CapacitorClipboard: 70bfdb42b877b320a6e511ab94fa7a6a55d57ecb
CapacitorCommunityKeepAwake: ae762ce29b53147d28cfcaae5273cd1db0c38fc4
CapacitorCommunityVolumeButtons: 1b84f7abf29cd9476cef9e8979b2854a64d2eed5
CapacitorCordova: 5967b9ba03915ef1d585469d6e31f31dc49be96f
CapacitorDialog: 9b934329026b2b0ffa56939bb06df3c67541a2ab
CapacitorHaptics: 70e47470fa1a6bd6338cd102552e3846b7f9a1b3
CapacitorNetwork: 07ec4c69c1bb696f41c23e00d31bda1bbb221bba
CapacitorPreferences: cbf154e5e5519b7f5ab33817a334dda1e98387f9
CapacitorStatusBar: 275cbf2f4dfc00388f519ef80c7ec22edda342c9
CordovaPlugins: 5a72a85b45469e68556bb172409f1b6d57b27236
Realm: 8b5cda39a41f17a1734da2f39c6004eb8745587a
RealmSwift: 0b4f808fed6898f1f6c26f501f740efd80dff0b4
WebnativellcCapacitorFilesharer: 10b111373d4dc49608935600dcbcc14605258c73
CapacitorDialog: 0e09f242f6c3f5e82e4dc76b20f2a056be57a579
CapacitorHaptics: 1f1e17041f435d8ead9ff2a34edd592c6aa6a8d6
CapacitorNetwork: 15cb4385f0913a8ceb5e9a4d7af1ec554bdb8de8
CapacitorPreferences: 6c98117d4d7508034a4af9db64d6b26fc75d7b94
CapacitorStatusBar: 416e9e53fd6397e668d4a181cd2131617d949bd6
CordovaPlugins: 2ecbba09775516c41764dbf78ade612427311b7e
Realm: b1b3bc68162fa242132eb7eefbf91d7c40f36a85
RealmSwift: 456cfd82a4f23dff8e3456980999331ab69bbf3e
WebnativellcCapacitorFilesharer: e3a5930240633db3335040251d66aac6762ff111
PODFILE CHECKSUM: 498821c0cfa2508609567fa95d7244c01cbef538
+50 -11
View File
@@ -183,6 +183,7 @@ class ApiClient {
]
let refreshRequest = AF.request("\(serverConfig.address)/auth/refresh", method: .post, headers: refreshHeaders)
.validate(statusCode: 200..<300)
refreshRequest.responseDecodable(of: RefreshResponse.self) { response in
switch response.result {
@@ -190,7 +191,6 @@ class ApiClient {
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
}
@@ -206,7 +206,8 @@ class ApiClient {
case .failure(let error):
AbsLogger.error(message: "handleTokenRefresh: Refresh request failed for server \(serverConfig.name): \(error)")
handleRefreshFailure()
let status = response.response?.statusCode
if status == 401 || status == 403 { handleRefreshFailure() }
callback?(nil)
}
}
@@ -313,12 +314,10 @@ class ApiClient {
private static func handleRefreshFailure() {
AbsLogger.info(message: "handleRefreshFailure: Token refresh failed, clearing session")
// Clear the current server connection
let configId = Store.serverConfig?.id
Store.serverConfig = nil
// Remove refresh token from secure storage
if let serverConfig = Store.serverConfig {
_ = secureStorage.removeRefreshToken(serverConnectionConfigId: serverConfig.id)
if let configId = configId {
_ = secureStorage.removeRefreshToken(serverConnectionConfigId: configId)
}
// Notify webview frontend about token refresh failure
@@ -511,7 +510,8 @@ class ApiClient {
public static func reportLocalPlaybackProgress(_ session: PlaybackSession) async -> Bool {
return await withCheckedContinuation { continuation in
postResourceWithTokenRefresh(endpoint: "api/session/local", parameters: session) { success in
let payload = PartialPlaybackSessionSyncPayload(from: session, includeDeviceInfo: true)
postResourceWithTokenRefresh(endpoint: "api/session/local", parameters: payload) { success in
continuation.resume(returning: success)
}
}
@@ -519,7 +519,10 @@ class ApiClient {
public static func reportAllLocalPlaybackSessions(_ sessions: [PlaybackSession]) async -> Bool {
return await withCheckedContinuation { continuation in
let payload = LocalPlaybackSessionSyncAllPayload(sessions: sessions, deviceInfo: sessions.first?.deviceInfo)
let payload = LocalPlaybackSessionSyncAllPayload(
sessions: sessions.map { PartialPlaybackSessionSyncPayload(from: $0, includeDeviceInfo: false) },
deviceInfo: sessions.first?.deviceInfo
)
postResourceWithTokenRefresh(endpoint: "api/session/local-all", parameters: payload) { success in
continuation.resume(returning: success)
}
@@ -668,8 +671,44 @@ struct LocalMediaProgressSyncResultsPayload: Codable {
var numLocalProgressUpdates: Int?
}
struct LocalPlaybackSessionSyncAllPayload: Codable {
var sessions: [PlaybackSession]
struct PartialPlaybackSessionSyncPayload: Encodable {
let id: String
let userId: String?
let libraryItemId: String?
let episodeId: String?
let mediaType: String
let displayTitle: String?
let displayAuthor: String?
let duration: Double
let playMethod: Int
let startedAt: Double?
let updatedAt: Double?
let timeListening: Double
let currentTime: Double
let mediaPlayer: String
let deviceInfo: [String: String?]?
init(from session: PlaybackSession, includeDeviceInfo: Bool) {
id = session.id
userId = session.userId
libraryItemId = session.libraryItemId
episodeId = session.episodeId
mediaType = session.mediaType
displayTitle = session.displayTitle
displayAuthor = session.displayAuthor
duration = session.duration
playMethod = session.playMethod
startedAt = session.startedAt
updatedAt = session.updatedAt
timeListening = session.timeListening
currentTime = session.currentTime
mediaPlayer = session.mediaPlayer
deviceInfo = includeDeviceInfo ? session.deviceInfo : nil
}
}
struct LocalPlaybackSessionSyncAllPayload: Encodable {
var sessions: [PartialPlaybackSessionSyncPayload]
var deviceInfo: [String: String?]?
}
+37 -49
View File
@@ -20,12 +20,12 @@ class Database {
debugPrint(error)
}
}
public func setServerConnectionConfig(config: ServerConnectionConfig) {
let config = config
let realm = try! Realm()
let existing: ServerConnectionConfig? = realm.object(ofType: ServerConnectionConfig.self, forPrimaryKey: config.id)
if let existing = existing {
do {
try existing.update {
@@ -40,19 +40,19 @@ class Database {
AbsLogger.error("setServerConn", message: "failed to update server config")
debugPrint(error)
}
setLastActiveConfigIndex(index: existing.index)
} else {
if config.index == 0 {
let lastConfig: ServerConnectionConfig? = realm.objects(ServerConnectionConfig.self).last
if lastConfig != nil {
config.index = lastConfig!.index + 1
} else {
config.index = 1
}
}
do {
try realm.write {
realm.add(config)
@@ -61,7 +61,7 @@ class Database {
AbsLogger.error(message: "failed to save server config")
debugPrint(exception)
}
setLastActiveConfigIndex(index: config.index)
}
}
@@ -78,11 +78,11 @@ class Database {
debugPrint("Failed to update server connection config token: \(error)")
}
}
public func deleteServerConnectionConfig(id: String) {
let realm = try! Realm()
let config = realm.object(ofType: ServerConnectionConfig.self, forPrimaryKey: id)
do {
try realm.write {
if config != nil {
@@ -94,27 +94,27 @@ class Database {
debugPrint(exception)
}
}
public func getServerConnectionConfigs() -> [ServerConnectionConfig] {
let realm = try! Realm()
return Array(realm.objects(ServerConnectionConfig.self))
}
public func setLastActiveConfigIndexToNil() {
setLastActiveConfigIndex(index: nil)
}
private func setLastActiveConfigIndex(index: Int?) {
let realm = try! Realm()
do {
try realm.write {
let existing = realm.objects(ServerConnectionConfigActiveIndex.self).last
if ( existing?.index != index ) {
if let existing = existing {
realm.delete(existing)
}
let activeConfig = ServerConnectionConfigActiveIndex()
activeConfig.index = index
realm.add(activeConfig)
@@ -125,12 +125,12 @@ class Database {
debugPrint(exception)
}
}
public func getLastActiveConfigIndex() -> Int? {
let realm = try! Realm()
return realm.objects(ServerConnectionConfigActiveIndex.self).first?.index ?? nil
}
public func setDeviceSettings(deviceSettings: DeviceSettings) {
let realm = try! Realm()
let existing = realm.objects(DeviceSettings.self)
@@ -144,7 +144,7 @@ class Database {
AbsLogger.error(message: "failed to save device settings")
}
}
public func getLocalLibraryItems(mediaType: MediaType? = nil) -> [LocalLibraryItem] {
do {
let realm = try Realm()
@@ -154,7 +154,7 @@ class Database {
return []
}
}
public func getLocalLibraryItem(byServerLibraryItemId: String) -> LocalLibraryItem? {
do {
let realm = try Realm()
@@ -164,7 +164,7 @@ class Database {
return nil
}
}
public func getLocalLibraryItem(localLibraryItemId: String) -> LocalLibraryItem? {
do {
let realm = try Realm()
@@ -174,12 +174,12 @@ class Database {
return nil
}
}
public func saveLocalLibraryItem(localLibraryItem: LocalLibraryItem) throws {
let realm = try Realm()
try realm.write { realm.add(localLibraryItem, update: .modified) }
}
public func getLocalFile(localFileId: String) -> LocalFile? {
do {
let realm = try Realm()
@@ -189,7 +189,7 @@ class Database {
return nil
}
}
public func getDownloadItem(downloadItemId: String) -> DownloadItem? {
do {
let realm = try Realm()
@@ -199,7 +199,7 @@ class Database {
return nil
}
}
public func getDownloadItem(libraryItemId: String) -> DownloadItem? {
do {
let realm = try Realm()
@@ -209,7 +209,7 @@ class Database {
return nil
}
}
public func getDownloadItem(downloadItemPartId: String) -> DownloadItem? {
do {
let realm = try Realm()
@@ -219,17 +219,17 @@ class Database {
return nil
}
}
public func saveDownloadItem(_ downloadItem: DownloadItem) throws {
let realm = try Realm()
return try realm.write { realm.add(downloadItem, update: .modified) }
}
public func getDeviceSettings() -> DeviceSettings {
let realm = try! Realm()
return realm.objects(DeviceSettings.self).first ?? getDefaultDeviceSettings()
}
public func getAllLocalMediaProgress() -> [LocalMediaProgress] {
do {
let realm = try Realm()
@@ -239,7 +239,7 @@ class Database {
return []
}
}
// For books this will just be the localLibraryItemId for podcast episodes this will be "{localLibraryItemId}-{episodeId}"
public func getLocalMediaProgress(localMediaProgressId: String) -> LocalMediaProgress? {
do {
@@ -250,7 +250,7 @@ class Database {
return nil
}
}
public func removeLocalMediaProgress(localMediaProgressId: String) throws {
let realm = try Realm()
try realm.write {
@@ -258,7 +258,7 @@ class Database {
realm.delete(progress!)
}
}
public func getAllPlaybackSessions() -> [PlaybackSession] {
do {
let realm = try Realm()
@@ -268,7 +268,7 @@ class Database {
return []
}
}
public func getPlaybackSession(id: String) -> PlaybackSession? {
do {
let realm = try Realm()
@@ -279,12 +279,12 @@ 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()
@@ -294,7 +294,7 @@ class Database {
return []
}
}
public func clearLogs() throws {
do {
let realm = try! Realm()
@@ -308,25 +308,13 @@ class Database {
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")
let keepLogCutoffMs = Int(Date().addingTimeInterval(TimeInterval(-1 * numberOfHoursToKeep * 3600)).timeIntervalSince1970 * 1000)
try realm.write {
realm.delete(realm.objects(LogEntry.self).filter("timestamp < %@", keepLogCutoffMs))
}
}
}
+41 -13
View File
@@ -24,6 +24,7 @@ export default {
inittingLibraries: false,
hasMounted: false,
disconnectTime: 0,
socketDisconnectedTime: 0,
timeLostFocus: 0,
currentLang: null
}
@@ -44,7 +45,7 @@ export default {
} else {
var timeSinceDisconnect = Date.now() - this.disconnectTime
if (timeSinceDisconnect > 5000) {
console.log('Time since disconnect was', timeSinceDisconnect, 'sync with server')
console.log('[default] Time since disconnect was', timeSinceDisconnect, 'sync with server')
setTimeout(() => {
this.syncLocalSessions(false)
}, 4000)
@@ -55,6 +56,28 @@ export default {
this.disconnectTime = Date.now()
}
}
},
socketConnected: {
handler(newVal, oldVal) {
if (!this.hasMounted) {
// watcher runs before mount, handling libraries/connection should be handled in mount
return
}
if (newVal) {
// if we havent been receiving socket events then external progress updates may have been missed
const timeSinceDisconnect = Date.now() - this.socketDisconnectedTime
if (timeSinceDisconnect > 30000 && this.isPlayerOpen) {
console.log('[default] socket reconnected after ' + timeSinceDisconnect + 'ms and player is open, triggering server media progress sync')
// used for triggering a server media progress sync if local media item is open in player
this.$eventBus.$emit('socket-reconnected')
} else {
console.log('[default] socket reconnected after ' + timeSinceDisconnect + 'ms')
}
} else {
console.log('[default] socket disconnected')
this.socketDisconnectedTime = Date.now()
}
}
}
},
computed: {
@@ -67,6 +90,9 @@ export default {
networkConnected() {
return this.$store.state.networkConnected
},
socketConnected() {
return this.$store.state.socketConnected
},
user() {
return this.$store.state.user.user
},
@@ -218,9 +244,9 @@ export default {
AbsLogger.info({ tag: 'default', message: 'Calling syncLocalSessions' })
const response = await this.$db.syncLocalSessionsWithServer(isFirstSync)
if (response?.error) {
console.error('[default] Failed to sync local sessions', response.error)
await AbsLogger.error({ tag: 'default', message: `syncLocalSessions: Failed to sync local sessions: ${response.error}` })
} else {
console.log('[default] Successfully synced local sessions')
await AbsLogger.info({ tag: 'default', message: 'syncLocalSessions: Successfully synced local sessions' })
// Reload local media progresses
await this.$store.dispatch('globals/loadLocalMediaProgress')
}
@@ -233,11 +259,13 @@ export default {
async userMediaProgressUpdated(payload) {
const prog = payload.data // MediaProgress
await AbsLogger.info({ tag: 'default', message: `userMediaProgressUpdate: Received updated media progress for current user from socket event. Media item id ${payload.id}` })
const mediaProgressId = payload.id
const itemLabel = `${prog.libraryItemId}${prog.episodeId ? ` episodeId: ${prog.episodeId}` : ''}`
// Check if this media item is currently open in the player, paused, and this progress update is coming from a different session
const isMediaOpenInPlayer = this.$store.getters['getIsMediaStreaming'](prog.libraryItemId, prog.episodeId)
if (isMediaOpenInPlayer && this.$store.getters['getCurrentPlaybackSessionId'] !== payload.sessionId && !this.$store.state.playerIsPlaying) {
await AbsLogger.info({ tag: 'default', message: `userMediaProgressUpdate: Item is currently open in player, paused and this progress update is coming from a different session. Updating playback time to ${payload.data.currentTime}` })
await AbsLogger.info({ tag: 'default', message: `userMediaProgressUpdate: Updating paused player playback time to ${payload.data.currentTime} (${itemLabel}, mediaProgressId: ${mediaProgressId})` })
this.$eventBus.$emit('playback-time-update', payload.data.currentTime)
}
@@ -248,17 +276,17 @@ export default {
// Progress update is more recent then local progress
if (localProg && localProg.lastUpdate < prog.lastUpdate) {
if (localProg.currentTime == prog.currentTime && localProg.isFinished == prog.isFinished) {
await AbsLogger.info({ tag: 'default', message: `userMediaProgressUpdate: server lastUpdate is more recent but progress is up-to-date (libraryItemId: ${prog.libraryItemId}${prog.episodeId ? ` episodeId: ${prog.episodeId}` : ''})` })
await AbsLogger.info({ tag: 'default', message: `userMediaProgressUpdate: server lastUpdate is more recent but progress is up-to-date (${itemLabel}, mediaProgressId: ${mediaProgressId}, server lastUpdate=${prog.lastUpdate} > local lastUpdate=${localProg.lastUpdate})` })
return
}
// Server progress is more up-to-date
await AbsLogger.info({ tag: 'default', message: `userMediaProgressUpdate: syncing progress from server with local item for "${prog.libraryItemId}" ${prog.episodeId ? `episode ${prog.episodeId}` : ''} | server lastUpdate=${prog.lastUpdate} > local lastUpdate=${localProg.lastUpdate}` })
const payload = {
await AbsLogger.info({ tag: 'default', message: `userMediaProgressUpdate: Syncing server progress to local (${itemLabel}, mediaProgressId: ${mediaProgressId}, server lastUpdate=${prog.lastUpdate} > local lastUpdate=${localProg.lastUpdate})` })
const syncPayload = {
localMediaProgressId: localProg.id,
mediaProgress: prog
}
newLocalMediaProgress = await this.$db.syncServerMediaProgressWithLocalMediaProgress(payload)
newLocalMediaProgress = await this.$db.syncServerMediaProgressWithLocalMediaProgress(syncPayload)
} else if (!localProg) {
// Check if local library item exists
// local media progress may not exist yet if it hasn't been played
@@ -270,20 +298,20 @@ export default {
const localEpisode = lliEpisodes.find((ep) => ep.serverEpisodeId === prog.episodeId)
if (localEpisode) {
// Add new local media progress
const payload = {
const syncPayload = {
localLibraryItemId: localLibraryItem.id,
localEpisodeId: localEpisode.id,
mediaProgress: prog
}
newLocalMediaProgress = await this.$db.syncServerMediaProgressWithLocalMediaProgress(payload)
newLocalMediaProgress = await this.$db.syncServerMediaProgressWithLocalMediaProgress(syncPayload)
}
} else {
// Add new local media progress
const payload = {
const syncPayload = {
localLibraryItemId: localLibraryItem.id,
mediaProgress: prog
}
newLocalMediaProgress = await this.$db.syncServerMediaProgressWithLocalMediaProgress(payload)
newLocalMediaProgress = await this.$db.syncServerMediaProgressWithLocalMediaProgress(syncPayload)
}
} else {
console.log(`[default] userMediaProgressUpdate no local media progress or lli found for this server item ${prog.id}`)
@@ -291,7 +319,7 @@ export default {
}
if (newLocalMediaProgress?.id) {
await AbsLogger.info({ tag: 'default', message: `userMediaProgressUpdate: local media progress updated for ${newLocalMediaProgress.id}` })
await AbsLogger.info({ tag: 'default', message: `userMediaProgressUpdate: Local media progress updated (${itemLabel}, localId: ${newLocalMediaProgress.id})` })
this.$store.commit('globals/updateLocalMediaProgress', newLocalMediaProgress)
}
},
+63 -6
View File
@@ -1,12 +1,12 @@
{
"name": "audiobookshelf-app",
"version": "0.12.0-beta",
"version": "0.14.1-beta",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "audiobookshelf-app",
"version": "0.12.0-beta",
"version": "0.14.1-beta",
"dependencies": {
"@capacitor-community/keep-awake": "^7.0.0",
"@capacitor-community/volume-buttons": "^7.0.0",
@@ -20,7 +20,7 @@
"@capacitor/ios": "^7.0.0",
"@capacitor/network": "^7.0.0",
"@capacitor/preferences": "^7.0.0",
"@capacitor/status-bar": "^7.0.0",
"@capacitor/status-bar": "^7.0.6",
"@nuxtjs/axios": "^5.13.6",
"@teckel/vue-pdf": "^4.3.5",
"@webnativellc/capacitor-filesharer": "7.0.4",
@@ -1883,9 +1883,9 @@
}
},
"node_modules/@capacitor/status-bar": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@capacitor/status-bar/-/status-bar-7.0.1.tgz",
"integrity": "sha512-iDv3mXYo9CdxYRVwt3/pRyuk25p7Sn4GfaS/zMZyVIqTzsvKLCIIH3GdKK+ta+nsNcAVpCw/t5jFEBt1D18ctA==",
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/@capacitor/status-bar/-/status-bar-7.0.6.tgz",
"integrity": "sha512-7AVqj46b26QikImQzWfsJmzG8NUJZBGkrVSuoeTo+SX/YH3hXH0MqwwFgMqMGHfa/BICDgSvTjM9miVFlq0+RQ==",
"license": "MIT",
"peerDependencies": {
"@capacitor/core": ">=7.0.0"
@@ -5102,6 +5102,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"file-uri-to-path": "1.0.0"
}
},
"node_modules/bluebird": {
"version": "3.7.2",
"license": "MIT"
@@ -7582,6 +7592,13 @@
"version": "2.0.5",
"license": "MIT"
},
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"license": "MIT",
"optional": true
},
"node_modules/fill-range": {
"version": "7.1.1",
"license": "MIT",
@@ -7813,6 +7830,20 @@
"version": "1.0.0",
"license": "ISC"
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"license": "MIT",
@@ -9992,6 +10023,13 @@
"thenify-all": "^1.0.0"
}
},
"node_modules/nan": {
"version": "2.28.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.28.0.tgz",
"integrity": "sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==",
"license": "MIT",
"optional": true
},
"node_modules/nanoid": {
"version": "3.3.11",
"funding": [
@@ -15859,6 +15897,25 @@
"node": ">=0.10.0"
}
},
"node_modules/watchpack-chokidar2/node_modules/fsevents": {
"version": "1.2.13",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.13.tgz",
"integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==",
"deprecated": "Upgrade to fsevents v2 to mitigate potential security issues",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"dependencies": {
"bindings": "^1.5.0",
"nan": "^2.12.1"
},
"engines": {
"node": ">= 4.0"
}
},
"node_modules/watchpack-chokidar2/node_modules/glob-parent": {
"version": "3.1.0",
"license": "ISC",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "audiobookshelf-app",
"version": "0.12.0-beta",
"version": "0.14.1-beta",
"author": "advplyr",
"scripts": {
"dev": "nuxt --hostname 0.0.0.0 --port 1337",
@@ -27,7 +27,7 @@
"@capacitor/ios": "^7.0.0",
"@capacitor/network": "^7.0.0",
"@capacitor/preferences": "^7.0.0",
"@capacitor/status-bar": "^7.0.0",
"@capacitor/status-bar": "^7.0.6",
"@nuxtjs/axios": "^5.13.6",
"@teckel/vue-pdf": "^4.3.5",
"buffer": "^6.0.3",
+1 -1
View File
@@ -10,7 +10,7 @@
<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">
<div class="flex justify-center items-center my-4 left-0 right-0 bottom-0 absolute p-4">
<p class="text-sm text-fg">{{ $strings.MessageReportBugsAndContribute }} <a class="underline" href="https://github.com/advplyr/audiobookshelf-app" target="_blank">GitHub</a></p>
<a href="https://github.com/advplyr/audiobookshelf-app" target="_blank" class="text-fg hover:scale-150 hover:rotate-6 transform duration-500 ml-2">
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" width="24" height="24" viewBox="0 0 24 24">
+13 -1
View File
@@ -135,8 +135,20 @@ export default {
this.$eventBus.$emit('play-item', { libraryItemId: nextBookNotRead.id })
}
}
},
libraryChanged(libraryId) {
// A collection belongs to a single library, so leave this page when a different library is
// selected rather than showing a collection that is not in the current library
if (!libraryId || libraryId !== this.collection.libraryId) {
this.$router.replace('/bookshelf/collections')
}
}
},
mounted() {}
mounted() {
this.$eventBus.$on('library-changed', this.libraryChanged)
},
beforeDestroy() {
this.$eventBus.$off('library-changed', this.libraryChanged)
}
}
</script>
+1 -2
View File
@@ -4,7 +4,7 @@
<div v-if="!downloadItemParts.length" class="py-6 text-center text-lg">No download item parts</div>
<template v-for="(itemPart, num) in downloadItemParts">
<div :key="itemPart.id" class="w-full">
<div :key="`${itemPart.downloadItemId}-${itemPart.id}`" class="w-full">
<div class="flex">
<div class="w-14">
<span v-if="itemPart.completed" class="material-symbols text-success">check_circle</span>
@@ -40,4 +40,3 @@ export default {
beforeDestroy() {}
}
</script>
+6 -13
View File
@@ -13,7 +13,7 @@
</div>
</div>
<div class="relative" @click="showFullscreenCover = true">
<covers-book-cover :library-item="libraryItem" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" no-bg raw @imageLoaded="coverImageLoaded" />
<covers-book-cover :library-item="libraryItem" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" no-bg raw />
<div v-if="!isPodcast" class="absolute bottom-0 left-0 h-1 z-10 box-shadow-progressbar" :class="userIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: coverWidth * progressPercent + 'px' }"></div>
</div>
</div>
@@ -172,7 +172,7 @@
<script>
import { Dialog } from '@capacitor/dialog'
import { AbsFileSystem, AbsDownloader } from '@/plugins/capacitor'
import { FastAverageColor } from 'fast-average-color'
import { getAverageColorFromCoverUrl } from '@/utils/coverAverageColor'
import cellularPermissionHelpers from '@/mixins/cellularPermissionHelpers'
export default {
@@ -496,17 +496,10 @@ export default {
},
async coverImageLoaded(fullCoverUrl) {
if (!fullCoverUrl) return
const fac = new FastAverageColor()
fac
.getColorAsync(fullCoverUrl)
.then((color) => {
this.coverRgb = color.rgba
this.coverBgIsLight = color.isLight
})
.catch((e) => {
console.log(e)
})
const avg = await getAverageColorFromCoverUrl(this, fullCoverUrl)
if (!avg) return
this.coverRgb = avg.rgba
this.coverBgIsLight = avg.isLight
},
moreButtonPress() {
this.showMoreMenu = true
+7
View File
@@ -162,13 +162,20 @@ export default {
if (this.playlist.id === playlist.id) {
this.$router.replace('/bookshelf/playlists')
}
},
libraryChanged() {
// Playlist contents are shown in the context of the selected library, so leave this page
// when the library changes rather than showing stale items
this.$router.replace('/bookshelf/playlists')
}
},
mounted() {
this.$eventBus.$on('library-changed', this.libraryChanged)
this.$socket.$on('playlist_updated', this.playlistUpdated)
this.$socket.$on('playlist_removed', this.playlistRemoved)
},
beforeDestroy() {
this.$eventBus.$off('library-changed', this.libraryChanged)
this.$socket.$off('playlist_updated', this.playlistUpdated)
this.$socket.$off('playlist_removed', this.playlistRemoved)
}
+15 -3
View File
@@ -19,7 +19,7 @@
</div>
</template>
<p v-if="podcastResults.length" class="uppercase text-xs text-fg-muted my-1 px-1 font-semibold">{{ $strings.LabelPodcasts }}</p>
<p v-if="podcastResults.length" class="font-semibold text-sm mb-1 mt-2">{{ $strings.LabelPodcasts }}</p>
<template v-for="item in podcastResults">
<div :key="item.libraryItem.id" class="text-fg select-none relative py-1">
<nuxt-link :to="`/item/${item.libraryItem.id}`">
@@ -28,6 +28,15 @@
</div>
</template>
<p v-if="episodeResults.length" class="font-semibold text-sm mb-1 mt-2">{{ $strings.HeaderEpisodes }}</p>
<template v-for="item in episodeResults">
<div :key="item.libraryItem.recentEpisode.id" class="text-fg select-none relative py-1">
<nuxt-link :to="`/item/${item.libraryItem.id}/${item.libraryItem.recentEpisode.id}`">
<cards-episode-search-card :episode="item.libraryItem.recentEpisode" :library-item="item.libraryItem" />
</nuxt-link>
</div>
</template>
<p v-if="seriesResults.length" class="font-semibold text-sm mb-1 mt-2">{{ $strings.LabelSeries }}</p>
<template v-for="seriesResult in seriesResults">
<div :key="seriesResult.series.id" class="w-full h-16 py-1">
@@ -77,6 +86,7 @@ export default {
isFetching: false,
bookResults: [],
podcastResults: [],
episodeResults: [],
seriesResults: [],
authorResults: [],
narratorResults: [],
@@ -91,7 +101,7 @@ export default {
return this.$store.getters['libraries/getBookCoverAspectRatio']
},
totalResults() {
return this.bookResults.length + this.seriesResults.length + this.authorResults.length + this.podcastResults.length + this.narratorResults.length + this.tagResults.length
return this.bookResults.length + this.seriesResults.length + this.authorResults.length + this.podcastResults.length + this.narratorResults.length + this.tagResults.length + this.episodeResults.length
}
},
methods: {
@@ -104,6 +114,7 @@ export default {
if (!this.lastSearch) {
this.bookResults = []
this.podcastResults = []
this.episodeResults = []
this.seriesResults = []
this.authorResults = []
this.narratorResults = []
@@ -111,7 +122,7 @@ export default {
return
}
this.isFetching = true
const results = await this.$nativeHttp.get(`/api/libraries/${this.currentLibraryId}/search?q=${value}&limit=5`).catch((error) => {
const results = await this.$nativeHttp.get(`/api/libraries/${this.currentLibraryId}/search?q=${value}`).catch((error) => {
console.error('Search error', error)
return null
})
@@ -125,6 +136,7 @@ export default {
this.bookResults = results?.book || []
this.podcastResults = results?.podcast || []
this.episodeResults = results?.episodes || []
this.seriesResults = results?.series || []
this.authorResults = results?.authors || []
this.narratorResults = results?.narrators || []
+2 -4
View File
@@ -51,7 +51,7 @@ class AbsDatabaseWeb extends WebPlugin {
ssc.customHeaders = serverConnectionConfig.customHeaders || {}
if (serverConnectionConfig.refreshToken) {
console.log('[AbsDatabase] Updating refresh token...', serverConnectionConfig.refreshToken)
console.log('[AbsDatabase] Updating refresh token...')
// Only using local storage for web version that is only used for testing
localStorage.setItem(`refresh_token_${ssc.id}`, serverConnectionConfig.refreshToken)
}
@@ -71,7 +71,7 @@ class AbsDatabaseWeb extends WebPlugin {
}
if (serverConnectionConfig.refreshToken) {
console.log('[AbsDatabase] Setting refresh token...', serverConnectionConfig.refreshToken)
console.log('[AbsDatabase] Setting refresh token...')
// Only using local storage for web version that is only used for testing
localStorage.setItem(`refresh_token_${ssc.id}`, serverConnectionConfig.refreshToken)
}
@@ -119,7 +119,6 @@ class AbsDatabaseWeb extends WebPlugin {
name: 'Audiobooks',
contentUrl: 'test',
absolutePath: '/audiobooks',
simplePath: 'audiobooks',
storageType: 'primary',
mediaType: 'book'
}
@@ -196,7 +195,6 @@ class AbsDatabaseWeb extends WebPlugin {
filename: 'lf1.mp3',
contentUrl: 'test',
absolutePath: 'test',
simplePath: 'test',
mimeType: 'audio/mpeg',
size: 39048290
}
+1 -2
View File
@@ -128,13 +128,12 @@ export default ({ app, store }, inject) => {
// Listen for token refresh events from native app
AbsDatabase.addListener('onTokenRefresh', (data) => {
console.log('[db] onTokenRefresh', data)
store.commit('user/setAccessToken', data.accessToken)
})
// Listen for token refresh failure events from native app
AbsDatabase.addListener('onTokenRefreshFailure', async (data) => {
console.log('[db] onTokenRefreshFailure', data)
console.log('[db] onTokenRefreshFailure')
// Clear store and redirect to login page
await store.dispatch('user/logout')
if (window.location.pathname !== '/connect') {
+24 -1
View File
@@ -1,11 +1,15 @@
import Vue from 'vue'
import { Capacitor } from '@capacitor/core'
import { AbsDownloader, AbsFileSystem } from '@/plugins/capacitor'
import enUsStrings from '../strings/en-us.json'
const defaultCode = 'en-us'
let $localStore = null
const languageCodeMap = {
be: { label: 'Беларуская', dateFnsLocale: 'be' },
bn: { label: 'বাংলা', dateFnsLocale: 'bn' },
bg: { label: 'Български', dateFnsLocale: 'bg' },
ca: { label: 'Català', dateFnsLocale: 'ca' },
cs: { label: 'Čeština', dateFnsLocale: 'cs' },
da: { label: 'Dansk', dateFnsLocale: 'da' },
@@ -39,6 +43,24 @@ function supplant(str, subs) {
})
}
function syncDownloadNotificationStrings() {
if (Capacitor.getPlatform() !== 'android') return
AbsDownloader.setDownloadNotificationStrings({
preparing: Vue.prototype.$strings.MessagePreparingDownloads,
downloadingFile: Vue.prototype.$strings.MessageDownloadingFile,
waitingForStorage: Vue.prototype.$strings.MessageWaitingForAvailableStorage,
downloads: Vue.prototype.$strings.HeaderDownloads,
cancel: Vue.prototype.$strings.ButtonCancel
}).catch((error) => console.warn('Failed to update download notification strings', error))
AbsFileSystem.setFolderPickerStrings({
writeAccessRequired: Vue.prototype.$strings.MessageStorageWriteAccessRequired,
allow: Vue.prototype.$strings.ButtonAllow,
cancel: Vue.prototype.$strings.ButtonCancel,
accessDenied: Vue.prototype.$strings.MessageStorageAccessDenied,
permissionDenied: Vue.prototype.$strings.MessageStoragePermissionDenied
}).catch((error) => console.warn('Failed to update folder picker strings', error))
}
Vue.prototype.$languageCodeOptions = Object.keys(languageCodeMap).map((code) => {
return {
text: languageCodeMap[code].label,
@@ -106,6 +128,7 @@ async function loadi18n(code) {
}
Vue.prototype.$setDateFnsLocale(languageCodeMap[code].dateFnsLocale)
syncDownloadNotificationStrings()
this.$eventBus.$emit('change-lang', code)
return true
@@ -143,5 +166,5 @@ async function initialize() {
export default ({ app, store }, inject) => {
$localStore = app.$localStore
initialize()
initialize().finally(syncDownloadNotificationStrings)
}
+4 -2
View File
@@ -43,7 +43,8 @@ export default function ({ store, $db, $socket }, inject) {
}
if (res.status >= 400) {
console.error(`[nativeHttp] ${res.status} status for url "${url}"`)
throw new Error(res.data)
const message = typeof res.data === 'string' ? res.data : `HTTP ${res.status}`
throw new Error(message)
}
return res.data
})
@@ -100,7 +101,8 @@ export default function ({ store, $db, $socket }, inject) {
if (retryResponse.status >= 400) {
console.error(`[nativeHttp] Retry request failed with status ${retryResponse.status}`)
throw new Error(retryResponse.data)
const message = typeof retryResponse.data === 'string' ? retryResponse.data : `HTTP ${retryResponse.status}`
throw new Error(message)
}
return retryResponse.data
+1 -1
View File
@@ -110,7 +110,7 @@ class ServerSocket extends EventEmitter {
}
onAuthFailed(data) {
console.log('[SOCKET] Auth failed', data)
console.log('[SOCKET] Auth failed: ' + (data?.message || 'Unknown reason'))
this.isAuthenticated = false
}
+3
View File
@@ -135,6 +135,9 @@ export const mutations = {
removeItemDownload(state, id) {
state.itemDownloads = state.itemDownloads.filter((i) => i.id != id)
},
clearItemDownloads(state) {
state.itemDownloads = []
},
setBookshelfListView(state, val) {
state.bookshelfListView = val
},
+5 -3
View File
@@ -12,7 +12,10 @@ export const state = () => ({
mobileFilterBy: 'all',
playbackRate: 1,
collapseSeries: false,
collapseBookSeries: false
collapseBookSeries: false,
podcastEpisodesOrderBy: 'publishedAt',
podcastEpisodesOrderDesc: null,
podcastEpisodesFilterBy: 'incomplete'
}
})
@@ -45,7 +48,7 @@ export const getters = {
return state.user.bookmarks.filter((bm) => bm.libraryItemId === libraryItemId)
},
getUserSetting: (state) => (key) => {
return state.settings?.[key] || null
return state.settings?.[key] ?? null
},
getUserCanUpdate: (state) => {
return !!state.user?.permissions?.update
@@ -231,7 +234,6 @@ export const mutations = {
state.user = user
},
setAccessToken(state, accessToken) {
console.log('[user] setAccessToken', accessToken)
state.accessToken = accessToken
},
removeMediaProgress(state, id) {
+16 -1
View File
@@ -63,6 +63,7 @@
"HeaderChapters": "الفصول",
"HeaderCollection": "مجموعة",
"HeaderCollectionItems": "عناصر المجموعة",
"HeaderConfirm": "تأكيد",
"HeaderConnectionStatus": "حالة الاتصال",
"HeaderDataSettings": "إعدادات البيانات",
"HeaderDetails": "التفاصيل",
@@ -91,6 +92,7 @@
"HeaderStatsRecentSessions": "الجلسات الأخيرة",
"HeaderTableOfContents": "جدول المحتويات",
"HeaderUserInterfaceSettings": "إعدادات واجهة المستخدم",
"HeaderWelcome": "مرحبا, <strong>{0}</strong>",
"HeaderYourStats": "إحصائياتك",
"LabelAddToPlaylist": "أضف إلى قائمة التشغيل",
"LabelAddedAt": "أضيفت على",
@@ -113,6 +115,7 @@
"LabelAutoSleepTimerAutoRewindHelp": "عندما ينتهي مؤقت النوم التلقائي، فإن تشغيل العنصر مرة أخرى سيؤدي إلى الإرجاع التلقائي.",
"LabelAutoSleepTimerHelp": "عند تشغيل الوسائط بين أوقات البداية والنهاية المحددة، سيبدأ مؤقت النوم تلقائيًا.",
"LabelBooks": "الكتب",
"LabelByAuthor": "بواسطة {0}",
"LabelChapterTrack": "مسار الفصل",
"LabelChapters": "الفصول",
"LabelClosePlayer": "إغلاق المشغل",
@@ -177,6 +180,9 @@
"LabelLayout": "التنسيق",
"LabelLayoutAuto": "تلقائي",
"LabelLayoutSinglePage": "صفحة واحدة",
"LabelLibrarySortByProgress": "المرحلة: الأحدث",
"LabelLibrarySortByProgressFinished": "المرحلة: تم الانتهاء",
"LabelLibrarySortByProgressStarted": "المرحلة: تم البدء",
"LabelLight": "فاتح",
"LabelLineSpacing": "تباعد الأسطر",
"LabelListenAgain": "الاستماع مجدداً",
@@ -236,6 +242,7 @@
"LabelScaleElapsedTimeBySpeed": "تعديل الوقت المنصرم حسب السرعة",
"LabelSeason": "الموسم",
"LabelSelectADevice": "اختيار جهاز",
"LabelSelectMediaType": "اختيار نوع الوسائط",
"LabelSequenceAscending": "تسلسل تصاعدي",
"LabelSequenceDescending": "تسلسل تنازلي",
"LabelSeries": "المسلسلات",
@@ -288,12 +295,17 @@
"MessageAudiobookshelfServerNotConnected": "خادم Audiobookshelf غير متصل",
"MessageAudiobookshelfServerRequired": "<strong>مهم!</strong> هذا التطبيق مصمم للعمل مع خادم Audiobookshelf تستضيفه أنت أو شخص تعرفه. هذا التطبيق لا يقدم أي محتوى.",
"MessageBookshelfEmpty": "الرف فارغ",
"MessageConfirmAppExit": "هل تريد الخروج من التطبيق؟",
"MessageConfirmDeleteEpisodeDownloadQueue": "هل أنت متأكد من تصفية قائمة انتظار تنزيل الحلقة؟",
"MessageConfirmDeleteLocalEpisode": "هل تريد بالتأكيد إزالة الحلقة المحلية \"{0}\" من جهازك؟ لن يتأثر الملف الموجود على الخادم.",
"MessageConfirmDeleteLocalFiles": "هل تريد بالتأكيد إزالة الملفات المحلية لهذا العنصر من جهازك؟ الملفات الموجودة على الخادم وتقدمك لن يتأثرا.",
"MessageConfirmDeleteServerConfig": "إزالة إعدادات هذا الخادم؟",
"MessageConfirmDeleteServerEpisode": "هل أنت متأكد من حذف الحلقة \"{0}\" من الخادم؟\nتحذير: سيتم حذف الملف الصوتي.",
"MessageConfirmDisableAutoTimer": "هل أنت متأكد أنك تريد تعطيل المؤقت التلقائي لبقية اليوم؟ سيتم إعادة تفعيل المؤقت في نهاية فترة مؤقت النوم التلقائي هذه، أو إذا قمت بإعادة تشغيل التطبيق.",
"MessageConfirmDiscardProgress": "هل أنت متأكد أنك تريد إعادة تعيين تقدمك؟",
"MessageConfirmDownloadUsingCellular": "أنت على وشك التنزيل باستخدام بيانات الجوال. قد يشمل ذلك رسوم استخدام بيانات من شركة الاتصالات. هل ترغب في المتابعة؟",
"MessageConfirmMarkAsFinished": "هل أنت متأكد أنك تريد وضع علامة \"مكتمل\" على هذا العنصر؟",
"MessageConfirmPlaybackTime": "هل تريد بدء التشغيل لـ \"{0}\" في الساعة {1}؟",
"MessageConfirmRemoveBookmark": "هل أنت متأكد أنك تريد إزالة العلامة المرجعية؟",
"MessageConfirmStreamingUsingCellular": "أنت على وشك البث باستخدام بيانات الجوال. قد يشمل ذلك رسوم استخدام بيانات من شركة الاتصالات. هل ترغب في المتابعة؟",
"MessageDiscardProgress": "إلغاء التقدم",
@@ -331,6 +343,7 @@
"MessageNoUserPlaylists": "ليس لديك أي قوائم تشغيل",
"MessageOldServerAuthReLoginRequired": "تم تحسين المصادقة في الخادم v2.26.0 لضمان الأمان. يُطلب من جميع المستخدمين إعادة تسجيل الدخول.",
"MessageOldServerAuthWarning": "الخادم يستخدم مصادقة قديمة",
"MessageOldServerAuthWarningHelp": "الخادم يعمل على إصدار أقدم من النسخة (2.26.0)، نظام مصادقة أكثر أماناً أضيف في النسخة (2.26.0). يوصى بشدة تحديث الخادم إلى آخر نسخة. إذا قمت بالفعل بتحديث الخادم قم بإعادة تسجيل الدخول لاستخدام المصادقة الجديدة.",
"MessageOldServerConnectionWarning": "تكوين اتصال الخادم يستخدم معرّف مستخدم قديم. يرجى حذف اتصال الخادم هذا وإعادة إضافته.",
"MessageOldServerConnectionWarningHelp": "لقد قمتَ بإعداد الاتصال بهذا الخادم قبل ترحيل قاعدة البيانات في الإصدار 2.3.0، الصادر في يونيو 2023. سيؤدي تحديث الخادم المستقبلي إلى إلغاء إمكانية تسجيل الدخول باستخدام هذا الاتصال القديم. يُرجى حذف اتصال الخادم الحالي والاتصال مرة أخرى (باستخدام نفس عنوان الخادم وبيانات الاعتماد). إذا كانت لديك أي وسائط مُنزّلة على هذا الجهاز، فيجب تنزيلها مرة أخرى لمزامنتها مع الخادم.",
"MessagePodcastSearchField": "أدخل مصطلح البحث أو عنوان URL الخاص بتغذية RSS",
@@ -357,5 +370,7 @@
"ToastPodcastCreateSuccess": "تم إنشاء البودكاست بنجاح",
"ToastRSSFeedCloseFailed": "فشل إغلاق مغذّي RSS",
"ToastRSSFeedCloseSuccess": "تم إغلاق مغذّي RSS",
"ToastStreamingNotAllowedOnCellular": "لا يُسمح بالبث عبر البيانات الخلوية"
"ToastStreamingNotAllowedOnCellular": "لا يُسمح بالبث عبر البيانات الخلوية",
"UnitMinutesShort": "{0}د",
"UnitSecondsShort": "{0}ث"
}
+123 -99
View File
@@ -1,6 +1,7 @@
{
"ButtonAdd": "Дадаць",
"ButtonAddNewServer": "Дадаць новы сервер",
"ButtonAllow": "Дазволіць",
"ButtonAuthors": "Аўтары",
"ButtonBack": "Назад",
"ButtonCancel": "Скасаваць",
@@ -11,11 +12,11 @@
"ButtonCollections": "Калекцыі",
"ButtonConnect": "Падлучыцца",
"ButtonConnectToServer": "Падлучыцца да сервера",
"ButtonCreate": "Ствараць",
"ButtonCreate": "Стварыць",
"ButtonCreateBookmark": "Стварыць закладку",
"ButtonCreateNewPlaylist": "Стварыць новы спіс прайгравання",
"ButtonCreateNewPlaylist": "Стварыць новы плэй-ліст",
"ButtonDelete": "Выдаліць",
"ButtonDeleteLocalEpisode": "Выдаліць лакальны эпізод",
"ButtonDeleteLocalEpisode": "Выдаліць лакальны выпуск",
"ButtonDeleteLocalFile": "Выдаліць лакальны файл",
"ButtonDeleteLocalItem": "Выдаліць лакальны элемент",
"ButtonDisableAutoTimer": "Адключыць аўтаматычны таймер",
@@ -30,15 +31,15 @@
"ButtonLogs": "Журналы",
"ButtonManageLocalFiles": "Кіраванне лакальнымі файламі",
"ButtonMaskServerAddress": "Схаваць адрас сервера",
"ButtonNewFolder": "Новая тэчка",
"ButtonNextEpisode": "Наступны эпізод",
"ButtonOk": "Добра",
"ButtonNewFolder": "Новая папка",
"ButtonNextEpisode": "Наступны выпуск",
"ButtonOk": "ОК",
"ButtonOpenFeed": "Адкрыць стужку",
"ButtonOverride": "Пераазначыць",
"ButtonPause": "Паўза",
"ButtonPause": "Прыпыніць",
"ButtonPlay": "Прайграць",
"ButtonPlayEpisode": "Прайграць эпізод",
"ButtonPlaylists": "Спісы прайгравання",
"ButtonPlayEpisode": "Прайграць выпуск",
"ButtonPlaylists": "Плэй-лісты",
"ButtonRead": "Чытаць",
"ButtonReadLess": "Чытаць менш",
"ButtonReadMore": "Чытаць больш",
@@ -50,40 +51,41 @@
"ButtonSendEbookToDevice": "Адправіць электронную кнігу на прыладу",
"ButtonSeries": "Серыі",
"ButtonSetTimer": "Устанавіць таймер",
"ButtonStream": "Трансляцыя",
"ButtonStream": "Трансліраваць",
"ButtonSubmit": "Адправіць",
"ButtonSwitchServerUser": "Змяніць Сервер/Карыстальніка",
"ButtonSwitchServerUser": "Змяніць сервер/карыстальніка",
"ButtonUnmaskServerAddress": "Паказаць адрас сервера",
"ButtonUserStats": "Статыстыка карыстальніка",
"ButtonYes": "Так",
"HeaderAccount": "Уліковы запіс",
"HeaderAdvanced": "Дадаткова",
"HeaderAndroidAutoSettings": "Налады Android Auto",
"HeaderAudioTracks": "Аўдыядарожкі",
"HeaderAudioTracks": "Аўдыятрэкі",
"HeaderChapters": "Раздзелы",
"HeaderCollection": "Калекцыя",
"HeaderCollectionItems": "Элементы калекцыі",
"HeaderConfirm": "Пацвердзіць",
"HeaderConnectionStatus": "Стан злучэння",
"HeaderDataSettings": "Налады дадзеных",
"HeaderDataSettings": "Налады даных",
"HeaderDetails": "Падрабязнасці",
"HeaderDownloads": "Спампоўкі",
"HeaderEbookFiles": "Файлы электронных кніг",
"HeaderEpisodes": "Эпізоды",
"HeaderEpisodes": "Выпускі",
"HeaderEreaderSettings": "Налады прылады для чытання",
"HeaderLatestEpisodes": "Апошнія эпізоды",
"HeaderLatestEpisodes": "Апошнія выпускі",
"HeaderLibraries": "Бібліятэкі",
"HeaderLocalFolders": "Лакальныя тэчкі",
"HeaderLocalFolders": "Лакальныя папкі",
"HeaderLocalLibraryItems": "Элементы лакальнай бібліятэкі",
"HeaderNewPlaylist": "Новы спіс прайгравання",
"HeaderNewPlaylist": "Новы плэй-ліст",
"HeaderOpenRSSFeed": "Адкрыць RSS-стужку",
"HeaderPlaybackSettings": "Налады прайгравання",
"HeaderPlaylist": "Спіс прайгравання",
"HeaderPlaylistItems": "Элементы спіса прайгравання",
"HeaderPlaylist": "Плэй-ліст",
"HeaderPlaylistItems": "Элементы плэй-ліста",
"HeaderProgressSyncFailed": "Не ўдалося сінхранізаваць прагрэс",
"HeaderRSSFeed": "RSS-стужка",
"HeaderRSSFeedGeneral": "Падрабязнасці RSS",
"HeaderRSSFeedIsOpen": "RSS-стужка адкрытая",
"HeaderSelectDownloadLocation": "Абраць месцазнаходжання для спампоўкі",
"HeaderSelectDownloadLocation": "Выберыце размяшчэнне для спампоўкі",
"HeaderSettings": "Налады",
"HeaderSleepTimer": "Таймер сну",
"HeaderSleepTimerSettings": "Налады таймера сну",
@@ -91,8 +93,9 @@
"HeaderStatsRecentSessions": "Апошнія сеансы",
"HeaderTableOfContents": "Змест",
"HeaderUserInterfaceSettings": "Налады інтэрфейсу карыстальніка",
"HeaderWelcome": "Вітаем, <strong>{0}</strong>",
"HeaderYourStats": "Ваша статыстыка",
"LabelAddToPlaylist": "Дадаць у спіс прайгравання",
"LabelAddToPlaylist": "Дадаць у плэй-ліст",
"LabelAddedAt": "Дата дабаўлення",
"LabelAddedDate": "Дададзена {0}",
"LabelAll": "Усе",
@@ -101,72 +104,76 @@
"LabelAndroidAutoBrowseLimitForGrouping": "Ліміт алфавітнага выпадальнага спісу",
"LabelAndroidAutoBrowseLimitForGroupingHelp": "Не выкарыстоўваць алфавітны выпадальны спіс, калі элементаў для паказу менш за гэтую колькасць",
"LabelAndroidAutoBrowseSeriesSequenceOrder": "Парадак кніг у серыі",
"LabelAskConfirmation": "Запытваць пацверджанне",
"LabelAskConfirmation": "Запытваць пацвярджэнне",
"LabelAuthor": "Аўтар",
"LabelAuthorFirstLast": "Аўтар (Імя Прозвішча)",
"LabelAuthorLastFirst": "Аўтар (Прозвішча, Імя)",
"LabelAuthorFirstLast": "Аўтар (імя, прозвішча)",
"LabelAuthorLastFirst": "Аўтар (прозвішча, імя)",
"LabelAuthors": "Аўтары",
"LabelAutoDownloadEpisodes": "Аўтаматычнае спампаванне эпізодаў",
"LabelAutoDownloadEpisodes": "Аўтаматычна спампоўваць выпускі",
"LabelAutoRewindTime": "Час аўтаматычнай перамоткі назад",
"LabelAutoSleepTimer": "Аўтаматычны таймер сну",
"LabelAutoSleepTimerAutoRewind": "Аўтаматычная перамотка таймера сну",
"LabelAutoSleepTimerAutoRewindHelp": "Калі таймер аўтаматычнага сну скончыцца, паўторнае прайграванне элемента аўтаматычна перамотвае вашу пазіцыю.",
"LabelAutoSleepTimerAutoRewindHelp": "Пасля заканчэння аўтаматычнага таймера сну паўторнае прайграванне элемента аўтаматычна перамотвае вашу пазіцыю.",
"LabelAutoSleepTimerHelp": "Калі медыя прайграецца ў вызначаны час паміж пачаткам і канцом, таймер сну будзе запушчаны аўтаматычна.",
"LabelBooks": "Кнігі",
"LabelChapterTrack": "Дарожка раздзела",
"LabelByAuthor": "– {0}",
"LabelChapterTrack": "Трэк раздзела",
"LabelChapters": "Раздзелы",
"LabelClosePlayer": "Зачыніць прайгравальнік",
"LabelClosePlayer": "Закрыць прайгравальнік",
"LabelCollapseSeries": "Згарнуць серыі",
"LabelComplete": "Завершана",
"LabelComplete": "Завяршыць",
"LabelContinueBooks": "Працягнуць кнігі",
"LabelContinueEpisodes": "Працягнуць эпізоды",
"LabelContinueListening": "Працягваць слухаць",
"LabelContinueEpisodes": "Працягнуць выпускі",
"LabelContinueListening": "Працяг праслухоўвання",
"LabelContinueReading": "Працягнуць чытанне",
"LabelContinueSeries": "Працягнуць серыі",
"LabelCustomTime": "Свой час",
"LabelDescription": "Апісанне",
"LabelDisableAudioFadeOut": "Адключыць змяншэнне гучнасці",
"LabelDisableAudioFadeOutHelp": "Гучнасць аўдыё пачне змяншацца, калі на таймеры сну застанецца менш за 1 хвіліну. Уключыце гэтую наладу, каб пазбегнуць змяншэння гучнасці.",
"LabelDisableAudioFadeOutHelp": "Гучнасць гуку пачне змяншацца, калі на таймеры сну застанецца менш за 1 хвіліну. Уключыце гэтую наладу, каб пазбегнуць змяншэння гучнасці.",
"LabelDisableAutoRewind": "Адключыць аўтаматычную перамотку назад",
"LabelDisableShakeToReset": "Адключыць скід страсеннем",
"LabelDisableShakeToResetHelp": "Калі патрасці прыладу падчас працы таймера АБО на працягу 2 хвілін пасля заканчэння таймера, таймер сну скідваецца. Уключыце гэтую наладу, каб адключыць скід страсеннем.",
"LabelDisableShakeToReset": "Адключыць скід устрэсваннем",
"LabelDisableShakeToResetHelp": "Калі патрэсці прыладу падчас працы таймера АБО на працягу 2 хвілін пасля заканчэння таймера, таймер сну скідваецца. Уключыце гэтую наладу, каб адключыць скід устрэсваннем.",
"LabelDisableVibrateOnReset": "Адключыць вібрацыю пры скідзе",
"LabelDisableVibrateOnResetHelp": "Калі таймер сну скідваецца, ваша прылада будзе вібраваць. Уключыце гэтую наладу, каб адключыць вібрацыю пры скідзе таймера сну.",
"LabelDiscover": "Знайсці",
"LabelDiscover": "Знаходкі",
"LabelDownload": "Спампаваць",
"LabelDownloadUsingCellular": "Спампоўка праз мабільны інтэрнэт",
"LabelDownloadUsingCellular": "Спампоўваць праз мабільны інтэрнэт",
"LabelDownloaded": "Спампавана",
"LabelDuration": "Працягласць",
"LabelEbook": "Электронная кніга",
"LabelEbooks": "Электронныя кнігі",
"LabelEnable": "Уключыць",
"LabelEnableMp3IndexSeeking": "Уключыць індэксаванне для перамоткі MP3",
"LabelEnableMp3IndexSeekingHelp": "Гэтую наладу варта ўключаць толькі, калі ў вас ёсць MP3-файлы, якія няправільна перамотваюцца. Хутчэй за ўсё, гэта выклікана файламі з зменным бітрэйтам (VBR). Гэтая налада прымусова ўключае індэксаванне для перамоткі, пры якім падчас чытання файла ствараецца адпаведнасць паміж часам і байтамі. У некаторых выпадках з вялікімі MP3-файламі можа быць затрымка пры перемотцы да канца файла.",
"LabelEnableMp3IndexSeeking": "Уключыць пошук па індэксе mp3",
"LabelEnableMp3IndexSeekingHelp": "Гэтую наладу варта ўключаць толькі, калі ў вас ёсць MP3-файлы, якія няправільна перамотваюцца. Хутчэй за ўсё, гэта выклікана файламі са зменным бітрэйтам (VBR). Гэтая налада прымусова ўключае індэксаванне для перамоткі, пры якім падчас чытання файла ствараецца адпаведнасць паміж часам і байтамі. У некаторых выпадках з вялікімі MP3-файламі можа быць затрымка пры перемотцы да канца файла.",
"LabelEnd": "Канец",
"LabelEndOfChapter": "Канец раздзела",
"LabelEndTime": "Час заканчэння",
"LabelEpisode": "Эпізод",
"LabelExplicit": "Відверты",
"LabelEpisode": "Выпуск",
"LabelExplicit": "Непрыстойнае",
"LabelFeedURL": "URL стужкі",
"LabelFile": "Файл",
"LabelFileBirthtime": "Час стварэння файла",
"LabelFileModified": "Час змянення файла",
"LabelFilename": "Імя файла",
"LabelFinished": "Скончана",
"LabelFolder": "Тэчка",
"LabelFilename": "Назва файла",
"LabelFinished": "Завершана",
"LabelFolder": "Папка",
"LabelFontBoldness": "Таўшчыня шрыфта",
"LabelFontFamily": "Сямейства шрыфтоў",
"LabelFontFamilySans": "Sans",
"LabelFontFamilySerif": "Serif",
"LabelFontScale": "Памер шрыфту",
"LabelGenre": "Жанр",
"LabelGenres": "Жанры",
"LabelHapticFeedback": "Тактыльная зваротная сувязь",
"LabelHasEbook": "Мае электронную кнігу",
"LabelHasSupplementaryEbook": "Мае дадатковую электронную кнігу",
"LabelHeavy": "Моцна",
"LabelHigh": "Высокі",
"LabelHeavy": "Моцная",
"LabelHigh": "Высокая",
"LabelHost": "Хост",
"LabelInProgress": "У працэсе",
"LabelIncomplete": "Незавершана",
"LabelInternalAppStorage": "Унутраная памяць прыкладання",
"LabelInternalAppStorage": "Унутраная памяць праграмы",
"LabelJumpBackwardsTime": "Час пераходу назад",
"LabelJumpForwardsTime": "Час пераходу наперад",
"LabelKeepScreenAwake": "Трымаць экран уключаным",
@@ -174,22 +181,25 @@
"LabelLayout": "Знешні выгляд",
"LabelLayoutAuto": "Аўто",
"LabelLayoutSinglePage": "Аднабаковы",
"LabelLight": "Святло",
"LabelLibrarySortByProgress": "Прагрэс: апошняе абнаўленне",
"LabelLibrarySortByProgressFinished": "Прагрэс: завершана",
"LabelLibrarySortByProgressStarted": "Прагрэс: пачата",
"LabelLight": "Лёгкая",
"LabelLineSpacing": "Міжрадковы інтэрвал",
"LabelListenAgain": "Паслухаць зноў",
"LabelLocalBooks": "Лакальныя кнігі",
"LabelLocalPodcasts": "Лакальныя падкасты",
"LabelLockOrientation": "Зафіксаваць арыентацыю",
"LabelLockPlayer": "Заблакаваць прайгравальнік",
"LabelLow": "Нізкі",
"LabelLockPlayer": "Заблакіраваць прайгравальнік",
"LabelLow": "Нізкая",
"LabelMediaType": "Тып медыя",
"LabelMedium": "Сярэдні",
"LabelMedium": "Сярэдняя",
"LabelMissing": "Адсутнічае",
"LabelMore": "Больш",
"LabelMoreInfo": "Больш інфармацыі",
"LabelName": "Імя",
"LabelNarrator": "Чытальнік",
"LabelNarrators": "Чытальнікі",
"LabelName": "Назва",
"LabelNarrator": "Дыктар",
"LabelNarrators": "Дыктары",
"LabelNavigateWithVolume": "Навігацыя з дапамогай клавіш гучнасці",
"LabelNavigateWithVolumeMirrored": "Люстраваны",
"LabelNavigateWithVolumeWhilePlaying": "Дазволіць клавішам гучнасці кіраваць навігацыяй падчас прайгравання",
@@ -197,13 +207,13 @@
"LabelNavigateWithVolumeWhilePlayingEnabled": "Уключана",
"LabelNever": "Ніколі",
"LabelNewestAuthors": "Новыя аўтары",
"LabelNewestEpisodes": "Новыя эпізоды",
"LabelNewestEpisodes": "Найноўшыя выпускі",
"LabelNo": "Не",
"LabelNotFinished": "Не скончана",
"LabelNotFinished": "Незавершана",
"LabelNotStarted": "Не пачата",
"LabelNumEpisodes": "{0} эпізодаў",
"LabelNumEpisodesIncomplete": "{0} эпізодаў, {1} незавершаных",
"LabelNumberOfEpisodes": "# з эпізодаў",
"LabelNumEpisodes": "{0} выпускаў",
"LabelNumEpisodesIncomplete": "{0} выпускаў, {1} незавершаных",
"LabelNumberOfEpisodes": "Колькасць выпускаў",
"LabelOff": "Выключына",
"LabelOn": "Уключана",
"LabelPassword": "Пароль",
@@ -229,30 +239,31 @@
"LabelReadAgain": "Чытаць зноў",
"LabelRecentSeries": "Апошнія серыі",
"LabelRecentlyAdded": "Нядаўна дададзеныя",
"LabelRemoveFromPlaylist": "Выдаліць з спіса прайгравання",
"LabelScaleElapsedTimeBySpeed": "Прыстасаваць мінулы час да хуткасці",
"LabelRemoveFromPlaylist": "Выдаліць з плэй-ліста",
"LabelScaleElapsedTimeBySpeed": "Маштабаваць мінулы час па хуткасці",
"LabelSeason": "Сезон",
"LabelSelectADevice": "Выбраць прыладу",
"LabelSequenceAscending": "Паслядоўнасць па ўзрастанні",
"LabelSequenceDescending": "Паслядоўнасць па змяншэнні",
"LabelSelectMediaType": "Выберыце тып медыя",
"LabelSequenceAscending": "Па ўзрастанні",
"LabelSequenceDescending": "Па ўбыванні",
"LabelSeries": "Серыі",
"LabelServerAddress": "Адрас сервера",
"LabelSetEbookAsPrimary": "Зрабіць асноўным",
"LabelSetEbookAsSupplementary": "Зрабіць дадатковым",
"LabelShakeSensitivity": "Чуйнасць да трасення",
"LabelShakeSensitivity": "Адчувальнасць да ўстрэсвання",
"LabelShowAll": "Паказаць усё",
"LabelSize": "Памер",
"LabelSleepTimer": "Таймер сну",
"LabelSleepTimerAlmostDoneChime": "Праіграйце звонак, калі амаль скончана",
"LabelSleepTimerAlmostDoneChimeHelp": "Памякчыць звонак, калі на таймеры сну застанецца 30 секунд",
"LabelStart": "Пачаць",
"LabelSleepTimerAlmostDoneChime": "Гукавы сігнал, калі амаль завершаны",
"LabelSleepTimerAlmostDoneChimeHelp": "Прайграць званок, калі да канца таймера сну застанецца 30 секунд",
"LabelStart": "Пачатак",
"LabelStartTime": "Час пачатку",
"LabelStatsBestDay": "Лепшы дзень",
"LabelStatsBestDay": "Найлепшы дзень",
"LabelStatsDailyAverage": "У сярэднім за дзень",
"LabelStatsDays": "Дзён",
"LabelStatsDaysListened": "Дзён праслухана",
"LabelStatsInARow": "без перапынку",
"LabelStatsItemsFinished": "Скончаныя элементы",
"LabelStatsItemsFinished": "Элементаў завершана",
"LabelStatsMinutes": "хвілін",
"LabelStatsMinutesListening": "Хвілін праслухоўвання",
"LabelStatsWeekListening": "Праслухана за тыдзень",
@@ -260,44 +271,50 @@
"LabelTag": "Метка",
"LabelTags": "Меткі",
"LabelTheme": "Тэма",
"LabelThemeBlack": "Чорны",
"LabelThemeBlack": "Чорная",
"LabelThemeDark": "Цёмная",
"LabelThemeLight": "Светлая",
"LabelTimeRemaining": "Засталося {0}",
"LabelTitle": "Назва",
"LabelTitle": "Загаловак",
"LabelTotalSize": "Агульны памер",
"LabelTotalTrack": "Агульная колькасць дарожак",
"LabelTracks": "Дарожкі",
"LabelTotalTrack": "Усяго трэкаў",
"LabelTracks": "Трэкі",
"LabelType": "Тып",
"LabelUnknown": "Невядома",
"LabelUnlockPlayer": "Разблакаваць прайгравальнік",
"LabelUseBookshelfView": "Выкарыстоўваць выгляд кніжнай паліцы",
"LabelUnlockPlayer": "Разблакіраваць прайгравальнік",
"LabelUseBookshelfView": "Паказваць кніжныя паліцы",
"LabelUser": "Карыстальнік",
"LabelUsername": "Імя карыстальніка",
"LabelVeryHigh": "Вельмі высокі",
"LabelVeryLow": "Вельмі нізкі",
"LabelVeryHigh": "Вельмі высокая",
"LabelVeryLow": "Вельмі нізкая",
"LabelYearReviewHide": "Схаваць вынікі года",
"LabelYearReviewShow": "Азнаёміцца з вынікамі года",
"LabelYourBookmarks": "Вашы закладкі",
"LabelYourProgress": "Ваш прагрэс",
"MessageAndroid10Downloads": "На Android 10 і ніжэй для спампоўкі будзе выкарыстоўвацца ўнутраная памяць прыкладання.",
"MessageAndroid10Downloads": "На Android 10 і ніжэй для спамповак будзе выкарыстоўвацца ўнутраная памяць праграмы.",
"MessageAttemptingServerConnection": "Спроба злучэння з серверам...",
"MessageAudiobookshelfServerNotConnected": "Сервер Audiobookshelf не падлучаны",
"MessageAudiobookshelfServerRequired": "<strong>Важна!</strong> Гэта праграма прызначана для працы з серверам Audiobookshelf, які вы ці хтосьці іншы абслугоўваеце. Праграма не змяшчае ўласнага кантэнту.",
"MessageBookshelfEmpty": "Кніжная паліца пустая",
"MessageConfirmDeleteLocalEpisode": "Выдаліць лакальны эпізод \"{0}\" з вашай прылады? Файл на серверы застанецца нязменным.",
"MessageConfirmDeleteLocalFiles": "Выдаліць лакальныя файлы гэтага элемента з вашай прылады? Файлы на серверы і ваш прагрэс застануцца нязменнымі.",
"MessageConfirmDisableAutoTimer": "Вы ўпэўнены, што жадаеце адключыць аўтаматычны таймер на сённяшні дзень? Таймер будзе зноў уключаны па заканчэнні гэтага перыяду аўтаматычнага таймера сну або пры перазапуску праграмы.",
"MessageConfirmDiscardProgress": "Вы ўпэўнены, што жадаеце скінуць свой прагрэс?",
"MessageConfirmDownloadUsingCellular": "Вы збіраецеся спампаваць, выкарыстоўваючы мабільны інтэрнэт. Гэта можа прывесці да дадатковых плат ад аператара. Вы жадаеце працягнуць?",
"MessageConfirmMarkAsFinished": "Вы ўпэўнены, што жадаеце пазначыць гэты элемент як скончаны?",
"MessageConfirmRemoveBookmark": "Вы ўпэўнены, што жадаеце выдаліць закладку?",
"MessageConfirmStreamingUsingCellular": "Вы збіраецеся трансляваць кантэнт праз мабільны інтэрнэт. Гэта можа прывесці да дадатковых платаў ад вашага аператара. Вы жадаеце працягнуць?",
"MessageConfirmAppExit": "Вы хацелі выйсці з праграмы?",
"MessageConfirmDeleteEpisodeDownloadQueue": "Вы ўпэўнены, што хочаце ачысціць чаргу спампоўвання выпускаў?",
"MessageConfirmDeleteLocalEpisode": "Выдаліць лакальны выпуск \"{0}\" з прылады? Файл на серверы гэта не закране.",
"MessageConfirmDeleteLocalFiles": "Выдаліць лакальныя файлы гэтага элемента з прылады? Файлы на серверы і прагрэс застануцца нязменнымі.",
"MessageConfirmDeleteServerConfig": "Выдаліць гэту канфігурацыю сервера?",
"MessageConfirmDeleteServerEpisode": "Вы ўпэўнены, што хочаце выдаліць выпуск \"{0}\" з сервера?\nУвага: будзе выдалены аўдыяфайл.",
"MessageConfirmDisableAutoTimer": "Вы ўпэўнены, што хочаце адключыць аўтаматычны таймер на сённяшні дзень? Таймер будзе зноў уключаны па заканчэнні перыяду гэтага аўтаматычнага таймера сну або пры перазапуску праграмы.",
"MessageConfirmDiscardProgress": "Вы ўпэўнены, што хочаце скінуць прагрэс?",
"MessageConfirmDownloadUsingCellular": "Вы збіраецеся спампаваць праз мабільны інтэрнэт. Гэта можа прывесці да дадатковых плат ад аператара. Хочаце працягнуць?",
"MessageConfirmMarkAsFinished": "Вы ўпэўнены, што хочаце пазначыць гэты элемент як завершаны?",
"MessageConfirmPlaybackTime": "Пачаць прайграванне \"{0}\" у {1}?",
"MessageConfirmRemoveBookmark": "Вы ўпэўнены, што хочаце выдаліць закладку?",
"MessageConfirmStreamingUsingCellular": "Вы збіраецеся трансляваць кантэнт праз мабільны інтэрнэт. Гэта можа прывесці да дадатковых плат ад аператара. Хочаце працягнуць?",
"MessageDiscardProgress": "Скінуць прагрэс",
"MessageDownloadCompleteProcessing": "Спампоўка завершана. Апрацоўка...",
"MessageDownloadCompleteProcessing": "Спампоўванне завершана. Апрацоўка...",
"MessageDownloading": "Спампоўванне...",
"MessageDownloadingEpisode": "Спампоўка эпізоду",
"MessageEpisodesQueuedForDownload": "{0} эпізод(аў) у чарзе для спампоўкі",
"MessageDownloadingEpisode": "Спампоўванне выпуску",
"MessageDownloadingFile": "Спампоўванне {0}",
"MessageEpisodesQueuedForDownload": "{0} выпуск(-аў) у чарзе спампоўвання",
"MessageFailedToRefreshToken": "Не ўдалося абнавіць токен, патрабуецца паўторны ўваход",
"MessageFeedURLWillBe": "URL стужкі будзе {0}",
"MessageFetching": "Атрыманне...",
@@ -306,8 +323,8 @@
"MessageItemMissing": "Элемент адсутнічае і патрабуе выпраўлення на серверы. Звычайна элемент пазначаецца як адсутны, таму што файлавыя шляхі недаступныя.",
"MessageLoading": "Загрузка...",
"MessageLoadingServerData": "Загрузка даных з сервера...",
"MessageLocalFolderDescription": "\"Унутраная памяць прыкладання\" даступная толькі для гэтага прыкладання. Гэта прыкладанне працуе толькі з медыя, якія былі спампаваныя непасрэдна праз яго. Каб іншыя прыкладанні маглі атрымаць доступ да медыя, спампаванага гэтым прыкладаннем, можна выкарыстоўваць агульныя тэчкі.",
"MessageMarkAsFinished": "Пазначыць як скончана",
"MessageLocalFolderDescription": "\"Унутраная памяць праграмы\" даступная толькі для гэтай праграмы. Гэта праграма працуе толькі з медыя, якія былі спампаваныя непасрэдна праз яго. Каб іншыя праграмы маглі атрымаць доступ да медыя, спампаванага гэтай праграмай, можна выкарыстоўваць абагуленыя папкі.",
"MessageMarkAsFinished": "Пазначыць як завершаную",
"MessageMediaLinkedToADifferentServer": "Медыя звязаны з серверам Audiobookshelf па іншым адрасе ({0}). Сінхранізацыя прагрэсу адбудзецца пры падключэнні да гэтага адрасу сервера.",
"MessageMediaLinkedToADifferentUser": "Медыя звязаны з гэтым серверам, але быў спампаваны іншым карыстальнікам. Прагрэс будзе сінхранізавацца толькі для карыстальніка, які яго спампаваў.",
"MessageMediaLinkedToServer": "Звязана з серверам {0}",
@@ -320,40 +337,47 @@
"MessageNoItemsFound": "Элементы не знойдзены",
"MessageNoListeningSessions": "Няма сеансаў праслухоўвання",
"MessageNoLogs": "Няма журналаў",
"MessageNoMediaFolders": "Няма тэчак з медыя",
"MessageNoMediaFolders": "Няма папак з медыя",
"MessageNoNetworkConnection": "Няма падлучэння да сеткі",
"MessageNoPodcastsFound": "Падкасты не знойдзены",
"MessageNoSeries": "Няма серый",
"MessageNoUpdatesWereNecessary": "Абнаўленні не патрабаваліся",
"MessageNoUserPlaylists": "У вас няма спісаў прайгравання",
"MessageNoUserPlaylists": "У вас няма плэй-лістоў",
"MessageOldServerAuthReLoginRequired": "Аўтэнтыфікацыя была палепшана для бяспекі ў серверы v2.26.0. Усім карыстальнікам патрабуецца паўторна ўвайсці ў сістэму.",
"MessageOldServerAuthWarning": "Сервер выкарыстоўвае састарэлую аўтэнтыфікацыю",
"MessageOldServerAuthWarningHelp": "Гэты сервер працуе пад версіяй, старэйшай за 2.26.0. У версіі 2.26.0 была дададзена больш бяспечная сістэма аўтэнтыфікацыі. Настойліва рэкамендуецца абнавіць сервер да апошняй версіі. Калі вы ўжо абнавілі сервер, увайдзіце зноў, каб выкарыстоўваць новую аўтэнтыфікацыю.",
"MessageOldServerConnectionWarning": "Канфігурацыя падлучэння да сервера выкарыстоўвае стары ідэнтыфікатар карыстальніка. Калі ласка, выдаліце і зноў дадайце гэта падлучэнне да сервера.",
"MessageOldServerConnectionWarningHelp": "Вы першапачаткова наладзілі падключэнне да гэтага сервера да міграцыі базы даных у версіі 2.3.0, якая выйшла ў чэрвені 2023 года. У будучым абнаўленні сервера магчымасць уваходу праз гэтае старое падключэнне будзе выдалена. Калі ласка, выдаліце існуючае падключэнне да сервера і падключыцеся зноў (выкарыстоўваючы той жа адрас сервера і ўліковыя даныя). Калі на гэтай прыладзе ёсць спампаваныя медыяфайлы, іх трэба будзе спампаваць зноў для сінхранізацыі з серверам.",
"MessagePodcastSearchField": "Увядзіце пошукавы запыт або URL RSS-стужкі",
"MessagePreparingDownloads": "Падрыхтоўка спампоўвання",
"MessageProgressSyncFailed": "Апошняя спроба адправіць ваш прагрэс праслухоўвання на сервер не ўдалася. Спробы сінхранізаваць прагрэс будуць працягвацца кожныя 15 секунд на працягу 1 хвіліны падчас прайгравання медыяфайла.",
"MessageReportBugsAndContribute": "Паведамляйце пра памылкі, прапануйце новыя функцыі і ўдзельнічайце на",
"MessageReportBugsAndContribute": "Паведамляйце пра памылкі, прапануйце функцыі і ўносьце свой уклад на",
"MessageSeriesAlreadyDownloaded": "Вы ўжо спампавалі ўсе кнігі з гэтай серыі.",
"MessageSeriesDownloadConfirm": "Спампаваць адсутныя {0} кніг(і) з {1} файл(амі), агульным памерам {2}, у тэчку {3}?",
"MessageSeriesDownloadConfirm": "Спампаваць адсутныя {0} кніг(і) з {1} файлам(-і), агульным памерам {2}, у папку {3}?",
"MessageSeriesDownloadConfirmIos": "Спампаваць адсутныя {0} кніг(і) з {1} файл(амі), агульным памерам {2}?",
"MessageSocketConnectedOverMeteredCellular": "Сокет падлучаны праз мабільны інтэрнэт з абмежаваным трафікам",
"MessageSocketConnectedOverMeteredWifi": "Сокет падлучаны праз Wi-Fi з абмежаваным трафікам",
"MessageSocketConnectedOverUnmeteredCellular": "Сокет падлучаны праз мабільны інтэрнэт без абмежавання трафіку",
"MessageSocketConnectedOverUnmeteredWifi": "Сокет падлучаны праз Wi-Fi без абмежавання трафіку",
"MessageSocketNotConnected": "Сокет не падлучаны",
"MessageStorageAccessDenied": "Доступ забаронены",
"MessageStoragePermissionDenied": "Няма дазволу",
"MessageStorageWriteAccessRequired": "У вас няма правоў на запіс у гэту папку. Хочаце даць доступ?",
"MessageWaitingForAvailableStorage": "Чаканне даступнага сховішча",
"NoteRSSFeedPodcastAppsHttps": "Папярэджанне: большасць праграм для падкастаў патрабуюць, каб URL RSS-стужкі выкарыстоўваў HTTPS",
"NoteRSSFeedPodcastAppsPubDate": "Папярэджанне: адзін ці больш вашых эпізодаў не маюць даты публікацыі. Некаторыя праграмы для падкастаў патрабуюць гэтага.",
"NoteRSSFeedPodcastAppsPubDate": "Папярэджанне: адзін ці больш выпускаў не маюць даты публікацыі. Некаторыя праграмы для падкастаў патрабуюць гэтага.",
"ToastBookmarkCreateFailed": "Не ўдалося стварыць закладку",
"ToastBookmarkRemoveFailed": "Не ўдалося выдаліць закладку",
"ToastBookmarkUpdateFailed": "Не ўдалося абнавіць закладку",
"ToastDownloadNotAllowedOnCellular": "Спампоўванне забаронена праз мабільны інтэрнэт",
"ToastItemMarkedAsFinishedFailed": "Не ўдалося пазначыць як Скончана",
"ToastItemMarkedAsNotFinishedFailed": "Не ўдалося пазначыць як Незавершанае",
"ToastPlaylistCreateFailed": "Не ўдалося стварыць спіс прайгравання",
"ToastItemMarkedAsFinishedFailed": "Не ўдалося пазначыць як завершаны",
"ToastItemMarkedAsNotFinishedFailed": "Не ўдалося пазначыць як незавершаны",
"ToastPlaylistCreateFailed": "Не ўдалося стварыць плэй-ліст",
"ToastPodcastCreateFailed": "Не ўдалося стварыць падкаст",
"ToastPodcastCreateSuccess": "Падкаст паспяхова створаны",
"ToastRSSFeedCloseFailed": "Не ўдалося закрыць RSS-стужку",
"ToastRSSFeedCloseSuccess": "RSS-стужка закрыта",
"ToastStreamingNotAllowedOnCellular": "Патокавая перадача праз мабільную сувязь забаронена"
"ToastStreamingNotAllowedOnCellular": "Патокавая перадача праз мабільную сувязь забаронена",
"UnitMinutesShort": "{0} хв",
"UnitSecondsShort": "{0} с"
}
+18 -1
View File
@@ -63,6 +63,7 @@
"HeaderChapters": "Глави",
"HeaderCollection": "Колекция",
"HeaderCollectionItems": "Елемент в колекция",
"HeaderConfirm": "Потвърди",
"HeaderConnectionStatus": "Състояние на връзката",
"HeaderDataSettings": "Настройки на данните",
"HeaderDetails": "Детайли",
@@ -91,6 +92,7 @@
"HeaderStatsRecentSessions": "Последни сесии",
"HeaderTableOfContents": "Съдържание",
"HeaderUserInterfaceSettings": "Настройки на потребителския интерфейс",
"HeaderWelcome": "Добре дошли, <strong>{0}</strong>",
"HeaderYourStats": "Вашата статистика",
"LabelAddToPlaylist": "Добави в плейлист",
"LabelAddedAt": "Добавено в",
@@ -113,6 +115,7 @@
"LabelAutoSleepTimerAutoRewindHelp": "Когато автоматичният таймер на изключване изтече, повторното пускане на елемента автоматично ще върне позицията ви назад.",
"LabelAutoSleepTimerHelp": "Когато се възпроизвеждате медия между посочените начален и краен час, автоматично ще стартира таймер за изключване.",
"LabelBooks": "Книги",
"LabelByAuthor": "от {0}",
"LabelChapterTrack": "Трак на глава",
"LabelChapters": "Глави",
"LabelClosePlayer": "Затвори плейъра",
@@ -155,6 +158,9 @@
"LabelFinished": "Дата на приключване",
"LabelFolder": "Папка",
"LabelFontBoldness": "Дебелина на шрифта",
"LabelFontFamily": "Шрифт",
"LabelFontFamilySans": "Sans",
"LabelFontFamilySerif": "Serif",
"LabelFontScale": "Мащаб на шрифта",
"LabelGenre": "Жанр",
"LabelGenres": "Жанрове",
@@ -174,6 +180,9 @@
"LabelLayout": "Оформление",
"LabelLayoutAuto": "Авто",
"LabelLayoutSinglePage": "Единична страница",
"LabelLibrarySortByProgress": "Прогрес: Последно обновление",
"LabelLibrarySortByProgressFinished": "Прогрес: Приключено",
"LabelLibrarySortByProgressStarted": "Прогрес: Започнато",
"LabelLight": "Светло",
"LabelLineSpacing": "Междуредие",
"LabelListenAgain": "Слушай отново",
@@ -233,6 +242,7 @@
"LabelScaleElapsedTimeBySpeed": "Скалируй изминалото време спрямо скоростта",
"LabelSeason": "Сезон",
"LabelSelectADevice": "Избери устройство",
"LabelSelectMediaType": "Изберете тип на медията",
"LabelSequenceAscending": "Възходяща последователност",
"LabelSequenceDescending": "Низходяща последователност",
"LabelSeries": "От сериите",
@@ -285,12 +295,17 @@
"MessageAudiobookshelfServerNotConnected": "Audiobookshelf сървър не е включен",
"MessageAudiobookshelfServerRequired": "<strong>ВАЖНО!</strong> Това приложение е създадено да работи с Audiobookshelf сървър, който вие или някой, когото познавате, хоства. Това приложение не предоставя никакво съдържание.",
"MessageBookshelfEmpty": "Библиотеката е празна",
"MessageConfirmAppExit": "Искате ли да излезете от апликацията?",
"MessageConfirmDeleteEpisodeDownloadQueue": "Сигурни ли сте, че искате да изчистите опашката за сваляне на епизоди?",
"MessageConfirmDeleteLocalEpisode": "Премахни \"{0}\" епизода от устройството? Файловете на сървъра няма да бъдат премахнати.",
"MessageConfirmDeleteLocalFiles": "Да премахна ли локалните файлове на този елемент от вашето устройство? Файловете на сървъра и вашият напредък няма да бъдат засегнати.",
"MessageConfirmDeleteServerConfig": "Премахнете тази сървърна настройка?",
"MessageConfirmDeleteServerEpisode": "Сигурни ли сте че искате да изтрийете епизод \"{0}\" от сървъра?\nПредупреждение: Това ще изтрийе и аудио файла.",
"MessageConfirmDisableAutoTimer": "Сигурни ли сте, че искате да деактивирате автоматичния таймер за остатъка от днешния ден? Таймерът ще бъде активиран отново в края на този период на автоматичния таймер за изключване или ако рестартирате приложението.",
"MessageConfirmDiscardProgress": "Сигурни ли сте, че искате да нулирате напредъка си?",
"MessageConfirmDownloadUsingCellular": "Ще изтегляте, използвайки мобилни данни. Това може да включва такси за данни от оператора. Искате ли да продължите?",
"MessageConfirmMarkAsFinished": "Сигурни ли сте, че искате да маркирате този елемент като завършен?",
"MessageConfirmPlaybackTime": "Започни възпроизвеждане на \"{0}\" в {1}?",
"MessageConfirmRemoveBookmark": "Сигурни ли сте, че искате да премахнете отметката?",
"MessageConfirmStreamingUsingCellular": "Ще стриймвате, използвайки мобилни данни. Това може да включва такси за данни от оператора. Искате ли да продължите?",
"MessageDiscardProgress": "Нулирай прогреса",
@@ -355,5 +370,7 @@
"ToastPodcastCreateSuccess": "Подкаст успешно създаден",
"ToastRSSFeedCloseFailed": "Неуспешно затваряне на RSS емисията",
"ToastRSSFeedCloseSuccess": "RSS емисията е затворена",
"ToastStreamingNotAllowedOnCellular": "Стриймването не е разрешено чрез мобилни данни"
"ToastStreamingNotAllowedOnCellular": "Стриймването не е разрешено чрез мобилни данни",
"UnitMinutesShort": "{0}мин",
"UnitSecondsShort": "{0}сек"
}
+10
View File
@@ -1,11 +1,13 @@
{
"ButtonAdd": "যোগ করুন",
"ButtonAddNewServer": "নতুন সার্ভার যোগ করুন",
"ButtonAllow": "অনুমতি দিন",
"ButtonAuthors": "লেখকগণ",
"ButtonBack": "পেছনে যান",
"ButtonCancel": "বাতিল করুন",
"ButtonCancelTimer": "টাইমার বাতিল করুন",
"ButtonClearFilter": "ফিল্টার পরিষ্কার করুন",
"ButtonClearLogs": "লগ মুছে ফেলুন",
"ButtonCloseFeed": "ফিড বন্ধ করুন",
"ButtonCollections": "সংগ্রহ",
"ButtonConnect": "সংযোগ করুন",
@@ -26,9 +28,12 @@
"ButtonLatest": "সর্বশেষ",
"ButtonLibrary": "লাইব্রেরি",
"ButtonLocalMedia": "স্থানীয় মিডিয়া",
"ButtonLogs": "লগসমূহ",
"ButtonManageLocalFiles": "স্থানীয় ফাইলগুলো পরিচালনা করুন",
"ButtonMaskServerAddress": "সার্ভারের ঠিকানা আড়াল করুন",
"ButtonNewFolder": "নতুন ফোল্ডার",
"ButtonNextEpisode": "পরবর্তী পর্ব",
"ButtonOk": "ঠিক আছে",
"ButtonOpenFeed": "ফিড খুলুন",
"ButtonOverride": "ওভাররাইড",
"ButtonPause": "বিরতি",
@@ -49,14 +54,17 @@
"ButtonStream": "স্ট্রীম",
"ButtonSubmit": "জমা দিন",
"ButtonSwitchServerUser": "সার্ভার/ব্যবহারকারী পরিবর্তন করুন",
"ButtonUnmaskServerAddress": "সার্ভারের ঠিকানা প্রকাশ করুন",
"ButtonUserStats": "ব্যবহারকারীর পরিসংখ্যান",
"ButtonYes": "হ্যাঁ",
"HeaderAccount": "অ্যাকাউন্ট",
"HeaderAdvanced": "অ্যাডভান্সড",
"HeaderAndroidAutoSettings": "অ্যান্ড্রয়েড অটো সেটিংস",
"HeaderAudioTracks": "অডিও ট্র্যাকসগুলো",
"HeaderChapters": "অধ্যায়",
"HeaderCollection": "সংগ্রহ",
"HeaderCollectionItems": "সংগ্রহ আইটেম",
"HeaderConfirm": "নিশ্চিত করুন",
"HeaderConnectionStatus": "সংযোগের অবস্থা",
"HeaderDataSettings": "ডেটা সেটিংস",
"HeaderDetails": "বিস্তারিত",
@@ -73,6 +81,7 @@
"HeaderPlaybackSettings": "প্লেব্যাক সেটিংস",
"HeaderPlaylist": "প্লেলিস্ট",
"HeaderPlaylistItems": "প্লেলিস্ট আইটেম",
"HeaderProgressSyncFailed": "অগ্রগতি সিঙ্ক ব্যর্থ হয়েছে",
"HeaderRSSFeed": "আরএসএস ফিডগুলো",
"HeaderRSSFeedGeneral": "আরএসএস বিবরণ",
"HeaderRSSFeedIsOpen": "আরএসএস ফিড খোলা আছে",
@@ -84,6 +93,7 @@
"HeaderStatsRecentSessions": "সাম্প্রতিক সেশন",
"HeaderTableOfContents": "বিষয়বস্তুর সারণী",
"HeaderUserInterfaceSettings": "ব্যবহারকারী ইন্টারফেস সেটিংস",
"HeaderWelcome": "স্বাগতম, <strong>{0}</strong>",
"HeaderYourStats": "আপনার পরিসংখ্যান",
"LabelAddToPlaylist": "প্লেলিস্টে যোগ করুন",
"LabelAddedAt": "এতে যোগ করা হয়েছে",
+23 -2
View File
@@ -1,6 +1,7 @@
{
"ButtonAdd": "Přidat",
"ButtonAddNewServer": "Přidat nový server",
"ButtonAllow": "Povolit",
"ButtonAuthors": "Autoři",
"ButtonBack": "Zpět",
"ButtonCancel": "Zrušit",
@@ -63,6 +64,7 @@
"HeaderChapters": "Kapitoly",
"HeaderCollection": "Kolekce",
"HeaderCollectionItems": "Položky kolekce",
"HeaderConfirm": "Potvrzení",
"HeaderConnectionStatus": "Stav připojení",
"HeaderDataSettings": "Nastavení dat",
"HeaderDetails": "Podrobnosti",
@@ -91,6 +93,7 @@
"HeaderStatsRecentSessions": "Poslední sezení",
"HeaderTableOfContents": "Obsah",
"HeaderUserInterfaceSettings": "Nastavení uživatelského rozhraní",
"HeaderWelcome": "Vítejte, <strong>{0}</strong>",
"HeaderYourStats": "Vaše statistiky",
"LabelAddToPlaylist": "Přidat do seznamu skladeb",
"LabelAddedAt": "Přidáno v",
@@ -113,6 +116,7 @@
"LabelAutoSleepTimerAutoRewindHelp": "Když dojde k uspání automatickým časovačem spánku, pozice přehrávání je posunuta zpět o vybraný čas.",
"LabelAutoSleepTimerHelp": "Během přehrávání média v časovém rozmezí \"Od\" a \"Do\" se automaticky spustí časovač spánku.",
"LabelBooks": "Knihy",
"LabelByAuthor": "od {0}",
"LabelChapterTrack": "Stopa kapitoly",
"LabelChapters": "Kapitoly",
"LabelClosePlayer": "Zavřít přehrávač",
@@ -158,7 +162,7 @@
"LabelFontFamily": "Rodina písem",
"LabelFontFamilySans": "Bezpatkové",
"LabelFontFamilySerif": "Patkové",
"LabelFontScale": "Měřítko písma",
"LabelFontScale": "Velikost písma",
"LabelGenre": "Žánr",
"LabelGenres": "Žánry",
"LabelHapticFeedback": "Haptická odezva",
@@ -177,6 +181,9 @@
"LabelLayout": "Rozvržení",
"LabelLayoutAuto": "Automatické",
"LabelLayoutSinglePage": "Jedna stránka",
"LabelLibrarySortByProgress": "Pokrok: naposledy aktualizováno",
"LabelLibrarySortByProgressFinished": "Pokrok: dokončeno",
"LabelLibrarySortByProgressStarted": "Pokrok: začato",
"LabelLight": "Slabá",
"LabelLineSpacing": "Řádkování",
"LabelListenAgain": "Poslouchat znovu",
@@ -236,6 +243,7 @@
"LabelScaleElapsedTimeBySpeed": "Škálovat uplynulý čas podle rychlosti",
"LabelSeason": "Sezóna",
"LabelSelectADevice": "Vyberte zařízení",
"LabelSelectMediaType": "Vyberte typ média",
"LabelSequenceAscending": "Řadit vzestupně",
"LabelSequenceDescending": "Řadit sestupně",
"LabelSeries": "Série",
@@ -288,18 +296,24 @@
"MessageAudiobookshelfServerNotConnected": "Server Audiobookshelf není připojen",
"MessageAudiobookshelfServerRequired": "<strong>Důležité!</strong> Tato aplikace je navržena, aby pracovala se serverem Audiobookshelf, který hostuje buď vy, nebo někdo, koho znáte. Tato aplikace neposkytuje žádný obsah.",
"MessageBookshelfEmpty": "Knihovna je prázdná",
"MessageConfirmAppExit": "Opravdu chcete ukončit aplikaci?",
"MessageConfirmDeleteEpisodeDownloadQueue": "Opravdu chcete vymazat frontu stahování epizod?",
"MessageConfirmDeleteLocalEpisode": "Odebrat místní epizodu „{0}“ ze zařízení? Soubor na serveru zůstane nezměněný.",
"MessageConfirmDeleteLocalFiles": "Odebrat místní soubory této položky ze zařízení? Soubory na serveru a váš pokrok nebudou ovlivněny.",
"MessageConfirmDeleteServerConfig": "Odebrat tuto konfiguraci serveru?",
"MessageConfirmDeleteServerEpisode": "Přejete si skutečně odstranit epizodu \"{0}\" z serveru?\nUpozornění: Toto odstraní zvukový soubor.",
"MessageConfirmDisableAutoTimer": "Určitě chcete vypnout automatický časovač pro zbytek dnešního dne? Časovač bude opět aktivován po uběhnutí doby automatického spánku nebo po restartování aplikace.",
"MessageConfirmDiscardProgress": "Opravdu chcete zahodit svůj pokrok?",
"MessageConfirmDownloadUsingCellular": "Chystáte se stahovat přes mobilní data. Toto může zahrnovat poplatky za mobilní data. Chcete pokračovat?",
"MessageConfirmMarkAsFinished": "Opravdu chcete tuto položku označit jako dokončenou?",
"MessageConfirmPlaybackTime": "Spustit přehrávání pro \"{0}\" v {1}?",
"MessageConfirmRemoveBookmark": "Opravdu chcete odebrat záložku?",
"MessageConfirmStreamingUsingCellular": "Chystáte se přehrávat přes mobilní data. Toto může zahrnovat poplatky za mobilní data. Chcete pokračovat?",
"MessageDiscardProgress": "Zahodit pokrok",
"MessageDownloadCompleteProcessing": "Stahování dokončeno. Zpracovává se...",
"MessageDownloading": "Stahuje se...",
"MessageDownloadingEpisode": "Stahuji epizodu",
"MessageDownloadingFile": "Stahování {0}",
"MessageEpisodesQueuedForDownload": "{0} Epizody zařazené do fronty ke stažení",
"MessageFailedToRefreshToken": "Nepovedlo se obnovit token, je nutné se znovu přihlásit",
"MessageFeedURLWillBe": "URL zdroje bude {0}",
@@ -335,6 +349,7 @@
"MessageOldServerConnectionWarning": "Konfigurace připojení k serveru používá staré ID uživatele. Odstraňte a znovu přidejte toto připojení k serveru.",
"MessageOldServerConnectionWarningHelp": "Připojení k tomuto serveru jste původně nastavili před migrací databáze ve verzi 2.3.0 vydané v červnu 2023. Budoucí aktualizace serveru odstraní možnost přihlášení pomocí tohoto starého připojení. Odstraňte prosím stávající připojení k serveru a připojte se znovu (pomocí stejné adresy serveru a přihlašovacích údajů). Pokud máte v tomto zařízení stažená média, bude nutné je pro synchronizaci se serverem stáhnout znovu.",
"MessagePodcastSearchField": "Zadejte hledaný pojem pro RSS feed URL",
"MessagePreparingDownloads": "Příprava stahování",
"MessageProgressSyncFailed": "Poslední pokus o nahlášení průběhu poslechu na server se nezdařil. Během přehrávání médií se budou požadavky na synchronizaci pokroku nadále pokoušet každých 15 až 60 sekund.",
"MessageReportBugsAndContribute": "Nahlašte chyby, vyžádejte si funkce a přispěte na",
"MessageSeriesAlreadyDownloaded": "Všechny knihy v této sérii jste již stáhli.",
@@ -345,6 +360,10 @@
"MessageSocketConnectedOverUnmeteredCellular": "Socket připojen přes neměřenou mobilní síť",
"MessageSocketConnectedOverUnmeteredWifi": "Socket připojen přes neměřenou Wi-Fi",
"MessageSocketNotConnected": "Socket není připojen",
"MessageStorageAccessDenied": "Přístup zamítnut",
"MessageStoragePermissionDenied": "Oprávnění neuděleno",
"MessageStorageWriteAccessRequired": "Nemáte přístup k zápisu do této složky. Chcete udělit přístup?",
"MessageWaitingForAvailableStorage": "Čekám na dostupnost úložiště",
"NoteRSSFeedPodcastAppsHttps": "Upozornění: Většina aplikací pro podcasty bude vyžadovat, aby adresa URL kanálu RSS používala protokol HTTPS",
"NoteRSSFeedPodcastAppsPubDate": "Upozornění: 1 nebo více epizod nemá datum vydání. Některé podcastové aplikace to vyžadují.",
"ToastBookmarkCreateFailed": "Vytvoření záložky se nezdařilo",
@@ -358,5 +377,7 @@
"ToastPodcastCreateSuccess": "Podcast byl úspěšně vytvořen",
"ToastRSSFeedCloseFailed": "Nepodařilo se zavřít RSS kanál",
"ToastRSSFeedCloseSuccess": "RSS kanál uzavřen",
"ToastStreamingNotAllowedOnCellular": "Přehrávání přes mobilní data není povoleno"
"ToastStreamingNotAllowedOnCellular": "Přehrávání přes mobilní data není povoleno",
"UnitMinutesShort": "{0}m",
"UnitSecondsShort": "{0}s"
}
+48 -37
View File
@@ -62,41 +62,41 @@
"HeaderAudioTracks": "Lydspor",
"HeaderChapters": "Kapitler",
"HeaderCollection": "Samling",
"HeaderCollectionItems": "Samlingselementer",
"HeaderCollectionItems": "Bøger i samlingen",
"HeaderConfirm": "Bekræft",
"HeaderConnectionStatus": "Forbindelsesstatus",
"HeaderDataSettings": "Data indstillinger",
"HeaderDetails": "Detaljer",
"HeaderDownloads": "Downloads",
"HeaderEbookFiles": "E-bogsfiler",
"HeaderEpisodes": "Episoder",
"HeaderEreaderSettings": "E-læser Indstillinger",
"HeaderLatestEpisodes": "Seneste episoder",
"HeaderEpisodes": "Afsnit",
"HeaderEreaderSettings": "Indstillinger for e-bogslæsere",
"HeaderLatestEpisodes": "Nyeste afsnit",
"HeaderLibraries": "Biblioteker",
"HeaderLocalFolders": "Lokale mapper",
"HeaderLocalLibraryItems": "Lokale biblioteks elementer",
"HeaderNewPlaylist": "Ny afspilningsliste",
"HeaderOpenRSSFeed": "Åbn RSS Feed",
"HeaderOpenRSSFeed": "Åbn RSS-feed",
"HeaderPlaybackSettings": "Afspilnings indstillinger",
"HeaderPlaylist": "Afspilningsliste",
"HeaderPlaylistItems": "Afspilningsliste Elementer",
"HeaderPlaylistItems": "Elementer i afspilningslisten",
"HeaderProgressSyncFailed": "Fremskridt synkronisering fejlede",
"HeaderRSSFeed": "RSS Feed",
"HeaderRSSFeedGeneral": "RSS Detaljer",
"HeaderRSSFeedIsOpen": "RSS Feed er Åben",
"HeaderRSSFeedGeneral": "RSS-detaljer",
"HeaderRSSFeedIsOpen": "RSS-feedet er åbent",
"HeaderSelectDownloadLocation": "Vælg Download Placering",
"HeaderSettings": "Indstillinger",
"HeaderSleepTimer": "Søvntimer",
"HeaderSleepTimer": "Sovefunktion",
"HeaderSleepTimerSettings": "Sleep Timer instillinger",
"HeaderStatsMinutesListeningChart": "Minutter Lyttet (sidste 7 dage)",
"HeaderStatsRecentSessions": "Seneste Sessions",
"HeaderStatsMinutesListeningChart": "Minutter lyttet (sidste 7 dage)",
"HeaderStatsRecentSessions": "Seneste sessioner",
"HeaderTableOfContents": "Indholdsfortegnelse",
"HeaderUserInterfaceSettings": "Indstillinger for brugergrænseflade",
"HeaderWelcome": "Velkommen, <strong>{0}</strong>",
"HeaderYourStats": "Dine Statistikker",
"LabelAddToPlaylist": "Tilføj til Afspilningsliste",
"HeaderYourStats": "Din statistik",
"LabelAddToPlaylist": "Tilføj til afspilningsliste",
"LabelAddedAt": "Tilføjet",
"LabelAddedDate": "Tilføjet {0}",
"LabelAddedDate": "Tilføjede {0}",
"LabelAll": "Alle",
"LabelAllowSeekingOnMediaControls": "Tillad positionssøgning på mediemeddelelseskontroller",
"LabelAlways": "Altid",
@@ -108,24 +108,24 @@
"LabelAuthorFirstLast": "Forfatter (Fornavn Efternavn)",
"LabelAuthorLastFirst": "Forfatter (Efternavn, Fornavn)",
"LabelAuthors": "Forfattere",
"LabelAutoDownloadEpisodes": "Auto Download Episoder",
"LabelAutoDownloadEpisodes": "Hent automatisk afsnit",
"LabelAutoRewindTime": "Automatisk tilbagespolingstid",
"LabelAutoSleepTimer": "Auto sleep timer",
"LabelAutoSleepTimer": "Auto søvn-timer",
"LabelAutoSleepTimerAutoRewind": "Automatisk sleep timer automatisk tilbagespoling",
"LabelAutoSleepTimerAutoRewindHelp": "Når den automatiske sleep-timer er færdig, vil genafspilning af elementet automatisk spole din position tilbage.",
"LabelAutoSleepTimerHelp": "Når der afspilles medie mellem de angivne start- og sluttidspunkter, starter en sleep-timer automatisk.",
"LabelBooks": "Bøger",
"LabelByAuthor": "Efter Forfatter",
"LabelByAuthor": "af {0}",
"LabelChapterTrack": "Kapitel spor",
"LabelChapters": "Kapitler",
"LabelClosePlayer": "Luk afspiller",
"LabelCollapseSeries": "Fold Serier Sammen",
"LabelComplete": "Fuldfør",
"LabelCollapseSeries": "Fold serier sammen",
"LabelComplete": "Afsluttet",
"LabelContinueBooks": "Fortsæt Bøger",
"LabelContinueEpisodes": "Fortsæt Episoder",
"LabelContinueListening": "Fortsæt med at lytte",
"LabelContinueReading": "Fortsæt med at læse",
"LabelContinueSeries": "Fortsæt Serien",
"LabelContinueSeries": "Fortsæt serien",
"LabelCustomTime": "Brugerdefineret tid",
"LabelDescription": "Beskrivelse",
"LabelDisableAudioFadeOut": "Deaktiver lydudtoning",
@@ -136,7 +136,7 @@
"LabelDisableVibrateOnReset": "Deaktiver vibrering ved nulstilling",
"LabelDisableVibrateOnResetHelp": "Når sleep-timeren nulstilles, vil din enhed vibrere. Aktiver denne indstilling for ikke at vibrere, når sleep-timeren nulstilles.",
"LabelDiscover": "Opdag",
"LabelDownload": "Download",
"LabelDownload": "Hent",
"LabelDownloadUsingCellular": "Download med mobildata",
"LabelDownloaded": "Downloadet",
"LabelDuration": "Varighed",
@@ -149,8 +149,8 @@
"LabelEndOfChapter": "Slutningen af kapitel",
"LabelEndTime": "Sluttidspunkt",
"LabelEpisode": "Afsnit",
"LabelExplicit": "Eksplisit",
"LabelFeedURL": "Feed URL",
"LabelExplicit": "Voksenindhold",
"LabelFeedURL": "Feed-URL",
"LabelFile": "Fil",
"LabelFileBirthtime": "Oprettelsestidspunkt for fil",
"LabelFileModified": "Fil ændret",
@@ -159,8 +159,8 @@
"LabelFolder": "Mappe",
"LabelFontBoldness": "Skrift tykkelse",
"LabelFontFamily": "Skrifttypefamilie",
"LabelFontFamilySans": "Font Sans",
"LabelFontFamilySerif": "Font Serif",
"LabelFontFamilySans": "Sans",
"LabelFontFamilySerif": "Serif",
"LabelFontScale": "Skriftstørrelse",
"LabelGenre": "Genre",
"LabelGenres": "Genrer",
@@ -177,12 +177,15 @@
"LabelJumpForwardsTime": "Spring frem tid",
"LabelKeepScreenAwake": "Hold skærm aktiv",
"LabelLanguage": "Sprog",
"LabelLayout": "Layout",
"LabelLayout": "Udseende",
"LabelLayoutAuto": "Automatisk",
"LabelLayoutSinglePage": "Enkel Side",
"LabelLibrarySortByProgress": "Fremgang: Sidst opdateret",
"LabelLibrarySortByProgressFinished": "Fremgang: Afsluttet",
"LabelLibrarySortByProgressStarted": "Fremgang: Startet",
"LabelLight": "Lys",
"LabelLineSpacing": "Linjeafstand",
"LabelListenAgain": "Lyt Igen",
"LabelListenAgain": "Lyt igen",
"LabelLocalBooks": "Lokale Bøger",
"LabelLocalPodcasts": "Lokale Podcasts",
"LabelLockOrientation": "Lås Orientering",
@@ -194,8 +197,8 @@
"LabelMore": "Mere",
"LabelMoreInfo": "Mere info",
"LabelName": "Navn",
"LabelNarrator": "Fortæller",
"LabelNarrators": "Fortællere",
"LabelNarrator": "Oplæser",
"LabelNarrators": "Oplæsere",
"LabelNavigateWithVolume": "Naviger med lydstyrketasterne",
"LabelNavigateWithVolumeMirrored": "Spejlet",
"LabelNavigateWithVolumeWhilePlaying": "Tillad navigering med lydstyrketasterne , mens du spiller",
@@ -203,7 +206,7 @@
"LabelNavigateWithVolumeWhilePlayingEnabled": "Tændt",
"LabelNever": "Aldrig",
"LabelNewestAuthors": "Nyeste Forfattere",
"LabelNewestEpisodes": "Nyeste Episoder",
"LabelNewestEpisodes": "Nyeste afsnit",
"LabelNo": "Nej",
"LabelNotFinished": "Ikke færdig",
"LabelNotStarted": "Ikke påbegyndt",
@@ -220,25 +223,26 @@
"LabelPlaybackTranscode": "Transcode",
"LabelPodcast": "Podcast",
"LabelPodcasts": "Podcast",
"LabelPreventIndexing": "Forhindrer, at dit feed bliver indekseret af iTunes og Google podcastkataloger",
"LabelPreventIndexing": "Forhindrer at dit feed bliver indekseret af iTunes- og Google-podcastkataloger",
"LabelProgress": "Fremskridt",
"LabelPubDate": "Udgivelsesdato",
"LabelPublishYear": "Udgivelsesår",
"LabelPublishedDate": "Publiceret {0}",
"LabelPublishedDate": "Udgivet {0}",
"LabelRSSFeedCustomOwnerEmail": "Brugerdefineret ejerens e-mail",
"LabelRSSFeedCustomOwnerName": "Brugerdefineret ejerens navn",
"LabelRSSFeedOpen": "RSS-feed åbent",
"LabelRSSFeedPreventIndexing": "Forhindrer indeksering",
"LabelRSSFeedPreventIndexing": "Forhindr indeksering",
"LabelRSSFeedSlug": "RSS-feed-slug",
"LabelRandomly": "Tilfældigt",
"LabelRead": "Læst",
"LabelReadAgain": "Læs Igen",
"LabelReadAgain": "Læs igen",
"LabelRecentSeries": "Seneste serier",
"LabelRecentlyAdded": "Nyligt Tilføjet",
"LabelRemoveFromPlaylist": "Fjern fra Playliste",
"LabelScaleElapsedTimeBySpeed": "Skalér Forløbet Tid med Hastighed",
"LabelSeason": "Sæson",
"LabelSelectADevice": "Vælg en Enhed",
"LabelSelectMediaType": "Vælg medietype",
"LabelSequenceAscending": "Sekvens Stigende",
"LabelSequenceDescending": "Sekvens Faldende",
"LabelSeries": "Serie",
@@ -258,7 +262,7 @@
"LabelStatsDays": "Dage",
"LabelStatsDaysListened": "Dage hørt",
"LabelStatsInARow": "i træk",
"LabelStatsItemsFinished": "Elementer færdige",
"LabelStatsItemsFinished": "færdiglæste bøger",
"LabelStatsMinutes": "minutter",
"LabelStatsMinutesListening": "Minutter hørt",
"LabelStatsWeekListening": "Ugens lytning",
@@ -289,14 +293,19 @@
"MessageAndroid10Downloads": "Android 10 og mindre vil bruge intern lagerplads til downloads.",
"MessageAttemptingServerConnection": "Prøver at tilgå server...",
"MessageAudiobookshelfServerNotConnected": "Audiobookshel server er ikke tilsluttet",
"MessageAudiobookshelfServerRequired": "<strong>Vigtig</strong> Denne app er designet til at fungere med en Audiobookshelf server, som du eller en du kender hoster. Denne app levere ikke noget indhold.",
"MessageAudiobookshelfServerRequired": "<strong>Vigtigt!</strong> Denne app er designet til at fungere med en Audiobookshelf-server, som du eller en du kender hoster. Denne app leverer ikke noget indhold.",
"MessageBookshelfEmpty": "Bogreol Tom",
"MessageConfirmAppExit": "Ville du lukke appen?",
"MessageConfirmDeleteEpisodeDownloadQueue": "Er du sikker på, at du vil rydde downloadkøen for episoder?",
"MessageConfirmDeleteLocalEpisode": "Slet lokal episode \"{0}\" fra din enhed? Filen på serveren vil ikke blive påvirket.",
"MessageConfirmDeleteLocalFiles": "Slet lokale filer af denne type fra din enhed? Filerne på serveren og din fremgang vil ikke blive påvirket.",
"MessageConfirmDeleteServerConfig": "Fjern denne serverkonfiguration?",
"MessageConfirmDeleteServerEpisode": "Er du sikker på at du vil slette episode \"{0}\" fra serveren?\nAdvarsel: Dette sletter lydfilen.",
"MessageConfirmDisableAutoTimer": "Er du sikker på at du vil slå auto-timer fra resten af dagen? Timeren vil blive slået til ved slutningen af auto-sleep timer perioden eller appen genstartes.",
"MessageConfirmDiscardProgress": "Er du sikker på at du vil nulstille din fremgang?",
"MessageConfirmDownloadUsingCellular": "Du er ved at downloade via mobildata. Dette kan medføre ekstra omkostninger fra din teleoperatør. Vil du fortsætte?",
"MessageConfirmMarkAsFinished": "Er du sikker på at du vil markere dette element færdig?",
"MessageConfirmPlaybackTime": "Start afspilning af \"{0}\" på {1}?",
"MessageConfirmRemoveBookmark": "Er du sikker på at du vil fjerne bogmærke?",
"MessageConfirmStreamingUsingCellular": "Du er ved at streame via mobildata. Dette kan medføre ekstra omkostninger fra din teleoperatør. Vil du fortsætte?",
"MessageDiscardProgress": "Kassér fremgang",
@@ -361,5 +370,7 @@
"ToastPodcastCreateSuccess": "Podcast oprettet med succes",
"ToastRSSFeedCloseFailed": "Mislykkedes lukning af RSS-feed",
"ToastRSSFeedCloseSuccess": "RSS-feed lukket",
"ToastStreamingNotAllowedOnCellular": "Det er ikke tillad at streame over mobildata"
"ToastStreamingNotAllowedOnCellular": "Det er ikke tillad at streame over mobildata",
"UnitMinutesShort": "{0}m",
"UnitSecondsShort": "{0}s"
}
+13 -6
View File
@@ -1,6 +1,7 @@
{
"ButtonAdd": "Hinzufügen",
"ButtonAddNewServer": "Neuen Server hinzufügen",
"ButtonAllow": "Erlauben",
"ButtonAuthors": "Autoren",
"ButtonBack": "Zurück",
"ButtonCancel": "Abbrechen",
@@ -70,7 +71,7 @@
"HeaderDownloads": "Downloads",
"HeaderEbookFiles": "E-Book-Dateien",
"HeaderEpisodes": "Episoden",
"HeaderEreaderSettings": "E-Reader-Einstellungen",
"HeaderEreaderSettings": "E-Reader Einstellungen",
"HeaderLatestEpisodes": "Neueste Episoden",
"HeaderLibraries": "Bibliotheken",
"HeaderLocalFolders": "Lokale Ordner",
@@ -121,8 +122,8 @@
"LabelClosePlayer": "Player schließen",
"LabelCollapseSeries": "Serien einklappen",
"LabelComplete": "Vollständig",
"LabelContinueBooks": "Bücher fortfahren",
"LabelContinueEpisodes": "Episoden fortfahren",
"LabelContinueBooks": "Bücher fortsetzen",
"LabelContinueEpisodes": "Episoden fortsetzen",
"LabelContinueListening": "Weiterhören",
"LabelContinueReading": "Weiterlesen",
"LabelContinueSeries": "Serie fortsetzen",
@@ -143,7 +144,7 @@
"LabelEbook": "E-Buch",
"LabelEbooks": "E-Bücher",
"LabelEnable": "Aktivieren",
"LabelEnableMp3IndexSeeking": "MP3-Indexsuche",
"LabelEnableMp3IndexSeeking": "MP3-Indexsuche aktivieren",
"LabelEnableMp3IndexSeekingHelp": "Diese Einstellung sollte nur aktiviert werden, wenn du MP3-Dateien hast, bei denen das Navigieren (Seeking) nicht korrekt funktioniert. Ungenaues Navigieren ist höchstwahrscheinlich auf MP3-Dateien mit variabler Bitrate (VBR) zurückzuführen. Diese Einstellung erzwingt das Index-Navigieren (Index Seeking), bei dem eine Zeit-zu-Byte-Zuordnung erstellt wird, während die Datei gelesen wird. In einigen Fällen kann es bei großen MP3-Dateien zu einer Verzögerung kommen, wenn gegen Ende der Datei navigiert wird.",
"LabelEnd": "Ende",
"LabelEndOfChapter": "Ende des Kapitels",
@@ -312,6 +313,7 @@
"MessageDownloadCompleteProcessing": "Download abgeschlossen. Wird verarbeitet …",
"MessageDownloading": "Wird heruntergeladen …",
"MessageDownloadingEpisode": "Episode herunterladen",
"MessageDownloadingFile": "Datei wird heruntergeladen {0}",
"MessageEpisodesQueuedForDownload": "{0} Episode(n) in der Warteschlange zum Herunterladen",
"MessageFailedToRefreshToken": "Aktualisierung des Tokens fehlgeschlagen, erneute Anmeldung erforderlich",
"MessageFeedURLWillBe": "Feed-URL wird {0} sein",
@@ -347,6 +349,7 @@
"MessageOldServerConnectionWarning": "Diese Serververbindung nutzt eine alte Nutzer-ID (user ID). Bitte entferne diese Serververbindung und füge sie wieder neu hinzu.",
"MessageOldServerConnectionWarningHelp": "Du hast diese Serververbindung vor der Datenbankmigration in 2.3.0, veröffentlicht im Juni 2023, eingerichtet. Ein zukünftiges Serverupdate wird die Möglichkeit entfernen, sich mit dieser Verbindung anzumelden. Bitte lösche die existierende Serververbindung und melde dich neu an (mit der gleichen Serveradresse und Zugangsdaten). Wenn du irgendwelche Medien auf dieses Gerät heruntergeladen hast, musst du sie anschließend erneut herunterladen, um den Status zu synchronisieren.",
"MessagePodcastSearchField": "Suchbegriff oder RSS-Feed URL eingeben",
"MessagePreparingDownloads": "Downloads werden vorbereitet",
"MessageProgressSyncFailed": "Der letzte Versuch den aktuellen Hörfortschritt an den Server zu melden ist fehlgeschlagen. Die Anfragen den Fortschritt zu synchronisieren wird alle 15 Sekunden bis 1 Minute versucht, während Medien abgespielt werden.",
"MessageReportBugsAndContribute": "Fehler melden, Funktionen anfordern und mitwirken",
"MessageSeriesAlreadyDownloaded": "Du hast bereits alle Bücher in dieser Serie heruntergeladen.",
@@ -357,6 +360,10 @@
"MessageSocketConnectedOverUnmeteredCellular": "Socket verbunden über ungetaktetes Mobilfunknetz",
"MessageSocketConnectedOverUnmeteredWifi": "Socket verbunden über ungetaktetes Mobilfunknetz",
"MessageSocketNotConnected": "Socket nicht verbunden",
"MessageStorageAccessDenied": "Zugang verweigert",
"MessageStoragePermissionDenied": "Erlaubnis verweigert",
"MessageStorageWriteAccessRequired": "Du hast keine Schreibrechte für diesen Ordner. Möchtest du Zugriff gewähren?",
"MessageWaitingForAvailableStorage": "Warten auf freien Speicherplatz",
"NoteRSSFeedPodcastAppsHttps": "Warnung: Die meisten Podcast-Apps verlangen, dass die URL des RSS-Feeds HTTPS verwendet",
"NoteRSSFeedPodcastAppsPubDate": "Warnung: 1 oder mehrere deiner Episoden haben kein Veröffentlichungsdatum. Einige Podcast-Apps verlangen dies.",
"ToastBookmarkCreateFailed": "Lesezeichen konnte nicht erstellt werden",
@@ -371,6 +378,6 @@
"ToastRSSFeedCloseFailed": "RSS-Feed konnte nicht geschlossen werden",
"ToastRSSFeedCloseSuccess": "RSS-Feed geschlossen",
"ToastStreamingNotAllowedOnCellular": "Das Streamen über mobile Daten ist nicht erlaubt",
"UnitMinutesShort": "{0}m",
"UnitSecondsShort": "{0}s"
"UnitMinutesShort": "{0} min",
"UnitSecondsShort": "{0} s"
}
+61 -6
View File
@@ -63,6 +63,7 @@
"HeaderChapters": "Κεφάλαια",
"HeaderCollection": "Συλλογή",
"HeaderCollectionItems": "Αντικείμενα Συλλογής",
"HeaderConfirm": "Επιβεβαίωση",
"HeaderConnectionStatus": "Στατιστικά Σύνδεσης",
"HeaderDataSettings": "Ρυθμίσεις Δεδομένων",
"HeaderDetails": "Λεπτομέρειες",
@@ -91,6 +92,7 @@
"HeaderStatsRecentSessions": "Πρόσφατες Συνεδρίες",
"HeaderTableOfContents": "Πίνακας Περιεχομένων",
"HeaderUserInterfaceSettings": "Ρυθμίσεις Διεπαφής Χρήστη",
"HeaderWelcome": "Καλωσήρθες, <strong>{0}</strong>",
"HeaderYourStats": "Τα Στατιστικά Σας",
"LabelAddToPlaylist": "Προσθήκη στην Λίστα Αναπαραγωγής",
"LabelAddedAt": "Προστέθηκε Στις",
@@ -99,6 +101,7 @@
"LabelAllowSeekingOnMediaControls": "Να επιτρέπεται η αλλαγή θέσης από τα χειριστήρια ειδοποιήσεων πολυμέσων",
"LabelAlways": "Πάντα",
"LabelAndroidAutoBrowseLimitForGrouping": "Όριο λίστας κατά αλφαβητική σειρά",
"LabelAndroidAutoBrowseLimitForGroupingHelp": "Μην χρησιμοποιείτε την αλφαβητική λίστα περιορισμού όταν υπάρχουν λιγότερα από αυτά τα στοιχεία για εμφάνιση",
"LabelAndroidAutoBrowseSeriesSequenceOrder": "Ταξινόμηση βιβλίων σειράς",
"LabelAskConfirmation": "Ερώτηση για επιβεβαίωση",
"LabelAuthor": "Συγγραφέας",
@@ -106,69 +109,119 @@
"LabelAuthorLastFirst": "Συγγραφέας (Επώνυμο, Όνομα)",
"LabelAuthors": "Συγγραφείς",
"LabelAutoDownloadEpisodes": "Αυτόματο Κατέβασμα Επεισοδίων",
"LabelAutoRewindTime": "Χρόνος αυτόματης επαναφοράς",
"LabelAutoSleepTimer": "Αυτόματος Χρονοδιακόπτης Ύπνου",
"LabelAutoSleepTimerAutoRewind": "Αυτόματη επαναφορά κατά τον χρονοδιακόπτη ύπνου",
"LabelAutoSleepTimerAutoRewindHelp": "Όταν ολοκληρωθεί ο αυτόματος χρονοδιακόπτης ύπνου, η επαναφορά της αναπαραγωγής θα γυρίζει αυτόματα τη θέση σας πίσω.",
"LabelAutoSleepTimerHelp": "Όταν αναπαράγεται περιεχόμενο μεταξύ των καθορισμένων χρόνων έναρξης και λήξης, ο χρονοδιακόπτης ύπνου θα ξεκινά αυτόματα.",
"LabelBooks": "Βιβλία",
"LabelByAuthor": "κατά {0}",
"LabelChapterTrack": "Κομμάτι Κεφαλαίου",
"LabelChapters": "Κεφάλαια",
"LabelClosePlayer": "Κλείσιμο αναπαραγωγής",
"LabelCollapseSeries": "Σύμπτυξη Σειράς",
"LabelComplete": "Ολοκλήρωση",
"LabelContinueBooks": "Συνέχεια Βιβλίων",
"LabelContinueEpisodes": "Συνέχεια Επεισοδίων",
"LabelContinueListening": "Συνέχεια Ακρόασης",
"LabelContinueReading": "Συνέχεια Ανάγνωσης",
"LabelContinueSeries": "Συνέχεια Σειράς",
"LabelCustomTime": "Προσαρμοσμένη ώρα",
"LabelDescription": "Περιγραφή",
"LabelDisableAudioFadeOut": "Απενεργοποίηση σταδιακής μείωσης ήχου",
"LabelDisableAudioFadeOutHelp": "Η ένταση του ήχου θα αρχίσει να μειώνεται όταν απομένει λιγότερο από 1 λεπτό στον χρονοδιακόπτη ύπνου. Ενεργοποιήστε αυτή τη ρύθμιση για να μην γίνεται σταδιακή μείωση.",
"LabelDisableAutoRewind": "Απενεργοποίηση αυτόματης επαναφοράς",
"LabelDisableShakeToReset": "Απενεργοποιήστε το κούνημα για επαναφορά",
"LabelDisableShakeToResetHelp": "Αν κουνήσετε τη συσκευή σας ενώ λειτουργεί ο χρονοδιακόπτης Ή εντός 2 λεπτών από τη λήξη του χρονοδιακόπτη, θα επαναφέρετε τον χρονοδιακόπτη ύπνου. Ενεργοποιήστε αυτήν τη ρύθμιση για να απενεργοποιήσετε το κούνημα για επαναφορά.",
"LabelDisableVibrateOnReset": "Απενεργοποίηση δόνησης κατά την επαναφορά",
"LabelDisableVibrateOnResetHelp": "Όταν γίνει επαναφορά του χρονοδιακόπτη ύπνου, η συσκευή σας θα δονηθεί. Ενεργοποιήστε αυτήν τη ρύθμιση για να μην δονείται κατά την επαναφορά του χρονοδιακόπτη ύπνου.",
"LabelDiscover": "Ανακάλυψη",
"LabelDownload": "Λήψη",
"LabelDownloadUsingCellular": "Λήψη με χρήση δεδομένων",
"LabelDownloaded": "Ληφθέντα",
"LabelDuration": "Διάρκεια",
"LabelEbook": "Ebook",
"LabelEbooks": "Ebooks",
"LabelEbook": "Ηλεκτρονικό Βιβλίο/ Ebook",
"LabelEbooks": "Ηλεκτρονικά Βιβλία / Ebooks",
"LabelEnable": "Ενεργοποίηση",
"LabelEnableMp3IndexSeeking": "Ενεργοποίηση αναζήτησης σε mp3 ευρετήριο",
"LabelEnableMp3IndexSeekingHelp": "Αυτή η ρύθμιση θα πρέπει να είναι ενεργοποιημένη μόνο εάν έχετε αρχεία mp3 που δεν έχει γίνει σωστή αναζήτηση. Η ανακριβής αναζήτηση πιθανότατα οφείλεται σε αρχεία MP3 με μεταβλητό ρυθμό μετάδοσης bit (VBR). Αυτή η ρύθμιση θα επιβάλει την αναζήτηση ευρετηρίου, στην οποία δημιουργείται μια αντιστοίχιση χρόνου-byte καθώς διαβάζεται το αρχείο. Σε ορισμένες περιπτώσεις, με μεγάλα αρχεία MP3, θα υπάρχει καθυστέρηση κατά την αναζήτηση προς το τέλος του αρχείου.",
"LabelEnd": "Τέλος",
"LabelEndOfChapter": "Τέλος Κεφαλαίου",
"LabelEndTime": "Ώρα λήξης",
"LabelEpisode": "Επεισόδιο",
"LabelExplicit": "Άσεμνο περιεχόμενο",
"LabelFeedURL": "URL ροής",
"LabelFile": "Αρχείο",
"LabelFileBirthtime": "Ώρα δημιουργίας αρχείου",
"LabelFileModified": "Τροποποιημένο αρχείο",
"LabelFilename": "Όνομα Αρχείου",
"LabelFinished": "Ολοκληρώθηκε",
"LabelFolder": "Φάκελος",
"LabelFontBoldness": "Ένταση γραμματοσειράς",
"LabelFontFamily": "Οικογένεια Γραμματοσειράς",
"LabelGenre": "Είδος",
"LabelGenres": "Είδη",
"LabelFontScale": "Μέγεθος γραμματοσειράς",
"LabelGenre": "Λογοτεχνικό Είδος",
"LabelGenres": "Λογοτεχνικά Είδη",
"LabelHapticFeedback": "Απτική ανατροφοδότηση",
"LabelHasEbook": "Έχει ebook",
"LabelHasSupplementaryEbook": "Διαθέτει συμπληρωματικό ηλεκτρονικό βιβλίο",
"LabelHeavy": "Έντονη",
"LabelHigh": "Υψηλή",
"LabelHost": "Διακομιστής",
"LabelInProgress": "Σε Εξέλιξη",
"LabelIncomplete": "Ελειπής",
"LabelInternalAppStorage": "Εσωτερικός χώρος αποθήκευσης εφαρμογών",
"LabelJumpBackwardsTime": "Πήγαινε πίσω το χρόνο",
"LabelJumpForwardsTime": "Πήγαινε μπρος το χρόνο",
"LabelKeepScreenAwake": "Να παραμείνει η οθόνη ενεργή",
"LabelLanguage": "Γλώσσα",
"LabelLayout": "Διάταξη",
"LabelLayoutAuto": "Αυτόματα",
"LabelLayoutSinglePage": "Μονή Σελίδα",
"LabelLibrarySortByProgress": "Πρόοδος: Τελευταία ενημέρωση",
"LabelLibrarySortByProgressFinished": "Πρόοδος: Ολοκληρώθηκε",
"LabelLibrarySortByProgressStarted": "Πρόοδος: Έναρξη",
"LabelLight": "Ανοιχτό",
"LabelLineSpacing": "Διάστημα γραμμών",
"LabelListenAgain": "Επανάληψη Ακρόασης",
"LabelLocalBooks": "Τοπικά Βιβλία",
"LabelLocalPodcasts": "Τοπικά Podcasts",
"LabelLockOrientation": "Κλείδωμα Προσανατολισμού",
"LabelLockPlayer": "Κλείδωμα Αναπαραγωγής",
"LabelLow": "Χαμηλά",
"LabelMediaType": "Τύπος Πολυμέσων",
"LabelMedium": "Μεσαίο",
"LabelMissing": "Λείπει η ετικέτα",
"LabelMore": "Περισσότερα",
"LabelMoreInfo": "Περισσότερες Πληροφορίες",
"LabelName": "Όνομα",
"LabelNarrator": "Αφηγητής",
"LabelNarrators": "Αφηγητές",
"LabelNavigateWithVolume": "Περιήγηση με τα πλήκτρα έντασης",
"LabelNavigateWithVolumeMirrored": "Καθρεπτισμός",
"LabelNavigateWithVolumeWhilePlaying": "Να επιτρέπεται η περιήγησηστα πλήκτρα έντασης ήχου κατά την αναπαραγωγή",
"LabelNavigateWithVolumeWhilePlayingDisabled": "Ανενεργό",
"LabelNavigateWithVolumeWhilePlayingEnabled": "Ενεργό",
"LabelNever": "Ποτέ",
"LabelNewestAuthors": "Πρόσφατοι Συγγραφείς",
"LabelNewestEpisodes": "Πρόσφατα Επεισόδια",
"LabelNo": "Όχι",
"LabelNotFinished": "Δεν έχει ολοκληρωθεί",
"LabelNotStarted": "Δεν Έχει Ξεκινήσει",
"LabelNumEpisodes": "{0} επεισόδια",
"LabelNumEpisodesIncomplete": "{0} επεισόδια, {1} μη ολοκληρωμένα",
"LabelNumberOfEpisodes": "# Επεισοδίων",
"LabelOff": "Ανενεργό",
"LabelOn": "Ενεργό",
"LabelPassword": "Κωδικός Πρόσβασης",
"LabelPath": "Διαδρομή",
"LabelPlaybackLocal": "Τοπικά",
"LabelPlaybackDirect": "Άμεση αναπαραγωγή",
"LabelPlaybackLocal": "Αναπαραγωγή τοπικού αρχείου",
"LabelPlaybackSpeed": "Ταχύτητα Αναπαραγωγής",
"LabelPodcast": "Πόντκαστ",
"LabelPodcasts": "Πόντκαστ",
"LabelProgress": "Πρόοδος",
"LabelPubDate": "Ημερομηνία έκδοσης",
"LabelPublishYear": "Χρονολογία Έκδοσης",
"LabelPublishedDate": "Εκδόθηκε {0}",
"LabelRandomly": "Τυχαία",
@@ -217,5 +270,7 @@
"MessageMarkAsFinished": "Σήμανση ως Ολοκληρωμένο",
"MessageNoItemsFound": "Δεν βρέθηκαν αντικείμενα",
"MessageNoNetworkConnection": "Δεν υπάρχει σύνδεση δικτύου",
"MessageNoUserPlaylists": "Δεν έχετε λίστες αναπαραγωγής"
"MessageNoUserPlaylists": "Δεν έχετε λίστες αναπαραγωγής",
"UnitMinutesShort": "{0} min",
"UnitSecondsShort": "{0} s"
}
Loaded 100 of 127 files, more files were not shown because too many files have changed in this diff. Show more